Skill: jackwener/tg-cli (version 0.4.1)

--- name: tg-cli description: CLI skill for Telegram to sync chats, search messages, filter keywords, and monitor groups from the terminal author: jackwener version: "1.0.0" tags: - telegram - tg - chat - monitor - cli --- # tg-cli Skill CLI tool for Telegram — sync chats, search messages, filter keywords, send messages, and monitor groups. ## Prerequisites ```bash # Install (requires Python 3.10+) uv tool install kabi-tg-cli # Or: pipx install kabi-tg-cli ``` ## Authentication Uses your Telegram account (MTProto). You must provide your own Telegram app credentials. ```bash export TG_API_ID=123456 export TG_API_HASH=your_telegram_app_hash # Or create a .env file with the same variables tg chats # First run: enter phone + verification code tg whoami # Check current user ``` ## Command Reference ### Telegram Operations ```bash 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 message ``` ### Search & Query ```bash 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 chart ``` ### Data Management ```bash 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 messages ``` ## Structured Output Major commands support `--json` and `--yaml` for machine-readable output. AI agents should prefer `--yaml` unless a strict JSON parser is required: ```bash tg search "Rust" --yaml tg status --yaml tg whoami --yaml tg today --yaml tg filter "招聘" --hours 48 --yaml ``` When 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](./SCHEMA.md). ## Refresh Model `tg-cli` is local-first. Query commands read from the local SQLite cache by default. - Use `tg refresh` as the normal entrypoint before analysis. - Use `--sync-first` when a single query should refresh before reading. - Use `tg listen --persist` if you want a near-real-time local cache. - Keep `tg sync-all` for lower-level scripts or schedulers. ## Common Patterns for AI Agents ```bash # 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 ```bash tg -v sync-all # Debug logging for troubleshooting tg -v refresh # See refresh behavior across dialogs tg -v stats # See SQL queries and timing ``` ## Error 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) - `refresh` and `sync-all` gracefully skip chats that can't be found ## Scheduling Examples live in the repository: - `examples/tg-refresh.cron` - `examples/systemd/tg-refresh.service` - `examples/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.

path: CHANGELOG.md
content: # Changelog

All notable changes to this project will be documented in this file.

## 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_ID` and `TG_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 refresh` as the recommended daily refresh entrypoint
- Added `--sync-first` to query commands
- Added `tg listen --persist` for automatic reconnect
- Improved local query safety with chat ambiguity detection and clearer `today` hints
- Added cron and systemd examples for scheduled refresh


path: CONTRIBUTING.md
content: # Contributing

## Development Setup

```bash
git clone [REDACTED_SECRET]:jackwener/tg-cli.git
cd tg-cli
uv sync --extra dev
```

## Local Checks

```bash
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:

```bash
tg whoami
tg refresh --yaml
tg recent --hours 24 --limit 5 --yaml
tg search "test" --hours 24 --sync-first --yaml
```

## Pull Requests

- Keep changes focused and small
- Add or update tests for behavior changes
- Update `README.md` and `SKILL.md` when command behavior changes
- Avoid committing local session files, `.env`, or `data/`


path: README.md
content: # tg-cli

[![CI](https://github.com/jackwener/tg-cli/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jackwener/tg-cli/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/kabi_tg_cli)](https://pypi.org/project/kabi-tg-cli/)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)

Telethon-powered Telegram CLI for local-first sync, search, export, and agent-friendly retrieval.

## More Projects

- [xiaohongshu-cli](https://github.com/jackwener/xiaohongshu-cli) — Xiaohongshu (小红书) CLI for notes and account workflows
- [twitter-cli](https://github.com/jackwener/twitter-cli) — Twitter/X CLI for timelines, search, and posting
- [bilibili-cli](https://github.com/jackwener/bilibili-cli) — Bilibili CLI for videos, users, search, and feeds
- [discord-cli](https://github.com/jackwener/discord-cli) — Discord CLI for local-first sync, search, and export

[English](#english) | [中文](#中文)

## 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](./SCHEMA.md)

## Installation

```bash
# Recommended: uv tool
uv tool install kabi-tg-cli

# Or: pipx / pip
pipx install kabi-tg-cli
pip install kabi-tg-cli
```

Install from GitHub:

```bash
uv tool install git+https://github.com/jackwener/tg-cli.git
```

Install from source:

```bash
git clone [REDACTED_SECRET]:jackwener/tg-cli.git
cd tg-cli
uv sync --extra dev
```

## Quick Start

```bash
# Apply for your own Telegram app credentials first:
# https://my.telegram.org/apps
export TG_API_ID=123456
export TG_API_HASH=your_telegram_app_hash

# First login
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 --persist
```

## Refresh Model

tg-cli is intentionally local-first:

- `tg refresh` is the recommended daily entrypoint
- `tg sync-all` is the lower-level primitive for scripts and schedulers
- `--sync-first` refreshes before a single query
- `tg listen --persist` reconnects automatically for a near-live cache

Most query commands read from local SQLite, not directly from Telegram.

## Usage

```bash
# 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

See [examples/tg-refresh.cron](https://github.com/jackwener/tg-cli/blob/main/examples/tg-refresh.cron).

### systemd user timer

See:

- [tg-refresh.service](https://github.com/jackwener/tg-cli/blob/main/examples/systemd/tg-refresh.service)
- [tg-refresh.timer](https://github.com/jackwener/tg-cli/blob/main/examples/systemd/tg-refresh.timer)

Typical flow:

```bash
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
```

## Use as AI Agent Skill

tg-cli ships with a [`SKILL.md`](./SKILL.md) for AI agent integration.

### Agent Output Recommendation

If an AI agent needs machine-readable output, prefer `--yaml` first:

- `--yaml` is usually more token-efficient than pretty-printed JSON
- It is still easy to parse for agents and scripts
- Keep `--json` for `jq`, strict JSON-only tooling, or exact downstream schemas
- Non-TTY stdout defaults to YAML automatically
- Use `OUTPUT=yaml|json|rich|auto` to override the default mode

Recommended agent workflow:

```bash
tg refresh --yaml
tg chats --yaml
tg recent --hours 24 --sync-first --yaml
tg search "keyword" --chat "GroupName" --sync-first --yaml
```

### Claude Code / Antigravity

```bash
mkdir -p .agents/skills
git clone [REDACTED_SECRET]:jackwener/tg-cli.git .agents/skills/tg-cli
```

### OpenClaw / ClawHub

```bash
clawhub install tg-cli
```

## Troubleshooting

- `Missing TG_API_ID / TG_API_HASH`
  - Apply for your own app credentials at [my.telegram.org/apps](https://my.telegram.org/apps).
- `No messages today`
  - Run `tg refresh` first, or use `tg today --sync-first`.
- `Chat '...' not found in database`
  - Run `tg refresh` first, or use the numeric `chat_id` from `tg chats --yaml`.
- Repeatedly running `sync-all`
  - Prefer `tg refresh` for daily use, `--sync-first` for single queries, or `tg 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` 覆盖

## 安装

```bash
# 推荐：uv tool
uv tool install kabi-tg-cli

# 或者：pipx / pip
pipx install kabi-tg-cli
pip install kabi-tg-cli
```

从 GitHub 安装：

```bash
uv tool install git+https://github.com/jackwener/tg-cli.git
```

从源码安装：

```bash
git clone [REDACTED_SECRET]:jackwener/tg-cli.git
cd tg-cli
uv sync --extra dev
```

## 快速开始

```bash
# 先去 https://my.telegram.org/apps 申请自己的 app credentials
export TG_API_ID=123456
export TG_API_HASH=your_telegram_app_hash

# 首次登录
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。

## 使用示例

```bash
# 同步
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

参考 [examples/tg-refresh.cron](https://github.com/jackwener/tg-cli/blob/main/examples/tg-refresh.cron)。

### systemd user timer

参考：

- [tg-refresh.service](https://github.com/jackwener/tg-cli/blob/main/examples/systemd/tg-refresh.service)
- [tg-refresh.timer](https://github.com/jackwener/tg-cli/blob/main/examples/systemd/tg-refresh.timer)

典型流程：

```bash
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`](./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 调用顺序：

```bash
tg refresh --yaml
tg chats --yaml
tg recent --hours 24 --sync-first --yaml
tg search "keyword" --chat "GroupName" --sync-first --yaml
```

### Claude Code / Antigravity

```bash
mkdir -p .agents/skills
git clone [REDACTED_SECRET]:jackwener/tg-cli.git .agents/skills/tg-cli
```

### OpenClaw / ClawHub

```bash
clawhub install tg-cli
```

## 常见问题

- 缺少 `TG_API_ID` / `TG_API_HASH`
  - 去 [my.telegram.org/apps](https://my.telegram.org/apps) 申请自己的 app credentials
- `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](https://github.com/jackwener/twitter-cli) — Twitter/X 时间线、搜索与发帖 CLI
- [bilibili-cli](https://github.com/jackwener/bilibili-cli) — Bilibili 视频、用户、搜索与动态 CLI
- [discord-cli](https://github.com/jackwener/discord-cli) — Discord 本地优先同步、检索与导出 CLI

## License

Apache-2.0


path: SCHEMA.md
content: # Structured Output Schema

`tg-cli` uses a shared agent-friendly envelope for machine-readable output.

## Success

```yaml
ok: true
schema_version: "1"
data: ...
```

## Error

```yaml
ok: false
schema_version: "1"
error:
  code: chat_not_found
  message: Chat 'foo' not found in database.
```

## Notes

- `--yaml` and `--json` both use this envelope
- non-TTY stdout defaults to YAML
- query commands usually return lists or dicts inside `data`
- `status` returns `data.authenticated` plus `data.user`
- `whoami` returns `data.user`


path: pyproject.toml
content: [build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "kabi-tg-cli"
version = "0.4.1"
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 = "[REDACTED_SECRET]" }]
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.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "ruff>=0.11", "twine>=6.1"]

[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"]


path: src/tg_cli/__init__.py
content: """tg-cli — Telegram CLI tool for syncing chats and local analysis."""


path: src/tg_cli/cli/__init__.py
content: """CLI package for tg-cli."""


path: src/tg_cli/cli/_chat.py
content: """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 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


path: src/tg_cli/cli/_output.py
content: """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


path: src/tg_cli/cli/_sync.py
content: """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


def _parse_chat(chat: str) -> str | int:
    try:
        return int(chat)
    except ValueError:
        return chat


async def sync_all_dialogs(
    *,
    limit: int,
    on_chat_done: Callable[[str, int, int], None] | 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)


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,
            )


path: src/tg_cli/cli/data.py
content: """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", invoke_without_command=True)
def data_group():
    """Data management commands (registered at top-level)."""
    pass


@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")


path: src/tg_cli/cli/main.py
content: """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)


path: src/tg_cli/cli/query.py
content: """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", invoke_without_command=True)
def query_group():
    """Query and analysis commands (registered at top-level)."""
    pass


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] | "
            f"[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] | "
            f"[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]"
    )


path: src/tg_cli/cli/tg.py
content: """Telegram subcommands — chats, history, sync, refresh, listen, info, whoami, status, send."""

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 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 _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 _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")
@structured_output_options
def tg_sync_all(limit: int, 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)

    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")
@structured_output_options
def tg_refresh(limit: int, 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)

    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")
@structured_output_options
def tg_send(chat: str, message: str, 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)
            return msg

    msg = asyncio.run(_run())
    payload = {"sent": 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 sent (id: {msg.id})")


path: src/tg_cli/client.py
content: """Telegram client with connection reuse and entity caching."""

from __future__ import annotations

import logging
from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager
from datetime import datetime, timezone

import click
from telethon import TelegramClient, events
from telethon.tl.types import Channel, Chat, User

from .config import (
    MissingTelegramCredentialsError,
    get_api_hash,
    get_api_id,
    get_session_path,
)
from .console import console
from .db import MessageDB

log = logging.getLogger(__name__)


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)


@asynccontextmanager
async def connect() -> AsyncGenerator[TelegramClient, None]:
    """Async context manager for Telegram client — single connection, reuse within scope."""
    try:
        api_id = get_api_id()
        api_hash = get_api_hash()
    except MissingTelegramCredentialsError as exc:
        raise click.ClickException(str(exc)) from exc

    c = TelegramClient(get_session_path(), api_id, api_hash)
    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,
) -> 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)
    """
    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

        # Pre-fetch participants for sender name cache
        sender_cache: dict[int, str] = {}
        try:
            async for user in client.iter_participants(entity):
                sender_cache[user.id] = _get_sender_name(user) or str(user.id)
        except Exception as e:
            log.debug("Failed to pre-fetch participants: %s", e)

        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

            sender_name = sender_cache.get(msg.sender_id) if msg.sender_id else None
            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)

        # Flush remaining
        if batch:
            inserted_count += db.insert_batch(batch)

        return inserted_count
    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,
) -> 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)

    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)

    for chat_id, (entity, dialog_name) in dialog_cache.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

        try:
            count = await fetch_history(
                client,
                entity,
                limit=limit_per_chat,
                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 Exception as e:
            console.print(f"  [red]✗ {chat_name}: {e}[/red]")
            results[chat_name] = 0

    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()


path: src/tg_cli/config.py
content: """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

from .exceptions import NotAuthenticatedError

# 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"

_API_SETUP_HELP = (
    "Missing Telegram app credentials. Set TG_API_ID and TG_API_HASH in your environment "
    "or .env file. You can create them at https://my.telegram.org/apps"
)




class MissingTelegramCredentialsError(NotAuthenticatedError):
    """Raised when TG_API_ID or TG_API_HASH is not configured."""


def get_api_id() -> int:
    val = os.environ.get("TG_API_ID", "")
    if not val:
        raise MissingTelegramCredentialsError(_API_SETUP_HELP)
    return int(val)


def get_api_hash() -> str:
    val = os.environ.get("TG_API_HASH", "")
    if not val:
        raise MissingTelegramCredentialsError(_API_SETUP_HELP)
    return val


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


path: src/tg_cli/console.py
content: """Shared Rich console instance for tg-cli."""

from rich.console import Console

console = Console(stderr=True)


path: src/tg_cli/db.py
content: """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:
            query += " AND chat_id = ?"
            params.append(chat_id)
        if sender:
            query += " AND sender_name LIKE ?"
            params.append(f"%{sender}%")
        if hours:
            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:
            query += " AND chat_id = ?"
            params.append(chat_id)
        if sender:
            query += " AND sender_name LIKE ?"
            params.append(f"%{sender}%")
        if hours:
            cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
            query += " AND timestamp >= ?"
            params.append(cutoff)
        query += " ORDER BY timestamp DESC"

        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:
            base_query += " AND chat_id = ?"
            params.append(chat_id)
        if sender:
            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:
            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:
            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:
            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:
            conditions.append("chat_id = ?")
            params.append(chat_id)
        if hours:
            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:
            conditions.append("chat_id = ?")
            params.append(chat_id)
        if hours:
            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()


path: src/tg_cli/exceptions.py
content: """Structured exception hierarchy for tg-cli."""

from __future__ import annotations


class TGCLIError(Exception):
    """Base exception for tg-cli."""


class NotAuthenticatedError(TGCLIError):
    """Raised when Telegram credentials are missing or session is invalid."""


class ChatNotFoundError(TGCLIError):
    """Raised when a chat cannot be resolved by name or ID."""


class SyncError(TGCLIError):
    """Raised when a sync operation fails."""


path: tests/conftest.py
content: """Shared test fixtures."""

import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pytest

# Ensure tests can import the package without an editable install.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))

# 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,
    )


path: tests/test_cli.py
content: """Tests for CLI commands — uses CliRunner with temp DB, no Telegram dependency."""

import json
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path

import pytest
import yaml
from click.testing import CliRunner

from tg_cli.cli.main import cli
from tg_cli.db import MessageDB


@pytest.fixture
def runner():
    return CliRunner()


class TestStats:
    def test_stats_output(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["stats"])
        assert result.exit_code == 0
        assert "TestGroup" in result.output
        assert "10" in result.output

    def test_stats_yaml(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["stats", "--yaml"])
        assert result.exit_code == 0
        payload = yaml.safe_load(result.output)
        assert payload["ok"] is True
        data = payload["data"]
        assert data["total"] == 10
        assert data["chats"][0]["chat_name"] == "TestGroup"

    def test_stats_auto_yaml_when_stdout_is_not_tty(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        monkeypatch.setenv("OUTPUT", "auto")
        result = runner.invoke(cli, ["stats"])
        assert result.exit_code == 0
        data = yaml.safe_load(result.output)["data"]
        assert data["total"] == 10


class TestSearch:
    def test_search_found(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["search", "Web3"])
        assert result.exit_code == 0
        assert "Web3" in result.output

    def test_search_not_found(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["search", "nonexistent_keyword_xyz"])
        assert result.exit_code == 0
        assert "No messages found" in result.output

    def test_search_with_sender_and_hours(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["search", "Web3", "--sender", "Alice", "--hours", "5"])
        assert result.exit_code == 0
        assert "Found 2 messages" in result.output
        assert "sender=Alice" in result.output
        assert "hours=5" in result.output

    def test_search_chat_not_found(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["search", "Web3", "--chat", "MissingGroup"])
        assert result.exit_code == 0
        assert "Chat 'MissingGroup' not found in database." in result.output

    def test_search_chat_not_found_yaml(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["search", "Web3", "--chat", "MissingGroup", "--yaml"])
        assert result.exit_code != 0
        payload = yaml.safe_load(result.output)
        assert payload["ok"] is False
        assert payload["error"]["code"] == "chat_not_found"

    def test_search_regex_found(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(
            cli,
            ["search", r"Message [12]: (Python|Web3)", "--regex", "--limit", "2"],
        )
        assert result.exit_code == 0
        assert "mode=regex" in result.output

    def test_search_regex_invalid(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["search", "(", "--regex"])
        assert result.exit_code == 0
        assert "Invalid regex pattern" in result.output

    def test_search_yaml(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["search", "Web3", "--yaml"])
        assert result.exit_code == 0
        payload = yaml.safe_load(result.output)
        assert payload["ok"] is True
        data = payload["data"]
        assert isinstance(data, list)
        assert data[0]["content"]


class TestRecent:
    def test_recent_found(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["recent", "--hours", "3", "--limit", "3"])
        assert result.exit_code == 0
        assert "Showing 2 recent messages" in result.output
        assert "hours=3" in result.output

    def test_recent_with_sender_filter(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["recent", "--sender", "Ali", "--hours", "5"])
        assert result.exit_code == 0
        assert "sender=Ali" in result.output

    def test_recent_chat_not_found(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["recent", "--chat", "MissingGroup"])
        assert result.exit_code == 0
        assert "Chat 'MissingGroup' not found in database." in result.output


class TestQueryChatNotFound:
    def test_today_chat_not_found(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["today", "--chat", "MissingGroup"])
        assert result.exit_code == 0
        assert "Chat 'MissingGroup' not found in database." in result.output

    def test_top_chat_not_found(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["top", "--chat", "MissingGroup"])
        assert result.exit_code == 0
        assert "Chat 'MissingGroup' not found in database." in result.output

    def test_timeline_chat_not_found(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["timeline", "--chat", "MissingGroup"])
        assert result.exit_code == 0
        assert "Chat 'MissingGroup' not found in database." in result.output

    def test_filter_chat_not_found(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["filter", "Web3", "--chat", "MissingGroup"])
        assert result.exit_code == 0
        assert "Chat 'MissingGroup' not found in database." in result.output


class TestTodayHints:
    def test_today_shows_refresh_hint_when_local_data_is_old(self, runner, tmp_path, monkeypatch):
        db_path = tmp_path / "test.db"
        db = MessageDB(db_path=db_path)
        db.insert_message(
            chat_id=100,
            chat_name="OldGroup",
            msg_id=1,
            sender_id=1,
            sender_name="Alice",
            content="old message",
            timestamp=datetime(2026, 3, 8, 0, 0, tzinfo=timezone.utc),
        )

        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["today"])
        assert result.exit_code == 0
        assert "No messages today." in result.output
        assert "Latest local message is from" in result.output
        assert "Run 'tg refresh'" in result.output
        assert "refresh." in result.output

    def test_today_shows_empty_db_hint(self, runner, tmp_path, monkeypatch):
        db_path = tmp_path / "test.db"
        MessageDB(db_path=db_path).close()

        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["today"])
        assert result.exit_code == 0
        assert "No messages today." in result.output
        assert "Local database is empty. Run 'tg refresh' first." in result.output


class TestRefreshAndSyncFirst:
    def test_refresh_yaml(self, runner, monkeypatch):
        import tg_cli.cli.tg as tg_mod

        async def fake_sync_all_dialogs(*, limit, on_chat_done=None):
            assert limit == 5000
            return {"ChatA": 2, "ChatB": 0}

        monkeypatch.setattr(tg_mod, "sync_all_dialogs", fake_sync_all_dialogs)
        result = runner.invoke(cli, ["refresh", "--yaml"])
        assert result.exit_code == 0
        data = yaml.safe_load(result.output)["data"]
        assert data["new_messages"] == 2
        assert data["updated_chats"] == ["ChatA"]


class TestStatus:
    def test_status_yaml(self, runner, monkeypatch):
        import tg_cli.cli.tg as tg_mod

        class FakeMe:
            id = 123
            first_name = "Alice"
            last_name = "Smith"
            username = "alice"
            phone = "123456"

        class FakeClient:
            async def get_me(self):
                return FakeMe()

        @asynccontextmanager
        async def fake_connect():
            yield FakeClient()

        monkeypatch.setattr(tg_mod, "connect", fake_connect)
        result = runner.invoke(cli, ["status", "--yaml"])
        assert result.exit_code == 0
        data = yaml.safe_load(result.output)
        assert data["ok"] is True
        assert data["schema_version"] == "1"
        assert data["data"]["authenticated"] is True
        assert data["data"]["user"]["username"] == "alice"

    def test_whoami_yaml(self, runner, monkeypatch):
        import tg_cli.cli.tg as tg_mod

        class FakeMe:
            id = 123
            first_name = "Alice"
            last_name = "Smith"
            username = "alice"
            phone = "123456"

        class FakeClient:
            async def get_me(self):
                return FakeMe()

        @asynccontextmanager
        async def fake_connect():
            yield FakeClient()

        monkeypatch.setattr(tg_mod, "connect", fake_connect)
        result = runner.invoke(cli, ["whoami", "--yaml"])
        assert result.exit_code == 0
        data = yaml.safe_load(result.output)
        assert data["ok"] is True
        assert data["schema_version"] == "1"
        assert data["data"]["user"]["username"] == "alice"
        assert data["data"]["user"]["name"] == "Alice Smith"

    def test_today_sync_first_refreshes_before_query(self, runner, tmp_path, monkeypatch):
        db_path = tmp_path / "test.db"
        MessageDB(db_path=db_path).close()

        import tg_cli.cli.query as query_mod
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)

        async def fake_sync_all_dialogs(*, limit, on_chat_done=None):
            with MessageDB(db_path=db_path) as db:
                db.insert_message(
                    chat_id=100,
                    chat_name="FreshGroup",
                    msg_id=1,
                    sender_id=1,
                    sender_name="Alice",
                    content="new today",
                    timestamp=datetime.now(timezone.utc),
                )
            return {"FreshGroup": 1}

        monkeypatch.setattr(query_mod, "sync_all_dialogs", fake_sync_all_dialogs)
        result = runner.invoke(cli, ["today", "--sync-first"])
        assert result.exit_code == 0
        assert "FreshGroup" in result.output

    def test_search_sync_first_syncs_single_chat(self, runner, tmp_path, monkeypatch):
        db_path = tmp_path / "test.db"
        MessageDB(db_path=db_path).close()

        import tg_cli.cli.query as query_mod
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)

        async def fake_sync_chat_dialog(chat, *, limit, on_progress=None):
            assert chat == "FreshGroup"
            with MessageDB(db_path=db_path) as db:
                db.insert_message(
                    chat_id=100,
                    chat_name="FreshGroup",
                    msg_id=1,
                    sender_id=1,
                    sender_name="Alice",
                    content="fresh web3 note",
                    timestamp=datetime.now(timezone.utc),
                )
            return 1

        monkeypatch.setattr(query_mod, "sync_chat_dialog", fake_sync_chat_dialog)
        result = runner.invoke(cli, ["search", "web3", "--chat", "FreshGroup", "--sync-first"])
        assert result.exit_code == 0
        assert "fresh web3 note" in result.output

    def test_stats_sync_first_refreshes_before_summary(self, runner, tmp_path, monkeypatch):
        db_path = tmp_path / "test.db"
        MessageDB(db_path=db_path).close()

        import tg_cli.cli.query as query_mod
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)

        async def fake_sync_all_dialogs(*, limit, on_chat_done=None):
            with MessageDB(db_path=db_path) as db:
                db.insert_message(
                    chat_id=100,
                    chat_name="FreshGroup",
                    msg_id=1,
                    sender_id=1,
                    sender_name="Alice",
                    content="fresh web3 note",
                    timestamp=datetime.now(timezone.utc),
                )
            return {"FreshGroup": 1}

        monkeypatch.setattr(query_mod, "sync_all_dialogs", fake_sync_all_dialogs)
        result = runner.invoke(cli, ["stats", "--sync-first", "--yaml"])
        assert result.exit_code == 0
        data = yaml.safe_load(result.output)["data"]
        assert data["total"] == 1
        assert data["chats"][0]["chat_name"] == "FreshGroup"


class TestListenPersist:
    def test_listen_persist_reconnects_until_stopped(self, runner, monkeypatch):
        import contextlib

        import tg_cli.cli.tg as tg_mod

        calls: list[str] = []
        sleeps: list[int] = []

        @contextlib.asynccontextmanager
        async def fake_connect():
            yield object()

        async def fake_listen(client, chats=None):
            calls.append("listen")
            return "disconnected" if len(calls) == 1 else "stopped"

        monkeypatch.setattr(tg_mod, "connect", fake_connect)
        monkeypatch.setattr(tg_mod, "listen", fake_listen)
        monkeypatch.setattr(tg_mod.time, "sleep", lambda seconds: sleeps.append(seconds))

        result = runner.invoke(cli, ["listen", "--persist", "--retry-seconds", "1"])
        assert result.exit_code == 0
        assert len(calls) == 2
        assert sleeps == [1]
        assert "Reconnecting in 1s" in result.output


class TestAmbiguousChat:
    def test_search_ambiguous_chat(self, runner, tmp_path, monkeypatch):
        db_path = tmp_path / "test.db"
        db = MessageDB(db_path=db_path)
        db.insert_message(
            chat_id=100,
            chat_name="Dev Group",
            msg_id=1,
            sender_id=1,
            sender_name="Alice",
            content="hello",
            timestamp=datetime(2026, 3, 10, 0, 0, tzinfo=timezone.utc),
        )
        db.insert_message(
            chat_id=200,
            chat_name="Dev Chat",
            msg_id=2,
            sender_id=2,
            sender_name="Bob",
            content="world",
            timestamp=datetime(2026, 3, 10, 1, 0, tzinfo=timezone.utc),
        )

        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["search", "hello", "--chat", "Dev"])
        assert result.exit_code == 0
        assert "matches multiple local chats" in result.output
        assert "Dev Group" in result.output
        assert "Dev Chat" in result.output


class TestExport:
    def test_export_text(self, runner, populated_db, tmp_path, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        out_file = str(tmp_path / "export.txt")
        result = runner.invoke(cli, ["export", "TestGroup", "-o", out_file])
        assert result.exit_code == 0
        assert "Exported" in result.output

        content = Path(out_file).read_text()
        assert "Alice:" in content

    def test_export_json(self, runner, populated_db, tmp_path, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        out_file = str(tmp_path / "export.json")
        result = runner.invoke(cli, ["export", "TestGroup", "-f", "json", "-o", out_file])
        assert result.exit_code == 0

        data = json.loads(Path(out_file).read_text())
        assert isinstance(data, list)
        assert len(data) > 0

    def test_export_not_found(self, runner, populated_db, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        result = runner.invoke(cli, ["export", "NonexistentGroup"])
        assert result.exit_code == 0
        assert "not found" in result.output

    def test_export_yaml(self, runner, populated_db, tmp_path, monkeypatch):
        db, db_path = populated_db
        import tg_cli.db as db_mod

        monkeypatch.setattr(db_mod, "get_db_path", lambda: db_path)
        out_file = str(tmp_path / "export.yaml")
        result = runner.invoke(cli, ["export", "TestGroup", "-f", "yaml", "-o", out_file])
        assert result.exit_code == 0

        data = yaml.safe_load(Path(out_file).read_text())
        assert isinstance(data, list)
        assert data[0]["chat_name"] == "TestGroup"


class TestHelp:
    def test_main_help(self, runner):
        result = runner.invoke(cli, ["--help"])
        assert result.exit_code == 0
        assert "tg" in result.output

    def test_tg_help(self, runner):
        result = runner.invoke(cli, ["chats", "--help"])
        assert result.exit_code == 0
        assert "chats" in result.output.lower() or "telegram" in result.output.lower()

    def test_today_help(self, runner):
        result = runner.invoke(cli, ["today", "--help"])
        assert result.exit_code == 0
        assert "today" in result.output.lower() or "chat" in result.output.lower()


path: tests/test_client.py
content: """Tests for Telegram client helpers without hitting the network."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone

import click
import pytest

from tg_cli.client import connect, fetch_history, sync_all


@dataclass
class FakeEntity:
    id: int
    title: str


@dataclass
class FakeDialog:
    entity: FakeEntity
    name: str


@dataclass
class FakeMessage:
    id: int
    sender_id: int
    text: str
    date: datetime
    message: str | None = None


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_participants(self, entity):
        if False:
            yield None

    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)
    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)
    assert results == {"Group A": 1, "Group B": 1}
    assert db.count() == 2


@pytest.mark.asyncio
async def test_connect_requires_user_provided_credentials(monkeypatch):
    monkeypatch.delenv("TG_API_ID", raising=False)
    monkeypatch.delenv("TG_API_HASH", raising=False)

    with pytest.raises(click.ClickException, match="Missing Telegram app credentials"):
        async with connect():
            pass


path: tests/test_config.py
content: """Tests for config module."""

import pytest


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_missing_raises(self, monkeypatch):
        monkeypatch.delenv("TG_API_ID", raising=False)
        from tg_cli.config import MissingTelegramCredentialsError, get_api_id

        with pytest.raises(MissingTelegramCredentialsError):
            get_api_id()

    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_missing_raises(self, monkeypatch):
        monkeypatch.delenv("TG_API_HASH", raising=False)
        from tg_cli.config import MissingTelegramCredentialsError, get_api_hash

        with pytest.raises(MissingTelegramCredentialsError):
            get_api_hash()

    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"


path: tests/test_db.py
content: """Tests for MessageDB — uses temp SQLite, no Telegram dependency."""

from conftest import make_msg

# ─────────────────────── insert_message ───────────────────────


class TestInsertMessage:
    def test_insert_and_count(self, db):
        ok = db.insert_message(**make_msg())
        assert ok is True
        assert db.count() == 1

    def test_duplicate_ignored(self, db):
        db.insert_message(**make_msg(msg_id=1))
        ok = db.insert_message(**make_msg(msg_id=1))
        assert ok is False
        assert db.count() == 1

    def test_different_msg_ids(self, db):
        db.insert_message(**make_msg(msg_id=1))
        db.insert_message(**make_msg(msg_id=2))
        assert db.count() == 2


# ─────────────────────── insert_batch ───────────────────────


class TestInsertBatch:
    def test_batch_insert(self, db):
        msgs = [make_msg(msg_id=i) for i in range(50)]
        result = db.insert_batch(msgs)
        assert result == 50
        assert db.count() == 50

    def test_batch_empty(self, db):
        result = db.insert_batch([])
        assert result == 0

    def test_batch_with_duplicates(self, db):
        db.insert_message(**make_msg(msg_id=1))
        msgs = [make_msg(msg_id=i) for i in range(1, 6)]
        inserted = db.insert_batch(msgs)
        assert inserted == 4
        assert db.count() == 5


# ─────────────────────── search ───────────────────────


class TestSearch:
    def test_search_found(self, db):
        db.insert_message(**make_msg(content="Rust is great"))
        db.insert_message(**make_msg(msg_id=2, content="Python is good"))
        results = db.search("Rust")
        assert len(results) == 1
        assert "Rust" in results[0]["content"]

    def test_search_not_found(self, db):
        db.insert_message(**make_msg(content="Hello"))
        results = db.search("Golang")
        assert len(results) == 0

    def test_search_case_insensitive(self, db):
        db.insert_message(**make_msg(content="Hello World"))
        results = db.search("hello")
        assert len(results) == 1

    def test_search_with_chat_filter(self, db):
        db.insert_message(**make_msg(chat_id=100, content="Web3 job"))
        db.insert_message(**make_msg(chat_id=200, msg_id=2, content="Web3 course"))
        results = db.search("Web3", chat_id=100)
        assert len(results) == 1

    def test_search_with_sender_filter(self, db):
        db.insert_message(**make_msg(msg_id=1, sender_name="Alice", content="Rust job"))
        db.insert_message(**make_msg(msg_id=2, sender_name="Bob", content="Rust course"))
        results = db.search("Rust", sender="Ali")
        assert len(results) == 1
        assert results[0]["sender_name"] == "Alice"

    def test_search_with_hours_filter(self, db):
        db.insert_message(**make_msg(msg_id=1, content="Rust today", hours_ago=1))
        db.insert_message(**make_msg(msg_id=2, content="Rust old", hours_ago=72))
        results = db.search("Rust", hours=24)
        assert len(results) == 1
        assert results[0]["content"] == "Rust today"

    def test_search_limit(self, db):
        for i in range(20):
            db.insert_message(**make_msg(msg_id=i, content=f"test msg {i}"))
        results = db.search("test", limit=5)
        assert len(results) == 5

    def test_search_regex_found(self, db):
        db.insert_message(**make_msg(msg_id=1, content="Rust and Go"))
        db.insert_message(**make_msg(msg_id=2, content="Python only"))
        results = db.search_regex(r"Rust.*Go")
        assert len(results) == 1
        assert results[0]["content"] == "Rust and Go"

    def test_search_regex_with_sender_filter(self, db):
        db.insert_message(**make_msg(msg_id=1, sender_name="Alice", content="Rust remote"))
        db.insert_message(**make_msg(msg_id=2, sender_name="Bob", content="Rust remote"))
        results = db.search_regex(r"rust\s+remote", sender="Ali")
        assert len(results) == 1
        assert results[0]["sender_name"] == "Alice"


# ─────────────────────── get_recent ───────────────────────


class TestGetRecent:
    def test_recent_within_hours(self, db):
        db.insert_message(**make_msg(msg_id=1, hours_ago=1))
        db.insert_message(**make_msg(msg_id=2, hours_ago=48))
        results = db.get_recent(hours=24)
        assert len(results) == 1

    def test_recent_all(self, db):
        db.insert_message(**make_msg(msg_id=1, hours_ago=1))
        db.insert_message(**make_msg(msg_id=2, hours_ago=720))
        results = db.get_recent(hours=None, limit=100)
        assert len(results) == 2

    def test_recent_with_chat_filter(self, db):
        db.insert_message(**make_msg(chat_id=100, msg_id=1))
        db.insert_message(**make_msg(chat_id=200, msg_id=2))
        results = db.get_recent(chat_id=100, hours=24)
        assert len(results) == 1

    def test_recent_with_sender_filter(self, db):
        db.insert_message(**make_msg(msg_id=1, sender_name="Alice"))
        db.insert_message(**make_msg(msg_id=2, sender_name="Bob"))
        results = db.get_recent(sender="Ali", hours=24)
        assert len(results) == 1
        assert results[0]["sender_name"] == "Alice"

    def test_recent_limit_returns_latest_messages(self, db):
        for i in range(5):
            db.insert_message(**make_msg(msg_id=10 + i, content=f"msg {i}", hours_ago=5 - i))
        results = db.get_recent(hours=24, limit=2)
        assert [r["content"] for r in results] == ["msg 3", "msg 4"]


# ─────────────────────── get_chats ───────────────────────


class TestGetChats:
    def test_chats_summary(self, db):
        for i in range(5):
            db.insert_message(**make_msg(chat_id=100, chat_name="GroupA", msg_id=i))
        for i in range(3):
            db.insert_message(**make_msg(chat_id=200, chat_name="GroupB", msg_id=100 + i))

        chats = db.get_chats()
        assert len(chats) == 2
        assert chats[0]["chat_name"] == "GroupA"
        assert chats[0]["msg_count"] == 5
        assert chats[1]["chat_name"] == "GroupB"
        assert chats[1]["msg_count"] == 3


# ─────────────────────── get_last_msg_id ───────────────────────


class TestGetLastMsgId:
    def test_returns_max_id(self, db):
        for i in [10, 20, 15]:
            db.insert_message(**make_msg(msg_id=i))
        assert db.get_last_msg_id(100) == 20

    def test_returns_none_for_empty(self, db):
        assert db.get_last_msg_id(999) is None


# ─────────────────────── get_latest_timestamp ───────────────────────


class TestGetLatestTimestamp:
    def test_latest_timestamp(self, db):
        db.insert_message(**make_msg(msg_id=1, hours_ago=3))
        db.insert_message(**make_msg(msg_id=2, hours_ago=1))
        latest = db.get_latest_timestamp()
        assert latest is not None
        assert latest.endswith("+00:00")

    def test_latest_timestamp_empty(self, db):
        assert db.get_latest_timestamp() is None


# ─────────────────────── resolve_chat_id ───────────────────────


class TestResolveChatId:
    def test_resolve_by_name(self, db):
        db.insert_message(**make_msg(chat_id=100, chat_name="MyGroup"))
        assert db.resolve_chat_id("MyGroup") == 100

    def test_resolve_by_partial_name(self, db):
        db.insert_message(**make_msg(chat_id=100, chat_name="DeJob—Web3招聘"))
        assert db.resolve_chat_id("DeJob") == 100

    def test_resolve_by_numeric_id(self, db):
        db.insert_message(**make_msg(chat_id=1570628112))
        assert db.resolve_chat_id("-1001570628112") == 1570628112

    def test_resolve_unknown(self, db):
        result = db.resolve_chat_id("nonexistent")
        assert result is None

    def test_resolve_ambiguous_returns_none(self, db):
        db.insert_message(**make_msg(chat_id=100, chat_name="Dev Group"))
        db.insert_message(**make_msg(chat_id=200, chat_name="Dev Chat", msg_id=2))
        assert db.resolve_chat_id("Dev") is None

    def test_find_chats_returns_all_partial_matches(self, db):
        db.insert_message(**make_msg(chat_id=100, chat_name="Dev Group"))
        db.insert_message(**make_msg(chat_id=200, chat_name="Dev Chat", msg_id=2))
        matches = db.find_chats("Dev")
        assert len(matches) == 2


# ─────────────────────── delete_chat ───────────────────────


class TestDeleteChat:
    def test_delete(self, db):
        for i in range(5):
            db.insert_message(**make_msg(chat_id=100, msg_id=i))
        db.insert_message(**make_msg(chat_id=200, msg_id=99))

        deleted = db.delete_chat(100)
        assert deleted == 5
        assert db.count() == 1

    def test_delete_nonexistent(self, db):
        deleted = db.delete_chat(999)
        assert deleted == 0


# ─────────────────────── context manager ───────────────────────


class TestContextManager:
    def test_context_manager(self, tmp_path):
        from tg_cli.db import MessageDB

        db_path = tmp_path / "ctx.db"
        with MessageDB(db_path=db_path) as d:
            d.insert_message(**make_msg())
            assert d.count() == 1


# ─────────────────────── top_senders ───────────────────────


class TestTopSenders:
    def test_top_senders(self, db):
        for i in range(5):
            db.insert_message(**make_msg(msg_id=i, sender_id=101, sender_name="Alice"))
        for i in range(3):
            db.insert_message(**make_msg(msg_id=10 + i, sender_id=202, sender_name="Bob"))

        results = db.top_senders()
        assert len(results) == 2
        assert results[0]["sender_name"] == "Alice"
        assert results[0]["msg_count"] == 5

    def test_top_senders_with_chat_filter(self, db):
        db.insert_message(**make_msg(chat_id=100, msg_id=1, sender_id=101, sender_name="Alice"))
        db.insert_message(**make_msg(chat_id=200, msg_id=2, sender_id=202, sender_name="Bob"))
        results = db.top_senders(chat_id=100)
        assert len(results) == 1

    def test_top_senders_with_hours(self, db):
        db.insert_message(**make_msg(msg_id=1, sender_id=101, sender_name="Alice", hours_ago=1))
        db.insert_message(**make_msg(msg_id=2, sender_id=202, sender_name="Bob", hours_ago=48))
        results = db.top_senders(hours=24)
        assert len(results) == 1

    def test_top_senders_limit(self, db):
        for i in range(10):
            db.insert_message(**make_msg(msg_id=i, sender_id=100 + i, sender_name=f"User{i}"))
        results = db.top_senders(limit=3)
        assert len(results) == 3

    def test_top_senders_keeps_same_name_different_ids_separate(self, db):
        db.insert_message(**make_msg(msg_id=1, sender_id=101, sender_name="Alex"))
        db.insert_message(**make_msg(msg_id=2, sender_id=202, sender_name="Alex"))
        results = db.top_senders(limit=10)
        assert len(results) == 2


# ─────────────────────── timeline ───────────────────────


class TestTimeline:
    def test_timeline_by_day(self, db):
        db.insert_message(**make_msg(msg_id=1, hours_ago=0))
        db.insert_message(**make_msg(msg_id=2, hours_ago=1))
        db.insert_message(**make_msg(msg_id=3, hours_ago=25))

        results = db.timeline(granularity="day")
        assert len(results) >= 1
        for r in results:
            assert "period" in r
            assert "msg_count" in r

    def test_timeline_by_hour(self, db):
        db.insert_message(**make_msg(msg_id=1, hours_ago=0))
        db.insert_message(**make_msg(msg_id=2, hours_ago=2))
        results = db.timeline(granularity="hour")
        assert len(results) >= 1

    def test_timeline_with_chat_filter(self, db):
        db.insert_message(**make_msg(chat_id=100, msg_id=1))
        db.insert_message(**make_msg(chat_id=200, msg_id=2))
        results = db.timeline(chat_id=100)
        total = sum(r["msg_count"] for r in results)
        assert total == 1

    def test_timeline_empty(self, db):
        results = db.timeline()
        assert results == []


# ─────────────────────── get_today ───────────────────────


class TestGetToday:
    def test_today_returns_recent(self, db):
        # Message from 1 hour ago should be "today"
        db.insert_message(**make_msg(msg_id=1, hours_ago=1))
        # Message from 48 hours ago should not be "today"
        db.insert_message(**make_msg(msg_id=2, hours_ago=48))
        results = db.get_today()
        assert len(results) == 1

    def test_today_with_chat_filter(self, db):
        db.insert_message(**make_msg(chat_id=100, msg_id=1, hours_ago=1))
        db.insert_message(**make_msg(chat_id=200, msg_id=2, hours_ago=1))
        results = db.get_today(chat_id=100)
        assert len(results) == 1


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal:  | SkillSpector: suspicious (CRITICAL)