
Sergei Mikhailov Tg Channel Reader
- 19 installs
- 82 repo stars
- Updated August 2, 2026
- aaaaqwq/claude-code-skills
sergei-mikhailov-tg-channel-reader is a skill that reads posts and comments from Telegram channels via MTProto (Pyrogram or Telethon).
About
sergei-mikhailov-tg-channel-reader is a skill that reads posts and comments from Telegram channels using MTProto (Pyrogram or Telethon). It fetches recent messages by time window from public or subscribed private channels, can pull discussion replies, and supports an unread-only mode for daily digests. A developer uses it to monitor or summarize Telegram channels. It requires TG_API_ID and TG_API_HASH credentials and a session file from my.telegram.org.
- Reads posts and comments from public or private Telegram channels via MTProto
- Supports time-window fetches, multi-channel reads, and unread-only digest mode
- Ships both Pyrogram and Telethon backends with a pre-flight diagnostic command
Sergei Mikhailov Tg Channel Reader by the numbers
- 19 all-time installs (skills.sh)
- Ranked #1,313 of 2,719 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
sergei-mikhailov-tg-channel-reader capabilities & compatibility
Free but requires personal TG_API_ID and TG_API_HASH credentials from my.telegram.org
- Capabilities
- telegram reader · channel monitoring · content digest
- Use cases
- research · web scraping · transcription
- Pricing
- Bring your own API key
What sergei-mikhailov-tg-channel-reader says it does
Read posts and comments from Telegram channels using MTProto (Pyrogram or Telethon).
This skill requires `TG_API_ID` and `TG_API_HASH` from [my.telegram.org]
npx skills add https://github.com/aaaaqwq/claude-code-skills --skill sergei-mikhailov-tg-channel-readerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 19 |
|---|---|
| repo stars | ★ 82 |
| Last updated | August 2, 2026 |
| Repository | aaaaqwq/claude-code-skills ↗ |
What it does
Read and summarize posts and comments from Telegram channels by time window via MTProto.
Who is it for?
Monitoring, digesting, or summarizing recent posts and comments from Telegram channels
Skip if: Posting to Telegram or reading via bot API without MTProto credentials
When should I use this skill?
You want to read, monitor, or summarize what is new in a Telegram channel
What you get
Time-windowed or unread-only fetches of channel posts and comments, ready to summarize
- JSON or text export of channel posts
- optional comments per post
- per-channel unread state
By the numbers
- 2 MTProto backends (Pyrogram, Telethon)
- default 24h fetch window
- comments mode limit auto-drops to 30 posts
Files
tg-channel-reader
Read posts and comments from Telegram channels using MTProto (Pyrogram or Telethon). Works with any public channel and private channels the user is subscribed to. Supports fetching discussion replies (comments) for individual posts.
Security notice: This skill requiresTG_API_IDandTG_API_HASHfrom my.telegram.org. The session file grants full Telegram account access — store it securely and never share it.
---
Exec Approvals
Just installed via `clawhub install`? Complete Setup & Installation (below) first — the skill needs pip install, credentials, and a session file before exec approvals matter.OpenClaw blocks unknown CLI commands by default. The user must approve tg-reader commands before they can run. If the command hangs or the user says nothing is happening — exec approval is likely pending.
Quick setup (recommended)
Run from the skill directory — checks prerequisites, installs pip packages if needed, and prints the approval commands to run:
cd ~/.openclaw/workspace/skills/sergei-mikhailov-tg-channel-reader
bash setup-tg-reader.shManual CLI approval
openclaw approvals allowlist add --gateway "$(which tg-reader)"
openclaw approvals allowlist add --gateway "$(which tg-reader-check)"
openclaw approvals allowlist add --gateway "$(which tg-reader-telethon)"Alternative: approve on first use
1. Control UI — open http://localhost:18789/, find the pending approval for tg-reader, click "Always allow". Docs 2. Messenger (Telegram, Slack, Discord) — the bot sends an approval request with an <id>. Reply: /approve <id> allow-always. Other options: allow-once, deny.
The approval prompt appears in the Control UI or as a bot message — not in the agent's conversation. This is a common source of confusion.
---
When to Use
- User asks to "check", "read", or "monitor" a Telegram channel
- Wants a digest or summary of recent posts
- Asks "what's new in @channel" or "summarize last 24h from @channel"
- Wants to track or compare multiple channels
- Wants channel info (title, description, subscribers) — use
tg-reader info
---
Quick Start
# 1. Run pre-flight diagnostic (fast, no Telegram connection)
tg-reader-check
# 2. Get channel info
tg-reader info @channel_name
# 3. Fetch recent posts
tg-reader fetch @channel_name --since 24h`tg-reader: command not found`? Runbash setup-tg-reader.shfrom the skill directory (it will install the package), or manually:cd ~/.openclaw/workspace/skills/sergei-mikhailov-tg-channel-reader && pip install .
---
Commands
tg-reader-check — Pre-flight Diagnostic
Always run before fetching. Fast offline check — no Telegram connection needed.
tg-reader-check
tg-reader-check --config-file /path/to/config.json
tg-reader-check --session-file /path/to/sessionReturns JSON with "status": "ok" or "status": "error" plus a problems array.
Verifies:
- Credentials available (env vars or
~/.tg-reader.json) - Session file exists on disk (with size, modification date)
- At least one MTProto backend installed (Pyrogram or Telethon)
- Detects stale sessions (config points to older file while a newer one exists)
tg-reader info — Channel Info
tg-reader info @channel_nameReturns title, description, subscriber count, and link.
tg-reader fetch — Read Posts
# Last 24 hours (default)
tg-reader fetch @channel_name --since 24h
# Last 7 days, up to 200 posts
tg-reader fetch @channel_name --since 7d --limit 200
# Multiple channels (fetched sequentially with 10s delay between each)
tg-reader fetch @channel1 @channel2 @channel3 --since 24h
# Custom delay between channels (seconds)
tg-reader fetch @channel1 @channel2 @channel3 --since 24h --delay 5
# Fetch posts with comments (single channel only, limit auto-drops to 30)
tg-reader fetch @channel_name --since 7d --comments
# More comments per post, custom delay between posts
tg-reader fetch @channel_name --since 24h --comments --comment-limit 20 --comment-delay 5
# Skip posts without text (media-only, no caption)
tg-reader fetch @channel_name --since 24h --text-only
# Human-readable output
tg-reader fetch @channel_name --since 24h --format text
# Write output to file instead of stdout (saves tokens)
tg-reader fetch @channel_name --since 24h --output
tg-reader fetch @channel_name --since 24h --comments --output comments.json
# Use Telethon instead of Pyrogram (one-time)
tg-reader fetch @channel_name --since 24h --telethon
# Read unread mode — only fetch new (unread) posts, no --since needed
# Requires "read_unread": true in ~/.tg-reader.json
tg-reader fetch @channel_name
# Override read_unread mode (fetch everything, don't update state)
tg-reader fetch @channel_name --since 7d --all
# Custom state file location
tg-reader fetch @channel_name --since 24h --state-file /path/to/state.jsontg-reader auth — First-time Authentication
tg-reader authCreates a session file. Only needed once.
---
Read Unread Mode
Only return new (unread) posts — the skill remembers what you've already seen. Useful for daily digests and monitoring workflows.
Setup
Option A — config file (~/.tg-reader.json):
{
"api_id": 12345,
"api_hash": "...",
"read_unread": true
}Option B — env var (works with ~/.openclaw/openclaw.json):
export TG_READ_UNREAD=trueEnv vars take priority over the config file. This lets you enable read_unread via openclaw.json Docker env alongside TG_API_ID/TG_API_HASH.
State is stored in ~/.tg-reader-state.json (configurable via "state_file" in config, TG_STATE_FILE env var, or --state-file flag).
Behavior
- `--since` is not needed when
read_unreadis enabled — the skill automatically returns all unread posts regardless of time - First run (no prior state for channel):
--sinceapplies as usual (default 24h); state file created - Subsequent runs: only posts newer than the last read are returned;
--sinceis ignored - `--all` flag: bypasses read_unread mode — fetches everything by
--sincewithout updating state (preserves your position) - New channel: behaves like a first run (no prior state)
- No new posts: state unchanged,
count: 0returned
Examples
# With read_unread enabled — just fetch, no --since needed
tg-reader fetch @channel_name
# First run for a new channel — --since determines initial window
tg-reader fetch @new_channel --since 7d
# Override: fetch everything, don't update tracking state
tg-reader fetch @channel_name --since 7d --allOutput
When read_unread mode is active, the JSON output includes a read_unread field:
{
"channel": "@channel_name",
"read_unread": {"enabled": true},
"count": 5,
"messages": [...]
}With --all: "read_unread": {"enabled": true, "overridden": true}
Limitations
- Tracking is post-level only — new comments on already-read posts are not caught
- If a channel changes its username, tracking resets (state is keyed by username)
- Concurrent runs for the same channel are safe but last writer wins
Diagnostic
tg-reader-check reports tracking status:
{
"tracking": {
"read_unread": true,
"state_file": "~/.tg-reader-state.json",
"state_file_exists": true,
"tracked_channels": 3
}
}---
Output Format
info
{
"id": -1001234567890,
"title": "Channel Name",
"username": "channel_name",
"description": "About this channel...",
"members_count": 42000,
"link": "https://t.me/channel_name"
}fetch
{
"channel": "@channel_name",
"fetched_at": "2026-02-22T10:00:00Z",
"since": "2026-02-21T10:00:00Z",
"count": 12,
"messages": [
{
"id": 1234,
"date": "2026-02-22T09:30:00Z",
"text": "Post content...",
"views": 5200,
"forwards": 34,
"link": "https://t.me/channel_name/1234",
"has_media": true,
"media_type": "MessageMediaType.PHOTO"
}
]
}fetch with --comments
{
"channel": "@channel_name",
"fetched_at": "2026-02-28T10:00:00Z",
"since": "2026-02-27T10:00:00Z",
"count": 5,
"comments_enabled": true,
"comments_available": true,
"messages": [
{
"id": 1234,
"text": "Post content...",
"has_media": false,
"comment_count": 2,
"comments": [
{
"id": 5678,
"date": "2026-02-28T09:35:00Z",
"text": "Great post!",
"from_user": "username123"
}
]
}
]
}Notes:
comments_available: false— channel has no linked discussion group (no comments possible)comments_erroron a message — rate limit hit for that post's commentsfrom_usermay benullfor anonymous comments- Images/videos in comments are not analyzed — only text is captured
- Default post limit drops to 30 when
--commentsis active (override with--limit)
---
After Fetching
1. Parse the JSON output 2. Posts with images/videos have has_media: true and a media_type field. Their text is in the text field (from the caption). Do not skip posts just because they have media — they often contain important text. 3. Images and videos are not analyzed (no OCR/vision) — only the text/caption is returned. 4. Summarize key themes, top posts by views, notable links 5. If comments_enabled: true, analyze comment sentiment and key themes alongside the main posts 6. Save summary to memory/YYYY-MM-DD.md if user wants to track over time
Saving to File (Token Economy)
Use --output when the result is large (especially with --comments) and you don't need to analyze it immediately. The full data goes to a file, and stdout returns only a short confirmation — this saves tokens.
Periodic updates pattern: set up a cron task that runs tg-reader fetch @channel --comments --output comments.json on schedule. The file gets updated regularly. When the user asks to analyze comments — read the file instead of re-fetching. This avoids consuming tokens on every fetch.
When --output is used without a filename, the default is tg-output.json. Stdout confirmation:
{"status": "ok", "output_file": "/absolute/path/to/tg-output.json", "count": 12}Saving Channel List
Store tracked channels in TOOLS.md:
## Telegram Channels
- @channel1 — why tracked
- @channel2 — why tracked---
Error Handling
Errors include an error_type and action field to help agents decide what to do automatically.
Channel Errors
error_type | Meaning | action |
|---|---|---|
access_denied | Channel is private, you were kicked, or access is restricted | remove_from_list_or_rejoin — ask user if they still have access; if not, remove the channel |
banned | You are banned from this channel | remove_from_list — remove the channel, tell the user |
not_found | Channel doesn't exist or username is wrong | check_username — verify the @username with the user |
invite_expired | Invite link is expired or invalid | request_new_invite — ask user for a new invite link |
flood_wait | Telegram rate limit | wait_Ns — waits ≤ 60 s are retried automatically; longer waits return this error |
comments_multi_channel | --comments used with multiple channels | remove_extra_channels_or_drop_comments — use one channel at a time |
System Errors
| Error | Action |
|---|---|
Session file not found | Run tg-reader-check — use the suggestion from output |
Missing credentials | Guide user through Setup (Step 1-2 below) |
tg-reader: command not found | Run bash setup-tg-reader.sh from the skill directory, or manually: pip install . Fallback: python3 -m tg_reader_unified |
AUTH_KEY_UNREGISTERED | Session expired — delete and re-auth (see below) |
Session Expired
rm -f ~/.tg-reader-session.session
tg-reader authAuth Code Not Arriving
Use the verbose debug script for full MTProto-level logs:
python3 debug_auth.pyWarning: debug_auth.py deletes existing session files before re-authenticating. It will ask for confirmation first.---
Library Selection
Two MTProto backends are supported:
| Backend | Command | Notes |
|---|---|---|
| Pyrogram (default) | tg-reader or tg-reader-pyrogram | Modern, actively maintained |
| Telethon | tg-reader-telethon | Alternative if Pyrogram has issues |
Switch persistently: export TG_USE_TELETHON=true Switch one-time: tg-reader fetch @channel --since 24h --telethon
---
Setup & Installation
Full details in README.md.
Step 1 — Get API Credentials
Go to https://my.telegram.org → API Development Tools → create an app → copy api_id and api_hash.
Step 2 — Save Credentials
Recommended (works in agents and servers):
cat > ~/.tg-reader.json << 'EOF'
{
"api_id": YOUR_ID,
"api_hash": "YOUR_HASH"
}
EOF
chmod 600 ~/.tg-reader.jsonAlternative (interactive shell only):
export TG_API_ID=YOUR_ID
export TG_API_HASH="YOUR_HASH"Set these in your current shell session. Avoid writing TG_API_HASH to shell profiles (~/.bashrc) — use ~/.tg-reader.json instead for persistent storage.
Note: Agents and servers don't load shell profiles. Use ~/.tg-reader.json (the recommended method above) for non-interactive environments.Step 3 — Install & Configure
npx clawhub@latest install sergei-mikhailov-tg-channel-reader
cd ~/.openclaw/workspace/skills/sergei-mikhailov-tg-channel-reader
bash setup-tg-reader.shThe setup script: installs Python packages (pip install .), checks credentials and session, runs tg-reader-check, and prints the exec approval commands for you to run manually.
On Linux with managed Python (Ubuntu/Debian), use a venv before running the setup script:
python3 -m venv ~/.venv/tg-reader
echo 'export PATH="$HOME/.venv/tg-reader/bin:$PATH"' >> ~/.bashrc && source ~/.bashrc<details> <summary>Manual install (without setup script)</summary>
cd ~/.openclaw/workspace/skills/sergei-mikhailov-tg-channel-reader
pip install pyrogram tgcrypto telethon && pip install .
openclaw approvals allowlist add --gateway "$(which tg-reader)"
openclaw approvals allowlist add --gateway "$(which tg-reader-check)"</details>
Step 4 — Authenticate
tg-reader authPyrogram will ask to confirm the phone number — answer y. The code arrives in the Telegram app (not SMS).
Step 5 — Verify
tg-reader-checkShould return "status": "ok". If not — fix the reported issues and re-run bash setup-tg-reader.sh.
---
Scheduled Tasks & Cron
This skill needs network access (MTProto connection to Telegram servers) and a session file. How you configure OpenClaw cron depends on the session target.
Important: When setting up a scheduled task that uses tg-reader, tell the user which approach you're using and what it means — so they can make an informed choice.Option A — sessionTarget: "main" (recommended)
The cron task sends a reminder to the main agent session. The agent then runs tg-reader in the main environment where the skill, credentials, and session file are already available.
Pros: No extra configuration — everything works out of the box. Cons: Not fully autonomous — the task sends a system event, the agent picks it up and executes. Requires payload.kind: "systemEvent" (OpenClaw cron API limitation for main target).
How to set up: 1. Create a cron task with sessionTarget: "main" and payload.kind: "systemEvent" 2. In the task description, include the exact tg-reader command to run 3. The agent receives the reminder and executes the command in its main session
Option B — sessionTarget: "isolated" (autonomous, complex setup)
The cron task runs in a Docker container — fully autonomous, no agent interaction needed. However, the container starts empty: no skill, no credentials, no session file.
Pros: Fully autonomous — runs on schedule without agent involvement. Cons: Requires Docker setup; session file must be mounted into the container (may not work reliably — session files are tied to the machine and Telegram may invalidate them in a new environment).
Required configuration in `~/.openclaw/openclaw.json`:
{
"agents": {
"defaults": {
"sandbox": {
"docker": {
"setupCommand": "clawhub install sergei-mikhailov-tg-channel-reader && cd ~/.openclaw/workspace/skills/sergei-mikhailov-tg-channel-reader && pip install pyrogram tgcrypto telethon && pip install .",
"env": {
"TG_API_ID": "YOUR_ID",
"TG_API_HASH": "YOUR_HASH",
"TG_READ_UNREAD": "true"
}
}
}
}
}
}Session file caveat: The Telegram session file (~/.tg-reader-session.session) must also be available inside the container. This may require Docker volume mounting and might not work reliably — Telegram can invalidate sessions when they appear from a different environment. If you encounter AUTH_KEY_UNREGISTERED errors in isolated mode, switch to Option A.
Explicit paths (both options)
When ~/ is not available or points to a different location, use explicit paths:
tg-reader-check \
--config-file /home/user/.tg-reader.json \
--session-file /home/user/.tg-reader-session
tg-reader fetch @channel --since 6h \
--config-file /home/user/.tg-reader.json \
--session-file /home/user/.tg-reader-sessionBoth flags work with all subcommands and both backends.
---
Security
- Session file (
~/.tg-reader-session.session) grants full account access — keep it safe - Never share or commit
TG_API_HASHor session files TG_API_HASHis a secret — store in env vars or config file, never in git
*.session
*.session-journal
.tg-reader.json
.tg-reader-state.json
.env
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.DS_Store
Changelog
---
[0.9.2] - 2026-03-05
Env var support for read_unread. TG_READ_UNREAD and TG_STATE_FILE env vars now work alongside the config file — lets you enable read_unread mode via ~/.openclaw/openclaw.json Docker env without needing ~/.tg-reader.json.
Added
TG_READ_UNREADenv var ("true"/"1") — enables read_unread mode; overrides config fileTG_STATE_FILEenv var — custom state file path; overrides config filetg-reader-checkreports whether read_unread comes from env or config file
---
[0.9.1] - 2026-03-05
Metadata fix. Set correct ClawHub display name to "Telegram Channel Reader".
---
[0.9.0] - 2026-03-05
Only see new posts. Enable read_unread mode and the skill remembers what you've already seen — subsequent runs return only unread posts, no --since needed. Great for daily digests and monitoring workflows. Add "read_unread": true to ~/.tg-reader.json and you're set.
Added
read_unreadmode: per-channellast_read_idstored in~/.tg-reader-state.json"read_unread": trueconfig option in~/.tg-reader.jsonto enable the mode"state_file"config option and--state-fileCLI flag for custom state file location--allCLI flag to bypass read_unread mode and fetch everything without updating stateread_unreadmetadata in JSON output when the mode is activetg_state.py— shared state management module (no heavy dependencies)tg-reader-checknow reports read_unread configuration and state file status
Changed
- When
read_unreadis active and state exists,--sinceis automatically ignored (all unread posts returned) - On first run (no state),
--sincestill applies (default 24h) _fetch_channel(Pyrogram) accepts optionalmin_id— breaks iteration at already-read messagesfetch_messages/iter_messages(Telethon) uses nativemin_idfor server-side filtering
---
[0.8.12] - 2026-03-05
Security scan fixes (round 2). Fixed remaining broad session discovery in reader.py and reader_telethon.py — now all three modules use the same restricted _SESSION_NAMES list. Restructured README credential examples to recommend ~/.tg-reader.json first instead of ~/.bashrc.
Changed
reader.py: replaced broad*.sessionglob with known tg-reader session names onlyreader_telethon.py: same fix — restricted session discovery to known namesREADME.md: credential setup now recommends~/.tg-reader.json(Option A); env vars demoted to Option D with warning against writing to shell profiles
---
[0.8.11] - 2026-03-05
Security scan fixes. Addressed OpenClaw security scanner findings to move from "Suspicious" to "Benign".
Changed
setup-tg-reader.sh: no longer auto-adds commands to exec allowlist — now prints the approval commands for the user to run manuallytg_check.py: session file discovery now searches only for known tg-reader session names instead of all*.sessionfiles (avoids exposing unrelated session paths)SKILL.md: replaced insecure~/.bashrccredential example with recommendation to use~/.tg-reader.json; updated setup script descriptions to reflect manual approval flow
---
[0.8.10] - 2026-03-04
Version bump. Internal version alignment — no functional changes.
---
[0.8.9] - 2026-03-04
Setup script for first-time installation. New setup-tg-reader.sh checks all prerequisites (Python version, CLI commands in PATH, MTProto libraries, credentials, session file), runs tg-reader-check, and automatically adds commands to OpenClaw exec approvals allowlist via openclaw approvals allowlist add --gateway. No more manual approval needed when using the setup script.
Added
setup-tg-reader.sh— pre-flight setup script with colored output, auto-install fromsetup.py, and automatic exec approvals configuration- SKILL.md: added CLI approval commands (
openclaw approvals allowlist add --gateway) and setup script reference
Changed
- SKILL.md: restructured Exec Approvals section — quick setup first, manual CLI second, UI/messenger third
---
[0.8.8] - 2026-03-01
Guard against hallucinated CLI flags. LLM agents sometimes invent flags like --hours or --days instead of using the correct --since flag. Now the CLI catches these typos and returns a helpful JSON error with the correct flag name — so the agent can self-correct instead of failing silently. All argparse errors are now JSON-formatted for agent readability.
Added
- Pre-flight check for common hallucinated flags (
--hours,--days,--weeks,--time,--period,--after,--from,--media) with suggested corrections - Custom
_JsonArgumentParser: all CLI errors now output structured JSON ({"error": "...", "action": "fix_command"}) instead of plain text
Changed
CLAUDE.md: updated current version to 0.8.8
---
[0.8.7] - 2026-03-01
Write output to a file instead of flooding the agent's context. New --output flag saves fetch results (especially large comment payloads) to a file. The agent gets a short confirmation on stdout instead of the full JSON — saving tokens. Works great with cron: schedule periodic updates to a file, then analyze on demand without re-fetching.
Added
--outputflag forfetchcommand — writes results to a file instead of stdout--outputwithout a filename defaults totg-output.json- When
--outputis used, stdout returns a short JSON confirmation:{"status": "ok", "output_file": "...", "count": N} SKILL.md: new "Saving to File (Token Economy)" section in After Fetching — explains the periodic update pattern
---
[0.8.6] - 2026-03-01
Exec approvals guidance and documentation cleanup. Users on Linux couldn't figure out where to confirm command execution — the approval prompt lives in the Control UI, not the chat. SKILL.md now has a dedicated "Exec Approvals" section so the agent can explain this. Both SKILL.md and README.md were audited for redundancy and readability.
Added
SKILL.md: new "Exec Approvals" section — tells the agent how to help users find and approve pending command executions in the Control UI
Changed
SKILL.md: reordered sections by importance — Output Format, After Fetching, and Error Handling moved up; Setup & Installation moved down (agent rarely needs it)SKILL.md: condensed Setup & Installation — removed step-by-step my.telegram.org walkthrough (duplicated README), kept essential commands onlySKILL.md: condensed Library Selection — removed code examples already shown in Commands sectionREADME.md: removed duplicate "Library Selection" section (already covered in Setup Step 4)README.md: moved orphaned troubleshooting items (confirmation code, ChannelInvalid, FloodWait) into the Troubleshooting sectionREADME.md: removed duplicate PATH instructions from Install section (kept in Troubleshooting)README.md: merged overlapping Security bullet points into a single clean list
---
[0.8.5] - 2026-03-01
Clear guide for running the skill on a schedule. The "Isolated Agents & Cron Jobs" section is now a full "Scheduled Tasks & Cron" guide with two approaches: sessionTarget: "main" (recommended — reminder-based, works out of the box) and sessionTarget: "isolated" (autonomous but requires Docker setup and session file mounting). The agent now explains the trade-offs to the user when setting up a cron task.
Changed
SKILL.md: replaced "Isolated Agents & Cron Jobs" section with expanded "Scheduled Tasks & Cron" covering both session target modes, configuration examples, and session file caveats- Agent instruction added: when creating a scheduled task, explain to the user which approach is used and what it means
---
[0.8.4] - 2026-02-28
Read what people are saying in the comments. Add --comments to a fetch command and the skill retrieves discussion replies for each channel post — great for sentiment analysis, audience feedback, and topic tracking. Works with both Pyrogram and Telethon backends.
Added
--commentsflag forfetchcommand — fetches discussion replies (comments) for each post in a single channel--comment-limit N— max comments per post (default 10)--comment-delay N— seconds between posts when fetching comments (default 3) to avoid rate limits- Output includes
comments_enabled,comments_availableflags and acommentsarray per message withid,date,text,from_user - Channels without a linked discussion group return
comments_available: falseinstead of an error
Changed
- Default
--limitdrops from 100 → 30 when--commentsis active (token economy — comments produce a lot of output) --commentsis restricted to a single channel; using it with multiple channels returns an actionable error (comments_multi_channel)
Error handling
- FloodWait during comment fetch: auto-retry once if ≤ 60 s, otherwise sets
comments_erroron the affected message and continues - Media-only comments (no text) are silently skipped
- Anonymous comments return
from_user: null
---
[0.8.3] - 2026-02-28
Posts with images and videos are no longer invisible. Previously, if a channel post contained a photo or video, the skill could return an empty text field — and the agent would skip it during summarization. Now every message includes has_media and media_type fields, and the text caption is always captured correctly. Images and videos themselves are not analyzed (no OCR/vision), but their accompanying text is fully preserved.
Fixed
- Pyrogram: made text extraction from media posts more explicit —
msg.textandmsg.captionare now checked separately instead of relying on Pythonorchain - Both backends:
has_media(boolean) andmedia_type(string) are now always included in the message output — media info is part of every response by default SKILL.md: removed instruction to "filter out media-only posts" — agents should never skip posts with media as they often contain important text in captions
Changed
- Replaced
--mediaflag with--text-only— by default all posts are included (media + text); use--text-onlyto exclude posts with no text (e.g. standalone images/videos without captions)
---
[0.8.2] - 2026-02-28
Security hardening after registry review. The debug script now asks for confirmation before deleting session files, and insecure session-copying instructions have been removed from the docs.
Fixed
debug_auth.py: added confirmation prompt before deleting.sessionand.session-journalfiles — no more silent deletionSKILL.md: documented thatdebug_auth.pydeletes session files (with confirmation)
Removed
- Removed
scpsession-copying instructions fromREADME_TELETHON.mdandTESTING_GUIDE.md— copying session files between machines is insecure and grants full Telegram account access
---
[0.8.0] - 2026-02-28
Multiple channels no longer cause Telegram to block your account. Previously, fetching several channels at once sent all requests in parallel — Telegram treated this as flood and rate-limited the session. Now channels are fetched one at a time with a 10-second pause between each, and short rate limits (≤ 60 s) are waited out automatically.
Changed
fetch_multiplein both Pyrogram and Telethon backends now processes channels sequentially instead of in parallel (asyncio.gatherremoved)- Pyrogram multi-channel fetch uses a single session for all channels (previously each channel opened its own session)
- FloodWait auto-retry: if Telegram says "wait N seconds" and N ≤ 60, the skill sleeps and retries once automatically; longer waits still return an error
Added
--delayflag forfetchcommand — configurable pause between channels (default 10 seconds)
---
[0.7.2] - 2026-02-28
Fixed: channels with non-existent usernames no longer crash the skill. Pyrogram throws a KeyError internally when a username like @disruptors_official doesn't exist — this wasn't caught before. Now any unrecognized error is handled gracefully and returns a clear JSON response instead of a stack trace.
Fixed
- Pyrogram
fetch_messages()andfetch_info()now catchKeyErrorfromresolve_peer/get_peer_by_username— maps toerror_type: "not_found" - Added generic
except Exceptionfallback to both functions (Telethon already had this) — maps toerror_type: "unexpected"withaction: "report_to_user"
---
[0.7.1] - 2026-02-28
The skill no longer crashes when a channel is private or you've been banned. Previously, a single channel error would break the whole request. Now the agent gets a clear JSON response with the error type and a suggested next step — remove the channel, wait, or ask you for a new invite link.
Improved
- Channel error handling: both Pyrogram and Telethon backends now catch
ChannelPrivate,ChannelBanned,ChatForbidden,ChatRestricted,UserBannedInChannel,InviteHashExpired, and more - Errors return structured JSON with
error_type(access_denied, banned, not_found, invite_expired, flood_wait) andactionfield for agent automation SKILL.md: updated Error Handling section with error_type/action reference table
---
[0.7.0] - 2026-02-28
New `tg-reader-check` command — instant diagnostics in one second. The agent runs it before reading channels and immediately sees whether credentials, session file, and libraries are all in place. If something is wrong, it gets a specific suggestion on how to fix it. No more mysterious errors on first run.
Added
tg-reader-checkcommand — offline diagnostic that verifies credentials, session files, and backend availability- Outputs structured JSON with
status,credentials,session,backends, andproblemsfields - Stale session detection: warns when config points to an older session while a newer one exists (common after re-auth)
- Shows
config_session_overrideanddefault_pathwhen config overrides the default session — helps spot mismatches - Supports
--config-fileand--session-fileflags (same as reader commands) SKILL.md: new "Pre-flight Check" section; agent should runtg-reader-checkbefore fetching_find_session_files()deduplication fix (Python 3.13+globmatches dotfiles with*)
---
[0.6.1] - 2026-02-28
The skill no longer hangs when the session file is missing. Previously, a missing file would silently trigger a Telegram re-auth prompt that the agent couldn't handle. Now you get a JSON error explaining where the file was expected, which session files were found on disk, and the exact command to fix it.
Fixed
- Session file validation:
fetchandinfocommands now check that the.sessionfile exists before connecting, instead of silently triggering a re-auth prompt - When the session file is missing, both Pyrogram and Telethon backends output a structured JSON error with: expected path, list of found
.sessionfiles in~and CWD, and a suggested--session-filefix get_config()now strips.sessionsuffix if the user passes a full filename (e.g.--session-file /path/to/foo.session), preventing Pyrogram/Telethon from looking forfoo.session.session
---
[0.6.0] - 2026-02-24
The skill now works in scheduled tasks (cron) and isolated agents. If your agent runs on a schedule or inside a sandbox without access to the home directory — just pass explicit paths to the config and session file. Everything works out of the box.
Added
--config-fileflag — pass explicit path to config JSON (overrides~/.tg-reader.json)--session-fileflag — pass explicit path to session file (overrides default session path)- Both flags work with all subcommands (
fetch,info,auth) and both backends (Pyrogram, Telethon) SKILL.md: new "Isolated Agents & Cron Jobs" section with usage examples
Fixed
- Skill now works in isolated sub-agent environments (e.g. OpenClaw cron with
sessionTarget: "isolated") where~/is not accessible
---
[0.5.0] - 2026-02-23
New `tg-reader info` command — learn everything about a channel in a second. Title, description, subscriber count, and link. Great for checking a channel before reading its posts, or building a list of channels with descriptions.
Added
tg-reader info @channel— new subcommand to fetch channel title, description, subscriber count and linkSKILL.md: documentedinfocommand in When to Use, How to Use, and Output Format sectionsSKILL.md:~/.tg-reader.jsonrecommended as primary credentials method for agent/server environments that don't load.bashrc/.zshrc
---
[0.4.3] - 2026-02-23
Fixed three bugs that could break authentication and post fetching. If tg-reader auth was giving you cryptic errors or posts wouldn't load — update to this version.
Fixed
reader.py: removedsystem_lang_codefrom PyrogramClientinit — parameter is Telethon-only and causedTypeErroron authreader.py: fixedTypeError: can't compare offset-naive and offset-aware datetimeswhen fetching messages —msg.datefrom Pyrogram is UTC-naive, now normalized before comparison withsincereader.py: removed iOS device spoofing (_DEVICE) — Telegram detects the mismatch between declared client identity and actual behaviour and terminates the session; Pyrogram's default identity is stable
---
[0.4.2] - 2026-02-23
Improved documentation for macOS and Linux. Installation instructions now cover both platforms, including Python virtual environments on Ubuntu/Debian.
Fixed
README.md: fixpython3 -m readerfallback topython3 -m tg_reader_unifiedREADME.md: add Linux venv install instructions for managed Python environments (Debian/Ubuntu)README.md: add macOS~/.zshrcforTG_USE_TELETHONalongside Linux~/.bashrcREADME.md: update PATH section to cover venv bin path, not just~/.local/binREADME.md: add note to confirm phone number withyduring Pyrogram authSKILL.md: add Linux venv install instructionsSKILL.md: add note to confirm phone number withyduring Pyrogram auth
---
[0.4.1] - 2026-02-23
Security hardened. The session file is now protected with restricted permissions, and secret keys no longer leak into logs.
Security
test_session.py: replaced partialapi_hash[:10]print with masked output (***) to prevent secret leakage in logs or shared terminalsSKILL.md: addedchmod 600step after auth to restrict session file permissions
---
[0.4.0] - 2026-02-23
The skill now integrates correctly with OpenClaw. Fixed the SKILL.md metadata format so OpenClaw can automatically detect that the skill needs Telegram credentials.
Fixed
SKILL.mdfrontmatter converted to single-line JSON as required by OpenClaw specrequires.envformat corrected to array of strings["TG_API_ID", "TG_API_HASH"]- Removed undocumented
requires.pythonfield from metadata - Removed optional env vars (
TG_SESSION,TG_USE_TELETHON) from gating filter - Added missing
primaryEnv: "TG_API_HASH"for openclaw.jsonapiKeysupport - Auth command in setup guide corrected from
python3 -m reader authtotg-reader auth - Fallback command in Error Handling corrected to
python3 -m tg_reader_unified
Added
- macOS (
~/.zshrc) credentials setup alongside Linux (~/.bashrc) in agent instructions CLAUDE.mdwith project context and documentation references for Claude Code
---
[0.3.0] - 2026-02-22
Added a second engine — Telethon. If the auth code isn't arriving via Pyrogram or you're hitting connection issues — try Telethon. One command, same result.
Added
- Telethon alternative implementation (
reader_telethon.py) - New command
tg-reader-telethonfor users experiencing Pyrogram auth issues - Comprehensive Telethon documentation (
README_TELETHON.md) - Testing guide (
TESTING_GUIDE.md) with troubleshooting steps - Session file compatibility notes
- Instructions for copying sessions between machines
Changed
- Updated
setup.pyto include both Pyrogram and Telethon versions - Added telethon>=1.24.0 to dependencies
- Enhanced README with Telethon usage section
Fixed
- Authentication code delivery issues by providing Telethon alternative
- Session management for users with existing Telethon sessions
---
[0.2.1] - 2026-02-22
One command `tg-reader` — and the skill picks the best engine automatically. No need to choose between Pyrogram and Telethon — it just works. But if you want manual control, the --telethon flag or an environment variable is at your service.
Added
- Unified entry point (
tg_reader_unified.py) for automatic selection between Pyrogram and Telethon - Support for
--telethonflag for one-time switch to Telethon - Support for
TG_USE_TELETHONenvironment variable for persistent library selection - Direct commands
tg-reader-pyrogramandtg-reader-telethonfor explicit implementation choice
Changed
tg-readercommand now uses unified entry point instead of direct Pyrogram call- Updated documentation with library selection instructions
setup.pynow includes all three entry points
Improved
- Simplified process for switching between Pyrogram and Telethon for users
- Better OpenClaw integration — single skill supports both libraries
---
[0.2.0] - 2026-02-22
Step-by-step setup guide included. Even if you've never worked with the Telegram API — the guide walks you through creating an app on my.telegram.org all the way to your first request.
Added
- Detailed Telegram API setup instructions in README
- Agent guidance in SKILL.md for missing credentials
- PATH fix instructions for tg-reader command not found
- Troubleshooting section with real-world errors
---
[0.1.0] - 2026-02-22
First release! Read Telegram channels straight from the terminal. Fetch posts from public and private channels for any time window — as JSON for automation or plain text for reading.
Initial release
- Fetch posts from Telegram channels via MTProto
- Support for multiple channels and time windows
- JSON and text output formats
- Secure credentials via env vars
Project Notes for Claude
Reference Documentation
Before answering any question about ClawHub commands, SKILL.md format, or skill configuration — fetch and read the relevant documentation page first:
- https://docs.openclaw.ai/ - OpenClaw documentation
- https://docs.openclaw.ai/tools/clawhub — ClawHub CLI commands (install, update, list, publish, etc.)
- https://docs.openclaw.ai/tools/skills — SKILL.md structure and frontmatter spec
- https://docs.openclaw.ai/tools/skills-config — skill configuration and openclaw.json
- https://docs.pyrogram.org/ — Pyrogram API reference; fetch before answering any question about Pyrogram behaviour, errors, or usage
- https://tl.telethon.dev/ — Telethon TL reference; fetch before answering any question about Telethon behaviour, errors, or usage
ClawHub CLI reference (from docs)
clawhub install <slug>
clawhub update <slug>
clawhub update --all
clawhub update --version <version> # single slug only
clawhub update --force # overwrite when local files don't match published version
clawhub list # reads .clawhub/lock.jsonKey conventions
- Language: All code comments, CHANGELOG entries, and commit messages must be in English
- CHANGELOG style: Lead with a user-friendly description (what changed and why it matters). Technical details (function names, error types, etc.) are allowed after the plain-language summary.
SKILL.mdfrontmattermetadatamust be a single-line JSON with theopenclawnamespace:
metadata: {"openclaw": {"requires": {"bins": [...], "env": [...]}, "primaryEnv": "..."}}namein SKILL.md frontmatter is the registry package ID (e.g.sergei-mikhailov-stt), not a display name- Display name is the
#heading in the body of SKILL.md
---
Project: sergei-mikhailov-tg-channel-reader
Type: OpenClaw skill (Python package published to ClawHub registry) Registry slug: sergei-mikhailov-tg-channel-reader ClawHub display name: Telegram Channel Reader (pass --name "Telegram Channel Reader" when publishing) Current version: 0.9.2 License: MIT
What it does
Reads posts from Telegram channels via MTProto (official protocol). Supports Pyrogram (default) and Telethon as interchangeable backends. Outputs JSON or plain text.
Key files
| File | Purpose |
|---|---|
SKILL.md | OpenClaw skill definition — frontmatter + agent instructions |
setup.py | Python package config, entry points, dependencies |
reader.py | Pyrogram implementation |
reader_telethon.py | Telethon implementation |
tg_reader_unified.py | Unified entry point — auto-selects backend |
tg_check.py | Offline diagnostic script (tg-reader-check) |
tg_state.py | Read-tracking state management (load/save per-channel last_read_id) |
CHANGELOG.md | Version history |
DISCLAIMER.md | Legal disclaimer |
README_TELETHON.md | Telethon-specific docs |
TESTING_GUIDE.md | Troubleshooting & test scenarios |
Entry points (from setup.py)
tg-reader → tg_reader_unified:main (auto-selects backend)
tg-reader-pyrogram → reader:main (force Pyrogram)
tg-reader-telethon → reader_telethon:main (force Telethon)
tg-reader-check → tg_check:main (offline diagnostic)Dependencies
pyrogram>=2.0.0
tgcrypto>=1.2.0
telethon>=1.24.0
python>=3.9Environment variables
| Var | Required | Notes |
|---|---|---|
TG_API_ID | Yes | Numeric ID from my.telegram.org |
TG_API_HASH | Yes | Secret — treat like a password, never commit |
TG_SESSION | No | Path to session file (default: ~/.tg-reader-session) |
TG_USE_TELETHON | No | Set to "true" to use Telethon instead of Pyrogram |
TG_READ_UNREAD | No | Set to "true" to enable read_unread mode (env overrides config) |
TG_STATE_FILE | No | Path to state file (default: ~/.tg-reader-state.json) |
.gitignore (critical — never commit these)
*.session
*.session-journal
.tg-reader.json
.envSKILL.md frontmatter note
metadata is single-line JSON as required by spec (fixed 2026-02-23).
Publishing workflow
1. Update version in setup.py 2. Update CHANGELOG.md 3. Ensure SKILL.md is valid per registry spec 4. Publish via ClawHub CLI (check docs for exact command)
Security constraints
- Never commit
TG_API_HASH,TG_API_ID, or*.sessionfiles - Session file (
~/.tg-reader-session.session) grants full Telegram account access - Credentials belong in env vars or
~/.tg-reader.json(outside the repo)
#!/usr/bin/env python3
"""
Debug auth script — verbose MTProto logging.
Run: python3 debug_auth.py
"""
import logging
import asyncio
import json
import os
from pathlib import Path
# Enable full Pyrogram debug output
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
# Silence noisy asyncio internals
logging.getLogger("asyncio").setLevel(logging.WARNING)
from pyrogram import Client
def get_config():
api_id = os.environ.get("TG_API_ID")
api_hash = os.environ.get("TG_API_HASH")
session_name = os.environ.get("TG_SESSION", str(Path.home() / ".tg-reader-session"))
if not api_id or not api_hash:
config_path = Path.home() / ".tg-reader.json"
if config_path.exists():
with open(config_path) as f:
cfg = json.load(f)
api_id = api_id or cfg.get("api_id")
api_hash = api_hash or cfg.get("api_hash")
session_name = cfg.get("session", session_name)
if not api_id or not api_hash:
print("ERROR: Set TG_API_ID and TG_API_HASH, or create ~/.tg-reader.json")
raise SystemExit(1)
print(f"api_id = {api_id}")
print(f"api_hash = {api_hash[:4]}{'*' * (len(str(api_hash)) - 4)}")
print(f"session = {session_name}")
return int(api_id), api_hash, session_name
async def main():
api_id, api_hash, session_name = get_config()
# Warn before deleting existing session files
existing = [Path(session_name + ext) for ext in (".session", ".session-journal")
if Path(session_name + ext).exists()]
if existing:
print("\n⚠️ The following session files will be DELETED for a clean re-auth:")
for p in existing:
print(f" {p}")
answer = input("\nProceed? [y/N] ").strip().lower()
if answer not in ("y", "yes"):
print("Aborted.")
return
for p in existing:
print(f"Removing: {p}")
p.unlink()
print("\n--- Connecting to Telegram ---\n")
async with Client(
session_name,
api_id=api_id,
api_hash=api_hash,
) as app:
me = await app.get_me()
print(f"\n--- Auth OK: {me.username or me.id} ---")
asyncio.run(main())
Disclaimer
No Warranty
This software is provided "as is", without warranty of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, and non-infringement. In no event shall the author be liable for any claim, damages, or other liability arising from the use of this software.
User Responsibility
By installing or using this skill, you acknowledge and agree that:
1. Telegram Terms of Service — You are solely responsible for ensuring your use complies with Telegram's Terms of Service. The author is not responsible for any account restrictions, suspensions, or bans imposed by Telegram.
2. Credential Security — You are fully responsible for securing your TG_API_ID, TG_API_HASH, and session files. These credentials grant full access to your Telegram account. The author bears no liability for any damages resulting from compromised credentials or session files.
3. Data Privacy — You are responsible for how you handle data fetched from Telegram channels. Ensure your usage complies with applicable privacy laws and regulations in your jurisdiction.
4. Agent Actions — You are responsible for all actions taken by your AI agent using this skill. The author is not liable for any unintended actions, data loss, or damages caused by agent behavior.
5. No Affiliation — This project is not affiliated with, endorsed by, or connected to Telegram Messenger Inc. in any way.
Limitation of Liability
To the maximum extent permitted by applicable law, the author shall not be liable for any indirect, incidental, special, consequential, or punitive damages, including but not limited to loss of data, loss of profits, or business interruption, arising out of or in connection with the use or inability to use this software.
Use at Your Own Risk
This software interacts with Telegram's MTProto protocol to access your account. You understand and accept that:
- MTProto session files grant complete access to your Telegram account
- Automated account usage may be restricted by Telegram at any time
- The author cannot guarantee uninterrupted or error-free operation
By using this software, you accept full responsibility for any consequences arising from its use.
MIT License
Copyright (c) 2026 Sergey A Mikhaylov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
#!/usr/bin/env python3
"""
tg-channel-reader — Telegram channel reader skill for OpenClaw
Reads posts from public/private Telegram channels via MTProto (Telethon)
"""
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
try:
from telethon import TelegramClient
from telethon.errors import (
FloodWaitError,
ChannelInvalidError,
ChannelPrivateError,
ChannelBannedError,
ChatForbiddenError,
ChatInvalidError,
ChatRestrictedError,
PeerIdInvalidError,
UsernameNotOccupiedError,
UserBannedInChannelError,
InviteHashExpiredError,
InviteHashInvalidError,
)
from telethon.tl.types import Channel
from telethon.tl.functions.channels import GetFullChannelRequest
except ImportError:
print(json.dumps({"error": "telethon not installed. Run: pip install telethon"}))
sys.exit(1)
def _channel_error(channel: str, error_type: str, message: str, action: str) -> dict:
"""Build a structured channel error dict for the agent."""
return {
"error": message,
"error_type": error_type,
"channel": channel,
"action": action,
}
# ── Session helpers ──────────────────────────────────────────────────────────
_SESSION_NAMES = [
".tg-reader-session.session",
".telethon-reader.session",
"tg-reader-session.session",
"telethon-reader.session",
]
def _find_session_files() -> list:
"""Find tg-reader session files in home directory and current working directory.
Only looks for known tg-reader session names — does not scan for
arbitrary *.session files to avoid exposing unrelated session paths.
"""
found = []
seen: set = set()
dirs_checked: set = set()
for d in [Path.home(), Path.cwd()]:
d = d.resolve()
if d in dirs_checked:
continue
dirs_checked.add(d)
for name in _SESSION_NAMES:
f = d / name
if f.exists():
resolved = f.resolve()
if resolved in seen:
continue
seen.add(resolved)
found.append(f)
found.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return found
def _validate_session(session_name: str) -> None:
"""Verify the session file exists; exit with a JSON error and hints if not.
Both Pyrogram and Telethon store sessions as ``{name}.session``.
This check prevents a silent re-auth prompt when the file is missing.
"""
session_file = Path(f"{session_name}.session")
if session_file.exists():
return
found = _find_session_files()
error: dict = {
"error": f"Session file not found: {session_file}",
"expected_path": str(session_file),
"fix": [
"Run 'tg-reader-telethon auth' to create a new session",
"Or set TG_SESSION=/path/to/existing-session (without .session suffix)",
"Or add {\"session\": \"/path/to/session\"} to ~/.tg-reader.json",
"Or pass --session-file /path/to/session (without .session suffix)",
],
}
if found:
error["found_sessions"] = [str(f) for f in found[:10]]
suggestion = str(found[0]).removesuffix(".session")
error["suggestion"] = f"Likely fix: use --session-file {suggestion}"
print(json.dumps(error, indent=2))
sys.exit(1)
# ── Config ──────────────────────────────────────────────────────────────────
def get_config(config_file=None, session_file=None):
"""Load credentials from env or config file (env takes priority).
Args:
config_file: Explicit path to config JSON (overrides ~/.tg-reader.json)
session_file: Explicit path to session file (overrides default and config value)
"""
api_id = os.environ.get("TG_API_ID")
api_hash = os.environ.get("TG_API_HASH")
session_name = os.environ.get("TG_SESSION", str(Path.home() / ".telethon-reader"))
if not api_id or not api_hash:
config_path = Path(config_file) if config_file else Path.home() / ".tg-reader.json"
if config_path.exists():
with open(config_path) as f:
cfg = json.load(f)
api_id = api_id or cfg.get("api_id")
api_hash = api_hash or cfg.get("api_hash")
session_name = cfg.get("session", session_name)
# Explicit --session-file overrides everything
if session_file:
session_name = session_file
if not api_id or not api_hash:
print(json.dumps({
"error": "Missing credentials. Set TG_API_ID and TG_API_HASH env vars, "
"or create ~/.tg-reader.json with {\"api_id\": ..., \"api_hash\": \"...\"}. "
"For isolated agents, pass --config-file /path/to/tg-reader.json"
}))
sys.exit(1)
# Normalize: strip .session suffix if user passed full filename
if session_name.endswith(".session"):
session_name = session_name[: -len(".session")]
return int(api_id), api_hash, session_name
# ── Core ─────────────────────────────────────────────────────────────────────
def parse_since(since: str) -> datetime:
"""Parse --since flag: '24h', '7d', '2026-02-01', etc."""
since = since.strip()
now = datetime.now(timezone.utc)
if since.endswith("h"):
return now - timedelta(hours=int(since[:-1]))
if since.endswith("d"):
return now - timedelta(days=int(since[:-1]))
if since.endswith("w"):
return now - timedelta(weeks=int(since[:-1]))
# Try ISO date
try:
dt = datetime.fromisoformat(since)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except ValueError:
raise ValueError(f"Cannot parse --since value: {since!r}. Use '24h', '7d', or 'YYYY-MM-DD'.")
async def _check_discussion_group(client, entity) -> bool:
"""Check whether the channel has a linked discussion group (comments)."""
try:
full = await client(GetFullChannelRequest(entity))
return full.full_chat.linked_chat_id is not None
except Exception:
return False
async def _fetch_comments(client, entity, message_id: int, comment_limit: int) -> list:
"""Fetch discussion replies (comments) for a single channel post.
Returns a list of comment dicts. Skips media-only comments (no text).
Re-raises FloodWaitError so the caller can handle retries.
"""
comments = []
try:
async for reply in client.iter_messages(entity, reply_to=message_id, limit=comment_limit):
text = reply.message or ""
if not text:
continue
from_user = None
if reply.sender:
from_user = getattr(reply.sender, "username", None) or str(reply.sender_id)
reply_date = reply.date.replace(tzinfo=timezone.utc)
comments.append({
"id": reply.id,
"date": reply_date.isoformat(),
"text": text,
"from_user": from_user,
})
except FloodWaitError:
raise # let caller handle retry
except Exception:
pass # comments unavailable for this post
return comments
async def fetch_messages(client: TelegramClient, channel: str, since: datetime, limit: int, text_only: bool,
comments: bool = False, comment_limit: int = 10, comment_delay: float = 3,
min_id: int = 0):
"""Fetch messages from a single channel."""
messages = []
try:
# Get the channel entity
entity = await client.get_entity(channel)
# Ensure it's a channel
if not isinstance(entity, Channel):
return {"error": f"'{channel}' is not a channel", "channel": channel}
# Check discussion group availability once (only when comments requested)
has_discussion = False
if comments:
has_discussion = await _check_discussion_group(client, entity)
# Fetch messages
msg_index = 0
async for msg in client.iter_messages(entity, limit=limit, min_id=min_id):
# Check if message is older than 'since'
msg_date = msg.date.replace(tzinfo=timezone.utc)
if msg_date < since:
break
# Extract message data
text = msg.message or ""
# --text-only: skip posts that have no text at all
if text_only and not text:
continue
entry = {
"id": msg.id,
"date": msg_date.isoformat(),
"text": text,
"views": msg.views or 0,
"forwards": msg.forwards or 0,
"link": f"https://t.me/{channel.lstrip('@')}/{msg.id}",
"has_media": msg.media is not None,
}
if msg.media:
entry["media_type"] = type(msg.media).__name__
# Fetch comments for this post
if comments and has_discussion:
if msg_index > 0:
await asyncio.sleep(comment_delay)
try:
post_comments = await _fetch_comments(client, entity, msg.id, comment_limit)
entry["comment_count"] = len(post_comments)
entry["comments"] = post_comments
except FloodWaitError as e:
if e.seconds <= _FLOOD_WAIT_MAX:
await asyncio.sleep(e.seconds)
try:
post_comments = await _fetch_comments(client, entity, msg.id, comment_limit)
entry["comment_count"] = len(post_comments)
entry["comments"] = post_comments
except Exception:
entry["comment_count"] = 0
entry["comments"] = []
else:
entry["comment_count"] = 0
entry["comments"] = []
entry["comments_error"] = f"Rate limited: retry after {e.seconds}s"
messages.append(entry)
msg_index += 1
except (ChannelPrivateError, ChatForbiddenError, ChatRestrictedError) as e:
return _channel_error(
channel, "access_denied",
f"Channel is private or access denied: {e}",
"remove_from_list_or_rejoin",
)
except (ChannelBannedError, UserBannedInChannelError) as e:
return _channel_error(
channel, "banned",
f"Banned from channel: {e}",
"remove_from_list",
)
except (ChannelInvalidError, ChatInvalidError, PeerIdInvalidError,
UsernameNotOccupiedError, ValueError) as e:
return _channel_error(
channel, "not_found",
f"Channel not found or username is incorrect: {e}",
"check_username",
)
except (InviteHashExpiredError, InviteHashInvalidError) as e:
return _channel_error(
channel, "invite_expired",
f"Invite link expired or invalid: {e}",
"request_new_invite",
)
except FloodWaitError as e:
return _channel_error(
channel, "flood_wait",
f"Rate limited: retry after {e.seconds}s",
f"wait_{e.seconds}s",
)
except Exception as e:
return _channel_error(
channel, "unexpected",
f"Unexpected error: {e}",
"report_to_user",
)
result = {
"channel": channel,
"fetched_at": datetime.now(timezone.utc).isoformat(),
"since": since.isoformat(),
"count": len(messages),
"messages": messages,
}
if comments:
result["comments_enabled"] = True
result["comments_available"] = has_discussion
return result
_FLOOD_WAIT_MAX = 60 # auto-retry only if wait is <= this many seconds
async def fetch_multiple(channels: list, since: datetime, limit: int, text_only: bool,
config_file=None, session_file=None, delay: float = 10,
min_ids: dict = None):
"""Fetch messages from multiple channels sequentially with delays.
Channels are fetched one at a time to avoid Telegram FloodWait.
If a FloodWait <= 60s is hit, the request is retried once automatically.
"""
api_id, api_hash, session_name = get_config(config_file, session_file)
_validate_session(session_name)
client = TelegramClient(session_name, api_id, api_hash)
await client.connect()
if not await client.is_user_authorized():
print(json.dumps({"error": "Not authorized. Please run: tg-reader-telethon auth"}))
await client.disconnect()
sys.exit(1)
results = []
try:
for i, channel in enumerate(channels):
channel_min_id = (min_ids or {}).get(channel, 0)
result = await fetch_messages(client, channel, since, limit, text_only,
min_id=channel_min_id)
# Auto-retry on FloodWait if wait is reasonable
if (isinstance(result, dict) and result.get("error_type") == "flood_wait"):
wait_action = result.get("action", "")
try:
wait_seconds = int(wait_action.replace("wait_", "").replace("s", ""))
except (ValueError, AttributeError):
wait_seconds = 0
if 0 < wait_seconds <= _FLOOD_WAIT_MAX:
await asyncio.sleep(wait_seconds)
result = await fetch_messages(client, channel, since, limit, text_only,
min_id=channel_min_id)
results.append(result)
# Delay between channels (skip after the last one)
if i < len(channels) - 1:
await asyncio.sleep(delay)
finally:
await client.disconnect()
return results
async def fetch_single(channel: str, since: datetime, limit: int, text_only: bool,
config_file=None, session_file=None,
comments: bool = False, comment_limit: int = 10, comment_delay: float = 3,
min_id: int = 0):
"""Fetch messages from a single channel."""
api_id, api_hash, session_name = get_config(config_file, session_file)
_validate_session(session_name)
client = TelegramClient(session_name, api_id, api_hash)
await client.connect()
if not await client.is_user_authorized():
print(json.dumps({"error": "Not authorized. Please run: tg-reader-telethon auth"}))
await client.disconnect()
sys.exit(1)
try:
return await fetch_messages(client, channel, since, limit, text_only,
comments=comments, comment_limit=comment_limit,
comment_delay=comment_delay, min_id=min_id)
finally:
await client.disconnect()
# ── Auth setup ───────────────────────────────────────────────────────────────
async def setup_auth(config_file=None, session_file=None):
"""Interactive first-time auth — creates session file."""
api_id, api_hash, session_name = get_config(config_file, session_file)
print(f"Starting auth for session: {session_name}.session")
print("You will receive a code in Telegram. Enter it when prompted.\n")
client = TelegramClient(session_name, api_id, api_hash)
# Use lambda to make phone input interactive
await client.start(phone=lambda: input("Enter phone number (with country code, e.g. +79991234567): "))
if await client.is_user_authorized():
me = await client.get_me()
print(f"\n✅ Authenticated as: {me.phone} ({me.first_name})")
print(json.dumps({
"status": "authenticated",
"user": me.username or str(me.id),
"phone": me.phone,
"session_file": f"{session_name}.session"
}))
else:
print(json.dumps({"error": "Authentication failed"}))
sys.exit(1)
await client.disconnect()
# ── Output helpers ────────────────────────────────────────────────────────────
def _print_text(result, since_label):
"""Print human-readable text output to stdout."""
items = result if isinstance(result, list) else [result]
for ch_result in items:
if "error" in ch_result:
print(f"[ERROR] {ch_result['channel']}: {ch_result['error']}")
continue
print(f"\n=== {ch_result['channel']} ({ch_result['count']} posts since {since_label}) ===")
for msg in ch_result["messages"]:
print(f"\n[{msg['date']}] {msg['link']}")
print(msg["text"][:500] + ("..." if len(msg["text"]) > 500 else ""))
if "comments" in msg and msg["comments"]:
print(f" [{msg['comment_count']} comments]")
for c in msg["comments"]:
user = c.get("from_user") or "anonymous"
print(f" @{user}: {c['text'][:200]}")
def _write_output(result, output_path, fmt, since_label):
"""Write output to a file and print a short confirmation to stdout."""
output_path = os.path.abspath(output_path)
with open(output_path, "w", encoding="utf-8") as f:
if fmt == "json":
json.dump(result, f, ensure_ascii=False, indent=2)
f.write("\n")
else:
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
_print_text(result, since_label)
f.write(buf.getvalue())
if isinstance(result, list):
count = sum(r.get("count", 0) for r in result if "error" not in r)
else:
count = result.get("count", 0) if "error" not in result else 0
print(json.dumps({"status": "ok", "output_file": output_path, "count": count}, ensure_ascii=False))
# ── CLI helpers ──────────────────────────────────────────────────────────────
# Common flags hallucinated by LLM agents instead of --since
_FLAG_TYPOS = {
"--hours": "--since (e.g. --since 24h)",
"--days": "--since (e.g. --since 7d)",
"--weeks": "--since (e.g. --since 2w)",
"--time": "--since (e.g. --since 24h)",
"--period": "--since (e.g. --since 24h)",
"--after": "--since (e.g. --since 24h)",
"--from": "--since (e.g. --since 24h or --since 2026-01-01)",
"--media": "--text-only (inverted: use --text-only to exclude media-only posts)",
}
def _check_flag_typos():
"""Catch common parameter hallucinations from LLM agents and exit with a helpful JSON error."""
for arg in sys.argv[1:]:
if arg in _FLAG_TYPOS:
print(json.dumps({
"error": f"Unknown flag: {arg}. Did you mean {_FLAG_TYPOS[arg]}?",
"action": "fix_command",
}))
sys.exit(1)
class _JsonArgumentParser(argparse.ArgumentParser):
"""ArgumentParser that outputs errors as JSON instead of plain text."""
def error(self, message):
# Check for flag typos in the error message
for typo, fix in _FLAG_TYPOS.items():
if typo in message:
print(json.dumps({
"error": f"Unknown flag: {typo}. Did you mean {fix}?",
"action": "fix_command",
}))
sys.exit(1)
print(json.dumps({"error": f"Invalid command: {message}", "action": "fix_command"}))
sys.exit(1)
# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
_check_flag_typos()
parser = _JsonArgumentParser(
prog="tg-reader-telethon",
description="Read Telegram channel posts for OpenClaw agent (Telethon version)"
)
# Global options (available to all subcommands)
parser.add_argument("--config-file", default=None,
help="Path to config JSON (overrides ~/.tg-reader.json)")
parser.add_argument("--session-file", default=None,
help="Path to session file (overrides default session path)")
sub = parser.add_subparsers(dest="cmd", required=True)
# fetch
fetch_p = sub.add_parser("fetch", help="Fetch posts from one or more channels")
fetch_p.add_argument("channels", nargs="+", help="Channel usernames e.g. @durov")
fetch_p.add_argument("--since", default="24h", help="Time window: 24h, 7d, 2w, or YYYY-MM-DD")
fetch_p.add_argument("--limit", type=int, default=100, help="Max posts per channel (default 100)")
fetch_p.add_argument("--text-only", action="store_true",
help="Skip posts that have no text (media-only without caption)")
fetch_p.add_argument("--delay", type=float, default=10,
help="Seconds to wait between channels (default 10)")
fetch_p.add_argument("--comments", action="store_true",
help="Fetch comments for each post (single channel only)")
fetch_p.add_argument("--comment-limit", type=int, default=10,
help="Max comments per post (default 10)")
fetch_p.add_argument("--comment-delay", type=float, default=3,
help="Seconds between comment fetches per post (default 3)")
fetch_p.add_argument("--format", choices=["json", "text"], default="json")
fetch_p.add_argument("--output", nargs="?", const="tg-output.json", default=None,
help="Write output to file instead of stdout (default: tg-output.json)")
fetch_p.add_argument("--all", action="store_true", dest="fetch_all",
help="Ignore read tracking and fetch all matching posts")
fetch_p.add_argument("--state-file", default=None,
help="Path to state file for read tracking (overrides config)")
# auth
sub.add_parser("auth", help="Authenticate with Telegram (first-time setup)")
args = parser.parse_args()
cf = args.config_file
sf = args.session_file
if args.cmd == "auth":
asyncio.run(setup_auth(cf, sf))
return
if args.cmd == "fetch":
try:
since_dt = parse_since(args.since)
except ValueError as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
# Validate --comments constraints
if args.comments:
if len(args.channels) > 1:
print(json.dumps({
"error": "--comments can only be used with a single channel",
"action": "remove_extra_channels_or_drop_comments",
}))
sys.exit(1)
# Lower default limit when fetching comments (token economy)
limit = args.limit
if args.comments and limit == 100:
limit = 30
# Read tracking (read_unread mode)
from tg_state import load_tracking_config, load_state, get_last_read_id, update_state, save_state
read_unread, state_file_path = load_tracking_config(cf)
if args.state_file:
state_file_path = args.state_file
use_tracking = read_unread and not args.fetch_all
state = None
min_id = 0
min_ids = {}
if use_tracking:
state = load_state(state_file_path)
if len(args.channels) == 1:
min_id = get_last_read_id(state, args.channels[0])
else:
min_ids = {ch: get_last_read_id(state, ch) for ch in args.channels}
# When tracking has state, --since is not needed — fetch all unread.
# On first run (no state, min_id=0), --since still applies (default 24h).
has_state = min_id > 0 or any(v > 0 for v in min_ids.values())
if has_state:
since_dt = datetime(2000, 1, 1, tzinfo=timezone.utc)
if len(args.channels) == 1:
result = asyncio.run(fetch_single(
args.channels[0], since_dt, limit, args.text_only, cf, sf,
comments=args.comments, comment_limit=args.comment_limit,
comment_delay=args.comment_delay, min_id=min_id))
else:
result = asyncio.run(fetch_multiple(args.channels, since_dt, limit, args.text_only, cf, sf,
delay=args.delay, min_ids=min_ids))
# Update tracking state after successful fetch
if use_tracking and state is not None:
if isinstance(result, list):
for ch_result in result:
if "error" not in ch_result and ch_result.get("messages"):
newest_id = max(m["id"] for m in ch_result["messages"])
update_state(state, ch_result["channel"], newest_id)
elif "error" not in result and result.get("messages"):
newest_id = max(m["id"] for m in result["messages"])
update_state(state, result["channel"], newest_id)
save_state(state, state_file_path)
# Add tracking metadata to output
if read_unread:
tracking_meta = {"enabled": True}
if args.fetch_all:
tracking_meta["overridden"] = True
if isinstance(result, list):
for ch_result in result:
if "error" not in ch_result:
ch_result["read_unread"] = tracking_meta.copy()
elif "error" not in result:
result["read_unread"] = tracking_meta
if args.output:
_write_output(result, args.output, args.format, args.since)
elif args.format == "json":
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
_print_text(result, args.since)
if __name__ == "__main__":
main()#!/usr/bin/env python3
"""
tg-channel-reader — Telegram channel reader skill for OpenClaw
Reads posts from public/private Telegram channels via MTProto (Pyrogram)
"""
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
try:
from pyrogram import Client
from pyrogram.errors import (
FloodWait,
ChannelInvalid,
ChannelPrivate,
ChannelBanned,
ChatForbidden,
ChatInvalid,
ChatRestricted,
PeerIdInvalid,
UsernameNotOccupied,
UserBannedInChannel,
InviteHashExpired,
InviteHashInvalid,
)
except ImportError:
print(json.dumps({"error": "pyrogram not installed. Run: pip install pyrogram tgcrypto"}))
sys.exit(1)
def _channel_error(channel: str, error_type: str, message: str, action: str) -> dict:
"""Build a structured channel error dict for the agent."""
return {
"error": message,
"error_type": error_type,
"channel": channel,
"action": action,
}
# Use Pyrogram's default device identity (Python MTProto client).
# Spoofing a mobile client causes Telegram to terminate sessions — the
# behaviour doesn't match and it's detected server-side.
_DEVICE: dict = {}
# ── Session helpers ──────────────────────────────────────────────────────────
_SESSION_NAMES = [
".tg-reader-session.session",
".telethon-reader.session",
"tg-reader-session.session",
"telethon-reader.session",
]
def _find_session_files() -> list:
"""Find tg-reader session files in home directory and current working directory.
Only looks for known tg-reader session names — does not scan for
arbitrary *.session files to avoid exposing unrelated session paths.
"""
found = []
seen: set = set()
dirs_checked: set = set()
for d in [Path.home(), Path.cwd()]:
d = d.resolve()
if d in dirs_checked:
continue
dirs_checked.add(d)
for name in _SESSION_NAMES:
f = d / name
if f.exists():
resolved = f.resolve()
if resolved in seen:
continue
seen.add(resolved)
found.append(f)
found.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return found
def _validate_session(session_name: str) -> None:
"""Verify the session file exists; exit with a JSON error and hints if not.
Both Pyrogram and Telethon store sessions as ``{name}.session``.
This check prevents a silent re-auth prompt when the file is missing.
"""
session_file = Path(f"{session_name}.session")
if session_file.exists():
return
found = _find_session_files()
error: dict = {
"error": f"Session file not found: {session_file}",
"expected_path": str(session_file),
"fix": [
"Run 'tg-reader auth' to create a new session",
"Or set TG_SESSION=/path/to/existing-session (without .session suffix)",
"Or add {\"session\": \"/path/to/session\"} to ~/.tg-reader.json",
"Or pass --session-file /path/to/session (without .session suffix)",
],
}
if found:
error["found_sessions"] = [str(f) for f in found[:10]]
suggestion = str(found[0]).removesuffix(".session")
error["suggestion"] = f"Likely fix: use --session-file {suggestion}"
print(json.dumps(error, indent=2))
sys.exit(1)
# ── Config ──────────────────────────────────────────────────────────────────
def get_config(config_file=None, session_file=None):
"""Load credentials from env or config file (env takes priority).
Args:
config_file: Explicit path to config JSON (overrides ~/.tg-reader.json)
session_file: Explicit path to session file (overrides default and config value)
"""
api_id = os.environ.get("TG_API_ID")
api_hash = os.environ.get("TG_API_HASH")
session_name = os.environ.get("TG_SESSION", str(Path.home() / ".tg-reader-session"))
if not api_id or not api_hash:
config_path = Path(config_file) if config_file else Path.home() / ".tg-reader.json"
if config_path.exists():
with open(config_path) as f:
cfg = json.load(f)
api_id = api_id or cfg.get("api_id")
api_hash = api_hash or cfg.get("api_hash")
session_name = cfg.get("session", session_name)
# Explicit --session-file overrides everything
if session_file:
session_name = session_file
if not api_id or not api_hash:
print(json.dumps({
"error": "Missing credentials. Set TG_API_ID and TG_API_HASH env vars, "
"or create ~/.tg-reader.json with {\"api_id\": ..., \"api_hash\": \"...\"}. "
"For isolated agents, pass --config-file /path/to/tg-reader.json"
}))
sys.exit(1)
# Normalize: strip .session suffix if user passed full filename
if session_name.endswith(".session"):
session_name = session_name[: -len(".session")]
return int(api_id), api_hash, session_name
# ── Core ─────────────────────────────────────────────────────────────────────
def parse_since(since: str) -> datetime:
"""Parse --since flag: '24h', '7d', '2026-02-01', etc."""
since = since.strip()
now = datetime.now(timezone.utc)
if since.endswith("h"):
return now - timedelta(hours=int(since[:-1]))
if since.endswith("d"):
return now - timedelta(days=int(since[:-1]))
if since.endswith("w"):
return now - timedelta(weeks=int(since[:-1]))
# Try ISO date
try:
dt = datetime.fromisoformat(since)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except ValueError:
raise ValueError(f"Cannot parse --since value: {since!r}. Use '24h', '7d', or 'YYYY-MM-DD'.")
async def _check_discussion_group(app, channel: str) -> bool:
"""Check whether the channel has a linked discussion group (comments)."""
try:
chat = await app.get_chat(channel)
return chat.linked_chat is not None
except Exception:
return False
async def _fetch_comments(app, channel: str, message_id: int, comment_limit: int) -> list:
"""Fetch discussion replies (comments) for a single channel post.
Returns a list of comment dicts. Skips media-only comments (no text).
Re-raises FloodWait so the caller can handle retries.
"""
comments = []
try:
async for reply in app.get_discussion_replies(channel, message_id, limit=comment_limit):
text = ""
if reply.text:
text = reply.text
elif reply.caption:
text = reply.caption
if not text:
continue
from_user = None
if reply.from_user:
from_user = reply.from_user.username or str(reply.from_user.id)
reply_date = reply.date if reply.date.tzinfo else reply.date.replace(tzinfo=timezone.utc)
comments.append({
"id": reply.id,
"date": reply_date.isoformat(),
"text": text,
"from_user": from_user,
})
except FloodWait:
raise # let caller handle retry
except Exception:
pass # comments unavailable for this post
return comments
async def _fetch_channel(app, channel: str, since: datetime, limit: int, text_only: bool,
comments: bool = False, comment_limit: int = 10, comment_delay: float = 3,
min_id: int = 0):
"""Fetch messages from a single channel using an existing Client session."""
# Check discussion group availability once (only when comments requested)
has_discussion = False
if comments:
has_discussion = await _check_discussion_group(app, channel)
messages = []
try:
msg_index = 0
async for msg in app.get_chat_history(channel, limit=limit):
msg_date = msg.date if msg.date.tzinfo else msg.date.replace(tzinfo=timezone.utc)
if msg_date < since:
break
# Break if we've reached already-read messages
if min_id and msg.id <= min_id:
break
# Pyrogram: text for plain messages, caption for media messages
text = ""
if msg.text:
text = msg.text
elif msg.caption:
text = msg.caption
# --text-only: skip posts that have no text at all
if text_only and not text:
continue
entry = {
"id": msg.id,
"date": msg_date.isoformat(),
"text": text,
"views": msg.views,
"forwards": msg.forwards,
"link": f"https://t.me/{channel.lstrip('@')}/{msg.id}",
"has_media": msg.media is not None,
}
if msg.media:
entry["media_type"] = str(msg.media)
# Fetch comments for this post
if comments and has_discussion:
if msg_index > 0:
await asyncio.sleep(comment_delay)
try:
post_comments = await _fetch_comments(app, channel, msg.id, comment_limit)
entry["comment_count"] = len(post_comments)
entry["comments"] = post_comments
except FloodWait as e:
if e.value <= _FLOOD_WAIT_MAX:
await asyncio.sleep(e.value)
try:
post_comments = await _fetch_comments(app, channel, msg.id, comment_limit)
entry["comment_count"] = len(post_comments)
entry["comments"] = post_comments
except Exception:
entry["comment_count"] = 0
entry["comments"] = []
else:
entry["comment_count"] = 0
entry["comments"] = []
entry["comments_error"] = f"Rate limited: retry after {e.value}s"
messages.append(entry)
msg_index += 1
except (ChannelPrivate, ChatForbidden, ChatRestricted) as e:
return _channel_error(
channel, "access_denied",
f"Channel is private or access denied: {e}",
"remove_from_list_or_rejoin",
)
except (ChannelBanned, UserBannedInChannel) as e:
return _channel_error(
channel, "banned",
f"Banned from channel: {e}",
"remove_from_list",
)
except (ChannelInvalid, ChatInvalid, PeerIdInvalid, UsernameNotOccupied) as e:
return _channel_error(
channel, "not_found",
f"Channel not found or username is incorrect: {e}",
"check_username",
)
except KeyError as e:
# Pyrogram raises KeyError from resolve_peer / get_peer_by_username
# when the username doesn't exist in Telegram's database
return _channel_error(
channel, "not_found",
f"Username not found: {e}",
"check_username",
)
except (InviteHashExpired, InviteHashInvalid) as e:
return _channel_error(
channel, "invite_expired",
f"Invite link expired or invalid: {e}",
"request_new_invite",
)
except FloodWait as e:
return _channel_error(
channel, "flood_wait",
f"Rate limited: retry after {e.value}s",
f"wait_{e.value}s",
)
except Exception as e:
return _channel_error(
channel, "unexpected",
f"Unexpected error: {e}",
"report_to_user",
)
result = {
"channel": channel,
"fetched_at": datetime.now(timezone.utc).isoformat(),
"since": since.isoformat(),
"count": len(messages),
"messages": messages,
}
if comments:
result["comments_enabled"] = True
result["comments_available"] = has_discussion
return result
_FLOOD_WAIT_MAX = 60 # auto-retry only if wait is <= this many seconds
async def fetch_messages(channel: str, since: datetime, limit: int, text_only: bool,
config_file=None, session_file=None,
comments: bool = False, comment_limit: int = 10, comment_delay: float = 3,
min_id: int = 0):
api_id, api_hash, session_name = get_config(config_file, session_file)
_validate_session(session_name)
async with Client(session_name, api_id=api_id, api_hash=api_hash, **_DEVICE) as app:
return await _fetch_channel(app, channel, since, limit, text_only,
comments=comments, comment_limit=comment_limit,
comment_delay=comment_delay, min_id=min_id)
async def fetch_multiple(channels: list, since: datetime, limit: int, text_only: bool,
config_file=None, session_file=None, delay: float = 10,
min_ids: dict = None):
"""Fetch messages from multiple channels sequentially with delays.
Channels are fetched one at a time to avoid Telegram FloodWait.
If a FloodWait <= 60s is hit, the request is retried once automatically.
"""
api_id, api_hash, session_name = get_config(config_file, session_file)
_validate_session(session_name)
results = []
async with Client(session_name, api_id=api_id, api_hash=api_hash, **_DEVICE) as app:
for i, channel in enumerate(channels):
channel_min_id = (min_ids or {}).get(channel, 0)
result = await _fetch_channel(app, channel, since, limit, text_only,
min_id=channel_min_id)
# Auto-retry on FloodWait if wait is reasonable
if (isinstance(result, dict) and result.get("error_type") == "flood_wait"):
wait_action = result.get("action", "")
try:
wait_seconds = int(wait_action.replace("wait_", "").replace("s", ""))
except (ValueError, AttributeError):
wait_seconds = 0
if 0 < wait_seconds <= _FLOOD_WAIT_MAX:
await asyncio.sleep(wait_seconds)
result = await _fetch_channel(app, channel, since, limit, text_only,
min_id=channel_min_id)
results.append(result)
# Delay between channels (skip after the last one)
if i < len(channels) - 1:
await asyncio.sleep(delay)
return results
# ── Channel info ─────────────────────────────────────────────────────────────
async def fetch_info(channel: str, config_file=None, session_file=None):
api_id, api_hash, session_name = get_config(config_file, session_file)
_validate_session(session_name)
async with Client(session_name, api_id=api_id, api_hash=api_hash) as app:
try:
chat = await app.get_chat(channel)
return {
"id": chat.id,
"title": chat.title,
"username": chat.username,
"description": chat.description,
"members_count": chat.members_count,
"link": f"https://t.me/{chat.username}" if chat.username else None,
}
except (ChannelPrivate, ChatForbidden, ChatRestricted) as e:
return _channel_error(
channel, "access_denied",
f"Channel is private or access denied: {e}",
"remove_from_list_or_rejoin",
)
except (ChannelBanned, UserBannedInChannel) as e:
return _channel_error(
channel, "banned",
f"Banned from channel: {e}",
"remove_from_list",
)
except (ChannelInvalid, ChatInvalid, PeerIdInvalid, UsernameNotOccupied) as e:
return _channel_error(
channel, "not_found",
f"Channel not found or username is incorrect: {e}",
"check_username",
)
except KeyError as e:
return _channel_error(
channel, "not_found",
f"Username not found: {e}",
"check_username",
)
except Exception as e:
return _channel_error(
channel, "unexpected",
f"Unexpected error: {e}",
"report_to_user",
)
# ── Auth setup ───────────────────────────────────────────────────────────────
async def setup_auth(config_file=None, session_file=None):
"""Interactive first-time auth — creates session file."""
api_id, api_hash, session_name = get_config(config_file, session_file)
print(f"Starting auth for session: {session_name}")
print("You will receive a code in Telegram. Enter it when prompted.")
async with Client(session_name, api_id=api_id, api_hash=api_hash, **_DEVICE) as app:
me = await app.get_me()
print(json.dumps({"status": "authenticated", "user": me.username or str(me.id)}))
# ── Output helpers ────────────────────────────────────────────────────────────
def _print_text(result, since_label):
"""Print human-readable text output to stdout."""
items = result if isinstance(result, list) else [result]
for ch_result in items:
if "error" in ch_result:
print(f"[ERROR] {ch_result['channel']}: {ch_result['error']}")
continue
print(f"\n=== {ch_result['channel']} ({ch_result['count']} posts since {since_label}) ===")
for msg in ch_result["messages"]:
print(f"\n[{msg['date']}] {msg['link']}")
print(msg["text"][:500] + ("..." if len(msg["text"]) > 500 else ""))
if "comments" in msg and msg["comments"]:
print(f" [{msg['comment_count']} comments]")
for c in msg["comments"]:
user = c.get("from_user") or "anonymous"
print(f" @{user}: {c['text'][:200]}")
def _write_output(result, output_path, fmt, since_label):
"""Write output to a file and print a short confirmation to stdout."""
output_path = os.path.abspath(output_path)
with open(output_path, "w", encoding="utf-8") as f:
if fmt == "json":
json.dump(result, f, ensure_ascii=False, indent=2)
f.write("\n")
else:
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
_print_text(result, since_label)
f.write(buf.getvalue())
if isinstance(result, list):
count = sum(r.get("count", 0) for r in result if "error" not in r)
else:
count = result.get("count", 0) if "error" not in result else 0
print(json.dumps({"status": "ok", "output_file": output_path, "count": count}, ensure_ascii=False))
# ── CLI helpers ──────────────────────────────────────────────────────────────
# Common flags hallucinated by LLM agents instead of --since
_FLAG_TYPOS = {
"--hours": "--since (e.g. --since 24h)",
"--days": "--since (e.g. --since 7d)",
"--weeks": "--since (e.g. --since 2w)",
"--time": "--since (e.g. --since 24h)",
"--period": "--since (e.g. --since 24h)",
"--after": "--since (e.g. --since 24h)",
"--from": "--since (e.g. --since 24h or --since 2026-01-01)",
"--media": "--text-only (inverted: use --text-only to exclude media-only posts)",
}
def _check_flag_typos():
"""Catch common parameter hallucinations from LLM agents and exit with a helpful JSON error."""
for arg in sys.argv[1:]:
if arg in _FLAG_TYPOS:
print(json.dumps({
"error": f"Unknown flag: {arg}. Did you mean {_FLAG_TYPOS[arg]}?",
"action": "fix_command",
}))
sys.exit(1)
class _JsonArgumentParser(argparse.ArgumentParser):
"""ArgumentParser that outputs errors as JSON instead of plain text."""
def error(self, message):
# Check for flag typos in the error message
for typo, fix in _FLAG_TYPOS.items():
if typo in message:
print(json.dumps({
"error": f"Unknown flag: {typo}. Did you mean {fix}?",
"action": "fix_command",
}))
sys.exit(1)
print(json.dumps({"error": f"Invalid command: {message}", "action": "fix_command"}))
sys.exit(1)
# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
_check_flag_typos()
parser = _JsonArgumentParser(
prog="tg-reader",
description="Read Telegram channel posts for OpenClaw agent"
)
# Global options (available to all subcommands)
parser.add_argument("--config-file", default=None,
help="Path to config JSON (overrides ~/.tg-reader.json)")
parser.add_argument("--session-file", default=None,
help="Path to session file (overrides default session path)")
sub = parser.add_subparsers(dest="cmd", required=True)
# fetch
fetch_p = sub.add_parser("fetch", help="Fetch posts from one or more channels")
fetch_p.add_argument("channels", nargs="+", help="Channel usernames e.g. @durov")
fetch_p.add_argument("--since", default="24h", help="Time window: 24h, 7d, 2w, or YYYY-MM-DD")
fetch_p.add_argument("--limit", type=int, default=100, help="Max posts per channel (default 100)")
fetch_p.add_argument("--text-only", action="store_true",
help="Skip posts that have no text (media-only without caption)")
fetch_p.add_argument("--delay", type=float, default=10,
help="Seconds to wait between channels (default 10)")
fetch_p.add_argument("--comments", action="store_true",
help="Fetch comments for each post (single channel only)")
fetch_p.add_argument("--comment-limit", type=int, default=10,
help="Max comments per post (default 10)")
fetch_p.add_argument("--comment-delay", type=float, default=3,
help="Seconds between comment fetches per post (default 3)")
fetch_p.add_argument("--format", choices=["json", "text"], default="json")
fetch_p.add_argument("--output", nargs="?", const="tg-output.json", default=None,
help="Write output to file instead of stdout (default: tg-output.json)")
fetch_p.add_argument("--all", action="store_true", dest="fetch_all",
help="Ignore read tracking and fetch all matching posts")
fetch_p.add_argument("--state-file", default=None,
help="Path to state file for read tracking (overrides config)")
# info
info_p = sub.add_parser("info", help="Get channel title, description and subscriber count")
info_p.add_argument("channel", help="Channel username e.g. @durov")
# auth
sub.add_parser("auth", help="Authenticate with Telegram (first-time setup)")
args = parser.parse_args()
cf = args.config_file
sf = args.session_file
if args.cmd == "info":
result = asyncio.run(fetch_info(args.channel, cf, sf))
print(json.dumps(result, ensure_ascii=False, indent=2))
return
if args.cmd == "auth":
asyncio.run(setup_auth(cf, sf))
return
if args.cmd == "fetch":
try:
since_dt = parse_since(args.since)
except ValueError as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
# Validate --comments constraints
if args.comments:
if len(args.channels) > 1:
print(json.dumps({
"error": "--comments can only be used with a single channel",
"action": "remove_extra_channels_or_drop_comments",
}))
sys.exit(1)
# Lower default limit when fetching comments (token economy)
limit = args.limit
if args.comments and limit == 100:
limit = 30
# Read tracking (read_unread mode)
from tg_state import load_tracking_config, load_state, get_last_read_id, update_state, save_state
read_unread, state_file_path = load_tracking_config(cf)
if args.state_file:
state_file_path = args.state_file
use_tracking = read_unread and not args.fetch_all
state = None
min_id = 0
min_ids = {}
if use_tracking:
state = load_state(state_file_path)
if len(args.channels) == 1:
min_id = get_last_read_id(state, args.channels[0])
else:
min_ids = {ch: get_last_read_id(state, ch) for ch in args.channels}
# When tracking has state, --since is not needed — fetch all unread.
# On first run (no state, min_id=0), --since still applies (default 24h).
has_state = min_id > 0 or any(v > 0 for v in min_ids.values())
if has_state:
since_dt = datetime(2000, 1, 1, tzinfo=timezone.utc)
if len(args.channels) == 1:
result = asyncio.run(fetch_messages(
args.channels[0], since_dt, limit, args.text_only, cf, sf,
comments=args.comments, comment_limit=args.comment_limit,
comment_delay=args.comment_delay, min_id=min_id))
else:
result = asyncio.run(fetch_multiple(args.channels, since_dt, limit, args.text_only, cf, sf,
delay=args.delay, min_ids=min_ids))
# Update tracking state after successful fetch
if use_tracking and state is not None:
if isinstance(result, list):
for ch_result in result:
if "error" not in ch_result and ch_result.get("messages"):
newest_id = max(m["id"] for m in ch_result["messages"])
update_state(state, ch_result["channel"], newest_id)
elif "error" not in result and result.get("messages"):
newest_id = max(m["id"] for m in result["messages"])
update_state(state, result["channel"], newest_id)
save_state(state, state_file_path)
# Add tracking metadata to output
if read_unread:
tracking_meta = {"enabled": True}
if args.fetch_all:
tracking_meta["overridden"] = True
if isinstance(result, list):
for ch_result in result:
if "error" not in ch_result:
ch_result["read_unread"] = tracking_meta.copy()
elif "error" not in result:
result["read_unread"] = tracking_meta
if args.output:
_write_output(result, args.output, args.format, args.since)
elif args.format == "json":
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
_print_text(result, args.since)
if __name__ == "__main__":
main()
📡 Telethon Version Guide
Alternative implementation using Telethon instead of Pyrogram
Why Telethon?
If you're experiencing issues with Pyrogram (e.g., authentication codes not arriving), you can use the Telethon version instead. Telethon is a mature, stable library with excellent Telegram API support.
Installation
The Telethon version is included in the same package:
pip3 install -e .This installs both commands:
tg-reader(Pyrogram version)tg-reader-telethon(Telethon version)
Setup
Step 1 — Use the same API credentials
You can use the same TG_API_ID and TG_API_HASH from my.telegram.org:
export TG_API_ID=12345678
export TG_API_HASH=your_api_hash_hereStep 2 — Authenticate with Telethon
tg-reader-telethon authThis will: 1. Ask for your phone number (e.g., +79991234567) 2. Send a code to your Telegram app 3. Create a session file at ~/.telethon-reader.session
Note: Telethon session files are different from Pyrogram sessions, so you'll need to authenticate separately.
Step 3 — Start reading channels
# Last 24 hours from a channel
tg-reader-telethon fetch @durov --since 24h
# Last week, multiple channels
tg-reader-telethon fetch @channel1 @channel2 --since 7d --limit 200
# Human-readable format
tg-reader-telethon fetch @channel_name --since 24h --format textUsage Examples
Fetch from a single channel
tg-reader-telethon fetch @durov --since 24hFetch from multiple channels
tg-reader-telethon fetch @durov @telegram --since 7d --limit 50Get posts from a specific date
tg-reader-telethon fetch @channel --since 2026-02-01Include media information
tg-reader-telethon fetch @channel --since 24h --mediaHuman-readable output
tg-reader-telethon fetch @channel --since 24h --format textDirect Python Usage
You can also run the script directly:
python3 -m reader_telethon auth
python3 -m reader_telethon fetch @durov --since 24hDifferences from Pyrogram Version
| Feature | Pyrogram | Telethon |
|---|---|---|
| Session file | ~/.tg-reader-session | ~/.telethon-reader.session |
| Command | tg-reader | tg-reader-telethon |
| Library | pyrogram + tgcrypto | telethon |
| Maturity | Modern, async-first | Mature, battle-tested |
Troubleshooting
Session file not found
- Make sure you ran
tg-reader-telethon authfirst - Check that
~/.telethon-reader.sessionexists
Authentication fails
- Verify your API credentials are correct
- Try creating a new application on my.telegram.org
- Wait 10-15 minutes if you've made too many auth attempts
Channel not found
- Ensure the channel username is correct (with @)
- For private channels, make sure you're subscribed
- Try accessing the channel in your Telegram app first
OpenClaw Integration
Once authenticated, OpenClaw can use either version:
# Use Pyrogram version (default)
tg-reader fetch @channel --since 24h
# Use Telethon version (alternative)
tg-reader-telethon fetch @channel --since 24hYou can configure which version to use in your OpenClaw skill settings.
License
MIT — same as the main project
sergei-mikhailov-tg-channel-reader
OpenClaw skill for reading Telegram channels via MTProto (Pyrogram or Telethon)
An OpenClaw skill that lets your AI agent fetch and summarize posts from any Telegram channel — public or private (if you're subscribed).
Features
- Fetch posts from one or multiple channels in one command
- Flexible time windows:
24h,7d,2w, or specific date - JSON output with views, forwards, and direct links
- Secure credential storage via env vars or config file
- Works with any public channel — no bot admin required
Why Use This Skill Instead of Web Monitoring?
OpenClaw can monitor Telegram channels via web scraping, but this skill uses MTProto — the same official protocol used by the Telegram app itself. Here's why it matters:
| Web monitoring | This skill (MTProto) | |
|---|---|---|
| Reliability | Breaks when Telegram updates its web UI | Always works — official protocol |
| Speed | Slow (browser rendering) | Fast — direct API calls |
| Private channels | Public only | Any channel you're subscribed to |
| Data richness | Text only | Views, forwards, links, dates |
| Rate limits | Frequent blocks & captchas | Soft limits, sufficient for personal use |
| Agent integration | Requires extra parsing | Clean JSON, ready for agent to analyze |
Bottom line: if you follow Telegram channels regularly and want your agent to summarize them, this skill is faster, more reliable, and gives you richer data than web monitoring.
Install via ClawHub
npx clawhub@latest install sergei-mikhailov-tg-channel-readerThen install Python dependencies:
cd ~/.openclaw/workspace/skills/sergei-mikhailov-tg-channel-reader
pip install pyrogram tgcrypto telethon
pip install -e .Linux users: if you get externally-managed-environment error, use a virtual environment:```bash
python3 -m venv ~/.venv/tg-reader
~/.venv/tg-reader/bin/pip install pyrogram tgcrypto telethon
~/.venv/tg-reader/bin/pip install -e .
echo 'export PATH="$HOME/.venv/tg-reader/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
```
Manual Install
cd ~/.openclaw/workspace/skills
git clone https://github.com/bzSega/sergei-mikhailov-tg-channel-reader
cd sergei-mikhailov-tg-channel-reader
pip install pyrogram tgcrypto telethon
pip install -e .Setup
Step 1 — Get Telegram API credentials
You need a personal Telegram API key. This is free and takes 2 minutes.
1. Open https://my.telegram.org in your browser 2. Enter your phone number (with country code, e.g. +79991234567) and click Send Code 3. Enter the confirmation code you receive in Telegram 4. Click "API Development Tools" 5. Fill in the form:
- App title: any name (e.g.
MyReader) - Short name: any short word (e.g.
myreader) - Other fields can be left as default
6. Click "Create application" 7. You'll see your credentials:
- App api_id — a number like
12345678 - App api_hash — a 32-character string like
a1b2c3d4e5f6789012345678abcdef12
Keep these credentials private. Never share or commit them to git.
Step 2 — Set credentials securely
Choose the method that fits your setup. Avoid writing TG_API_HASH to shell profiles (~/.bashrc) — it ends up in backups, shell history, and is visible to other users on shared machines.
Option A: `~/.tg-reader.json` (recommended)
cat > ~/.tg-reader.json << 'EOF'
{
"api_id": 12345678,
"api_hash": "your_api_hash_here"
}
EOF
chmod 600 ~/.tg-reader.jsonWorks everywhere — agents, servers, interactive shells. File is outside the project and never committed.
Option B: `direnv` (recommended for developers)
# Install direnv, then create .envrc in your working directory
echo 'export TG_API_ID=12345678' >> .envrc
echo 'export TG_API_HASH=your_api_hash_here' >> .envrc
echo '.envrc' >> .gitignore
direnv allowOption C: System keychain (most secure)
# Linux (secret-tool)
secret-tool store --label="TG API" service tg-reader username api
# Then retrieve at runtime: secret-tool lookup service tg-reader username apiOption D: Environment variables (interactive shell only)
export TG_API_ID=12345678
export TG_API_HASH="your_api_hash_here"Set in your current shell session. For persistent storage, use Option A instead.
Avoid storing TG_API_HASH in files that are backed up to the cloud or shared between users.Step 3 — Authenticate once
tg-reader authYou'll be asked for your phone number. Enter it with country code (e.g. +79991234567) and confirm with y when prompted. You'll then receive a confirmation code in your Telegram app — look for a message from the official "Telegram" service chat (not SMS).
If the code doesn't arrive — check all devices where Telegram is open (phone, desktop, web).
Authentication creates a session file at ~/.tg-reader-session.session. You only need to do this once.
Step 4 — Choose your library (optional)
By default, tg-reader uses Pyrogram. You can switch to Telethon if needed:
| Method | Command |
|---|---|
| One-time flag | tg-reader fetch @durov --since 24h --telethon |
| Persistent env var | export TG_USE_TELETHON=true (or add "use_telethon": true to ~/.tg-reader.json) |
| Direct command | tg-reader-pyrogram or tg-reader-telethon |
Both implementations use the same API credentials and provide identical functionality. Telethon uses a separate session file (~/.telethon-reader.session).
Step 5 — Start reading
# Last 24 hours from a channel
tg-reader fetch @durov --since 24h
# Last week, multiple channels
tg-reader fetch @channel1 @channel2 --since 7d --limit 200
# Human-readable format
tg-reader fetch @channel_name --since 24h --format textUsage with OpenClaw
Once installed and authenticated, just ask your agent:
"Summarize the last 24 hours from @durov"
"What's new in @hacker_news_feed this week?"
"Check all my tracked channels and give me a digest"
The agent will automatically use tg-reader and summarize the results.
Output Example
{
"channel": "@durov",
"fetched_at": "2026-02-22T10:00:00Z",
"count": 3,
"messages": [
{
"id": 735,
"date": "2026-02-22T08:15:00Z",
"text": "Post content here...",
"views": 120000,
"forwards": 4200,
"link": "https://t.me/durov/735"
}
]
}Troubleshooting
`tg-reader: command not found`
If installed via venv, make sure the venv bin is in your PATH:
echo 'export PATH="$HOME/.venv/tg-reader/bin:$PATH"' >> ~/.bashrc
source ~/.bashrcOtherwise add ~/.local/bin:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrcOr run directly with Python:
python3 -m tg_reader_unified auth
python3 -m tg_reader_unified fetch @channel --since 24hConfirmation code not arriving
- Check all your Telegram devices — the code goes to the Telegram app, not SMS
- Look for a message from the official "Telegram" service chat
- If you hit a rate limit on my.telegram.org, wait a few hours and try again
`ChannelInvalid` error
- For public channels: double-check the username spelling
- For private channels: make sure you're subscribed with the authenticated account
`FloodWait` error
- Telegram is rate-limiting requests
- The error shows how many seconds to wait — just retry after that
Security
This skill uses MTProto — the same protocol as the official Telegram app. This means:
- `TG_API_HASH` is a secret — treat it like a password. Never commit it to git, never share it.
- Session file = full account access —
~/.tg-reader-session.sessiongrants complete access to your Telegram account. Keep it on your machine only. - Never copy session files between machines or share them with anyone.
- Your agent can read private channels you're subscribed to — this is by design, but be aware of it.
What the skill does NOT do:
- Does not send messages on your behalf
- Does not modify or delete anything
- Does not share your data with third parties
Best practices:
- Store credentials in env vars or
~/.tg-reader.json(outside the project), not in files tracked by git - Add
*.sessionand.tg-reader.jsonto.gitignore - Revoke your API app on my.telegram.org if credentials are compromised
Legal
By using this skill you agree to the terms in DISCLAIMER.md.
License
MIT — made by @bzSega
#!/usr/bin/env bash
# setup-tg-reader.sh — Pre-flight setup for tg-channel-reader OpenClaw skill
# Checks prerequisites and guides through exec approval configuration.
#
# Usage:
# bash setup-tg-reader.sh
#
# What it does:
# 1. Verifies Python 3.9+ is available
# 2. Checks if tg-reader CLI is installed and in PATH
# 3. Installs Python package if needed (pip install .)
# 4. Verifies Telegram credentials (env vars or ~/.tg-reader.json)
# 5. Verifies session file exists
# 6. Runs tg-reader-check diagnostic
# 7. Prints exec approval instructions for OpenClaw (manual step)
set -euo pipefail
# ── Colors ───────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
ok() { echo -e "${GREEN}✓${NC} $1"; }
warn() { echo -e "${YELLOW}⚠${NC} $1"; }
fail() { echo -e "${RED}✗${NC} $1"; }
info() { echo -e "${CYAN}→${NC} $1"; }
ERRORS=0
echo ""
echo "══════════════════════════════════════════════════════"
echo " tg-channel-reader — Setup & Diagnostics"
echo "══════════════════════════════════════════════════════"
echo ""
# ── Step 1: Python ───────────────────────────────────────────────────────────
echo "── Python ──"
if command -v python3 &>/dev/null; then
PY_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
PY_MAJOR=$(python3 -c "import sys; print(sys.version_info.major)")
PY_MINOR=$(python3 -c "import sys; print(sys.version_info.minor)")
if [ "$PY_MAJOR" -ge 3 ] && [ "$PY_MINOR" -ge 9 ]; then
ok "Python $PY_VERSION"
else
fail "Python $PY_VERSION (need 3.9+)"
ERRORS=$((ERRORS + 1))
fi
else
fail "python3 not found"
ERRORS=$((ERRORS + 1))
fi
echo ""
# ── Step 2: tg-reader CLI ───────────────────────────────────────────────────
echo "── CLI Commands ──"
NEED_INSTALL=0
for cmd in tg-reader tg-reader-check; do
if command -v "$cmd" &>/dev/null; then
ok "$cmd → $(which "$cmd")"
else
fail "$cmd not found in PATH"
NEED_INSTALL=1
fi
done
# Optional backends
for cmd in tg-reader-pyrogram tg-reader-telethon; do
if command -v "$cmd" &>/dev/null; then
ok "$cmd → $(which "$cmd")"
else
warn "$cmd not found (optional)"
fi
done
if [ "$NEED_INSTALL" -eq 1 ]; then
echo ""
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$SCRIPT_DIR/setup.py" ]; then
info "Installing from $SCRIPT_DIR ..."
pip install "$SCRIPT_DIR" 2>&1 | tail -1
# Re-check
if command -v tg-reader &>/dev/null; then
ok "tg-reader installed successfully"
else
fail "tg-reader still not in PATH after install"
info "Try: pip install . && hash -r"
info "Or add the pip bin directory to PATH"
ERRORS=$((ERRORS + 1))
fi
else
fail "setup.py not found — run this script from the skill directory"
ERRORS=$((ERRORS + 1))
fi
fi
echo ""
# ── Step 3: Python libraries ────────────────────────────────────────────────
echo "── MTProto Libraries ──"
PYROGRAM_OK=0
TELETHON_OK=0
if python3 -c "import pyrogram" 2>/dev/null; then
PYRO_VER=$(python3 -c "import pyrogram; print(pyrogram.__version__)" 2>/dev/null || echo "unknown")
ok "Pyrogram $PYRO_VER"
PYROGRAM_OK=1
else
warn "Pyrogram not installed (pip install pyrogram tgcrypto)"
fi
if python3 -c "import telethon" 2>/dev/null; then
TEL_VER=$(python3 -c "import telethon; print(telethon.__version__)" 2>/dev/null || echo "unknown")
ok "Telethon $TEL_VER"
TELETHON_OK=1
else
warn "Telethon not installed (pip install telethon)"
fi
if [ "$PYROGRAM_OK" -eq 0 ] && [ "$TELETHON_OK" -eq 0 ]; then
fail "No MTProto backend installed — at least one is required"
info "Run: pip install pyrogram tgcrypto telethon"
ERRORS=$((ERRORS + 1))
fi
echo ""
# ── Step 4: Credentials ─────────────────────────────────────────────────────
echo "── Credentials ──"
CREDS_OK=0
if [ -n "${TG_API_ID:-}" ] && [ -n "${TG_API_HASH:-}" ]; then
ok "TG_API_ID and TG_API_HASH set via environment"
CREDS_OK=1
fi
CONFIG_FILE="${HOME}/.tg-reader.json"
if [ -f "$CONFIG_FILE" ]; then
ok "Config file: $CONFIG_FILE"
CREDS_OK=1
else
if [ "$CREDS_OK" -eq 0 ]; then
fail "No credentials found"
info "Set TG_API_ID + TG_API_HASH env vars"
info "Or create ~/.tg-reader.json: {\"api_id\": ..., \"api_hash\": \"...\"}"
info "Get credentials at https://my.telegram.org → API Development Tools"
ERRORS=$((ERRORS + 1))
fi
fi
echo ""
# ── Step 5: Session file ────────────────────────────────────────────────────
echo "── Session File ──"
SESSION_FOUND=0
for f in "${HOME}/.tg-reader-session.session" "${HOME}/.telethon-reader.session"; do
if [ -f "$f" ]; then
SIZE=$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null || echo "?")
ok "$f (${SIZE} bytes)"
SESSION_FOUND=1
fi
done
if [ "$SESSION_FOUND" -eq 0 ]; then
# Check current directory for known tg-reader session names only
for f in tg-reader-session.session .tg-reader-session.session telethon-reader.session .telethon-reader.session; do
if [ -f "$f" ]; then
ok "Found session: $(pwd)/$f"
SESSION_FOUND=1
fi
done
fi
if [ "$SESSION_FOUND" -eq 0 ]; then
warn "No session file found — run: tg-reader auth"
fi
echo ""
# ── Step 6: tg-reader-check ─────────────────────────────────────────────────
echo "── Diagnostic (tg-reader-check) ──"
if command -v tg-reader-check &>/dev/null; then
CHECK_OUTPUT=$(tg-reader-check 2>&1 || true)
if echo "$CHECK_OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('status')=='ok' else 1)" 2>/dev/null; then
ok "tg-reader-check passed"
else
warn "tg-reader-check reported issues:"
echo " $CHECK_OUTPUT" | head -5
fi
else
warn "tg-reader-check not available — skipping"
fi
echo ""
# ── Step 7: OpenClaw Exec Approvals ─────────────────────────────────────────
echo "── OpenClaw Exec Approvals ──"
info "OpenClaw blocks unknown CLI commands by default."
info "Approve tg-reader commands using one of these methods:"
echo ""
echo " Option A (CLI):"
for cmd in tg-reader tg-reader-check tg-reader-pyrogram tg-reader-telethon; do
CMD_PATH=$(which "$cmd" 2>/dev/null || true)
if [ -n "$CMD_PATH" ]; then
echo " openclaw approvals allowlist add --gateway \"$CMD_PATH\""
fi
done
echo ""
echo " Option B (Control UI):"
echo " 1. Open http://localhost:18789/"
echo " 2. Find the pending approval for tg-reader"
echo " 3. Click \"Always allow\""
echo ""
echo " Option C (Messenger):"
echo " Reply to the bot's approval request:"
echo " /approve <id> allow-always"
echo ""
# ── Summary ──────────────────────────────────────────────────────────────────
echo "══════════════════════════════════════════════════════"
if [ "$ERRORS" -eq 0 ]; then
echo -e " ${GREEN}All checks passed.${NC} Ready to use tg-reader."
else
echo -e " ${RED}${ERRORS} issue(s) found.${NC} Fix them and re-run this script."
fi
echo "══════════════════════════════════════════════════════"
echo ""
exit "$ERRORS"
from setuptools import setup, find_packages
setup(
name="sergei-mikhailov-tg-channel-reader",
version="0.9.2",
description="OpenClaw skill: read Telegram channels via MTProto",
author="Sergey Mikhailov",
url="https://github.com/bzSega/sergei-mikhailov-tg-channel-reader",
license="MIT",
py_modules=["reader", "reader_telethon", "tg_reader_unified", "tg_check", "tg_state"],
install_requires=[
"pyrogram>=2.0.0",
"tgcrypto>=1.2.0",
"telethon>=1.24.0",
],
entry_points={
"console_scripts": [
"tg-reader=tg_reader_unified:main",
"tg-reader-pyrogram=reader:main",
"tg-reader-telethon=reader_telethon:main",
"tg-reader-check=tg_check:main",
],
},
python_requires=">=3.9",
)
#!/usr/bin/env python3
"""Test if Telethon session is authorized."""
import asyncio
import os
from telethon import TelegramClient
async def test_session():
# Get credentials from environment variables
api_id = os.environ.get("TG_API_ID")
api_hash = os.environ.get("TG_API_HASH")
if not api_id or not api_hash:
print("Error: TG_API_ID and TG_API_HASH environment variables must be set")
print("Example:")
print(" export TG_API_ID=12345678")
print(" export TG_API_HASH=your_api_hash_here")
return
session_name = os.path.expanduser("~/.telethon-reader")
print(f"Testing session: {session_name}.session")
print(f"API ID: {api_id}")
print(f"API Hash: {'*' * len(api_hash)}")
client = TelegramClient(session_name, int(api_id), api_hash)
await client.connect()
is_auth = await client.is_user_authorized()
print(f"Is authorized: {is_auth}")
if is_auth:
me = await client.get_me()
print(f"Logged in as: {me.username or me.id}")
else:
print("Session is not authorized!")
await client.disconnect()
if __name__ == "__main__":
asyncio.run(test_session())