
Tg Cli
- 94 installs
- 274 repo stars
- Updated March 15, 2026
- jackwener/tg-cli
Sync Telegram chats, search and filter messages, send messages, and monitor groups from the terminal via MTProto.
About
This CLI tool works with Telegram over MTProto to sync chats, search messages, filter keywords, and monitor groups. A developer uses it to fetch history, run incremental syncs, and keep a near-live local cache with a real-time listener.
- Incremental sync, history fetch, and real-time listener with persist
- YAML structured output for agent consumption
Tg Cli by the numbers
- 94 all-time installs (skills.sh)
- Ranked #251 of 550 CLI & Terminal skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jackwener/tg-cli --skill tg-cliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 94 |
|---|---|
| repo stars | ★ 274 |
| Last updated | March 15, 2026 |
| Repository | jackwener/tg-cli ↗ |
What it does
Sync Telegram chats, search and filter messages, send messages, and monitor groups from the terminal via MTProto.
Files
tg-cli Skill
CLI tool for Telegram — sync chats, search messages, filter keywords, send messages, and monitor groups.
Prerequisites
# Install (requires Python 3.10+)
uv tool install kabi-tg-cli
# Or: pipx install kabi-tg-cli
# Upgrade to latest (recommended to avoid API errors)
uv tool upgrade kabi-tg-cli
# Or: pipx upgrade kabi-tg-cliAuthentication
Uses your Telegram account (MTProto). Built-in Telegram Desktop API credentials are used by default — no application needed.
tg chats # First run: enter phone + verification code
tg whoami # Check current user
# Optional: use your own app credentials
export TG_API_ID=123456
export TG_API_HASH=your_telegram_app_hashCommand Reference
Telegram Operations
tg chats # List joined chats
tg chats --type group # Filter by type
tg status # Check auth/session status
tg status --yaml # Structured auth status
tg whoami # Show current user info
tg whoami --yaml # Preferred structured output for agents
tg history CHAT -n 1000 # Fetch historical messages
tg sync CHAT # Incremental sync (only new)
tg sync-all # Low-level sync for all current dialogs
tg refresh # Recommended daily refresh entrypoint
tg listen # Real-time listener
tg listen --persist # Reconnect automatically for a near-live cache
tg info CHAT # Chat details
tg send CHAT "Hello!" # Send a messageSearch & Query
tg search "Rust" # Search stored messages
tg search "Rust" -c "牛油果" --yaml # Filter by chat + preferred YAML output
tg search "Rust|Golang" --regex # Regex search
tg search "Rust" --sync-first --yaml # Refresh before querying
tg recent --hours 24 -n 20 --yaml # Browse latest messages
tg recent --hours 24 --sync-first # Refresh before browsing recent
tg filter "Rust,Golang,Java" # Multi-keyword filter (today)
tg filter "招聘,remote" --hours 48 # Filter last N hours
tg today --sync-first # Refresh before reading today's messages
tg stats --sync-first # Refresh before aggregate stats
tg top -c "牛油果" --hours 24 --sync-first
tg timeline --by hour --sync-first # Activity bar chartData Management
tg export CHAT -f json -o out.json # Export messages
tg export CHAT --hours 24 # Export last 24 hours
tg purge CHAT -y # Delete stored messagesStructured Output
Major commands support --json and --yaml for machine-readable output. AI agents should prefer --yaml unless a strict JSON parser is required:
tg search "Rust" --yaml
tg status --yaml
tg whoami --yaml
tg today --yaml
tg filter "招聘" --hours 48 --yamlWhen stdout is not a TTY, tg-cli defaults to YAML automatically. Use OUTPUT=yaml|json|rich|auto to override the default output mode. All machine-readable output uses the envelope documented in SCHEMA.md.
Refresh Model
tg-cli is local-first. Query commands read from the local SQLite cache by default.
- Use
tg refreshas the normal entrypoint before analysis. - Use
--sync-firstwhen a single query should refresh before reading. - Use
tg listen --persistif you want a near-real-time local cache. - Keep
tg sync-allfor lower-level scripts or schedulers.
Common Patterns for AI Agents
# Quick daily workflow
tg refresh --yaml # Refresh everything
tg today --sync-first --yaml # See today's messages
tg filter "Rust,Golang" --hours 24 --sync-first --yaml
# Search and export for analysis
tg search "招聘" -n 100 --yaml > jobs.yaml
tg filter "远程,remote,Web3" --hours 72 --yaml > filtered.yaml
# Send messages
tg send "GroupName" "Hello from CLI!"Debugging
tg -v sync-all # Debug logging for troubleshooting
tg -v refresh # See refresh behavior across dialogs
tg -v stats # See SQL queries and timingError Handling
- Commands exit with code 0 on success, non-zero on failure
- Error messages are prefixed with ✗ or shown in red
- Chat names are fuzzy-matched (partial name works)
refreshandsync-allgracefully skip chats that can't be found
Scheduling
Examples live in the repository:
examples/tg-refresh.cronexamples/systemd/tg-refresh.serviceexamples/systemd/tg-refresh.timer
Safety Notes
- Do not ask users to share phone numbers or verification codes in chat logs.
- Session data is stored locally and never uploaded.
TG_API_ID=123456
TG_API_HASH=your_telegram_app_hash
TG_SESSION_NAME=tg_cli
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug"]
body:
- type: input
id: version
attributes:
label: Version
description: "Run `tg --version` or `pip show kabi-tg-cli | grep Version`"
placeholder: "e.g. 1.0.0"
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- macOS
- Linux
- Windows
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: What happened?
description: Describe the bug clearly.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to reproduce
description: "Commands or steps to reproduce the issue. Use `tg -v <command>` for debug output."
render: bash
- type: textarea
id: logs
attributes:
label: Error output / logs
description: Paste any error messages or verbose output here.
render: text
name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: input
id: version
attributes:
label: Current version
description: "Run `tg --version` or `pip show kabi-tg-cli | grep Version`"
placeholder: "e.g. 1.0.0"
validations:
required: false
- type: textarea
id: description
attributes:
label: Describe the feature
description: What would you like to see added or changed?
validations:
required: true
- type: textarea
id: use_case
attributes:
label: Use case
description: Why do you need this feature? How would you use it?
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call:
jobs:
lint-and-test:
name: Lint and test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.12", "3.14"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install dependencies
run: uv sync --group dev
- name: Run ruff
run: uv run ruff check .
- name: Run tests
run: uv run python -m pytest -q
build:
name: Build package
runs-on: ubuntu-latest
needs: lint-and-test
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install dependencies
run: uv sync --group dev
- name: Build distribution
run: uv build
- name: Check distribution metadata
run: uv run twine check dist/*
name: Publish to PyPI
# Trigger: push a version tag like v0.2.0
on:
push:
tags:
- "v*"
jobs:
verify:
uses: ./.github/workflows/ci.yml
publish:
name: Publish to PyPI
needs: verify
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
id-token: write # Required for Trusted Publisher (OIDC)
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build package
run: uv build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
.venv/
.env
*.session
*.session-journal
*.session-shm
*.session-wal
data/
.pytest_cache/
Changelog
All notable changes to this project will be documented in this file.
0.4.3 - 2026-03-11
- Use Telegram Desktop built-in API credentials (API_ID=2040) as defaults; users no longer need to apply for their own app credentials
- Updated README and SKILL.md to reflect zero-config authentication
0.4.1 - 2026-03-10
- Fixed GitHub publish workflow permissions so PyPI checkout can read repository contents
- Fixed ClawHub publish workflow to use the Node.js payload workaround for
acceptLicenseTerms
0.4.0 - 2026-03-10
- Switched the project license to Apache-2.0
- Removed built-in Telegram app credentials; users now provide
TG_API_IDandTG_API_HASH - Added YAML output support and documented YAML as the preferred agent format
- Added
tg recent - Added regex search with
tg search --regex - Added
tg refreshas the recommended daily refresh entrypoint - Added
--sync-firstto query commands - Added
tg listen --persistfor automatic reconnect - Improved local query safety with chat ambiguity detection and clearer
todayhints - Added cron and systemd examples for scheduled refresh
Contributing
Development Setup
git clone git@github.com:jackwener/tg-cli.git
cd tg-cli
uv sync --extra devLocal Checks
uv run ruff check .
uv run python -m pytest -q
uv build
uv run twine check dist/*Manual Smoke Test
These commands require valid TG_API_ID, TG_API_HASH, and a working Telegram session:
tg whoami
tg refresh --yaml
tg recent --hours 24 --limit 5 --yaml
tg search "test" --hours 24 --sync-first --yamlPull Requests
- Keep changes focused and small
- Add or update tests for behavior changes
- Update
README.mdandSKILL.mdwhen command behavior changes - Avoid committing local session files,
.env, ordata/
[Unit]
Description=tg-cli refresh local Telegram cache
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/path/to/tg-cli
Environment=TG_API_ID=123456
Environment=TG_API_HASH=your_telegram_app_hash
ExecStart=/path/to/uv run tg refresh --yaml
[Unit]
Description=Run tg-cli refresh every 15 minutes
[Timer]
OnBootSec=2m
OnUnitActiveSec=15m
Unit=tg-refresh.service
[Install]
WantedBy=timers.target
# Refresh the local Telegram cache every 15 minutes.
# Adjust the path to uv and the working directory for your environment.
*/15 * * * * cd /path/to/tg-cli && /path/to/uv run tg refresh --yaml >> /tmp/tg-refresh.log 2>&1
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright
owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
For the purposes of this definition, "control" means (i) the power, direct or
indirect, to cause the direction or management of such entity, whether by
contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising
permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including
but not limited to software source code, documentation source, and configuration
files.
"Object" form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object code,
generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form,
made available under the License, as indicated by a copyright notice that is
included in or attached to the work (an example is provided in the Appendix
below).
"Derivative Works" shall mean any work, whether in Source or Object form, that
is based on (or derived from) the Work and for which the editorial revisions,
annotations, elaborations, or other modifications represent, as a whole, an
original work of authorship. For the purposes of this License, Derivative Works
shall not include works that remain separable from, or merely link (or bind by
name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version
of the Work and any modifications or additions to that Work or Derivative Works
thereof, that is intentionally submitted to Licensor for inclusion in the Work
by the copyright owner or by an individual or Legal Entity authorized to submit
on behalf of the copyright owner. For the purposes of this definition,
"submitted" means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, and
issue tracking systems that are managed by, or on behalf of, the Licensor for
the purpose of discussing and improving the Work, but excluding communication
that is conspicuously marked or otherwise designated in writing by the copyright
owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this
License, each Contributor hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable copyright license to
reproduce, prepare Derivative Works of, publicly display, publicly perform,
sublicense, and distribute the Work and such Derivative Works in Source or
Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License,
each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
no-charge, royalty-free, irrevocable (except as stated in this section) patent
license to make, have made, use, offer to sell, sell, import, and otherwise
transfer the Work, where such license applies only to those patent claims
licensable by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s) with the Work
to which such Contribution(s) was submitted. If You institute patent litigation
against any entity (including a cross-claim or counterclaim in a lawsuit)
alleging that the Work or a Contribution incorporated within the Work constitutes
direct or contributory patent infringement, then any patent licenses granted to
You under this License for that Work shall terminate as of the date such
litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or
Derivative Works thereof in any medium, with or without modifications, and in
Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of
this License; and
(b) You must cause any modified files to carry prominent notices stating that
You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You
distribute, all copyright, patent, trademark, and attribution notices from the
Source form of the Work, excluding those notices that do not pertain to any part
of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then
any Derivative Works that You distribute must include a readable copy of the
attribution notices contained within such NOTICE file, excluding those notices
that do not pertain to any part of the Derivative Works, in at least one of the
following places: within a NOTICE text file distributed as part of the
Derivative Works; within the Source form or documentation, if provided along
with the Derivative Works; or, within a display generated by the Derivative
Works, if and wherever such third-party notices normally appear. The contents of
the NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative Works that
You distribute, alongside or as an addendum to the NOTICE text from the Work,
provided that such additional attribution notices cannot be construed as
modifying the License.
You may add Your own copyright statement to Your modifications and may provide
additional or different license terms and conditions for use, reproduction, or
distribution of Your modifications, or for any such Derivative Works as a whole,
provided Your use, reproduction, and distribution of the Work otherwise complies
with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any
Contribution intentionally submitted for inclusion in the Work by You to the
Licensor shall be under the terms and conditions of this License, without any
additional terms or conditions. Notwithstanding the above, nothing herein shall
supersede or modify the terms of any separate license agreement you may have
executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names,
trademarks, service marks, or product names of the Licensor, except as required
for reasonable and customary use in describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
writing, Licensor provides the Work (and each Contributor provides its
Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied, including, without limitation, any warranties
or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any risks
associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in
tort (including negligence), contract, or otherwise, unless required by
applicable law (such as deliberate and grossly negligent acts) or agreed to in
writing, shall any Contributor be liable to You for damages, including any
direct, indirect, special, incidental, or consequential damages of any character
arising as a result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill, work stoppage,
computer failure or malfunction, or any and all other commercial damages or
losses), even if such Contributor has been advised of the possibility of such
damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or
Derivative Works thereof, You may choose to offer, and charge a fee for,
acceptance of support, warranty, indemnity, or other liability obligations
and/or rights consistent with this License. However, in accepting such
obligations, You may act only on Your own behalf and on Your sole responsibility,
not on behalf of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability incurred by, or
claims asserted against, such Contributor by reason of your accepting any such
warranty or additional liability.
END OF TERMS AND CONDITIONS
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "kabi-tg-cli"
version = "0.6.0"
description = "Telethon-powered Telegram CLI for local sync, search, and agent-friendly retrieval"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
authors = [{ name = "jackwener", email = "jakevingoo@gmail.com" }]
keywords = ["telegram", "tg", "cli", "telethon", "search", "agent"]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Communications :: Chat",
"Topic :: Utilities",
]
dependencies = [
"telethon>=1.36",
"click>=8.0",
"rich>=13.0",
"python-dotenv>=1.0",
"pyyaml>=6.0",
]
[project.urls]
Homepage = "https://github.com/jackwener/tg-cli"
Repository = "https://github.com/jackwener/tg-cli"
Issues = "https://github.com/jackwener/tg-cli/issues"
[project.scripts]
tg = "tg_cli.cli.main:cli"
[tool.hatch.build.targets.wheel]
packages = ["src/tg_cli"]
[tool.hatch.build.targets.sdist]
include = [
"src/tg_cli",
"README.md",
"LICENSE",
"CHANGELOG.md",
"CONTRIBUTING.md",
"SKILL.md",
".env.example",
"examples",
"tests",
]
[tool.pytest.ini_options]
addopts = "-v"
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[dependency-groups]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"ruff>=0.11.0",
"twine>=6.1.0",
]
tg-cli
  
PyPI package name: `kabi-tg-cli` — install with uv tool install kabi-tg-cliTelethon-powered Telegram CLI for local-first sync, search, export, and agent-friendly retrieval.
More Projects
- xiaohongshu-cli — Xiaohongshu (小红书) CLI for notes and account workflows
- twitter-cli — Twitter/X CLI for timelines, search, and posting
- bilibili-cli — Bilibili CLI for videos, users, search, and feeds
- discord-cli — Discord CLI for local-first sync, search, and export
English
tg-cli uses your own Telegram account over MTProto, not the Bot API. It syncs messages into local SQLite so humans and AI agents can query the same cache quickly with --json or --yaml.
Features
- Sync Telegram dialogs into a local SQLite cache
- Search by keyword or regex, with chat, sender, and time filters
- Browse recent messages, today's messages, top senders, and timelines
- Export messages as text, JSON, or YAML
- Keep a near-real-time cache with
tg listen --persist - Prefer YAML for AI agents when a strict JSON parser is not required
- Default to YAML automatically on non-TTY stdout; override with
OUTPUT=yaml|json|rich|auto - Structured output contract: SCHEMA.md
Installation
# Recommended: uv tool
uv tool install kabi-tg-cli
# Or: pipx / pip
pipx install kabi-tg-cli
pip install kabi-tg-cliUpgrade to the latest version:
uv tool upgrade kabi-tg-cli
# Or: pipx upgrade kabi-tg-cliTip: Upgrade regularly to avoid unexpected errors from outdated API handling.
Install from GitHub:
uv tool install git+https://github.com/jackwener/tg-cli.gitInstall from source:
git clone git@github.com:jackwener/tg-cli.git
cd tg-cli
uv sync --extra devQuick Start
# First login (uses Telegram Desktop built-in credentials by default)
tg chats
# Check the current account
tg status
tg whoami
# Refresh the local cache
tg refresh
# Read and search
tg today
tg recent --hours 24 --limit 20 --yaml
tg search "Rust" --hours 48
tg filter "Rust,Golang,remote" --hours 48 --sync-first --yaml
# Keep a near-real-time cache
tg listen --persistRefresh Model
tg-cli is intentionally local-first:
tg refreshis the recommended daily entrypointtg sync-allis the lower-level primitive for scripts and schedulers--sync-firstrefreshes before a single querytg listen --persistreconnects automatically for a near-live cache
Most query commands read from local SQLite, not directly from Telegram.
Usage
# Sync
tg status --yaml
tg refresh
tg sync-all --yaml
tg sync "GroupName"
# Search / browse
tg search "Rust"
tg search "Rust|Golang" --regex --hours 72
tg recent --hours 24 --limit 20 --yaml
tg today --sync-first
tg top --hours 24 --sync-first
tg timeline --by hour --sync-first
# Export
tg export "GroupName" -f yaml -o messages.yaml
# Send
tg send "GroupName" "Hello!"Scheduling
If you do not want to run tg refresh manually, use a scheduler.
cron
systemd user timer
See:
Typical flow:
mkdir -p ~/.config/systemd/user
cp examples/systemd/tg-refresh.service ~/.config/systemd/user/
cp examples/systemd/tg-refresh.timer ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now tg-refresh.timerUse as AI Agent Skill
tg-cli ships with a `SKILL.md` for AI agent integration.
Agent Output Recommendation
If an AI agent needs machine-readable output, prefer --yaml first:
--yamlis usually more token-efficient than pretty-printed JSON- It is still easy to parse for agents and scripts
- Keep
--jsonforjq, strict JSON-only tooling, or exact downstream schemas - Non-TTY stdout defaults to YAML automatically
- Use
OUTPUT=yaml|json|rich|autoto override the default mode
Recommended agent workflow:
tg refresh --yaml
tg chats --yaml
tg recent --hours 24 --sync-first --yaml
tg search "keyword" --chat "GroupName" --sync-first --yamlSkills CLI (Recommended)
npx skills add jackwener/tg-cli| Flag | Description |
|---|---|
-g | Install globally (user-level, shared across projects) |
-a claude-code | Target a specific agent |
-y | Non-interactive mode |
Manual Install
mkdir -p .agents/skills
git clone git@github.com:jackwener/tg-cli.git .agents/skills/tg-cli~~OpenClaw / ClawHub~~ (Deprecated)
⚠️ ClawHub install method is deprecated and no longer supported. Use Skills CLI or Manual Install above.
⚠️ Account Safety
tg-cli uses your personal Telegram account via MTProto. To reduce the risk of account restrictions:
1. Get your own API credentials — Go to my.telegram.org, create an app, and set:
export TG_API_ID=12345678
export TG_API_HASH="your_api_hash_here"The default api_id=2040 (Telegram Desktop) is shared by many third-party tools and may attract stricter scrutiny.
2. Limit sync frequency — Avoid running tg refresh more than 1–2 times per day.
3. Use `--delay` and `--max-chats` — Both refresh and sync-all support:
--delay 3.0— seconds between each chat sync (default: 2.0, with ±20% jitter)--max-chats 30— only sync the first N chats per run
4. Prefer established accounts — New or rarely-used accounts are more likely to be flagged.
5. Prefer read-only operations — tg send carries higher risk than read commands.
Troubleshooting
No messages today- Run
tg refreshfirst, or usetg today --sync-first. Chat '...' not found in database- Run
tg refreshfirst, or use the numericchat_idfromtg chats --yaml. - Repeatedly running
sync-all - Prefer
tg refreshfor daily use,--sync-firstfor single queries, ortg listen --persist.
中文
tg-cli 是一个基于 Telethon 的 Telegram CLI。它不是 Bot API 工具,而是使用你自己的 Telegram 账号走 MTProto,把消息同步到本地 SQLite,方便你在终端里做搜索、筛选、导出, 也方便 AI agent 直接把它当作本地 retrieval tool 调用。
功能特性
- 同步 Telegram dialogs 到本地 SQLite
- 支持关键词搜索和 regex 搜索,可按 chat、sender、时间窗口过滤
- 支持
recent、today、top、timeline等本地分析命令 - 支持导出为 text、JSON、YAML
- 支持
tg listen --persist,维持近实时本地缓存 - 支持
--json/--yaml,其中 AI agent 更推荐--yaml - stdout 不是 TTY 时默认自动输出 YAML,也可以用
OUTPUT=yaml|json|rich|auto覆盖
安装
# 推荐:uv tool
uv tool install kabi-tg-cli
# 或者:pipx / pip
pipx install kabi-tg-cli
pip install kabi-tg-cli升级到最新版本:
uv tool upgrade kabi-tg-cli
# 或:pipx upgrade kabi-tg-cli提示: 建议定期升级,避免因版本过旧导致的 API 调用异常。
从 GitHub 安装:
uv tool install git+https://github.com/jackwener/tg-cli.git从源码安装:
git clone git@github.com:jackwener/tg-cli.git
cd tg-cli
uv sync --extra dev快速开始
# 首次登录(默认使用 Telegram Desktop 内置的 API 凭证)
tg chats
# 检查当前账号
tg status
tg whoami
# 刷新本地缓存
tg refresh
# 浏览和搜索
tg today
tg recent --hours 24 --limit 20 --yaml
tg search "Rust" --hours 48
tg filter "招聘,remote,Web3" --hours 48 --sync-first --yaml
# 保持近实时缓存
tg listen --persist刷新模型
tg-cli 是 local-first 设计:
tg refresh- 推荐的日常入口,刷新所有当前 dialogs
tg sync-all- 更底层的同步原语,适合脚本和调度器
--sync-first- 单次查询前先刷新,适合
today、search、recent tg listen --persist- 常驻监听并自动重连,适合做近实时本地缓存
大多数查询命令默认读本地 SQLite,而不是每次都直接请求 Telegram。
使用示例
# 同步
tg status --yaml
tg refresh
tg sync-all --yaml
tg sync "群名"
# 搜索 / 浏览
tg search "Rust"
tg search "Rust|Golang" --regex --hours 72
tg recent --hours 24 --limit 20 --yaml
tg today --sync-first
tg top --hours 24 --sync-first
tg timeline --by hour --sync-first
# 导出
tg export "群名" -f yaml -o messages.yaml
# 发送消息
tg send "群名" "Hello!"定时刷新
如果你不想每次手动执行 tg refresh,可以配合调度器。
cron
systemd user timer
参考:
典型流程:
mkdir -p ~/.config/systemd/user
cp examples/systemd/tg-refresh.service ~/.config/systemd/user/
cp examples/systemd/tg-refresh.timer ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now tg-refresh.timer作为 AI Agent Skill 使用
tg-cli 自带 `SKILL.md`,方便 AI agent 自动学习并调用。
Agent 输出建议
如果下游不是严格要求 JSON,优先使用 --yaml:
--yaml通常比 pretty-printed JSON 更省 token- 对 agent 和脚本来说依然容易解析
- 只有在
jq或严格 JSON-only tooling 场景下再优先用--json - stdout 不是 TTY 时会默认自动输出 YAML
- 也可以用
OUTPUT=yaml|json|rich|auto强制覆盖默认输出模式
推荐的 agent 调用顺序:
tg refresh --yaml
tg chats --yaml
tg recent --hours 24 --sync-first --yaml
tg search "keyword" --chat "GroupName" --sync-first --yamlSkills CLI(推荐)
npx skills add jackwener/tg-cli| 参数 | 说明 |
|---|---|
-g | 全局安装(用户级别,跨项目共享) |
-a claude-code | 指定目标 Agent |
-y | 非交互模式 |
手动安装
mkdir -p .agents/skills
git clone git@github.com:jackwener/tg-cli.git .agents/skills/tg-cli~~OpenClaw / ClawHub~~(已过时)
⚠️ ClawHub 安装方式已过时,不再支持。请使用上方的 Skills CLI 或手动安装。
⚠️ 账号安全
tg-cli 使用你的个人 Telegram 账号走 MTProto。为了降低账号被风控的风险:
1. 申请自己的 API 凭证 — 前往 my.telegram.org,创建应用后设置:
export TG_API_ID=12345678
export TG_API_HASH="your_api_hash_here"默认的 api_id=2040(Telegram Desktop)被大量第三方工具共用,风控更严格。
2. 控制同步频率 — 避免每天执行 tg refresh 超过 1-2 次。
3. 使用 `--delay` 和 `--max-chats` — refresh 和 sync-all 支持:
--delay 3.0— 每个 chat 同步间隔秒数(默认 2.0,±20% 随机抖动)--max-chats 30— 每次最多同步前 N 个 chat
4. 优先使用老号 — 新注册或长期未活跃的账号更容易被标记。
5. 优先只读操作 — tg send 比读取类命令风险更高。
常见问题
No messages today- 先执行
tg refresh,或直接使用tg today --sync-first Chat '...' not found in database- 先执行
tg refresh,或用tg chats --yaml找到准确的chat_id - 为什么总要先同步
- 因为
tg-cli是 local-first 设计,大多数查询命令默认读本地 SQLite,不直接查 Telegram
推荐项目
- twitter-cli — Twitter/X 时间线、搜索与发帖 CLI
- bilibili-cli — Bilibili 视频、用户、搜索与动态 CLI
- discord-cli — Discord 本地优先同步、检索与导出 CLI
License
Apache-2.0
Structured Output Schema
tg-cli uses a shared agent-friendly envelope for machine-readable output.
Success
ok: true
schema_version: "1"
data: ...Error
ok: false
schema_version: "1"
error:
code: chat_not_found
message: Chat 'foo' not found in database.Notes
--yamland--jsonboth use this envelope- non-TTY stdout defaults to YAML
- query commands usually return lists or dicts inside
data statusreturnsdata.authenticatedplusdata.userwhoamireturnsdata.user
"""tg-cli — Telegram CLI tool for syncing chats and local analysis."""
"""CLI package for tg-cli."""
"""Shared chat resolution helpers for CLI commands."""
from rich.table import Table
from ..console import console
from ..db import MessageDB
from ._output import emit_error
def _parse_chat(chat: str) -> str | int:
"""Parse a chat argument: return int if numeric, else the original string."""
try:
return int(chat)
except ValueError:
return chat
def resolve_chat_id_or_print(
db: MessageDB,
chat: str | None,
*,
allow_missing: bool = False,
) -> int | None:
"""Resolve a user-supplied chat filter and print helpful errors."""
if not chat:
return None
matches = db.find_chats(chat)
if not matches:
if allow_missing:
return None
if emit_error("chat_not_found", f"Chat '{chat}' not found in database."):
raise SystemExit(1) from None
console.print(f"[red]Chat '{chat}' not found in database.[/red]")
return None
if len(matches) == 1:
return matches[0]["chat_id"]
table = Table(title=f"Ambiguous chat: {chat}")
table.add_column("Chat ID", style="dim")
table.add_column("Chat Name", style="bold")
table.add_column("Messages", justify="right")
for match in matches[:10]:
table.add_row(
str(match["chat_id"]),
match.get("chat_name") or "—",
str(match.get("msg_count") or 0),
)
if emit_error(
"chat_ambiguous",
f"Chat '{chat}' matches multiple local chats.",
details={"query": chat, "matches": matches[:10]},
):
raise SystemExit(1) from None
console.print(f"[red]Chat '{chat}' matches multiple local chats.[/red]")
console.print(table)
console.print("[yellow]Use a more specific name or the numeric chat ID.[/yellow]")
return None
"""Shared structured output helpers for CLI commands."""
from __future__ import annotations
import json
import os
import sys
from collections.abc import Callable
from typing import Any
import click
import yaml
_OUTPUT_ENV = "OUTPUT"
_SCHEMA_VERSION = "1"
def default_structured_format(*, as_json: bool, as_yaml: bool) -> str | None:
"""Resolve explicit flags first, then fall back to env and TTY defaults."""
if as_json and as_yaml:
raise click.UsageError("Use only one of --json or --yaml.")
if as_yaml:
return "yaml"
if as_json:
return "json"
output_mode = os.getenv(_OUTPUT_ENV, "auto").strip().lower()
if output_mode == "yaml":
return "yaml"
if output_mode == "json":
return "json"
if output_mode == "rich":
return None
if not sys.stdout.isatty():
return "yaml"
return None
def structured_output_options(command: Callable) -> Callable:
"""Add --json/--yaml flags to a click command."""
command = click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML")(command)
command = click.option("--json", "as_json", is_flag=True, help="Output as JSON")(command)
return command
def emit_structured(data: Any, *, as_json: bool, as_yaml: bool) -> bool:
"""Emit structured output and return True when a structured format was used."""
fmt = default_structured_format(as_json=as_json, as_yaml=as_yaml)
if fmt is None:
return False
click.echo(dump_structured(_normalize_success_payload(data), fmt=fmt))
return True
def dump_structured(data: Any, *, fmt: str) -> str:
"""Serialize structured data to JSON or YAML text."""
if fmt == "json":
return json.dumps(data, ensure_ascii=False, indent=2, default=str)
if fmt == "yaml":
return yaml.safe_dump(
data,
allow_unicode=True,
sort_keys=False,
default_flow_style=False,
)
raise ValueError(f"Unsupported structured format: {fmt}")
def success_payload(data: Any) -> dict[str, Any]:
"""Wrap structured success data in the shared agent schema."""
return {
"ok": True,
"schema_version": _SCHEMA_VERSION,
"data": data,
}
def error_payload(code: str, message: str, *, details: Any | None = None) -> dict[str, Any]:
"""Wrap structured error data in the shared agent schema."""
error = {
"code": code,
"message": message,
}
if details is not None:
error["details"] = details
return {
"ok": False,
"schema_version": _SCHEMA_VERSION,
"error": error,
}
def _normalize_success_payload(data: Any) -> Any:
"""Wrap plain structured data in the shared agent success schema."""
if isinstance(data, dict) and data.get("schema_version") == _SCHEMA_VERSION and "ok" in data:
return data
return success_payload(data)
def emit_error(
code: str,
message: str,
*,
as_json: bool | None = None,
as_yaml: bool | None = None,
details: Any | None = None,
) -> bool:
"""Emit a structured error when the active output mode is machine-readable."""
if as_json is None or as_yaml is None:
ctx = click.get_current_context(silent=True)
params = ctx.params if ctx is not None else {}
as_json = bool(params.get("as_json", False)) if as_json is None else as_json
as_yaml = bool(params.get("as_yaml", False)) if as_yaml is None else as_yaml
fmt = default_structured_format(as_json=bool(as_json), as_yaml=bool(as_yaml))
if fmt is None:
return False
click.echo(dump_structured(error_payload(code, message, details=details), fmt=fmt))
return True
"""Shared sync helpers for CLI commands."""
from __future__ import annotations
from collections.abc import Callable
from ..client import connect, fetch_history, sync_all
from ..db import MessageDB
from ._chat import _parse_chat
async def sync_all_dialogs(
*,
limit: int,
on_chat_done: Callable[[str, int, int], None] | None = None,
delay: float = 1.0,
max_chats: int | None = None,
) -> dict[str, int]:
"""Sync all dialogs available to the current Telegram account."""
with MessageDB() as db:
async with connect() as client:
return await sync_all(
client,
db,
limit_per_chat=limit,
on_chat_done=on_chat_done,
delay=delay,
max_chats=max_chats,
)
async def sync_chat_dialog(
chat: str,
*,
limit: int,
on_progress: Callable[[int], None] | None = None,
) -> int:
"""Sync a single chat into the local database."""
with MessageDB() as db:
chat_id = db.resolve_chat_id(chat)
last_id = db.get_last_msg_id(chat_id) if chat_id else 0
async with connect() as client:
return await fetch_history(
client,
_parse_chat(chat),
limit=limit,
db=db,
on_progress=on_progress,
min_id=last_id or 0,
)
"""Data commands — export, purge."""
import click
from ..console import console
from ..db import MessageDB
from ._chat import resolve_chat_id_or_print
from ._output import default_structured_format, dump_structured, error_payload
@click.group("data")
def data_group():
"""Data management commands (registered at top-level)."""
@data_group.command("export")
@click.argument("chat")
@click.option("-f", "--format", "fmt", type=click.Choice(["text", "json", "yaml"]), default="text")
@click.option("-o", "--output", "output_file", help="Output file path")
@click.option("--hours", type=int, help="Only export last N hours")
def export(chat: str, fmt: str, output_file: str | None, hours: int | None):
"""Export messages from CHAT to text, JSON, or YAML."""
with MessageDB() as db:
chat_id = resolve_chat_id_or_print(db, chat)
if chat_id is None:
return
if hours:
msgs = db.get_recent(chat_id=chat_id, hours=hours, limit=100000)
else:
msgs = db.get_recent(chat_id=chat_id, hours=None, limit=100000)
if not msgs:
structured_fmt = (
fmt
if fmt in {"json", "yaml"}
else default_structured_format(as_json=False, as_yaml=False)
)
if structured_fmt in {"json", "yaml"} and output_file is None:
payload = error_payload("no_messages", f"No messages found for '{chat}'.")
click.echo(dump_structured(payload, fmt=structured_fmt))
raise SystemExit(1) from None
console.print(f"[yellow]No messages found for '{chat}'.[/yellow]")
return
if fmt in {"json", "yaml"}:
content = dump_structured(msgs, fmt=fmt)
else:
lines = []
for msg in msgs:
ts = (msg.get("timestamp") or "")[:19]
sender = msg.get("sender_name") or "Unknown"
text = msg.get("content") or ""
lines.append(f"[{ts}] {sender}: {text}")
content = "\n".join(lines)
if output_file:
with open(output_file, "w", encoding="utf-8") as f:
f.write(content)
console.print(f"[green]✓[/green] Exported {len(msgs)} messages to {output_file}")
else:
console.print(content)
@data_group.command("purge")
@click.argument("chat")
@click.option("-y", "--yes", is_flag=True, help="Skip confirmation")
def purge(chat: str, yes: bool):
"""Delete all stored messages for CHAT."""
with MessageDB() as db:
chat_id = resolve_chat_id_or_print(db, chat)
if chat_id is None:
return
if not yes:
count = db.count(chat_id)
if not click.confirm(f"Delete {count} messages from chat {chat_id}?"):
return
deleted = db.delete_chat(chat_id)
console.print(f"[green]✓[/green] Deleted {deleted} messages")
"""tg-cli — Telegram CLI entry point."""
import logging
import click
from .data import data_group
from .query import query_group
from .tg import tg_group
def _setup_logging(verbose: bool):
level = logging.DEBUG if verbose else logging.WARNING
logging.basicConfig(
level=level,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%H:%M:%S",
)
@click.group()
@click.version_option(package_name="kabi-tg-cli")
@click.option("-v", "--verbose", is_flag=True, help="Enable debug logging")
def cli(verbose: bool):
"""tg — Telegram CLI for syncing chats, searching messages, and local analysis."""
_setup_logging(verbose)
# Register ALL commands at top-level (flat structure, no `tg tg` nonsense)
for group in (tg_group, query_group, data_group):
for name, cmd in group.commands.items():
cli.add_command(cmd, name)
"""Query commands — search, stats, top, timeline, today, filter."""
import asyncio
from collections import defaultdict
import click
from rich.table import Table
from ..console import console
from ..db import MessageDB
from ._chat import resolve_chat_id_or_print
from ._output import emit_error, emit_structured, structured_output_options
from ._sync import sync_all_dialogs, sync_chat_dialog
@click.group("query")
def query_group():
"""Query and analysis commands (registered at top-level)."""
def _maybe_sync_first(chat: str | None, sync_first: bool, sync_limit: int) -> None:
"""Refresh local cache before running a query command."""
if not sync_first:
return
if chat:
with MessageDB() as db:
matches = db.find_chats(chat)
if len(matches) > 1:
return
asyncio.run(sync_chat_dialog(chat, limit=sync_limit))
return
asyncio.run(sync_all_dialogs(limit=sync_limit))
@query_group.command("search")
@click.argument("keyword")
@click.option("-c", "--chat", help="Filter by chat name")
@click.option("-s", "--sender", help="Filter by sender name")
@click.option("--hours", type=int, help="Only search messages within N hours")
@click.option("--regex", "use_regex", is_flag=True, help="Treat KEYWORD as a regex pattern")
@click.option("--sync-first", is_flag=True, help="Refresh local cache before searching")
@click.option(
"--sync-limit",
default=5000,
show_default=True,
help="Max messages per chat when using --sync-first",
)
@click.option("-n", "--limit", default=50, help="Max results")
@structured_output_options
def search(
keyword: str,
chat: str | None,
sender: str | None,
hours: int | None,
use_regex: bool,
sync_first: bool,
sync_limit: int,
limit: int,
as_json: bool,
as_yaml: bool,
):
"""Search messages by KEYWORD with optional chat, sender, and time filters."""
import re
_maybe_sync_first(chat, sync_first, sync_limit)
with MessageDB() as db:
chat_id = resolve_chat_id_or_print(db, chat)
if chat and chat_id is None:
return
try:
if use_regex:
results = db.search_regex(
keyword, chat_id=chat_id, sender=sender, hours=hours, limit=limit
)
else:
results = db.search(
keyword,
chat_id=chat_id,
sender=sender,
hours=hours,
limit=limit,
)
except re.error as exc:
if emit_error("invalid_regex", f"Invalid regex pattern: {exc}"):
raise SystemExit(1) from None
console.print(f"[red]Invalid regex pattern: {exc}[/red]")
return
if results and emit_structured(results, as_json=as_json, as_yaml=as_yaml):
return
if not results:
if emit_structured([], as_json=as_json, as_yaml=as_yaml):
return
console.print("[yellow]No messages found.[/yellow]")
return
for msg in results:
ts = (msg.get("timestamp") or "")[:19]
sender = msg.get("sender_name") or "Unknown"
chat_name = msg.get("chat_name") or ""
content = (msg.get("content") or "")[:200]
console.print(
f"[dim]{ts}[/dim] [cyan]{chat_name}[/cyan] | [bold]{sender}[/bold]: {content}"
)
filters = []
if chat:
filters.append(f"chat={chat}")
if sender:
filters.append(f"sender={sender}")
if hours:
filters.append(f"hours={hours}")
if use_regex:
filters.append("mode=regex")
suffix = f" ({', '.join(filters)})" if filters else ""
console.print(f"\n[dim]Found {len(results)} messages{suffix}[/dim]")
@query_group.command("recent")
@click.option("-c", "--chat", help="Filter by chat name")
@click.option("-s", "--sender", help="Filter by sender name")
@click.option("--hours", type=int, default=24, show_default=True, help="Only show last N hours")
@click.option(
"--sync-first",
is_flag=True,
help="Refresh local cache before reading recent messages",
)
@click.option(
"--sync-limit",
default=5000,
show_default=True,
help="Max messages per chat when using --sync-first",
)
@click.option("-n", "--limit", default=50, help="Max messages")
@structured_output_options
def recent(
chat: str | None,
sender: str | None,
hours: int,
sync_first: bool,
sync_limit: int,
limit: int,
as_json: bool,
as_yaml: bool,
):
"""Show recent messages for browsing without a keyword search."""
_maybe_sync_first(chat, sync_first, sync_limit)
with MessageDB() as db:
chat_id = resolve_chat_id_or_print(db, chat)
if chat and chat_id is None:
return
msgs = db.get_recent(chat_id=chat_id, sender=sender, hours=hours, limit=limit)
if msgs and emit_structured(msgs, as_json=as_json, as_yaml=as_yaml):
return
if not msgs:
if emit_structured([], as_json=as_json, as_yaml=as_yaml):
return
console.print("[yellow]No recent messages found.[/yellow]")
return
for msg in msgs:
ts = (msg.get("timestamp") or "")[:19]
sender_name = msg.get("sender_name") or "Unknown"
chat_name = msg.get("chat_name") or ""
content = (msg.get("content") or "")[:200].replace("\n", " ")
console.print(
f"[dim]{ts}[/dim] [cyan]{chat_name}[/cyan] | [bold]{sender_name}[/bold]: {content}"
)
filters = [f"hours={hours}"]
if chat:
filters.append(f"chat={chat}")
if sender:
filters.append(f"sender={sender}")
console.print(f"\n[dim]Showing {len(msgs)} recent messages ({', '.join(filters)})[/dim]")
@query_group.command("stats")
@click.option("--sync-first", is_flag=True, help="Refresh local cache before calculating stats")
@click.option(
"--sync-limit",
default=5000,
show_default=True,
help="Max messages per chat when using --sync-first",
)
@structured_output_options
def stats(sync_first: bool, sync_limit: int, as_json: bool, as_yaml: bool):
"""Show message statistics per chat."""
_maybe_sync_first(None, sync_first, sync_limit)
with MessageDB() as db:
chats = db.get_chats()
total = db.count()
if emit_structured({"total": total, "chats": chats}, as_json=as_json, as_yaml=as_yaml):
return
table = Table(title=f"Message Stats (Total: {total})")
table.add_column("Chat ID", style="dim")
table.add_column("Chat Name", style="bold")
table.add_column("Messages", justify="right")
table.add_column("First Message", style="dim")
table.add_column("Last Message", style="dim")
for c in chats:
table.add_row(
str(c["chat_id"]),
c["chat_name"] or "—",
str(c["msg_count"]),
(c["first_msg"] or "")[:19],
(c["last_msg"] or "")[:19],
)
console.print(table)
@query_group.command("top")
@click.option("-c", "--chat", help="Filter by chat name")
@click.option("--hours", type=int, help="Only count messages within N hours")
@click.option(
"--sync-first",
is_flag=True,
help="Refresh local cache before calculating top senders",
)
@click.option(
"--sync-limit",
default=5000,
show_default=True,
help="Max messages per chat when using --sync-first",
)
@click.option("-n", "--limit", default=20, help="Top N senders")
@structured_output_options
def top(
chat: str | None,
hours: int | None,
sync_first: bool,
sync_limit: int,
limit: int,
as_json: bool,
as_yaml: bool,
):
"""Show most active senders."""
_maybe_sync_first(chat, sync_first, sync_limit)
with MessageDB() as db:
chat_id = resolve_chat_id_or_print(db, chat)
if chat and chat_id is None:
return
results = db.top_senders(chat_id=chat_id, hours=hours, limit=limit)
if results and emit_structured(results, as_json=as_json, as_yaml=as_yaml):
return
if not results:
if emit_structured([], as_json=as_json, as_yaml=as_yaml):
return
console.print("[yellow]No sender data found.[/yellow]")
return
table = Table(title="Top Senders")
table.add_column("#", style="dim", justify="right")
table.add_column("Sender", style="bold")
table.add_column("Messages", justify="right")
table.add_column("First", style="dim")
table.add_column("Last", style="dim")
for i, r in enumerate(results, 1):
table.add_row(
str(i),
r["sender_name"],
str(r["msg_count"]),
(r["first_msg"] or "")[:10],
(r["last_msg"] or "")[:10],
)
console.print(table)
@query_group.command("timeline")
@click.option("-c", "--chat", help="Filter by chat name")
@click.option("--hours", type=int, help="Only show last N hours")
@click.option("--by", "granularity", type=click.Choice(["day", "hour"]), default="day")
@click.option(
"--sync-first",
is_flag=True,
help="Refresh local cache before building the timeline",
)
@click.option(
"--sync-limit",
default=5000,
show_default=True,
help="Max messages per chat when using --sync-first",
)
@structured_output_options
def timeline(
chat: str | None,
hours: int | None,
granularity: str,
sync_first: bool,
sync_limit: int,
as_json: bool,
as_yaml: bool,
):
"""Show message activity over time as a bar chart."""
_maybe_sync_first(chat, sync_first, sync_limit)
with MessageDB() as db:
chat_id = resolve_chat_id_or_print(db, chat)
if chat and chat_id is None:
return
results = db.timeline(chat_id=chat_id, hours=hours, granularity=granularity)
if results and emit_structured(results, as_json=as_json, as_yaml=as_yaml):
return
if not results:
if emit_structured([], as_json=as_json, as_yaml=as_yaml):
return
console.print("[yellow]No timeline data.[/yellow]")
return
max_count = max(r["msg_count"] for r in results)
bar_width = 40
for r in results:
period = r["period"]
count = r["msg_count"]
bar_len = int(count / max_count * bar_width) if max_count > 0 else 0
bar = "█" * bar_len
console.print(f"[dim]{period}[/dim] {bar} [bold]{count}[/bold]")
@query_group.command("today")
@click.option("-c", "--chat", help="Filter by chat name")
@click.option(
"--sync-first",
is_flag=True,
help="Refresh local cache before reading today's messages",
)
@click.option(
"--sync-limit",
default=5000,
show_default=True,
help="Max messages per chat when using --sync-first",
)
@structured_output_options
def today(chat: str | None, sync_first: bool, sync_limit: int, as_json: bool, as_yaml: bool):
"""Show today's messages, grouped by chat."""
from datetime import datetime
_maybe_sync_first(chat, sync_first, sync_limit)
with MessageDB() as db:
chat_id = resolve_chat_id_or_print(db, chat)
if chat and chat_id is None:
return
msgs = db.get_today(chat_id=chat_id)
latest_ts = db.get_latest_timestamp(chat_id=chat_id)
if msgs and emit_structured(msgs, as_json=as_json, as_yaml=as_yaml):
return
if not msgs:
if emit_structured([], as_json=as_json, as_yaml=as_yaml):
return
console.print("[yellow]No messages today.[/yellow]")
if latest_ts:
latest_local = datetime.fromisoformat(latest_ts).astimezone()
console.print(
"[dim]Latest local message is from "
f"{latest_local.strftime('%Y-%m-%d %H:%M:%S %Z')}. "
"Run 'tg refresh' to refresh.[/dim]"
)
else:
console.print("[dim]Local database is empty. Run 'tg refresh' first.[/dim]")
return
# Group by chat
grouped: dict[str, list[dict]] = defaultdict(list)
for m in msgs:
grouped[m.get("chat_name") or "Unknown"].append(m)
for chat_name, chat_msgs in sorted(grouped.items(), key=lambda x: -len(x[1])):
console.print(f"\n[bold cyan]═══ {chat_name} ({len(chat_msgs)} msgs) ═══[/bold cyan]")
for m in chat_msgs:
ts = (m.get("timestamp") or "")[11:19]
sender = m.get("sender_name") or "Unknown"
content = (m.get("content") or "")[:200].replace("\n", " ")
console.print(f" [dim]{ts}[/dim] [bold]{sender[:15]}[/bold]: {content}")
console.print(f"\n[green]Total: {len(msgs)} messages today[/green]")
@query_group.command("filter")
@click.argument("keywords")
@click.option("-c", "--chat", help="Filter by chat name")
@click.option("--hours", type=int, help="Only search last N hours (default: today)")
@click.option("--sync-first", is_flag=True, help="Refresh local cache before filtering")
@click.option(
"--sync-limit",
default=5000,
show_default=True,
help="Max messages per chat when using --sync-first",
)
@structured_output_options
def filter_msgs(
keywords: str,
chat: str | None,
hours: int | None,
sync_first: bool,
sync_limit: int,
as_json: bool,
as_yaml: bool,
):
"""Filter messages by KEYWORDS (comma-separated, OR logic).
Examples:
tg filter "Rust,Golang,Java"
tg filter "招聘,remote,远程" --hours 48
tg filter "Rust" --chat "牛油果" --json
"""
import re
keyword_list = [k.strip() for k in keywords.split(",") if k.strip()]
if not keyword_list:
if emit_error("invalid_keywords", "Please provide at least one keyword."):
raise SystemExit(1) from None
console.print("[red]Please provide at least one keyword.[/red]")
return
_maybe_sync_first(chat, sync_first, sync_limit)
with MessageDB() as db:
chat_id = resolve_chat_id_or_print(db, chat)
if chat and chat_id is None:
return
if hours:
msgs = db.get_recent(chat_id=chat_id, hours=hours, limit=100000)
else:
msgs = db.get_today(chat_id=chat_id)
# Filter messages containing ANY of the keywords (case-insensitive)
pattern = re.compile("|".join(re.escape(k) for k in keyword_list), re.IGNORECASE)
matched = [m for m in msgs if m.get("content") and pattern.search(m["content"])]
if not matched:
if emit_structured([], as_json=as_json, as_yaml=as_yaml):
return
console.print(f"[yellow]No messages matching: {', '.join(keyword_list)}[/yellow]")
return
if emit_structured(matched, as_json=as_json, as_yaml=as_yaml):
return
# Group by chat
grouped: dict[str, list[dict]] = defaultdict(list)
for m in matched:
grouped[m.get("chat_name") or "Unknown"].append(m)
for chat_name, chat_msgs in sorted(grouped.items(), key=lambda x: -len(x[1])):
console.print(f"\n[bold cyan]═══ {chat_name} ({len(chat_msgs)} matches) ═══[/bold cyan]")
for m in chat_msgs:
ts = (m.get("timestamp") or "")[:19]
sender = m.get("sender_name") or "Unknown"
content = (m.get("content") or "")[:300].replace("\n", " ")
# Highlight keywords
for kw in keyword_list:
content = re.sub(
re.escape(kw),
f"[bold red]{kw}[/bold red]",
content,
flags=re.IGNORECASE,
)
console.print(
f" [dim]{ts}[/dim] [bold]{sender[:15]}[/bold]: ",
end="",
)
console.print(content, markup=True, highlight=False)
console.print(
f"\n[green]Found {len(matched)} messages matching "
f"'{', '.join(keyword_list)}' "
f"(from {len(msgs)} total)[/green]"
)
"""Telegram subcommands — send, edit, delete, and more."""
import asyncio
import time
import click
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.table import Table
from ..client import connect, fetch_history, get_chat_info, list_chats, listen
from ..console import console
from ..db import MessageDB
from ._chat import _parse_chat, resolve_chat_id_or_print
from ._output import (
default_structured_format,
dump_structured,
emit_structured,
error_payload,
structured_output_options,
success_payload,
)
from ._sync import sync_all_dialogs, sync_chat_dialog
def _telegram_user_payload(me) -> dict[str, str | int]:
"""Normalize Telegram user info for structured agent output."""
name = " ".join(part for part in [me.first_name, me.last_name] if part).strip()
return {
"id": me.id,
"name": name,
"username": me.username or "",
"first_name": me.first_name or "",
"last_name": me.last_name or "",
"phone": me.phone or "",
}
@click.group("tg")
def tg_group():
"""Telegram operations — connect, fetch, sync, listen."""
pass
@tg_group.command("chats")
@click.option("--type", "chat_type", help="Filter by type: user, group, supergroup, channel")
@structured_output_options
def tg_chats(chat_type: str | None, as_json: bool, as_yaml: bool):
"""List joined Telegram chats."""
async def _run():
async with connect() as client:
return await list_chats(client, chat_type)
chats = asyncio.run(_run())
if emit_structured(chats, as_json=as_json, as_yaml=as_yaml):
return
table = Table(title="Telegram Chats")
table.add_column("ID", style="dim")
table.add_column("Name", style="bold")
table.add_column("Type", style="cyan")
table.add_column("Unread", justify="right")
for c in chats:
table.add_row(str(c["id"]), c["name"], c["type"], str(c["unread"]))
console.print(table)
console.print(f"\nTotal: {len(chats)} chats")
@tg_group.command("history")
@click.argument("chat")
@click.option("-n", "--limit", default=1000, help="Max messages to fetch")
@structured_output_options
def tg_history(chat: str, limit: int, as_json: bool, as_yaml: bool):
"""Fetch historical messages from CHAT (name, username, or numeric ID)."""
async def _run():
with MessageDB() as db:
async with connect() as client:
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
task = progress.add_task(f"Fetching messages from {chat}...", total=None)
def on_progress(count: int):
progress.update(task, description=f"Stored {count} messages...")
count = await fetch_history(
client, _parse_chat(chat), limit=limit, db=db, on_progress=on_progress
)
return count
count = asyncio.run(_run())
payload = {"stored": count, "chat": chat}
if emit_structured(payload, as_json=as_json, as_yaml=as_yaml):
return
console.print(f"\n[green]\u2713[/green] Stored {count} messages from {chat}")
@tg_group.command("sync")
@click.argument("chat")
@click.option("-n", "--limit", default=5000, help="Max messages per sync")
@structured_output_options
def tg_sync(chat: str, limit: int, as_json: bool, as_yaml: bool):
"""Incremental sync — fetch only new messages from CHAT."""
async def _run():
with MessageDB() as db:
# Resolve chat_id to get last_msg_id
chat_id = resolve_chat_id_or_print(db, chat, allow_missing=True)
matches = db.find_chats(chat)
if len(matches) > 1:
resolve_chat_id_or_print(db, chat)
return None
last_id = db.get_last_msg_id(chat_id) if chat_id else 0
if last_id:
console.print(f"Syncing from msg_id > {last_id}...")
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
task_id = progress.add_task(f"Syncing {chat}...", total=None)
def on_progress(count: int):
progress.update(task_id, description=f"Stored {count} new messages...")
return await sync_chat_dialog(chat, limit=limit, on_progress=on_progress)
count = asyncio.run(_run())
if count is None:
return
payload = {"synced": count, "chat": chat}
if emit_structured(payload, as_json=as_json, as_yaml=as_yaml):
return
console.print(f"\n[green]\u2713[/green] Synced {count} new messages from {chat}")
@tg_group.command("sync-all")
@click.option("-n", "--limit", default=5000, help="Max messages per chat")
@click.option(
"--delay",
default=1.0,
show_default=True,
help="Seconds between chat syncs (anti-ban). Set 0 to disable.",
)
@click.option(
"--max-chats",
default=None,
type=int,
help="Max number of chats to sync per run (default: all)",
)
@structured_output_options
def tg_sync_all(limit: int, delay: float, max_chats: int | None, as_json: bool, as_yaml: bool):
"""Sync all currently available Telegram dialogs with a single connection."""
async def _run():
on_chat_done = None
if not as_json and not as_yaml:
console.print("Syncing all available chats...")
def _on_chat_done(name: str, new_count: int, total: int):
if new_count > 0:
console.print(f" [green]✓[/green] {name}: +{new_count} (total: {total})")
else:
console.print(f" [dim]✓ {name}: no new messages[/dim]")
on_chat_done = _on_chat_done
return await sync_all_dialogs(
limit=limit, on_chat_done=on_chat_done, delay=delay, max_chats=max_chats
)
results = asyncio.run(_run())
total_new = sum(results.values())
payload = {"new_messages": total_new, "chats": len(results), "results": results}
if emit_structured(payload, as_json=as_json, as_yaml=as_yaml):
return
console.print(f"\n[green]✓[/green] Synced {total_new} new messages across {len(results)} chats")
@tg_group.command("refresh")
@click.option("-n", "--limit", default=5000, help="Max messages per chat")
@click.option(
"--delay",
default=1.0,
show_default=True,
help="Seconds between chat syncs (anti-ban). Set 0 to disable.",
)
@click.option(
"--max-chats",
default=None,
type=int,
help="Max number of chats to sync per run (default: all)",
)
@structured_output_options
def tg_refresh(limit: int, delay: float, max_chats: int | None, as_json: bool, as_yaml: bool):
"""Refresh the local cache from all current Telegram dialogs."""
async def _run():
on_chat_done = None
if not as_json and not as_yaml:
console.print("Refreshing local cache...")
def _on_chat_done(name: str, new_count: int, total: int):
if new_count > 0:
console.print(f" [green]✓[/green] {name}: +{new_count} (total: {total})")
else:
console.print(f" [dim]✓ {name}: no new messages[/dim]")
on_chat_done = _on_chat_done
return await sync_all_dialogs(
limit=limit, on_chat_done=on_chat_done, delay=delay, max_chats=max_chats
)
results = asyncio.run(_run())
total_new = sum(results.values())
updated = [
name
for name, count in sorted(results.items(), key=lambda item: (-item[1], item[0]))
if count > 0
]
payload = {
"new_messages": total_new,
"chats": len(results),
"updated_chats": updated,
"results": results,
}
if emit_structured(payload, as_json=as_json, as_yaml=as_yaml):
return
console.print(f"\n[green]✓[/green] Refreshed {len(results)} chats, {total_new} new messages.")
if updated:
console.print(f"[dim]Most recently updated: {', '.join(updated[:5])}[/dim]")
@tg_group.command("listen")
@click.argument("chats", nargs=-1)
@click.option("--persist", is_flag=True, help="Reconnect automatically if the connection drops")
@click.option(
"--retry-seconds",
default=5,
show_default=True,
help="Reconnect delay when using --persist",
)
def tg_listen(chats: tuple[str, ...], persist: bool, retry_seconds: int):
"""Real-time listener for new messages. Optionally specify CHATS to filter."""
parsed: list[str | int] | None = None
if chats:
parsed = []
for c in chats:
try:
parsed.append(int(c))
except ValueError:
parsed.append(c)
async def _run_once():
async with connect() as client:
return await listen(client, chats=parsed)
while True:
try:
result = asyncio.run(_run_once())
except click.ClickException:
raise
except Exception as exc:
if not persist:
raise
console.print(
f"[yellow]Listener disconnected: {exc}. Retrying in {retry_seconds}s...[/yellow]"
)
time.sleep(retry_seconds)
continue
if not persist or result == "stopped":
break
console.print(
f"[yellow]Listener disconnected. Reconnecting in {retry_seconds}s...[/yellow]"
)
time.sleep(retry_seconds)
@tg_group.command("info")
@click.argument("chat")
@structured_output_options
def tg_info(chat: str, as_json: bool, as_yaml: bool):
"""Show detailed info about CHAT."""
async def _run():
async with connect() as client:
return await get_chat_info(client, _parse_chat(chat))
info = asyncio.run(_run())
if not info:
console.print(f"[red]Could not find chat: {chat}[/red]")
return
if emit_structured(info, as_json=as_json, as_yaml=as_yaml):
return
table = Table(title="Chat Info", show_header=False)
table.add_column("Field", style="bold")
table.add_column("Value")
for k, v in info.items():
table.add_row(k, v)
console.print(table)
@tg_group.command("whoami")
@structured_output_options
def tg_whoami(as_json: bool, as_yaml: bool):
"""Show current logged-in user info."""
async def _run():
async with connect() as client:
me = await client.get_me()
return me
fmt = default_structured_format(as_json=as_json, as_yaml=as_yaml)
try:
me = asyncio.run(_run())
except Exception as exc:
if fmt is not None:
click.echo(dump_structured(error_payload("auth_error", str(exc)), fmt=fmt))
raise SystemExit(1) from None
raise click.ClickException(str(exc)) from exc
info = _telegram_user_payload(me)
if emit_structured(success_payload({"user": info}), as_json=as_json, as_yaml=as_yaml):
return
name = " ".join(p for p in [me.first_name, me.last_name] if p)
table = Table(title=f"👤 {name}")
table.add_column("Field", style="bold cyan")
table.add_column("Value", style="green")
table.add_row("ID", str(me.id))
table.add_row("Name", name)
if me.username:
table.add_row("Username", f"@{me.username}")
if me.phone:
table.add_row("Phone", f"+{me.phone}")
console.print(table)
@tg_group.command("status")
@structured_output_options
def tg_status(as_json: bool, as_yaml: bool):
"""Show Telegram authentication status."""
async def _run():
async with connect() as client:
me = await client.get_me()
return {
"authenticated": True,
"id": me.id,
"first_name": me.first_name or "",
"last_name": me.last_name or "",
"username": me.username or "",
"phone": me.phone or "",
}
fmt = default_structured_format(as_json=as_json, as_yaml=as_yaml)
try:
info = asyncio.run(_run())
except Exception as exc:
if fmt is not None:
click.echo(dump_structured(error_payload("auth_error", str(exc)), fmt=fmt))
raise SystemExit(1) from None
raise click.ClickException(str(exc)) from exc
user = {key: value for key, value in info.items() if key != "authenticated"}
if emit_structured(
success_payload({"authenticated": True, "user": user}),
as_json=as_json,
as_yaml=as_yaml,
):
return
name = " ".join(part for part in [info["first_name"], info["last_name"]] if part).strip()
console.print(f"[green]✓[/green] Authenticated as [bold]{name or info['id']}[/bold]")
if info["username"]:
console.print(f"[dim]@{info['username']}[/dim]")
@tg_group.command("send")
@click.argument("chat")
@click.argument("message")
@click.option("-r", "--reply", type=int, default=None, help="Message ID to reply to")
@click.option("--no-preview", is_flag=True, help="Disable link preview")
@structured_output_options
def tg_send(
chat: str,
message: str,
reply: int | None,
no_preview: bool,
as_json: bool,
as_yaml: bool,
):
"""Send a MESSAGE to CHAT (name, username, or numeric ID)."""
async def _run():
async with connect() as client:
msg = await client.send_message(
_parse_chat(chat),
message,
reply_to=reply,
link_preview=not no_preview,
)
return msg
msg = asyncio.run(_run())
payload = {"sent": True, "msg_id": msg.id, "chat": chat}
if reply is not None:
payload["reply_to"] = reply
if emit_structured(payload, as_json=as_json, as_yaml=as_yaml):
return
console.print(f"[green]\u2713[/green] Message sent (id: {msg.id})")
@tg_group.command("edit")
@click.argument("chat")
@click.argument("msg_id", type=int)
@click.argument("new_text")
@click.option("--no-preview", is_flag=True, help="Disable link preview")
@structured_output_options
def tg_edit(chat: str, msg_id: int, new_text: str, no_preview: bool, as_json: bool, as_yaml: bool):
"""Edit a previously sent message. CHAT MSG_ID NEW_TEXT."""
async def _run():
async with connect() as client:
return await client.edit_message(
_parse_chat(chat),
msg_id,
new_text,
link_preview=not no_preview,
)
asyncio.run(_run())
payload = {"edited": True, "msg_id": msg_id, "chat": chat}
if emit_structured(payload, as_json=as_json, as_yaml=as_yaml):
return
console.print(f"[green]\u2713[/green] Message {msg_id} edited")
@tg_group.command("delete")
@click.argument("chat")
@click.argument("msg_ids", nargs=-1, type=int, required=True)
@structured_output_options
def tg_delete(chat: str, msg_ids: tuple[int, ...], as_json: bool, as_yaml: bool):
"""Delete one or more messages. CHAT MSG_ID [MSG_ID ...]."""
async def _run():
async with connect() as client:
await client.delete_messages(_parse_chat(chat), list(msg_ids))
asyncio.run(_run())
payload = {"deleted": True, "msg_ids": list(msg_ids), "chat": chat}
if emit_structured(payload, as_json=as_json, as_yaml=as_yaml):
return
console.print(f"[green]\u2713[/green] Deleted {len(msg_ids)} message(s)")
"""Telegram client with connection reuse and entity caching."""
from __future__ import annotations
import asyncio
import logging
import random
from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from telethon import TelegramClient, events
from telethon.errors import FloodWaitError
from telethon.tl.types import Channel, Chat, User
from .config import (
get_api_hash,
get_api_id,
get_session_path,
is_default_api_id,
)
from .console import console
from .db import MessageDB
log = logging.getLogger(__name__)
# Telegram Desktop 5.x fingerprint — makes the session look like a real client
_DEVICE_MODEL = "Desktop"
_SYSTEM_VERSION = "macOS 15.3"
_APP_VERSION = "5.12.1"
_LANG_CODE = "en"
_SYSTEM_LANG_CODE = "en-US"
# Progressive sync: limit for first-time chat sync (no prior messages in DB)
_FIRST_SYNC_LIMIT = 500
def _get_sender_name(sender: User | Channel | Chat | None) -> str | None:
if sender is None:
return None
if isinstance(sender, User):
parts = [sender.first_name or "", sender.last_name or ""]
name = " ".join(p for p in parts if p)
return name or sender.username or str(sender.id)
return getattr(sender, "title", None) or str(sender.id)
_default_api_warned = False
@asynccontextmanager
async def connect() -> AsyncGenerator[TelegramClient, None]:
"""Async context manager for Telegram client — single connection, reuse within scope."""
global _default_api_warned
api_id = get_api_id()
api_hash = get_api_hash()
if not _default_api_warned and is_default_api_id():
_default_api_warned = True
console.print(
"[yellow]⚠ Using default Telegram Desktop API credentials (api_id=2040).\n"
" This increases the risk of account restrictions.\n"
" Get your own at https://my.telegram.org and set TG_API_ID / TG_API_HASH.[/yellow]"
)
c = TelegramClient(
get_session_path(),
api_id,
api_hash,
device_model=_DEVICE_MODEL,
system_version=_SYSTEM_VERSION,
app_version=_APP_VERSION,
lang_code=_LANG_CODE,
system_lang_code=_SYSTEM_LANG_CODE,
)
await c.start()
try:
yield c
finally:
await c.disconnect()
async def list_chats(
client: TelegramClient,
chat_type: str | None = None,
) -> list[dict]:
"""List all dialogs (chats/groups/channels) the user has joined."""
results = []
async for dialog in client.iter_dialogs():
entity = dialog.entity
t = "unknown"
if isinstance(entity, User):
t = "user"
elif isinstance(entity, Chat):
t = "group"
elif isinstance(entity, Channel):
t = "channel" if entity.broadcast else "supergroup"
if chat_type and t != chat_type:
continue
results.append(
{
"id": dialog.id,
"name": dialog.name,
"type": t,
"unread": dialog.unread_count,
}
)
return results
async def get_chat_info(client: TelegramClient, chat: str | int) -> dict | None:
"""Get detailed information about a chat."""
try:
entity = await client.get_entity(chat)
except Exception as e:
log.debug("get_chat_info failed for %s: %s", chat, e)
return None
info: dict[str, str] = {}
info["Title"] = getattr(entity, "title", None) or getattr(entity, "first_name", "") or str(chat)
info["ID"] = str(entity.id)
if isinstance(entity, User):
info["Type"] = "User"
info["Username"] = f"@{entity.username}" if entity.username else "—"
info["Phone"] = entity.phone or "—"
elif isinstance(entity, Chat):
info["Type"] = "Group"
info["Members"] = str(getattr(entity, "participants_count", "?"))
elif isinstance(entity, Channel):
info["Type"] = "Channel" if entity.broadcast else "Supergroup"
info["Username"] = f"@{entity.username}" if entity.username else "—"
try:
from telethon.tl.functions.channels import GetFullChannelRequest
full = await client(GetFullChannelRequest(entity))
info["Members"] = str(full.full_chat.participants_count or "?")
if full.full_chat.about:
info["Description"] = full.full_chat.about[:200]
except Exception as e:
info["Members"] = "?"
log.debug("Failed to get full channel info: %s", e)
return info
async def fetch_history(
client: TelegramClient,
chat: str | int,
limit: int = 1000,
db: MessageDB | None = None,
on_progress: Callable[[int], None] | None = None,
min_id: int = 0,
batch_delay: float = 0,
) -> int:
"""Fetch historical messages from a chat and store them in the database.
Args:
client: Connected TelegramClient instance
chat: Group name, username, or numeric ID
limit: Max messages to fetch
db: Database instance (creates one if None)
on_progress: Callback invoked every batch with current count
min_id: Only fetch messages with id > min_id (for incremental sync)
batch_delay: Seconds to sleep between DB write batches (with ±30% jitter).
Throttles iter_messages pagination. Set to 0 to disable.
"""
owns_db = db is None
if db is None:
db = MessageDB()
try:
entity = await client.get_entity(chat)
chat_name = (
getattr(entity, "title", None) or getattr(entity, "first_name", None) or str(chat)
)
chat_id = entity.id
# Lazy sender name resolution — avoids risky iter_participants API
sender_cache: dict[int, str] = {}
batch: list[dict] = []
inserted_count = 0
BATCH_SIZE = 200
async for msg in client.iter_messages(entity, limit=limit, min_id=min_id):
if msg.text is None and msg.message is None:
continue
# Extract sender name from Telethon's cached _sender (zero API calls)
sender_name = None
if msg.sender_id:
if msg.sender_id in sender_cache:
sender_name = sender_cache[msg.sender_id]
else:
# Telethon caches sender in msg._sender from the response
cached = getattr(msg, "_sender", None) or getattr(msg, "sender", None)
if cached:
sender_name = _get_sender_name(cached)
if sender_name:
sender_cache[msg.sender_id] = sender_name
content = msg.text or msg.message or ""
ts = msg.date
if ts and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
batch.append(
dict(
chat_id=chat_id,
chat_name=chat_name,
msg_id=msg.id,
sender_id=msg.sender_id,
sender_name=sender_name,
content=content,
timestamp=ts or datetime.now(timezone.utc),
)
)
if len(batch) >= BATCH_SIZE:
inserted_count += db.insert_batch(batch)
batch.clear()
if on_progress:
on_progress(inserted_count)
# Anti-ban: throttle between pagination batches
if batch_delay > 0:
jitter = batch_delay * random.uniform(-0.3, 0.3)
await asyncio.sleep(batch_delay + jitter)
# Flush remaining
if batch:
inserted_count += db.insert_batch(batch)
return inserted_count
except FloodWaitError as e:
console.print(f"[yellow]⚠ Telegram rate limit hit, waiting {e.seconds}s...[/yellow]")
await asyncio.sleep(e.seconds + random.uniform(1, 3))
return 0
finally:
if owns_db:
db.close()
async def sync_all(
client: TelegramClient,
db: MessageDB,
limit_per_chat: int = 5000,
on_chat_done: Callable[[str, int, int], None] | None = None,
delay: float = 1.0,
max_chats: int | None = None,
) -> dict[str, int]:
"""Sync all chats in the database using a single connection.
Args:
on_chat_done: Callback(chat_name, new_count, total_in_chat)
delay: Seconds to wait between each chat sync (with ±20% jitter).
Set to 0 to disable. Helps avoid triggering Telegram rate limits.
max_chats: Max number of chats to sync per run. None = no limit.
Returns:
dict mapping chat_name to new message count
"""
results: dict[str, int] = {}
stored_chats = {c["chat_id"]: c for c in db.get_chats()}
dialog_cache: dict[int, tuple[object, str]] = {}
try:
async for dialog in client.iter_dialogs():
entity = dialog.entity
dialog_cache[entity.id] = (entity, dialog.name)
except Exception as e:
log.debug("Failed to build dialog cache: %s", e)
items = list(dialog_cache.items())
if max_chats is not None:
items = items[:max_chats]
total = len(items)
for idx, (chat_id, (entity, dialog_name)) in enumerate(items):
chat_info = stored_chats.get(chat_id, {})
chat_name = chat_info.get("chat_name") or dialog_name or str(chat_id)
last_id = db.get_last_msg_id(chat_id) or 0
# Progressive sync: use lower limit for first-time chat sync
effective_limit = limit_per_chat
if last_id == 0 and limit_per_chat > _FIRST_SYNC_LIMIT:
effective_limit = _FIRST_SYNC_LIMIT
log.debug("First sync for %s, limiting to %d messages", chat_name, effective_limit)
try:
count = await fetch_history(
client,
entity,
limit=effective_limit,
db=db,
min_id=last_id,
)
results[chat_name] = count
if on_chat_done:
on_chat_done(chat_name, count, chat_info.get("msg_count", 0) + count)
except FloodWaitError as e:
console.print(
f" [yellow]⚠ {chat_name}: rate limited, waiting {e.seconds}s...[/yellow]"
)
await asyncio.sleep(e.seconds + random.uniform(1, 3))
results[chat_name] = 0
except Exception as e:
console.print(f" [red]✗ {chat_name}: {e}[/red]")
results[chat_name] = 0
# Anti-ban: sleep with random jitter between chat syncs
if delay > 0 and idx < total - 1:
jitter = delay * random.uniform(-0.2, 0.2)
await asyncio.sleep(delay + jitter)
return results
async def listen(
client: TelegramClient,
chats: list[str | int] | None = None,
db: MessageDB | None = None,
):
"""Real-time listen for new messages in specified chats (or all chats)."""
owns_db = db is None
if db is None:
db = MessageDB()
try:
me = await client.get_me()
console.print(f"[green]✓[/green] Logged in as [bold]{me.first_name}[/bold] ({me.phone})")
console.print("[dim]Listening for messages... Press Ctrl+C to stop.[/dim]")
@client.on(events.NewMessage(chats=chats))
async def handler(event):
msg = event.message
chat = await event.get_chat()
sender = await event.get_sender()
chat_name = (
getattr(chat, "title", None) or getattr(chat, "first_name", None) or "Unknown"
)
sender_name = _get_sender_name(sender)
content = msg.text or msg.message or ""
ts = msg.date
if ts and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
db.insert_message(
chat_id=chat.id,
chat_name=chat_name,
msg_id=msg.id,
sender_id=msg.sender_id,
sender_name=sender_name,
content=content,
timestamp=ts or datetime.now(timezone.utc),
)
time_str = ts.strftime("%H:%M:%S") if ts else "??:??:??"
console.print(
f"[dim]{time_str}[/dim] [cyan]{chat_name}[/cyan] | "
f"[bold]{sender_name or 'Unknown'}[/bold]: {content[:200]}"
)
status = "disconnected"
try:
await client.run_until_disconnected()
except KeyboardInterrupt:
status = "stopped"
console.print("\n[yellow]Stopped listening.[/yellow]")
finally:
db_count = db.count()
console.print(f"[green]Total messages in DB: {db_count}[/green]")
return status
finally:
if owns_db:
db.close()
"""Configuration management - loads from .env or environment variables."""
from __future__ import annotations
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
# Load .env from project root
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
def _load_env() -> None:
"""Load .env from cwd first, then fall back to the source checkout."""
for candidate in (Path.cwd() / ".env", _PROJECT_ROOT / ".env"):
if candidate.is_file():
load_dotenv(candidate)
return
def _default_data_home() -> Path:
"""Return a platform-appropriate base directory for application data."""
if raw := os.environ.get("XDG_DATA_HOME", ""):
return Path(raw).expanduser()
home = Path.home()
if sys.platform == "darwin":
return home / "Library" / "Application Support"
if os.name == "nt":
local_appdata = os.environ.get("LOCALAPPDATA", "")
if local_appdata:
return Path(local_appdata).expanduser()
return home / "AppData" / "Local"
return home / ".local" / "share"
def _resolve_env_path(raw: str) -> Path:
"""Resolve user-provided paths relative to the current working directory."""
path = Path(raw).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
return path
_load_env()
APP_NAME = "tg-cli"
# Telegram Desktop built-in credentials (public, no application needed)
_DEFAULT_API_ID = 2040
_DEFAULT_API_HASH = "b18441a1ff607e10a989891a5462e627"
def get_api_id() -> int:
val = os.environ.get("TG_API_ID", "")
if val:
return int(val)
return _DEFAULT_API_ID
def get_api_hash() -> str:
val = os.environ.get("TG_API_HASH", "")
if val:
return val
return _DEFAULT_API_HASH
def is_default_api_id() -> bool:
"""Return True if the user has NOT set a custom TG_API_ID."""
return not os.environ.get("TG_API_ID", "")
def get_session_name() -> str:
return os.environ.get("TG_SESSION_NAME", "tg_cli")
def get_session_path() -> str:
"""Return session file path inside data/ directory."""
data_dir = get_data_dir()
name = get_session_name()
return str(data_dir / name)
def get_data_dir() -> Path:
"""Return data directory, create if not exists."""
raw = os.environ.get("DATA_DIR", "")
if raw:
d = _resolve_env_path(raw)
else:
d = _default_data_home() / APP_NAME
d.mkdir(parents=True, exist_ok=True)
return d
def get_db_path() -> Path:
raw = os.environ.get("DB_PATH", "")
if raw:
p = _resolve_env_path(raw)
else:
p = get_data_dir() / "messages.db"
p.parent.mkdir(parents=True, exist_ok=True)
return p
"""Shared Rich console instance for tg-cli."""
from rich.console import Console
console = Console(stderr=True)
"""SQLite database for storing chat messages."""
from __future__ import annotations
import json
import logging
import re
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from .config import get_db_path
log = logging.getLogger(__name__)
_CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL DEFAULT 'telegram',
chat_id INTEGER NOT NULL,
chat_name TEXT,
msg_id INTEGER NOT NULL,
sender_id INTEGER,
sender_name TEXT,
content TEXT,
timestamp TEXT NOT NULL,
raw_json TEXT,
UNIQUE(platform, chat_id, msg_id)
);
"""
_CREATE_INDEX = """
CREATE INDEX IF NOT EXISTS idx_messages_chat_ts ON messages(chat_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_messages_content ON messages(content);
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_name);
"""
def _canonical_chat_id(chat_id: int) -> int:
"""Normalize Telegram chat IDs to the bare numeric ID stored in SQLite.
Only strips the -100 prefix from negative IDs (Telegram's convention for
channels/supergroups). Positive IDs starting with 100 are left as-is.
"""
if chat_id < 0:
digits = str(abs(chat_id))
if digits.startswith("100") and len(digits) > 3:
return int(digits[3:])
return abs(chat_id)
return chat_id
class MessageDB:
"""SQLite message store with context manager support."""
def __init__(self, db_path: Path | str | None = None):
if db_path is None:
self.db_path = get_db_path()
else:
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(str(self.db_path))
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.executescript(_CREATE_TABLE + _CREATE_INDEX)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
def find_chats(self, chat_str: str) -> list[dict]:
"""Return chats matching a numeric ID, exact name, or partial name."""
chats = self.get_chats()
try:
numeric_id = _canonical_chat_id(int(chat_str))
exact_id_matches = [c for c in chats if c["chat_id"] == numeric_id]
if exact_id_matches:
return exact_id_matches
except ValueError:
pass
exact_name_matches = [
c for c in chats if c["chat_name"] and c["chat_name"].casefold() == chat_str.casefold()
]
if exact_name_matches:
return exact_name_matches
partial_matches = [
c for c in chats if c["chat_name"] and chat_str.casefold() in c["chat_name"].casefold()
]
return partial_matches
def resolve_chat_id(self, chat_str: str) -> int | None:
"""Resolve a chat string (name or numeric ID) to a unique database chat_id."""
matches = self.find_chats(chat_str)
if len(matches) == 1:
return matches[0]["chat_id"]
return None
def insert_message(
self,
*,
platform: str = "telegram",
chat_id: int,
chat_name: str | None,
msg_id: int,
sender_id: int | None,
sender_name: str | None,
content: str | None,
timestamp: datetime,
raw_json: dict[str, Any] | None = None,
) -> bool:
"""Insert a message, returns True if inserted (not duplicate)."""
try:
cursor = self.conn.execute(
"""INSERT OR IGNORE INTO messages
(
platform,
chat_id,
chat_name,
msg_id,
sender_id,
sender_name,
content,
timestamp,
raw_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
platform,
chat_id,
chat_name,
msg_id,
sender_id,
sender_name,
content,
timestamp.isoformat(),
json.dumps(raw_json, ensure_ascii=False) if raw_json else None,
),
)
self.conn.commit()
return cursor.rowcount > 0
except sqlite3.Error as e:
log.debug("insert_message failed: %s", e)
return False
def insert_batch(self, messages: list[dict], platform: str = "telegram") -> int:
"""Batch insert messages in a single transaction.
Returns the number of rows actually inserted, excluding duplicates.
"""
if not messages:
return 0
rows = [
(
platform,
m["chat_id"],
m.get("chat_name"),
m["msg_id"],
m.get("sender_id"),
m.get("sender_name"),
m.get("content"),
(
m["timestamp"].isoformat()
if isinstance(m["timestamp"], datetime)
else m["timestamp"]
),
json.dumps(m["raw_json"], ensure_ascii=False) if m.get("raw_json") else None,
)
for m in messages
]
try:
before = self.conn.total_changes
self.conn.executemany(
"""INSERT OR IGNORE INTO messages
(
platform,
chat_id,
chat_name,
msg_id,
sender_id,
sender_name,
content,
timestamp,
raw_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
rows,
)
self.conn.commit()
return self.conn.total_changes - before
except sqlite3.Error as e:
log.warning("insert_batch failed: %s", e)
return 0
def search(
self,
keyword: str,
chat_id: int | None = None,
sender: str | None = None,
hours: int | None = None,
limit: int = 50,
) -> list[dict]:
"""Search messages by keyword."""
query = "SELECT * FROM messages WHERE content LIKE ?"
params: list[Any] = [f"%{keyword}%"]
if chat_id is not None:
query += " AND chat_id = ?"
params.append(chat_id)
if sender is not None:
query += " AND sender_name LIKE ?"
params.append(f"%{sender}%")
if hours is not None:
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
query += " AND timestamp >= ?"
params.append(cutoff)
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
rows = self.conn.execute(query, params).fetchall()
return [dict(r) for r in rows]
def search_regex(
self,
pattern: str,
chat_id: int | None = None,
sender: str | None = None,
hours: int | None = None,
limit: int = 50,
) -> list[dict]:
"""Search messages by regex pattern."""
regex = re.compile(pattern, re.IGNORECASE)
query = "SELECT * FROM messages WHERE content IS NOT NULL"
params: list[Any] = []
if chat_id is not None:
query += " AND chat_id = ?"
params.append(chat_id)
if sender is not None:
query += " AND sender_name LIKE ?"
params.append(f"%{sender}%")
if hours is not None:
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
query += " AND timestamp >= ?"
params.append(cutoff)
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit * 10)
rows = self.conn.execute(query, params).fetchall()
results: list[dict] = []
for row in rows:
msg = dict(row)
content = msg.get("content") or ""
if regex.search(content):
results.append(msg)
if len(results) >= limit:
break
return results
def get_recent(
self,
chat_id: int | None = None,
sender: str | None = None,
hours: int | None = 24,
limit: int = 500,
) -> list[dict]:
"""Get the latest messages, returned in chronological order."""
if hours is not None:
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
base_query = "SELECT * FROM messages WHERE timestamp >= ?"
params: list[Any] = [cutoff]
else:
base_query = "SELECT * FROM messages WHERE 1=1"
params = []
if chat_id is not None:
base_query += " AND chat_id = ?"
params.append(chat_id)
if sender is not None:
base_query += " AND sender_name LIKE ?"
params.append(f"%{sender}%")
query = (
f"SELECT * FROM ({base_query} ORDER BY timestamp DESC LIMIT ?) ORDER BY timestamp ASC"
)
rows = self.conn.execute(query, params + [limit]).fetchall()
return [dict(r) for r in rows]
def get_today(
self,
chat_id: int | None = None,
tz_offset_hours: int | None = None,
limit: int = 5000,
) -> list[dict]:
"""Get today's messages (in local timezone).
Args:
tz_offset_hours: Local timezone offset from UTC.
If None, auto-detect from system timezone.
"""
# Today 00:00 in local time → UTC
now_utc = datetime.now(timezone.utc)
if tz_offset_hours is not None:
local_tz = timezone(timedelta(hours=tz_offset_hours))
else:
# Auto-detect system timezone
local_tz = datetime.now().astimezone().tzinfo
today_local = now_utc.astimezone(local_tz).replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)
cutoff_utc = today_local.astimezone(timezone.utc).isoformat()
query = "SELECT * FROM messages WHERE timestamp >= ?"
params: list[Any] = [cutoff_utc]
if chat_id is not None:
query += " AND chat_id = ?"
params.append(chat_id)
query += " ORDER BY chat_name, timestamp ASC LIMIT ?"
params.append(limit)
rows = self.conn.execute(query, params).fetchall()
return [dict(r) for r in rows]
def get_chats(self) -> list[dict]:
"""Get all known chats with message counts."""
rows = self.conn.execute(
"""SELECT chat_id, chat_name, COUNT(*) as msg_count,
MIN(timestamp) as first_msg, MAX(timestamp) as last_msg
FROM messages
GROUP BY chat_id
ORDER BY msg_count DESC"""
).fetchall()
return [dict(r) for r in rows]
def get_last_msg_id(self, chat_id: int) -> int | None:
"""Get the latest msg_id for a chat, used for incremental sync."""
row = self.conn.execute(
"SELECT MAX(msg_id) FROM messages WHERE chat_id = ?", (chat_id,)
).fetchone()
return row[0] if row and row[0] is not None else None
def count(self, chat_id: int | None = None) -> int:
if chat_id is not None:
row = self.conn.execute(
"SELECT COUNT(*) FROM messages WHERE chat_id = ?", (chat_id,)
).fetchone()
else:
row = self.conn.execute("SELECT COUNT(*) FROM messages").fetchone()
return row[0]
def get_latest_timestamp(self, chat_id: int | None = None) -> str | None:
"""Return the latest stored message timestamp for a chat or the whole DB."""
if chat_id is not None:
row = self.conn.execute(
"SELECT MAX(timestamp) FROM messages WHERE chat_id = ?", (chat_id,)
).fetchone()
else:
row = self.conn.execute("SELECT MAX(timestamp) FROM messages").fetchone()
return row[0] if row and row[0] is not None else None
def delete_chat(self, chat_id: int) -> int:
"""Delete all messages for a chat. Returns number of deleted rows."""
cursor = self.conn.execute("DELETE FROM messages WHERE chat_id = ?", (chat_id,))
self.conn.commit()
return cursor.rowcount
def top_senders(
self,
chat_id: int | None = None,
hours: int | None = None,
limit: int = 20,
) -> list[dict]:
"""Get most active senders ranked by message count."""
conditions = ["(sender_id IS NOT NULL OR sender_name IS NOT NULL)"]
params: list[Any] = []
if chat_id is not None:
conditions.append("chat_id = ?")
params.append(chat_id)
if hours is not None:
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
conditions.append("timestamp >= ?")
params.append(cutoff)
where = " AND ".join(conditions)
rows = self.conn.execute(
f"""SELECT MAX(sender_name) as sender_name, sender_id, COUNT(*) as msg_count,
MIN(timestamp) as first_msg, MAX(timestamp) as last_msg
FROM messages WHERE {where}
GROUP BY COALESCE(CAST(sender_id AS TEXT), 'name:' || COALESCE(sender_name, ''))
ORDER BY msg_count DESC
LIMIT ?""",
params + [limit],
).fetchall()
return [dict(r) for r in rows]
def timeline(
self,
chat_id: int | None = None,
hours: int | None = None,
granularity: str = "day",
) -> list[dict]:
"""Get message count grouped by time period."""
if granularity == "hour":
time_expr = "substr(timestamp, 1, 13)" # YYYY-MM-DDTHH
else:
time_expr = "substr(timestamp, 1, 10)" # YYYY-MM-DD
conditions = ["1=1"]
params: list[Any] = []
if chat_id is not None:
conditions.append("chat_id = ?")
params.append(chat_id)
if hours is not None:
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
conditions.append("timestamp >= ?")
params.append(cutoff)
where = " AND ".join(conditions)
rows = self.conn.execute(
f"""SELECT {time_expr} as period, COUNT(*) as msg_count
FROM messages WHERE {where}
GROUP BY period
ORDER BY period ASC""",
params,
).fetchall()
return [dict(r) for r in rows]
def close(self):
self.conn.close()
"""Shared test fixtures."""
import os
from datetime import datetime, timedelta, timezone
import pytest
# Set env vars before importing
os.environ.setdefault("TG_API_ID", "0")
os.environ.setdefault("TG_API_HASH", "test")
os.environ.setdefault("OUTPUT", "rich")
from tg_cli.db import MessageDB
@pytest.fixture
def db(tmp_path):
"""Create a temporary database for testing."""
db_path = tmp_path / "test.db"
d = MessageDB(db_path=db_path)
yield d
d.close()
@pytest.fixture
def populated_db(tmp_path, monkeypatch):
"""Create a temp DB with sample data and patch config to use it."""
db_path = tmp_path / "test.db"
monkeypatch.setenv("DB_PATH", str(db_path))
import tg_cli.config as config_mod
monkeypatch.setattr(config_mod, "_PROJECT_ROOT", tmp_path)
db = MessageDB(db_path=db_path)
now = datetime.now(timezone.utc)
messages = [
dict(
chat_id=100,
chat_name="TestGroup",
msg_id=i,
sender_id=42,
sender_name="Alice",
content=f"Message {i}: {'Web3' if i % 2 == 0 else 'Python'} discussion",
timestamp=now - timedelta(hours=i),
)
for i in range(1, 11)
]
db.insert_batch(messages)
yield db, db_path
db.close()
def make_msg(
chat_id: int = 100,
chat_name: str = "TestChat",
msg_id: int = 1,
sender_id: int = 42,
sender_name: str = "Alice",
content: str = "Hello World",
hours_ago: float = 0,
):
ts = datetime.now(timezone.utc) - timedelta(hours=hours_ago)
return dict(
chat_id=chat_id,
chat_name=chat_name,
msg_id=msg_id,
sender_id=sender_id,
sender_name=sender_name,
content=content,
timestamp=ts,
)
"""Tests for Telegram client helpers without hitting the network."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import pytest
from tg_cli.client import fetch_history, sync_all
@dataclass
class FakeEntity:
id: int
title: str
@dataclass
class FakeDialog:
entity: FakeEntity
name: str
@dataclass
class FakeSender:
id: int
first_name: str = "User"
last_name: str = ""
username: str | None = None
@dataclass
class FakeMessage:
id: int
sender_id: int
text: str
date: datetime
message: str | None = None
_sender: object = None
def __post_init__(self):
if self._sender is None:
self._sender = FakeSender(id=self.sender_id)
class FakeClient:
def __init__(self, dialogs: list[FakeDialog], messages_by_chat: dict[int, list[FakeMessage]]):
self._dialogs = dialogs
self._messages_by_chat = messages_by_chat
async def get_entity(self, chat):
if isinstance(chat, FakeEntity):
return chat
for dialog in self._dialogs:
if chat == dialog.entity.id or chat == dialog.name:
return dialog.entity
raise ValueError(f"unknown chat: {chat}")
async def iter_dialogs(self):
for dialog in self._dialogs:
yield dialog
async def iter_messages(self, entity, limit: int, min_id: int = 0):
messages = self._messages_by_chat.get(entity.id, [])
for msg in messages[:limit]:
if msg.id > min_id:
yield msg
@pytest.mark.asyncio
async def test_fetch_history_returns_inserted_count(db):
entity = FakeEntity(id=100, title="Test Group")
client = FakeClient(
dialogs=[FakeDialog(entity=entity, name="Test Group")],
messages_by_chat={
100: [
FakeMessage(id=1, sender_id=1, text="old", date=datetime.now(timezone.utc)),
FakeMessage(id=2, sender_id=1, text="new-1", date=datetime.now(timezone.utc)),
FakeMessage(id=3, sender_id=1, text="new-2", date=datetime.now(timezone.utc)),
]
},
)
db.insert_message(
chat_id=100,
chat_name="Test Group",
msg_id=1,
sender_id=1,
sender_name="Alice",
content="old",
timestamp=datetime.now(timezone.utc),
)
inserted = await fetch_history(client, 100, db=db, limit=10, batch_delay=0)
assert inserted == 2
@pytest.mark.asyncio
async def test_sync_all_discovers_dialogs_from_client(db):
dialogs = [
FakeDialog(entity=FakeEntity(id=100, title="Group A"), name="Group A"),
FakeDialog(entity=FakeEntity(id=200, title="Group B"), name="Group B"),
]
client = FakeClient(
dialogs=dialogs,
messages_by_chat={
100: [FakeMessage(id=1, sender_id=1, text="hello", date=datetime.now(timezone.utc))],
200: [FakeMessage(id=1, sender_id=2, text="world", date=datetime.now(timezone.utc))],
},
)
results = await sync_all(client, db, limit_per_chat=10, delay=0)
assert results == {"Group A": 1, "Group B": 1}
assert db.count() == 2
@pytest.mark.asyncio
async def test_sync_all_max_chats_limits_synced_dialogs(db):
dialogs = [
FakeDialog(entity=FakeEntity(id=100, title="Group A"), name="Group A"),
FakeDialog(entity=FakeEntity(id=200, title="Group B"), name="Group B"),
FakeDialog(entity=FakeEntity(id=300, title="Group C"), name="Group C"),
]
client = FakeClient(
dialogs=dialogs,
messages_by_chat={
100: [FakeMessage(id=1, sender_id=1, text="hello", date=datetime.now(timezone.utc))],
200: [FakeMessage(id=1, sender_id=2, text="world", date=datetime.now(timezone.utc))],
300: [FakeMessage(id=1, sender_id=3, text="bye", date=datetime.now(timezone.utc))],
},
)
results = await sync_all(client, db, limit_per_chat=10, delay=0, max_chats=1)
assert len(results) == 1
assert db.count() == 1
@pytest.mark.asyncio
async def test_connect_uses_default_credentials_when_env_unset(monkeypatch):
"""When TG_API_ID/TG_API_HASH are not set, connect() should use Telegram Desktop defaults."""
monkeypatch.delenv("TG_API_ID", raising=False)
monkeypatch.delenv("TG_API_HASH", raising=False)
from tg_cli.config import get_api_hash, get_api_id
api_id = get_api_id()
api_hash = get_api_hash()
# Defaults should be set (Telegram Desktop credentials)
assert api_id is not None
assert api_hash is not None
assert isinstance(api_id, int)
assert len(api_hash) > 0
"""Tests for config module."""
class TestConfig:
def test_get_api_id(self, monkeypatch):
monkeypatch.setenv("TG_API_ID", "12345")
from tg_cli.config import get_api_id
assert get_api_id() == 12345
def test_get_api_id_default(self, monkeypatch):
monkeypatch.delenv("TG_API_ID", raising=False)
from tg_cli.config import get_api_id
assert get_api_id() == 2040
def test_get_api_hash(self, monkeypatch):
monkeypatch.setenv("TG_API_HASH", "abc123")
from tg_cli.config import get_api_hash
assert get_api_hash() == "abc123"
def test_get_api_hash_default(self, monkeypatch):
monkeypatch.delenv("TG_API_HASH", raising=False)
from tg_cli.config import get_api_hash
assert get_api_hash() == "b18441a1ff607e10a989891a5462e627"
def test_get_session_name_default(self, monkeypatch):
monkeypatch.delenv("TG_SESSION_NAME", raising=False)
from tg_cli.config import get_session_name
assert get_session_name() == "tg_cli"
def test_get_session_name_custom(self, monkeypatch):
monkeypatch.setenv("TG_SESSION_NAME", "my_session")
from tg_cli.config import get_session_name
assert get_session_name() == "my_session"
def test_get_db_path_default(self, monkeypatch, tmp_path):
monkeypatch.delenv("DB_PATH", raising=False)
monkeypatch.delenv("DATA_DIR", raising=False)
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg"))
import tg_cli.config as cfg
path = cfg.get_db_path()
assert path.name == "messages.db"
assert path.parent.exists()
assert path.parent == tmp_path / "xdg" / "tg-cli"
def test_get_data_dir(self, monkeypatch, tmp_path):
monkeypatch.delenv("DATA_DIR", raising=False)
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg"))
import tg_cli.config as cfg
d = cfg.get_data_dir()
assert d.exists()
assert d == tmp_path / "xdg" / "tg-cli"
def test_get_data_dir_from_env_relative_to_cwd(self, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("DATA_DIR", "./runtime-data")
import tg_cli.config as cfg
d = cfg.get_data_dir()
assert d == tmp_path / "runtime-data"
def test_get_db_path_from_env_relative_to_cwd(self, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("DB_PATH", "./runtime/messages.db")
import tg_cli.config as cfg
path = cfg.get_db_path()
assert path == tmp_path / "runtime" / "messages.db"
"""Tests for _output.py helpers."""
import json
import click
import pytest
import yaml
from tg_cli.cli._output import (
default_structured_format,
dump_structured,
emit_error,
emit_structured,
error_payload,
success_payload,
)
class TestSuccessPayload:
def test_basic(self):
p = success_payload({"key": "val"})
assert p["ok"] is True
assert p["schema_version"] == "1"
assert p["data"] == {"key": "val"}
def test_list_data(self):
p = success_payload([1, 2, 3])
assert p["data"] == [1, 2, 3]
class TestErrorPayload:
def test_basic(self):
p = error_payload("not_found", "Chat not found")
assert p["ok"] is False
assert p["error"]["code"] == "not_found"
assert p["error"]["message"] == "Chat not found"
assert "details" not in p["error"]
def test_with_details(self):
p = error_payload("err", "msg", details={"foo": "bar"})
assert p["error"]["details"] == {"foo": "bar"}
class TestDumpStructured:
def test_json(self):
data = {"key": "值"}
result = dump_structured(data, fmt="json")
parsed = json.loads(result)
assert parsed["key"] == "值"
def test_yaml(self):
data = {"key": "值"}
result = dump_structured(data, fmt="yaml")
parsed = yaml.safe_load(result)
assert parsed["key"] == "值"
def test_unsupported_format(self):
with pytest.raises(ValueError, match="Unsupported"):
dump_structured({}, fmt="xml")
class TestDefaultStructuredFormat:
def test_json_flag(self):
assert default_structured_format(as_json=True, as_yaml=False) == "json"
def test_yaml_flag(self):
assert default_structured_format(as_json=False, as_yaml=True) == "yaml"
def test_both_flags_raises(self):
with pytest.raises(click.UsageError):
default_structured_format(as_json=True, as_yaml=True)
def test_env_json(self, monkeypatch):
monkeypatch.setenv("OUTPUT", "json")
assert default_structured_format(as_json=False, as_yaml=False) == "json"
def test_env_yaml(self, monkeypatch):
monkeypatch.setenv("OUTPUT", "yaml")
assert default_structured_format(as_json=False, as_yaml=False) == "yaml"
def test_env_rich(self, monkeypatch):
monkeypatch.setenv("OUTPUT", "rich")
assert default_structured_format(as_json=False, as_yaml=False) is None
class TestEmitStructured:
def test_returns_false_when_no_format(self, monkeypatch):
monkeypatch.setenv("OUTPUT", "rich")
assert emit_structured({"a": 1}, as_json=False, as_yaml=False) is False
def test_returns_true_when_json(self):
assert emit_structured({"a": 1}, as_json=True, as_yaml=False) is True
class TestEmitError:
def test_returns_false_when_rich_mode(self, monkeypatch):
monkeypatch.setenv("OUTPUT", "rich")
result = emit_error("err", "msg", as_json=False, as_yaml=False)
assert result is False
def test_returns_true_when_json(self):
result = emit_error("err", "msg", as_json=True, as_yaml=False)
assert result is True