tgq
A read-only Telegram client for the terminal. Search for channels, inspect their metadata, export their history. JSONL on stdout, logs on stderr.
Telegram only, by design. Sentiment, text parsing, topic modelling and coordination detection belong downstream — pipe rows into whatever does them.
Scope
Read-only, deliberately — this is a boundary, not an unfinished state. Nothing here sends, posts, reacts, joins, or modifies anything. Three reasons, in the order that actually matters:
- Every command is safe to re-run. That is what makes the pipe model work: you can loop, retry, and script without wondering what happened last time. A tool that can post cannot be re-run casually.
- It keeps the account-risk profile low. Telegram limits and bans accounts for write-shaped automation — sending, mass-joining, reacting. None of that is reachable from here. Member listing is the one read the platform treats as a spam signal; it is implemented because cohort analysis needs it, and it is the one command to use sparingly.
- A research tool must not perturb what it observes. An instrument that can post into the channels it measures cannot support a clean claim about them.
Reads not yet implemented are fair game: in-channel message search, discussion threads, media download. If write access is ever wanted, it belongs behind a separate command surface, or in a separate tool — not as a flag on these.
Install
uv tool install tgq-cli # or: pipx install tgq-cli
The distribution is tgq-cli; the command it installs is tgq.
Start with no account
tgq preview reads Telegram's public web preview at t.me/s/<channel> — no
account, no credentials, no session:
tgq preview ceuta_news --since 2026-07-27 > out/msgs.jsonl
Lower fidelity than messages, and the gaps are explicit rather than papered
over. Available: text, timestamps, view counts, forward origin (channel and
message id). Emitted as null: forwards, reactions, replies_count,
channel_id, edit_date, post_author, and fwd_from_date — so
latency_seconds from tgq forwards is always null on preview data.
Subscriber count is logged to stderr, not included in rows.
Both backends emit an identical key set, so downstream filters work
unchanged. Where preview cannot supply a value it is null, never substituted
or omitted — a test enforces the key sets match, because an absent key
silently passes numeric comparisons in jq (null < 5 is true).
Channels are keyed by @username when no numeric id exists.
Setup for the MTProto backend
Credentials come from https://my.telegram.org:
export TG_API_ID=... TG_API_HASH=...
The first network command prompts for your phone number and a login code, then
caches a session at ~/.config/tgq/tgq.session (override with --session).
That file authenticates as your account — it is created 0600 inside a 0700
directory, and must never be committed or shared.
Commands
| Command | Network | Purpose |
|---|---|---|
tgq search QUERY |
session | Find channels and groups by name or title |
tgq preview CHANNEL |
no auth | Public web preview: text, views, forward origin |
tgq channels NAMES... |
session | Channel metadata: subscribers, verified, kind, created_at |
tgq messages CHANNEL |
session | History with views, forwards, reactions, forward origin |
tgq members GROUP |
session | Group members with account flags and last-seen |
tgq forwards |
none | origin → relay edges with cascade latency |
tgq activity |
none | Per-channel span and days_silent, most-dormant first |
Channel arguments accept name, @name, or a https://t.me/name URL.
Worked examples
Find channels. Emits the same rows as channels, so results feed straight
into collection.
tgq search fnideq --kind megagroup
tgq search ceuta | jq -r 'select(.subscribers > 1000) | .username'
Telegram's global search matches username and title only, and returns
single-digit result counts in practice — seven for fnideq, nine for
bitcoin. An empty result is not evidence that no such channel exists.
Discovery on Telegram is genuinely weak; a sampling frame built from search
alone must say so, and should be expanded via the forward graph below.
Collect a window. Pages backwards and stops at --since, rather than
reading history to the beginning.
tgq preview ceuta_news --since 2026-07-27 > out/msgs.jsonl
tgq messages ceuta_news --since 2026-07-27 --until 2026-08-02 > out/msgs.jsonl
Coordination: few actors, many accounts. Forward edges carry the gap between origin post and relay. Implausibly tight latency fanning out from one origin is harder to produce accidentally than text similarity.
tgq forwards < out/msgs.jsonl \
| jq -s '[.[] | select(.latency_seconds != null and .latency_seconds < 5)]
| group_by(.origin)
| map({origin: .[0].origin, relays: length})
| sort_by(-.relays)'
The != null guard is mandatory. jq evaluates null < 5 as true, so
omitting it reports every edge of unknown latency as sub-5-second coordination —
including all preview-derived edges.
This also needs a null model before it means anything: identical content across channels is normal when everyone reposts the same news. Pull the same calendar weeks from prior years to get the expected co-posting rate.
Members and account cohorts. Emits user ids, usernames, names, bot and premium flags, and last-seen — the inputs for co-membership and cohort analysis. Telegram user ids are broadly sequential over time, so batch-registered accounts cluster in id space.
tgq members somegroup > out/members.jsonl
jq -s 'group_by(.user_id / 1000000000 | floor)
| map({id_band: .[0].user_id, accounts: length})' < out/members.jsonl
Read the coverage line on stderr before using the output. Three different causes produce a short list and the tool distinguishes them:
67 of 67 members retrieved (100.0%) coverage
3 of 67 members retrieved (4.5%) -- stopped at your --limit
5 of 14679 members retrieved (0.0%) -- the admin has HIDDEN this group's
member list, so enumeration is blocked server-side
Two hard limits, measured rather than assumed:
- Broadcast channels expose no participants at all —
ChatAdminRequiredError. Members work on megagroups (public groups) only. - Admins of groups with 100+ members can hide the list
(
channels.toggleParticipantsHidden). The server then returns almost nothing whilecountstill reports the true total, so a hidden group is indistinguishable from a tiny one unless you read the warning. Size alone does not restrict enumeration.
Reach. views and forwards come straight off the platform. Both stay
null when Telegram omits them — a real zero and an absent field are different
facts, so they are never conflated.
jq -s 'map(select(.views != null)) | {posts: length, views: (map(.views) | add)}' \
< out/msgs.jsonl
Dormancy. Channels that stopped posting sort to the top. What counts as dormant is left to the caller.
tgq activity --as-of 2026-08-11 < out/msgs.jsonl \
| jq -s 'map(select(.days_silent > 7))'
Snowball discovery. Frame expansion is a shell loop, not code. Filter to
@-prefixed origins: numeric and user: keys are not channel usernames, and
feeding a bare digit string to channels makes Telethon treat it as a phone
number.
tgq forwards < out/msgs.jsonl \
| jq -r 'select(.origin | startswith("@")) | .origin[1:]' \
| sort -u > out/discovered.txt
tgq channels --from-file out/discovered.txt > out/channels.jsonl
Provenance
Every row carries source_url, retrieved_at and source_kind (primary for
direct platform retrieval). Derived rows must restate their own kind.
Responsible use
This tool collects public data from a live platform. Before using it:
- Public channels only.
t.me/s/serves broadcast channels; public groups render nothing there and require the MTProto backend. Nothing here accesses private groups, and no feature should be added that does. - Collected rows contain personal data about identifiable people. If you are in a jurisdiction with data-protection law, that law applies to you. Decide your retention, pseudonymisation and publication posture before collecting, not after.
- Telegram's API Terms §1.5 prohibit using collected data to train or fine-tune AI models. Classifying with a pre-trained model is inference and is unaffected; fitting one on the corpus is not.
- The preview backend is not covered by the API Terms — it is ordinary web access, subject to Telegram's website terms. Assess that yourself.
- The MTProto backend acts as your own account. Telegram limits and bans
accounts for aggressive automation.
tgq membersis the highest-risk command here — bulk member enumeration is the platform's primary spam signal — so run it sparingly and bound it with--limit. Aged accounts fare better than new ones. - Rate limiting.
previewdefaults to--delay 1.0seconds between page requests; raise it for large runs.messagessleeps throughFLOOD_WAITunder 900s and aborts beyond that rather than stalling indefinitely.
Notes
- Telethon is pinned to
1.44.0. Upstream moved to Codeberg; v2 has been in alpha since Oct 2025 without shipping, so this builds against the v1 API. - A channel that will not resolve is logged and skipped, so one dead entry in a
frame of hundreds does not abort collection. A rate limit is not skippable and
aborts the run.
channelsexits non-zero if nothing resolved at all. previewdeduplicates by message id and stops if a page yields nothing new, so a stuck pagination cursor cannot silently duplicate rows.
Development
git clone https://github.com/owahltinez/tgq-cli && cd tgq-cli
uv sync --extra dev
uv run pytest -q # no network access required
uv run ruff check src tests && uv run ruff format --check src tests
The network surface is confined to client.py and preview.fetch_page;
everything else takes plain dicts, duck-typed objects, or fixture HTML, so the
suite runs entirely offline. CI runs those three commands on 3.11, 3.12 and
3.13.
Releasing
Publishing is a tag push. .github/workflows/release.yml refuses a tag whose
version disagrees with pyproject.toml, re-runs lint and tests against the
tagged commit, and uploads to PyPI via trusted publishing — there is no
API token stored in the repository.
# bump `version` in pyproject.toml first, and commit it
git tag v0.1.0 && git push origin v0.1.0
One-time setup on PyPI, under the tgq-cli project's Publishing settings:
add a GitHub publisher with owner owahltinez, repository tgq-cli, workflow
release.yml, environment pypi.
Licence
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tgq_cli-0.1.0.tar.gz.
File metadata
- Download URL: tgq_cli-0.1.0.tar.gz
- Upload date:
- Size: 40.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
087adeb64ddbae6d717d87cfa2c111b305cc27e34cd033cfeabbbe9b428c56ed
|
|
| MD5 |
7da1ba3b38caeaf75ff7480e9e7d82e2
|
|
| BLAKE2b-256 |
5830071c507aada1295607a53d1009b7a199bec116bbf0f7402e14a2ab265e5d
|
Provenance
The following attestation bundles were made for tgq_cli-0.1.0.tar.gz:
Publisher:
release.yml on owahltinez/tgq-cli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tgq_cli-0.1.0.tar.gz -
Subject digest:
087adeb64ddbae6d717d87cfa2c111b305cc27e34cd033cfeabbbe9b428c56ed - Sigstore transparency entry: 2416789985
- Sigstore integration time:
-
Permalink:
owahltinez/tgq-cli@35039449617c76153891c2351cd38cf4f6116a52 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/owahltinez
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@35039449617c76153891c2351cd38cf4f6116a52 -
Trigger Event:
push
-
Statement type:
File details
Details for the file tgq_cli-0.1.0-py3-none-any.whl.
File metadata
- Download URL: tgq_cli-0.1.0-py3-none-any.whl
- Upload date:
- Size: 25.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d98a712519f2e2ae7dd2399d54fa1a20cf59483de67831d9f0f3cd2853976e2f
|
|
| MD5 |
8588a414cefc40db66c7d190fd14218a
|
|
| BLAKE2b-256 |
99020ce5d0d893a3e07549b472b2983a70e6fbc1714f0c13c614c301726c84e6
|
Provenance
The following attestation bundles were made for tgq_cli-0.1.0-py3-none-any.whl:
Publisher:
release.yml on owahltinez/tgq-cli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tgq_cli-0.1.0-py3-none-any.whl -
Subject digest:
d98a712519f2e2ae7dd2399d54fa1a20cf59483de67831d9f0f3cd2853976e2f - Sigstore transparency entry: 2416790298
- Sigstore integration time:
-
Permalink:
owahltinez/tgq-cli@35039449617c76153891c2351cd38cf4f6116a52 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/owahltinez
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@35039449617c76153891c2351cd38cf4f6116a52 -
Trigger Event:
push
-
Statement type: