diff --git a/.env.example b/.env.example index 0540963..9ebb237 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1 @@ -# TG_PHONE is phone number in international format. -# Like +4123456789. -TG_PHONE=your_telegram_phone_number - -# APP_HASH, APP_ID is from https://my.telegram.org/. -APP_ID=your_app_id -APP_HASH=your_app_hash - -GROUP_IDS=-1001234567890 +REDIS_URL=rediss://default:@: diff --git a/.gitignore b/.gitignore index 202642b..0e4a6f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,20 @@ -*.sqlite3 +# project +*.csv +output/ +plans/ - - -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins +# go *.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` *.test - -# Code coverage profiles and other test artifacts *.out coverage.* -*.coverprofile -profile.cov - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file go.work go.work.sum -# env file +# env .env -# Editor/IDE -# .idea/ -# .vscode/ +# tooling +.idea/ +.vscode/ +.claude/ diff --git a/README.md b/README.md index dd52c37..ffd82da 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,166 @@ -# Export Telegram group members +# export-telegram-group-members -*In 2025, I rewrote this project using Go. The original Python version of this project can be found at `feature/python` branch.* +Export Telegram group members with Go and [gotd/td](https://github.com/gotd/td). +Admin rights give full member visibility. The login session and export history +live in **Redis**, so you can run the tool on any device from just a `.env` +file: no re-login and no copying session or CSV files. + +## How to use + +1. Clone this repository and install [Go](https://go.dev/dl/): + +```bash +git clone https://github.com/tiennm99/export-telegram-group-members.git +``` + +2. Create a new Telegram app at [https://my.telegram.org](https://my.telegram.org) and get the `api_id` and `api_hash`. +3. Create a free Redis database (e.g. [Upstash](https://upstash.com)) and copy its `rediss://` connection URL. +4. Copy `.env.example` to `.env` and fill in `REDIS_URL`. +5. Store Telegram config in Redis: + +```bash +go run ./cmd/configure +``` + +`configure` prompts for `api_id`, `api_hash`, phone, and group IDs. You can +pass any of them as flags instead: `--api-id`, `--api-hash`, `--phone`, and +`--groups -1001234567890,-1009876543210`. All commands read the stored group +IDs from this Redis config. + +6. Crawl the configured groups: + +```bash +go run ./cmd/crawl +``` + +Every group ID stored in the Redis config is crawled. To change the list, run +`configure` again. + +To crawl other groups just once, pass their IDs; they replace the configured +list for that run only: + +```bash +go run ./cmd/crawl -1001234567890 -1009876543210 +``` + +The first crawl asks for the Telegram login code once, then stores the session +in Redis. Any later run on any device pointed at the same Redis reuses the +Redis config and session and **does not** prompt again. + +Group IDs use the usual marked form: `-100` for supergroups and channels, +`-` for basic groups. The account must already be a member of each group. + +## Compare two crawls + +Compare membership changes for one group between two saved crawls with +git-diff-style terminal output: + +```bash +go run ./cmd/compare +``` + +If `time1` and `time2` are omitted, the command compares the latest two crawls +for that group: + +```bash +go run ./cmd/compare +``` + +If `group_id` is also omitted, the command uses the first group in the stored +configuration: + +```bash +go run ./cmd/compare +``` + +## Compare two groups + +Fetch two groups live from Telegram and compare their members by Telegram ID: + +```bash +go run ./cmd/compare-groups +``` + +Members in both groups are listed unprefixed, members only in the first group +with `-`, and members only in the second group with `+`. No crawl is saved to +Redis; the command uses Redis only for the Telegram config and login session, +the same way `crawl` does. + +## Export a crawl to CSV + +Export one group's saved crawl from Redis: + +```bash +go run ./cmd/export [group_id] [timecrawl] +``` + +When `timecrawl` is omitted, the latest crawl for the group is exported. When +both arguments are omitted, the first group in the stored configuration and its +latest crawl are used. An explicit crawl time must use `yyyymmddhhmmss` format: + +```bash +go run ./cmd/export -1001234567890 20260724120000 +``` + +CSV files are written to: + +```text +output/-.csv +``` + +Each file contains the columns `id`, `username`, `first_name`, and `last_name`. +Formula-like Telegram text is prefixed with an apostrophe for safe spreadsheet +opening. On POSIX systems, the `output/` directory is created with `0700` +permissions and each CSV file is written with `0600` permissions. The `output/` +directory is ignored by Git. + +## Build binaries + +The code is pure Go, so static binaries build without cgo: + +```bash +CGO_ENABLED=0 go build -o bin/ ./cmd/... +``` + +## Configuration + +| Variable | Description | +|----------|-------------| +| `REDIS_URL` | Redis connection string (`rediss://default:@:`) | + +Telegram `api_id`, `api_hash`, `phone`, and `group_ids` are stored in Redis by `configure`. + +## How data is stored + +All data lives in Redis under the `telegram-export` prefix. No key references another, so deleting any key can never corrupt another's state: + +``` +telegram-export:config -> JSON config: + { api_id, api_hash, phone, group_ids } +telegram-export:session-gotd -> gotd session JSON (login) +telegram-export:group:: -> one group's export as JSON: + { group_id, title, time, + members: [{ id, username, first_name, last_name }] } +``` + +All groups in one crawl share one timestamp. The login session grants full +account access, so restrict Redis access accordingly; deleting +`telegram-export:session-gotd` forces a clean re-login. + +### Upgrading from the Python version + +The config and export keys keep the same format, so existing crawls stay +readable by `compare` and `export`. The Python version kept a Telethon session +under `telegram-export:session`. On first run, the Go version converts it into +`telegram-export:session-gotd`, so you do not need a new login code. The old key +is only read, never changed, and can be deleted afterwards. + +## Development + +```bash +go vet ./... +go test ./... +``` + +Tests run offline: Redis is faked with miniredis, and Telegram calls are not +exercised. diff --git a/cmd/compare-groups/main.go b/cmd/compare-groups/main.go new file mode 100644 index 0000000..ce26e78 --- /dev/null +++ b/cmd/compare-groups/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" + comparegroupscmd "github.com/tiennm99/export-telegram-group-members/internal/commands/comparegroups" +) + +func main() { + cli.Main(comparegroupscmd.Run) +} diff --git a/cmd/compare/main.go b/cmd/compare/main.go new file mode 100644 index 0000000..5f7fa76 --- /dev/null +++ b/cmd/compare/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" + comparecmd "github.com/tiennm99/export-telegram-group-members/internal/commands/compare" +) + +func main() { + cli.Main(comparecmd.Run) +} diff --git a/cmd/configure/main.go b/cmd/configure/main.go new file mode 100644 index 0000000..e493ce0 --- /dev/null +++ b/cmd/configure/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" + configurecmd "github.com/tiennm99/export-telegram-group-members/internal/commands/configure" +) + +func main() { + cli.Main(configurecmd.Run) +} diff --git a/cmd/crawl/main.go b/cmd/crawl/main.go new file mode 100644 index 0000000..24e4fd5 --- /dev/null +++ b/cmd/crawl/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" + crawlcmd "github.com/tiennm99/export-telegram-group-members/internal/commands/crawl" +) + +func main() { + cli.Main(crawlcmd.Run) +} diff --git a/cmd/export/main.go b/cmd/export/main.go new file mode 100644 index 0000000..58b50c2 --- /dev/null +++ b/cmd/export/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" + exportcmd "github.com/tiennm99/export-telegram-group-members/internal/commands/export" +) + +func main() { + cli.Main(exportcmd.Run) +} diff --git a/compare.py b/compare.py deleted file mode 100644 index cd4df80..0000000 --- a/compare.py +++ /dev/null @@ -1,131 +0,0 @@ -import argparse -import sys - -RESET = '\033[0m' -GREEN = '\033[32m' -RED = '\033[31m' -CYAN = '\033[36m' - - -def parse_args(): - parser = argparse.ArgumentParser( - description='Compare member changes between two crawls for one group.', - ) - parser.add_argument( - 'group_id', - type=int, - nargs='?', - help='Telegram group id to compare (defaults to the first configured group)', - ) - parser.add_argument( - 'times', - nargs='*', - help='Optional pair: time1 time2. When omitted, latest two crawls are used.', - ) - args = parser.parse_args() - if len(args.times) not in (0, 2): - parser.error('provide both time1 and time2, or omit both to compare latest two crawls') - return args - - -def main(): - args = parse_args() - - # Lazy import: keeps `python compare.py --help` working when REDIS_URL is unset. - from common import diff_group_members, get_group_export, list_group_exports - - group_id = args.group_id - if group_id is None: - from config import load_app_config - - group_id = load_app_config()['group_ids'][0] - - if args.times: - before_time, after_time = args.times - before_record = get_group_export(group_id, before_time) - after_record = get_group_export(group_id, after_time) - if before_record is None: - print(f'export not found for group {group_id} at {before_time}', file=sys.stderr) - return 1 - if after_record is None: - print(f'export not found for group {group_id} at {after_time}', file=sys.stderr) - return 1 - else: - exports = list_group_exports(group_id) - if len(exports) < 2: - print(f'need at least 2 exports for group {group_id}', file=sys.stderr) - return 1 - before_record, after_record = exports[-2:] - - added, removed = diff_group_members(before_record, after_record) - print_summary(group_id, before_record, after_record, added, removed) - return 0 - - -def print_summary(group_id, before_record, after_record, added, removed): - title = after_record.get('title') or before_record.get('title') or '' - before_members = before_record.get('members', []) - after_members = after_record.get('members', []) - before_time = before_record.get('time') - after_time = after_record.get('time') - label = f'{title} ({group_id})' if title else str(group_id) - - print(f'diff --telegram-group "{label}"') - print(color(f'--- crawl/{before_time}', RED)) - print(color(f'+++ crawl/{after_time}', GREEN)) - print(color( - f'@@ members: {len(before_members)} -> {len(after_members)} ' - f'(+{len(added)} -{len(removed)}) @@', - CYAN, - )) - print() - - widths = column_widths([*removed, *added]) - for member in removed: - print(color(format_member_row('-', member, widths), RED)) - for member in added: - print(color(format_member_row('+', member, widths), GREEN)) - - if not added and not removed: - print(' no membership changes.') - - -def color(text, code): - if not sys.stdout.isatty(): - return text - return f'{code}{text}{RESET}' - - -def member_columns(member): - username = member.get('username') - full_name = ' '.join( - part for part in [member.get('first_name'), member.get('last_name')] - if part - ) - return [ - str(member.get('id')), - f'@{username}' if username else '', - full_name, - ] - - -def column_widths(members): - widths = [2, 8, 4] - for member in members: - for index, value in enumerate(member_columns(member)): - widths[index] = max(widths[index], len(value)) - return widths - - -def format_member_row(prefix, member, widths): - member_id, username, full_name = member_columns(member) - return ( - f'{prefix} ' - f'{member_id:<{widths[0]}} ' - f'{username:<{widths[1]}} ' - f'{full_name:<{widths[2]}}' - ).rstrip() - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/compare_groups.py b/compare_groups.py deleted file mode 100644 index ddd95ef..0000000 --- a/compare_groups.py +++ /dev/null @@ -1,91 +0,0 @@ -import argparse -import sys - -from compare import CYAN, GREEN, RED, color, column_widths, format_member_row - - -def parse_args(argv=None): - parser = argparse.ArgumentParser( - description=( - 'Fetch two Telegram groups live and compare their members. ' - 'Nothing is saved to Redis.' - ), - ) - parser.add_argument('group_a', type=int, help='first Telegram group id') - parser.add_argument('group_b', type=int, help='second Telegram group id') - return parser.parse_args(argv) - - -def main(argv=None): - args = parse_args(argv) - - # Lazy imports: keep `--help` working when REDIS_URL is unset. Redis still - # supplies the Telegram config and login session; no export is written. - from common import member_dict - from config import load_app_config - from crawl import fetch_group, login - - client = login(load_app_config()) - - groups = [] - for group_id in (args.group_a, args.group_b): - try: - entity, members = fetch_group(client, group_id, 'fetching') - except Exception as e: - print(f'error accessing group {group_id}: {e}', file=sys.stderr) - return 1 - if entity is None: - print(f'{group_id} is not a group.', file=sys.stderr) - return 1 - groups.append({ - 'group_id': group_id, - 'title': entity.title, - 'members': [member_dict(m) for m in members], - }) - - group_a, group_b = groups - only_a, only_b, both = split_members(group_a, group_b) - print() - print_summary(group_a, group_b, only_a, only_b, both) - return 0 - - -def split_members(group_a, group_b): - """Return (only in A, only in B, in both) by Telegram member id.""" - a_members = {m['id']: m for m in group_a['members']} - b_members = {m['id']: m for m in group_b['members']} - only_a = [a_members[i] for i in sorted(a_members.keys() - b_members.keys())] - only_b = [b_members[i] for i in sorted(b_members.keys() - a_members.keys())] - both = [b_members[i] for i in sorted(a_members.keys() & b_members.keys())] - return only_a, only_b, both - - -def group_label(group): - return f'{group["title"]} ({group["group_id"]})' - - -def print_summary(group_a, group_b, only_a, only_b, both): - print(f'diff --telegram-groups "{group_label(group_a)}" "{group_label(group_b)}"') - print(color(f'--- {group_label(group_a)}', RED)) - print(color(f'+++ {group_label(group_b)}', GREEN)) - print(color( - f'@@ members: {len(group_a["members"])} vs {len(group_b["members"])} ' - f'(both {len(both)}, -{len(only_a)} only first, +{len(only_b)} only second) @@', - CYAN, - )) - print() - - widths = column_widths([*only_a, *only_b, *both]) - for member in both: - print(format_member_row(' ', member, widths)) - for member in only_a: - print(color(format_member_row('-', member, widths), RED)) - for member in only_b: - print(color(format_member_row('+', member, widths), GREEN)) - - if not group_a['members'] and not group_b['members']: - print(' both groups have no visible members.') - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/configure.py b/configure.py deleted file mode 100644 index 4f2a435..0000000 --- a/configure.py +++ /dev/null @@ -1,76 +0,0 @@ -import argparse -import getpass - - -def parse_args(): - parser = argparse.ArgumentParser( - description='Store Telegram crawl config in Redis.', - ) - parser.add_argument('--api-id', type=int, help='Telegram API ID') - parser.add_argument('--api-hash', help='Telegram API hash') - parser.add_argument('--phone', help='Telegram account phone') - parser.add_argument( - '--groups', - help='Comma-separated Telegram group IDs to crawl', - ) - return parser.parse_args() - - -def prompt_required(label): - value = input(f'{label}: ').strip() - if not value: - raise SystemExit(f'{label.lower()} is required') - return value - - -def prompt_api_id(value): - if value is not None: - return value - try: - return int(prompt_required('API ID')) - except ValueError as exc: - raise SystemExit('api id must be an integer') from exc - - -def prompt_api_hash(value): - api_hash = value or getpass.getpass('API hash: ') - if not api_hash: - raise SystemExit('api hash is required') - return api_hash - - -def prompt_phone(value): - return value or prompt_required('Phone') - - -def prompt_groups(value): - return parse_group_ids(value or prompt_required('Group IDs')) - - -def parse_group_ids(value): - group_ids = [part.strip() for part in value.split(',') if part.strip()] - if not group_ids: - raise SystemExit('at least one group id is required') - try: - return [int(group_id) for group_id in group_ids] - except ValueError as exc: - raise SystemExit('group ids must be comma-separated integers') from exc - - -def main(): - args = parse_args() - api_id = prompt_api_id(args.api_id) - api_hash = prompt_api_hash(args.api_hash) - phone = prompt_phone(args.phone) - group_ids = prompt_groups(args.groups) - - # Lazy import: keeps `python configure.py --help` working when REDIS_URL is unset. - from config import save_app_config - - config = save_app_config(api_id, api_hash, phone, group_ids) - print(f'saved Redis config for {config["phone"]}: {len(group_ids)} group(s).') - return 0 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/crawl.py b/crawl.py deleted file mode 100644 index 7a250ae..0000000 --- a/crawl.py +++ /dev/null @@ -1,87 +0,0 @@ -import argparse -import getpass - -from telethon.errors import SessionPasswordNeededError -from telethon.sessions import StringSession -from telethon.sync import TelegramClient -from telethon.tl.types import Chat, Channel - -from common import new_run_time, save_group_export -from config import load_app_config -from session_store import load_session, save_session - - -def parse_args(argv=None): - parser = argparse.ArgumentParser( - description='Export members from Telegram groups.', - ) - parser.add_argument( - 'group_ids', - nargs='*', - type=int, - metavar='group_id', - help='Telegram group ID to crawl; defaults to the configured group IDs', - ) - return parser.parse_args(argv) - - -def login(app_config): - """Connect to Telegram, signing in only when the stored session is missing.""" - # Session loads from Redis: a saved string means no re-login on any device. - client = TelegramClient( - StringSession(load_session()), - app_config['api_id'], - app_config['api_hash'], - ) - - client.connect() - if not client.is_user_authorized(): - client.send_code_request(app_config['phone']) - try: - client.sign_in(code=input('Enter code: ')) - except SessionPasswordNeededError: - client.sign_in(password=getpass.getpass()) - - save_session(client.session.save()) - return client - - -def fetch_group(client, group_id, action): - """Return (entity, members sorted by id), or (None, []) for a non-group.""" - entity = client.get_entity(group_id) - if not isinstance(entity, (Chat, Channel)): - return None, [] - print(f'{action} {entity.title} (ID: {group_id})') - members = client.get_participants(entity) - members.sort(key=lambda x: x.id) - return entity, members - - -def main(argv=None): - args = parse_args(argv) - app_config = load_app_config() - # Command-line IDs override the configured list for one-off crawls. - group_ids = args.group_ids or app_config['group_ids'] - - client = login(app_config) - - run_time = new_run_time() - saved = 0 - for group_id in group_ids: - try: - entity, members = fetch_group(client, group_id, 'exporting') - if entity is None: - print(f'skip {group_id} because it is not a group.') - continue - save_group_export(group_id, entity.title, members, run_time) - saved += 1 - print(f'export {entity.title} done.') - except Exception as e: - print(f'error accessing group {group_id}: {e}') - - print(f'saved run {run_time}: {saved} group(s) to Redis.') - return 0 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/export.py b/export.py deleted file mode 100644 index 5420ab2..0000000 --- a/export.py +++ /dev/null @@ -1,169 +0,0 @@ -import argparse -import csv -import os -import sys -import tempfile -from datetime import datetime -from pathlib import Path - -OUTPUT_DIR = Path('output') -CSV_FIELDS = ['id', 'username', 'first_name', 'last_name'] -FORMULA_PREFIXES = ('=', '+', '-', '@', '\t', '\r') - - -def parse_args(argv=None): - parser = argparse.ArgumentParser( - description='Export a Redis-backed Telegram group crawl to CSV.', - ) - parser.add_argument( - 'group_id', - type=int, - nargs='?', - help='Telegram group ID (defaults to the first configured group)', - ) - parser.add_argument( - 'timecrawl', - nargs='?', - type=parse_run_time, - help='Crawl time in yyyymmddhhmmss format (defaults to latest)', - ) - return parser.parse_args(argv) - - -def parse_run_time(value): - if not is_valid_run_time(value): - if len(value) != 14 or not value.isdigit(): - raise argparse.ArgumentTypeError( - 'timecrawl must use yyyymmddhhmmss format', - ) - raise argparse.ArgumentTypeError( - 'timecrawl must be a valid date and time', - ) - return value - - -def is_valid_run_time(value): - if not isinstance(value, str) or len(value) != 14 or not value.isdigit(): - return False - try: - datetime.strptime(value, '%Y%m%d%H%M%S') - except ValueError: - return False - return True - - -def main(argv=None): - args = parse_args(argv) - - group_id = args.group_id - try: - # Lazy imports keep `python export.py --help` working without REDIS_URL. - from common import get_group_export, latest_group_export_time - - if group_id is None: - from config import load_app_config - - group_id = load_app_config()['group_ids'][0] - - run_time = args.timecrawl - if run_time is None: - run_time = latest_group_export_time(group_id) - if run_time is None: - print(f'no exports found for group {group_id}', file=sys.stderr) - return 1 - record = get_group_export(group_id, run_time) - except Exception as exc: - print(f'could not read export configuration or history: {exc}', file=sys.stderr) - return 1 - - if record is None: - print( - f'export not found or invalid for group {group_id} at {run_time}', - file=sys.stderr, - ) - return 1 - - try: - members = validate_record(record, group_id, run_time) - output_path = write_csv(group_id, run_time, members) - except (OSError, ValueError, csv.Error) as exc: - print(f'could not export group {group_id} at {run_time}: {exc}', file=sys.stderr) - return 1 - - print(f'exported {len(members)} members to {output_path}') - return 0 - - -def validate_record(record, expected_group_id, expected_time): - if not is_valid_run_time(expected_time): - raise ValueError('Redis export key has an invalid crawl time') - if not isinstance(record, dict): - raise ValueError('Redis export is not an object') - if record.get('group_id') != expected_group_id: - raise ValueError('Redis export group ID does not match its key') - if record.get('time') != expected_time: - raise ValueError('Redis export time does not match its key') - - members = record.get('members') - if not isinstance(members, list): - raise ValueError('Redis export members must be a list') - - validated = [] - for index, member in enumerate(members): - if not isinstance(member, dict): - raise ValueError(f'member {index} is not an object') - member_id = member.get('id') - if not isinstance(member_id, int) or isinstance(member_id, bool): - raise ValueError(f'member {index} has an invalid ID') - - row = {'id': member_id} - for field in CSV_FIELDS[1:]: - value = member.get(field) - if value is not None and not isinstance(value, str): - raise ValueError(f'member {index} has an invalid {field}') - row[field] = escape_formula(value) - validated.append(row) - return validated - - -def escape_formula(value): - if value and ( - value[0] in ('\t', '\r', '\n') - or value.lstrip().startswith(FORMULA_PREFIXES) - ): - return f"'{value}" - return value - - -def write_csv(group_id, run_time, members, output_dir=None): - output_dir = output_dir or OUTPUT_DIR - output_path = output_dir / f'{group_id}-{run_time}.csv' - output_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - output_dir.chmod(0o700) - - temp_path = None - try: - with tempfile.NamedTemporaryFile( - mode='w', - encoding='utf-8', - newline='', - dir=output_dir, - prefix=f'.{output_path.name}.', - delete=False, - ) as csv_file: - temp_path = Path(csv_file.name) - writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS) - writer.writeheader() - writer.writerows(members) - os.chmod(temp_path, 0o600) - os.replace(temp_path, output_path) - except Exception: - if temp_path is not None: - temp_path.unlink(missing_ok=True) - raise - - return output_path - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/go.mod b/go.mod index b9344b5..e078a1f 100644 --- a/go.mod +++ b/go.mod @@ -1,63 +1,54 @@ module github.com/tiennm99/export-telegram-group-members -go 1.24.0 - -toolchain go1.24.10 +go 1.27.1 require ( - github.com/celestix/gotgproto v1.0.0-beta22 + github.com/alicebob/miniredis/v2 v2.40.0 + github.com/gotd/td v0.162.0 github.com/joho/godotenv v1.5.1 - gorm.io/driver/sqlite v1.6.0 + github.com/mattn/go-isatty v0.0.24 + github.com/redis/go-redis/v9 v9.23.0 + golang.org/x/term v0.47.0 ) require ( - github.com/AnimeKaizoku/cacher v1.0.3-0.20250508132714-ddc7471efeef // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/coder/websocket v1.8.14 // indirect - github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.15 // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect - github.com/glebarez/go-sqlite v1.22.0 // indirect - github.com/glebarez/sqlite v1.11.0 // indirect - github.com/go-faster/errors v0.7.1 // indirect - github.com/go-faster/jx v1.1.0 // indirect + github.com/go-faster/errors v0.8.0 // indirect + github.com/go-faster/jx v1.2.0 // indirect github.com/go-faster/xor v1.0.0 // indirect github.com/go-faster/yaml v0.4.6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gotd/ige v0.2.2 // indirect + github.com/gotd/ige v0.3.0 // indirect + github.com/gotd/log v0.1.0 // indirect github.com/gotd/neo v0.1.5 // indirect - github.com/gotd/td v0.132.0 // indirect - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/ogen-go/ogen v1.15.2 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/segmentio/asm v1.2.0 // indirect - go.opentelemetry.io/otel v1.38.0 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.38.0 // indirect - go.uber.org/atomic v1.11.0 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/ogen-go/ogen v1.23.0 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/yuin/goldmark v1.8.5 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/atomic v1.12.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.46.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect - golang.org/x/tools v0.38.0 // indirect + go.uber.org/zap v1.28.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gorm.io/gorm v1.30.0 // indirect - modernc.org/libc v1.61.8 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.8.2 // indirect - modernc.org/sqlite v1.34.4 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 49dcede..20332b8 100644 --- a/go.sum +++ b/go.sum @@ -1,30 +1,29 @@ -github.com/AnimeKaizoku/cacher v1.0.3-0.20250508132714-ddc7471efeef h1:y8llZexgesBazUg/zdIlrZKHPFJ2zk9YxlLAJ5x4DpQ= -github.com/AnimeKaizoku/cacher v1.0.3-0.20250508132714-ddc7471efeef/go.mod h1:jw0de/b0K6W7Y3T9rHCMGVKUf6oG7hENNcssxYcZTCc= -github.com/celestix/gotgproto v1.0.0-beta22 h1:Iu78cFA08nV8+flmxKs9CJ3W73+HG30fx0nLOs5A6fI= -github.com/celestix/gotgproto v1.0.0-beta22/go.mod h1:JYC9Js/5KLUhFR5M2RslQi2DFAcF7EdrgJMXo0YrzGQ= +github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc= +github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= -github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= -github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= -github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= -github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= -github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= -github.com/go-faster/jx v1.1.0 h1:ZsW3wD+snOdmTDy9eIVgQdjUpXRRV4rqW8NS3t+20bg= -github.com/go-faster/jx v1.1.0/go.mod h1:vKDNikrKoyUmpzaJ0OkIkRQClNHFX/nF3dnTJZb3skg= -github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/errors v0.8.0 h1:9T9eJrM+72dFk7n4DfhuaDDe6cyuFCSW2oNUkN77Yqc= +github.com/go-faster/errors v0.8.0/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38= github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= @@ -35,117 +34,94 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk= -github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0= +github.com/gotd/ige v0.3.0 h1:4f6LEHWsVDLBG0bT9wWG2/9TZb5aWm265G8ZlTXmRRU= +github.com/gotd/ige v0.3.0/go.mod h1:FE9bTaQtvfArizAcZuI4sS6gXaEUBmixdUufVHoCKac= +github.com/gotd/log v0.1.0 h1:4LJUEvafD1xtBwx2QkrlzFnRgbYXTlWqJPDi8BvrLbU= +github.com/gotd/log v0.1.0/go.mod h1:5ilhdu1Ux0QvDY/FF3Ojfw24Ws3SlCtyLwOpXy8KYXs= +github.com/gotd/log/logzap v0.1.1 h1:O6l7d8HUbODe+UMcrM47eXYDwdJ6RNmpQejLjrlcEIQ= +github.com/gotd/log/logzap v0.1.1/go.mod h1:5ObZkITbfhbsBOLzBkzmMk9QxXc0eNQpimau7zRL+Y8= github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= -github.com/gotd/td v0.132.0 h1:Iqm3S2b+8kDgA9237IDXRxj7sryUpvy+4Cr50/0tpx4= -github.com/gotd/td v0.132.0/go.mod h1:4CDGYS+rDtOqotRheGaF9MS5g6jaUewvSXqBNJnx8SQ= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= -github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/gotd/td v0.162.0 h1:3pUzBsEbb0I5QquvfSkXihkVBXI+QTtdd/upijjUbkw= +github.com/gotd/td v0.162.0/go.mod h1:ZsGbErIos7XHF7HL0VlsjL6mmX4b1JpinHfDb7gOdvs= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/ogen-go/ogen v1.15.2 h1:Hy5XNcDgWur758Kf0+DTQFN8cyBOs58EjDD3NMqih54= -github.com/ogen-go/ogen v1.15.2/go.mod h1:bS+BP2cV7+IGjOM24znBmh+PrpZvYFXA7o3BNF4Hj2E= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ogen-go/ogen v1.23.0 h1:QaWeKm2KZ2zy7NkqqO1Vdl5idNqlG+svxdgwVAX+zbo= +github.com/ogen-go/ogen v1.23.0/go.mod h1:bwwvC3AmCV+LrL5lazyQwwof90402mdcSyI0FOzzpfM= +github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s= +github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE= +go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= -golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= -gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= -gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= -modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= -modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.23.12 h1:UF08a38c4B+K3VoGipBrVWLFUCHd8+X20QZtFAIlQNk= -modernc.org/ccgo/v4 v4.23.12/go.mod h1:vdN4h2WR5aEoNondUx26K7G8X+nuBscYnAEWSRmN2/0= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.6.1 h1:+Qf6xdG8l7B27TQ8D8lw/iFMUj1RXRBOuMUWziJOsk8= -modernc.org/gc/v2 v2.6.1/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/libc v1.61.8 h1:50KrjlFFoKq9ABh+bNVUf5SfVfQ4NY7CEyFBh65qc60= -modernc.org/libc v1.61.8/go.mod h1:XloulGc0yIRM+91kbwrp7jNi/mfYPAvDOD2qwzWEij0= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= -modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= -modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= diff --git a/internal/commands/cli/cli.go b/internal/commands/cli/cli.go new file mode 100644 index 0000000..c30132a --- /dev/null +++ b/internal/commands/cli/cli.go @@ -0,0 +1,73 @@ +// Package cli holds what every command shares: process wiring and +// positional-argument parsing. +package cli + +import ( + "context" + "fmt" + "io" + "os" + "strconv" + + "github.com/mattn/go-isatty" +) + +// IO is a command's terminal. Color is set when Out is a terminal, so piped +// output stays free of ANSI codes. +type IO struct { + In io.Reader + Out io.Writer + Err io.Writer + Color bool +} + +// RunFunc is a command entry point. It returns an error to exit with status 1. +type RunFunc func(ctx context.Context, args []string, term IO) error + +// Main runs a command against the process terminal and exits on error. +func Main(run RunFunc) { + term := IO{ + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + Color: isatty.IsTerminal(os.Stdout.Fd()), + } + if err := run(context.Background(), os.Args[1:], term); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// WantsHelp reports whether args ask for usage. Commands parse positionals +// by hand because the flag package would read a negative group ID such as +// -1001234567890 as an unknown flag. +func WantsHelp(args []string) bool { + for _, arg := range args { + if arg == "-h" || arg == "--help" { + return true + } + } + return false +} + +// ParseGroupID parses one group ID argument. +func ParseGroupID(value string) (int64, error) { + id, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid group id %q: must be an integer", value) + } + return id, nil +} + +// ParseGroupIDs parses every argument as a group ID. +func ParseGroupIDs(args []string) ([]int64, error) { + ids := make([]int64, 0, len(args)) + for _, arg := range args { + id, err := ParseGroupID(arg) + if err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, nil +} diff --git a/internal/commands/cli/cli_test.go b/internal/commands/cli/cli_test.go new file mode 100644 index 0000000..28a3e10 --- /dev/null +++ b/internal/commands/cli/cli_test.go @@ -0,0 +1,28 @@ +package cli + +import ( + "reflect" + "testing" +) + +func TestParseGroupIDsAcceptsNegativeIDs(t *testing.T) { + got, err := ParseGroupIDs([]string{"-1001234567890", "-42"}) + if err != nil { + t.Fatalf("ParseGroupIDs: %v", err) + } + if !reflect.DeepEqual(got, []int64{-1001234567890, -42}) { + t.Fatalf("ParseGroupIDs = %v", got) + } +} + +func TestParseGroupIDsRejectsNonInteger(t *testing.T) { + if _, err := ParseGroupIDs([]string{"not-a-group"}); err == nil { + t.Fatal("ParseGroupIDs accepted a non-integer") + } +} + +func TestWantsHelp(t *testing.T) { + if !WantsHelp([]string{"-100", "--help"}) || WantsHelp([]string{"-100"}) { + t.Fatal("WantsHelp misread arguments") + } +} diff --git a/internal/commands/compare/command.go b/internal/commands/compare/command.go new file mode 100644 index 0000000..739ed26 --- /dev/null +++ b/internal/commands/compare/command.go @@ -0,0 +1,129 @@ +// Package compare shows member changes for one group between two saved +// crawls. +package compare + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" + "github.com/tiennm99/export-telegram-group-members/internal/members" + "github.com/tiennm99/export-telegram-group-members/internal/store" +) + +const usage = `usage: go run ./cmd/compare [group_id [time1 time2]] + +Compare member changes between two crawls for one group. Without times, the +latest two crawls are used; without a group, the first configured group.` + +type request struct { + groupID int64 + hasGroupID bool + times []string +} + +// Run compares two crawls of one group. +func Run(ctx context.Context, args []string, term cli.IO) error { + if cli.WantsHelp(args) { + fmt.Fprintln(term.Out, usage) + return nil + } + req, err := parseArgs(args) + if err != nil { + return fmt.Errorf("%w\n%s", err, usage) + } + + st, err := store.OpenFromEnv(ctx) + if err != nil { + return err + } + defer st.Close() + + if !req.hasGroupID { + cfg, err := st.LoadConfig(ctx) + if err != nil { + return err + } + req.groupID = cfg.GroupIDs[0] + } + + before, after, err := loadPair(ctx, st, req) + if err != nil { + return err + } + added, removed := members.Diff(before.Members, after.Members) + printReport(members.Printer{Out: term.Out, Color: term.Color}, req.groupID, before, after, added, removed) + return nil +} + +func parseArgs(args []string) (request, error) { + switch len(args) { + case 0: + return request{}, nil + case 1, 3: + groupID, err := cli.ParseGroupID(args[0]) + if err != nil { + return request{}, err + } + return request{groupID: groupID, hasGroupID: true, times: args[1:]}, nil + default: + return request{}, errors.New("provide both time1 and time2, or omit both to compare latest two crawls") + } +} + +func loadPair(ctx context.Context, st *store.Store, req request) (store.GroupExport, store.GroupExport, error) { + if len(req.times) == 2 { + var pair [2]store.GroupExport + for i, runTime := range req.times { + record, err := st.GetGroupExport(ctx, req.groupID, runTime) + if errors.Is(err, store.ErrExportNotFound) { + return store.GroupExport{}, store.GroupExport{}, fmt.Errorf("export not found for group %d at %s", req.groupID, runTime) + } + if err != nil { + return store.GroupExport{}, store.GroupExport{}, err + } + pair[i] = record + } + return pair[0], pair[1], nil + } + + records, err := st.LatestGroupExports(ctx, req.groupID, 2) + if err != nil { + return store.GroupExport{}, store.GroupExport{}, err + } + if len(records) < 2 { + return store.GroupExport{}, store.GroupExport{}, fmt.Errorf("need at least 2 exports for group %d", req.groupID) + } + return records[0], records[1], nil +} + +func printReport(out members.Printer, groupID int64, before, after store.GroupExport, added, removed []store.Member) { + title := after.Title + if title == "" { + title = before.Title + } + label := strconv.FormatInt(groupID, 10) + if title != "" { + label = fmt.Sprintf("%s (%d)", title, groupID) + } + + out.Line(fmt.Sprintf("diff --telegram-group %q", label), "") + out.Line("--- crawl/"+before.Time, members.Red) + out.Line("+++ crawl/"+after.Time, members.Green) + out.Line(fmt.Sprintf("@@ members: %d -> %d (+%d -%d) @@", + len(before.Members), len(after.Members), len(added), len(removed)), members.Cyan) + out.Line("", "") + + table := members.NewTable(removed, added) + for _, member := range removed { + out.Line(table.Row("-", member), members.Red) + } + for _, member := range added { + out.Line(table.Row("+", member), members.Green) + } + if len(added) == 0 && len(removed) == 0 { + out.Line(" no membership changes.", "") + } +} diff --git a/internal/commands/compare/command_test.go b/internal/commands/compare/command_test.go new file mode 100644 index 0000000..27e4476 --- /dev/null +++ b/internal/commands/compare/command_test.go @@ -0,0 +1,63 @@ +package compare + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/alicebob/miniredis/v2" + + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" +) + +func TestParseArgs(t *testing.T) { + for _, args := range [][]string{{}, {"-100"}, {"-100", "t1", "t2"}} { + if _, err := parseArgs(args); err != nil { + t.Errorf("parseArgs(%v): %v", args, err) + } + } + for _, args := range [][]string{{"-100", "t1"}, {"a", "b", "c", "d"}, {"x"}} { + if _, err := parseArgs(args); err == nil { + t.Errorf("parseArgs(%v) accepted bad input", args) + } + } +} + +func TestRunComparesLatestTwoCrawls(t *testing.T) { + mr := miniredis.RunT(t) + t.Setenv("REDIS_URL", "redis://"+mr.Addr()) + mr.Set("telegram-export:group:-100:20260701000000", + `{"group_id":-100,"title":"Team","time":"20260701000000","members":[{"id":1,"username":"amy"},{"id":2,"username":null}]}`) + mr.Set("telegram-export:group:-100:20260702000000", + `{"group_id":-100,"title":"Team","time":"20260702000000","members":[{"id":2},{"id":3,"username":"cat","first_name":"Cat"}]}`) + + var out bytes.Buffer + if err := Run(context.Background(), []string{"-100"}, cli.IO{Out: &out}); err != nil { + t.Fatalf("Run: %v", err) + } + + want := strings.Join([]string{ + `diff --telegram-group "Team (-100)"`, + `--- crawl/20260701000000`, + `+++ crawl/20260702000000`, + `@@ members: 2 -> 2 (+1 -1) @@`, + ``, + `- 1 @amy`, + `+ 3 @cat Cat`, + ``, + }, "\n") + if out.String() != want { + t.Fatalf("output:\n%s\nwant:\n%s", out.String(), want) + } +} + +func TestRunNeedsTwoCrawls(t *testing.T) { + mr := miniredis.RunT(t) + t.Setenv("REDIS_URL", "redis://"+mr.Addr()) + + err := Run(context.Background(), []string{"-100"}, cli.IO{Out: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), "need at least 2 exports for group -100") { + t.Fatalf("Run error = %v", err) + } +} diff --git a/internal/commands/comparegroups/command.go b/internal/commands/comparegroups/command.go new file mode 100644 index 0000000..1d06d1d --- /dev/null +++ b/internal/commands/comparegroups/command.go @@ -0,0 +1,99 @@ +// Package comparegroups fetches two groups live from Telegram and compares +// their members. It saves no crawl to Redis. +package comparegroups + +import ( + "context" + "errors" + "fmt" + + "github.com/gotd/td/tg" + + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" + "github.com/tiennm99/export-telegram-group-members/internal/members" + "github.com/tiennm99/export-telegram-group-members/internal/prompt" + "github.com/tiennm99/export-telegram-group-members/internal/store" + "github.com/tiennm99/export-telegram-group-members/internal/telegram" +) + +const usage = `usage: go run ./cmd/compare-groups + +Fetch two Telegram groups live and compare their members. No crawl is saved +to Redis; Redis only supplies the Telegram config and login session.` + +// Run fetches both groups and prints who is in both, only A, and only B. +func Run(ctx context.Context, args []string, term cli.IO) error { + if cli.WantsHelp(args) { + fmt.Fprintln(term.Out, usage) + return nil + } + if len(args) != 2 { + return errors.New(usage) + } + groupIDs, err := cli.ParseGroupIDs(args) + if err != nil { + return fmt.Errorf("%w\n%s", err, usage) + } + + st, err := store.OpenFromEnv(ctx) + if err != nil { + return err + } + defer st.Close() + + cfg, err := st.LoadConfig(ctx) + if err != nil { + return err + } + + ask := prompt.New(term.In, term.Out) + return telegram.Run(ctx, cfg, st.SessionStorage(), ask, func(ctx context.Context, api *tg.Client) error { + fetcher := telegram.NewFetcher(api) + var groups [2]telegram.Group + for i, groupID := range groupIDs { + group, err := fetcher.FetchGroup(ctx, groupID, func(title string) { + fmt.Fprintf(term.Out, "fetching %s (ID: %d)\n", title, groupID) + }) + if errors.Is(err, telegram.ErrNotGroup) { + return fmt.Errorf("%d is not a group", groupID) + } + if err != nil { + return fmt.Errorf("error accessing group %d: %w", groupID, err) + } + groups[i] = group + } + + fmt.Fprintln(term.Out) + printReport(members.Printer{Out: term.Out, Color: term.Color}, groups[0], groups[1]) + return nil + }) +} + +func printReport(out members.Printer, a, b telegram.Group) { + onlyA, onlyB, both := members.Split(a.Members, b.Members) + + out.Line(fmt.Sprintf("diff --telegram-groups %q %q", label(a), label(b)), "") + out.Line("--- "+label(a), members.Red) + out.Line("+++ "+label(b), members.Green) + out.Line(fmt.Sprintf("@@ members: %d vs %d (both %d, -%d only first, +%d only second) @@", + len(a.Members), len(b.Members), len(both), len(onlyA), len(onlyB)), members.Cyan) + out.Line("", "") + + table := members.NewTable(onlyA, onlyB, both) + for _, member := range both { + out.Line(table.Row(" ", member), "") + } + for _, member := range onlyA { + out.Line(table.Row("-", member), members.Red) + } + for _, member := range onlyB { + out.Line(table.Row("+", member), members.Green) + } + if len(a.Members) == 0 && len(b.Members) == 0 { + out.Line(" both groups have no visible members.", "") + } +} + +func label(group telegram.Group) string { + return fmt.Sprintf("%s (%d)", group.Title, group.ID) +} diff --git a/internal/commands/comparegroups/command_test.go b/internal/commands/comparegroups/command_test.go new file mode 100644 index 0000000..3d15d7e --- /dev/null +++ b/internal/commands/comparegroups/command_test.go @@ -0,0 +1,50 @@ +package comparegroups + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" + "github.com/tiennm99/export-telegram-group-members/internal/members" + "github.com/tiennm99/export-telegram-group-members/internal/store" + "github.com/tiennm99/export-telegram-group-members/internal/telegram" +) + +func TestPrintReport(t *testing.T) { + a := telegram.Group{ID: -101, Title: "A", Members: []store.Member{ + {ID: 1, Username: "amy"}, {ID: 2, Username: "bob"}, + }} + b := telegram.Group{ID: -202, Title: "B", Members: []store.Member{ + {ID: 2, Username: "bob"}, {ID: 3, Username: "cat"}, + }} + + var out bytes.Buffer + printReport(members.Printer{Out: &out}, a, b) + + want := strings.Join([]string{ + `diff --telegram-groups "A (-101)" "B (-202)"`, + `--- A (-101)`, + `+++ B (-202)`, + `@@ members: 2 vs 2 (both 1, -1 only first, +1 only second) @@`, + ``, + ` 2 @bob`, + `- 1 @amy`, + `+ 3 @cat`, + ``, + }, "\n") + if out.String() != want { + t.Fatalf("output:\n%s\nwant:\n%s", out.String(), want) + } +} + +func TestRunRequiresTwoGroupIDsBeforeRedis(t *testing.T) { + t.Setenv("REDIS_URL", "") + for _, args := range [][]string{{"-100"}, {"-100", "x"}} { + err := Run(context.Background(), args, cli.IO{Out: &bytes.Buffer{}}) + if err == nil || strings.Contains(err.Error(), "REDIS_URL") { + t.Errorf("Run(%v) error = %v, want a usage error", args, err) + } + } +} diff --git a/internal/commands/configure/command.go b/internal/commands/configure/command.go new file mode 100644 index 0000000..6901059 --- /dev/null +++ b/internal/commands/configure/command.go @@ -0,0 +1,118 @@ +// Package configure stores the Telegram crawl config in Redis. +package configure + +import ( + "context" + "errors" + "flag" + "fmt" + "strconv" + "strings" + + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" + "github.com/tiennm99/export-telegram-group-members/internal/prompt" + "github.com/tiennm99/export-telegram-group-members/internal/store" +) + +// Run asks for any value not given as a flag, then saves the config. +func Run(ctx context.Context, args []string, term cli.IO) error { + flags := flag.NewFlagSet("configure", flag.ContinueOnError) + flags.SetOutput(term.Err) + flags.Usage = func() { + fmt.Fprintln(term.Err, "usage: go run ./cmd/configure [--api-id ID] [--api-hash HASH] [--phone PHONE] [--groups ID,ID]") + fmt.Fprintln(term.Err, "\nStore Telegram crawl config in Redis.") + flags.PrintDefaults() + } + apiID := flags.Int("api-id", 0, "Telegram API ID") + apiHash := flags.String("api-hash", "", "Telegram API hash") + phone := flags.String("phone", "", "Telegram account phone") + groups := flags.String("groups", "", "Comma-separated Telegram group IDs to crawl") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + + ask := prompt.New(term.In, term.Out) + cfg, err := collect(ask, *apiID, *apiHash, *phone, *groups) + if err != nil { + return err + } + + st, err := store.OpenFromEnv(ctx) + if err != nil { + return err + } + defer st.Close() + + if err := st.SaveConfig(ctx, cfg); err != nil { + return err + } + fmt.Fprintf(term.Out, "saved Redis config for %s: %d group(s).\n", cfg.Phone, len(cfg.GroupIDs)) + return nil +} + +func collect(ask *prompt.Prompter, apiID int, apiHash, phone, groups string) (store.AppConfig, error) { + var err error + if apiID == 0 { + raw, err := required(ask.Line, "API ID") + if err != nil { + return store.AppConfig{}, err + } + if apiID, err = strconv.Atoi(raw); err != nil { + return store.AppConfig{}, errors.New("api id must be an integer") + } + } + if apiHash == "" { + if apiHash, err = required(ask.Secret, "API hash"); err != nil { + return store.AppConfig{}, err + } + } + if phone == "" { + if phone, err = required(ask.Line, "Phone"); err != nil { + return store.AppConfig{}, err + } + } + if groups == "" { + if groups, err = required(ask.Line, "Group IDs"); err != nil { + return store.AppConfig{}, err + } + } + groupIDs, err := ParseGroupList(groups) + if err != nil { + return store.AppConfig{}, err + } + return store.AppConfig{APIID: apiID, APIHash: apiHash, Phone: phone, GroupIDs: groupIDs}, nil +} + +func required(read func(string) (string, error), label string) (string, error) { + value, err := read(label) + if err != nil { + return "", err + } + if value == "" { + return "", fmt.Errorf("%s is required", strings.ToLower(label)) + } + return value, nil +} + +// ParseGroupList parses comma-separated group IDs, ignoring empty entries. +func ParseGroupList(value string) ([]int64, error) { + var ids []int64 + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + id, err := strconv.ParseInt(part, 10, 64) + if err != nil { + return nil, errors.New("group ids must be comma-separated integers") + } + ids = append(ids, id) + } + if len(ids) == 0 { + return nil, errors.New("at least one group id is required") + } + return ids, nil +} diff --git a/internal/commands/configure/command_test.go b/internal/commands/configure/command_test.go new file mode 100644 index 0000000..a351902 --- /dev/null +++ b/internal/commands/configure/command_test.go @@ -0,0 +1,45 @@ +package configure + +import ( + "bytes" + "reflect" + "strings" + "testing" + + "github.com/tiennm99/export-telegram-group-members/internal/prompt" +) + +func TestParseGroupList(t *testing.T) { + got, err := ParseGroupList(" -1001, ,-2 ") + if err != nil || !reflect.DeepEqual(got, []int64{-1001, -2}) { + t.Fatalf("ParseGroupList = %v, %v", got, err) + } + for _, bad := range []string{"", " , ", "abc"} { + if _, err := ParseGroupList(bad); err == nil { + t.Errorf("ParseGroupList(%q) accepted bad input", bad) + } + } +} + +func TestCollectPromptsOnlyForMissingValues(t *testing.T) { + var out bytes.Buffer + ask := prompt.New(strings.NewReader("+1555\n-100,-200\n"), &out) + + cfg, err := collect(ask, 123, "hash", "", "") + if err != nil { + t.Fatalf("collect: %v", err) + } + if cfg.Phone != "+1555" || !reflect.DeepEqual(cfg.GroupIDs, []int64{-100, -200}) || cfg.APIID != 123 { + t.Fatalf("collect = %+v", cfg) + } + if strings.Contains(out.String(), "API ID") || !strings.Contains(out.String(), "Phone: ") { + t.Fatalf("unexpected prompts: %q", out.String()) + } +} + +func TestCollectRejectsBadAPIID(t *testing.T) { + ask := prompt.New(strings.NewReader("abc\n"), &bytes.Buffer{}) + if _, err := collect(ask, 0, "", "", ""); err == nil || !strings.Contains(err.Error(), "integer") { + t.Fatalf("collect error = %v", err) + } +} diff --git a/internal/commands/crawl/command.go b/internal/commands/crawl/command.go new file mode 100644 index 0000000..21e5a8d --- /dev/null +++ b/internal/commands/crawl/command.go @@ -0,0 +1,93 @@ +// Package crawl fetches group members from Telegram and saves each group as +// one export in Redis. +package crawl + +import ( + "context" + "errors" + "fmt" + + "github.com/gotd/td/tg" + + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" + "github.com/tiennm99/export-telegram-group-members/internal/prompt" + "github.com/tiennm99/export-telegram-group-members/internal/store" + "github.com/tiennm99/export-telegram-group-members/internal/telegram" +) + +const usage = `usage: go run ./cmd/crawl [group_id ...] + +Export members from Telegram groups. Group IDs given here replace the +configured list for this run only.` + +// Run crawls the given groups, or the configured ones when none are given. +func Run(ctx context.Context, args []string, term cli.IO) error { + if cli.WantsHelp(args) { + fmt.Fprintln(term.Out, usage) + return nil + } + // Parse before touching Redis or Telegram, so bad input fails offline. + groupIDs, err := cli.ParseGroupIDs(args) + if err != nil { + return fmt.Errorf("%w\n%s", err, usage) + } + + st, err := store.OpenFromEnv(ctx) + if err != nil { + return err + } + defer st.Close() + + cfg, err := st.LoadConfig(ctx) + if err != nil { + return err + } + if len(groupIDs) == 0 { + groupIDs = cfg.GroupIDs + } + + ask := prompt.New(term.In, term.Out) + return telegram.Run(ctx, cfg, st.SessionStorage(), ask, func(ctx context.Context, api *tg.Client) error { + runTime := store.NewRunTime() + saved := crawlGroups(ctx, term, telegram.NewFetcher(api), st, groupIDs, runTime) + fmt.Fprintf(term.Out, "saved run %s: %d group(s) to Redis.\n", runTime, saved) + return nil + }) +} + +// crawlGroups saves every group it can read. One failing group is reported +// and skipped so the rest of the run still lands. +func crawlGroups( + ctx context.Context, + term cli.IO, + fetcher *telegram.Fetcher, + st *store.Store, + groupIDs []int64, + runTime string, +) int { + saved := 0 + for _, groupID := range groupIDs { + group, err := fetcher.FetchGroup(ctx, groupID, func(title string) { + fmt.Fprintf(term.Out, "exporting %s (ID: %d)\n", title, groupID) + }) + if errors.Is(err, telegram.ErrNotGroup) { + fmt.Fprintf(term.Out, "skip %d because it is not a group.\n", groupID) + continue + } + if err == nil { + err = st.SaveGroupExport(ctx, store.GroupExport{ + GroupID: groupID, + Title: group.Title, + Time: runTime, + Members: group.Members, + }) + } + if err != nil { + fmt.Fprintf(term.Out, "error accessing group %d: %v\n", groupID, err) + continue + } + saved++ + fmt.Fprintf(term.Out, "export %s done.\n", group.Title) + } + return saved +} diff --git a/internal/commands/crawl/command_test.go b/internal/commands/crawl/command_test.go new file mode 100644 index 0000000..9feec1b --- /dev/null +++ b/internal/commands/crawl/command_test.go @@ -0,0 +1,30 @@ +package crawl + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" +) + +func TestRunRejectsBadGroupIDBeforeRedis(t *testing.T) { + t.Setenv("REDIS_URL", "") + var out bytes.Buffer + + err := Run(context.Background(), []string{"not-a-group"}, cli.IO{Out: &out, Err: &out}) + if err == nil || !strings.Contains(err.Error(), `invalid group id "not-a-group"`) { + t.Fatalf("Run error = %v", err) + } +} + +func TestRunHelp(t *testing.T) { + var out bytes.Buffer + if err := Run(context.Background(), []string{"-h"}, cli.IO{Out: &out}); err != nil { + t.Fatalf("Run -h: %v", err) + } + if !strings.Contains(out.String(), "go run ./cmd/crawl") { + t.Fatalf("help = %q", out.String()) + } +} diff --git a/internal/commands/export/command.go b/internal/commands/export/command.go new file mode 100644 index 0000000..2cf01e3 --- /dev/null +++ b/internal/commands/export/command.go @@ -0,0 +1,112 @@ +// Package export writes one saved group crawl from Redis to a CSV file. +package export + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" + "github.com/tiennm99/export-telegram-group-members/internal/store" +) + +const usage = `usage: go run ./cmd/export [group_id [timecrawl]] + +Export a Redis-backed Telegram group crawl to CSV. Without timecrawl +(yyyymmddhhmmss), the latest crawl is used; without a group, the first +configured group.` + +// outputDir is where CSV files land; a var so tests can redirect it. +var outputDir = "output" + +// Run exports one crawl to output/-.csv. +func Run(ctx context.Context, args []string, term cli.IO) error { + if cli.WantsHelp(args) { + fmt.Fprintln(term.Out, usage) + return nil + } + if len(args) > 2 { + return errors.New(usage) + } + var ( + groupID int64 + hasGroupID bool + runTime string + err error + ) + if len(args) >= 1 { + if groupID, err = cli.ParseGroupID(args[0]); err != nil { + return fmt.Errorf("%w\n%s", err, usage) + } + hasGroupID = true + } + if len(args) == 2 { + runTime = args[1] + if err := validateRunTime(runTime); err != nil { + return fmt.Errorf("%w\n%s", err, usage) + } + } + + st, err := store.OpenFromEnv(ctx) + if err != nil { + return err + } + defer st.Close() + + record, groupID, runTime, err := loadRecord(ctx, st, groupID, hasGroupID, runTime) + if err != nil { + return err + } + + rows, err := validateRecord(record, groupID, runTime) + if err == nil { + var path string + if path, err = writeCSV(outputDir, groupID, runTime, rows); err == nil { + fmt.Fprintf(term.Out, "exported %d members to %s\n", len(rows), path) + return nil + } + } + return fmt.Errorf("could not export group %d at %s: %w", groupID, runTime, err) +} + +// loadRecord resolves the default group and latest time, then reads that +// export. It returns the resolved group ID and time with the record. +func loadRecord(ctx context.Context, st *store.Store, groupID int64, hasGroupID bool, runTime string) (store.GroupExport, int64, string, error) { + if !hasGroupID { + cfg, err := st.LoadConfig(ctx) + if err != nil { + return store.GroupExport{}, 0, "", fmt.Errorf("could not read export configuration or history: %w", err) + } + groupID = cfg.GroupIDs[0] + } + if runTime == "" { + times, err := st.GroupExportTimes(ctx, groupID) + if err != nil { + return store.GroupExport{}, 0, "", fmt.Errorf("could not read export configuration or history: %w", err) + } + if len(times) == 0 { + return store.GroupExport{}, 0, "", fmt.Errorf("no exports found for group %d", groupID) + } + runTime = times[len(times)-1] + } + + record, err := st.GetGroupExport(ctx, groupID, runTime) + if errors.Is(err, store.ErrExportNotFound) { + return store.GroupExport{}, 0, "", fmt.Errorf("export not found or invalid for group %d at %s", groupID, runTime) + } + if err != nil { + return store.GroupExport{}, 0, "", fmt.Errorf("could not read export configuration or history: %w", err) + } + return record, groupID, runTime, nil +} + +func validateRunTime(value string) error { + if !store.IsRunTime(value) { + return errors.New("timecrawl must use yyyymmddhhmmss format") + } + if _, err := time.Parse(store.RunTimeLayout, value); err != nil { + return errors.New("timecrawl must be a valid date and time") + } + return nil +} diff --git a/internal/commands/export/command_test.go b/internal/commands/export/command_test.go new file mode 100644 index 0000000..fc7d959 --- /dev/null +++ b/internal/commands/export/command_test.go @@ -0,0 +1,125 @@ +package export + +import ( + "bytes" + "context" + "encoding/csv" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/alicebob/miniredis/v2" + + "github.com/tiennm99/export-telegram-group-members/internal/commands/cli" + "github.com/tiennm99/export-telegram-group-members/internal/store" +) + +func setup(t *testing.T) (*miniredis.Miniredis, string) { + t.Helper() + mr := miniredis.RunT(t) + t.Setenv("REDIS_URL", "redis://"+mr.Addr()) + dir := filepath.Join(t.TempDir(), "output") + old := outputDir + outputDir = dir + t.Cleanup(func() { outputDir = old }) + return mr, dir +} + +func TestRunExportsLatestCrawl(t *testing.T) { + mr, dir := setup(t) + mr.Set("telegram-export:group:-100:20260701000000", `{"group_id":-100,"time":"20260701000000","members":[]}`) + mr.Set("telegram-export:group:-100:20260702000000", + `{"group_id":-100,"time":"20260702000000","members":[{"id":1,"username":"amy","first_name":"Amy","last_name":"Lee"},{"id":2,"username":null,"first_name":"=SUM(A1)","last_name":null}]}`) + + var out bytes.Buffer + if err := Run(context.Background(), []string{"-100"}, cli.IO{Out: &out}); err != nil { + t.Fatalf("Run: %v", err) + } + + path := filepath.Join(dir, "-100-20260702000000.csv") + if !strings.Contains(out.String(), "exported 2 members to "+path) { + t.Fatalf("output = %q", out.String()) + } + file, err := os.Open(path) + if err != nil { + t.Fatalf("open CSV: %v", err) + } + defer file.Close() + rows, err := csv.NewReader(file).ReadAll() + if err != nil { + t.Fatalf("read CSV: %v", err) + } + want := [][]string{ + {"id", "username", "first_name", "last_name"}, + {"1", "amy", "Amy", "Lee"}, + {"2", "", "'=SUM(A1)", ""}, + } + if !reflect.DeepEqual(rows, want) { + t.Fatalf("rows = %v, want %v", rows, want) + } + + for p, mode := range map[string]os.FileMode{dir: 0o700, path: 0o600} { + if info, err := os.Stat(p); err != nil || info.Mode().Perm() != mode { + t.Errorf("%s mode = %v, %v; want %v", p, info.Mode().Perm(), err, mode) + } + } +} + +func TestRunRejectsMismatchedRecord(t *testing.T) { + mr, _ := setup(t) + mr.Set("telegram-export:group:-100:20260702000000", `{"group_id":-999,"time":"20260702000000","members":[]}`) + + err := Run(context.Background(), []string{"-100", "20260702000000"}, cli.IO{Out: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), "group ID does not match its key") { + t.Fatalf("Run error = %v", err) + } +} + +func TestRunReportsMissingExports(t *testing.T) { + setup(t) + err := Run(context.Background(), []string{"-100"}, cli.IO{Out: &bytes.Buffer{}}) + if err == nil || err.Error() != "no exports found for group -100" { + t.Fatalf("Run error = %v", err) + } +} + +func TestValidateRunTime(t *testing.T) { + tests := map[string]string{ + "20260724120000": "", + "2026072412": "yyyymmddhhmmss format", + "20260230120000": "valid date and time", + "20261324000000": "valid date and time", + } + for value, want := range tests { + err := validateRunTime(value) + if (want == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), want)) { + t.Errorf("validateRunTime(%q) = %v, want %q", value, err, want) + } + } +} + +func TestEscapeFormula(t *testing.T) { + tests := map[string]string{ + "": "", + "alice": "alice", + "=1+1": "'=1+1", + " @cmd": "' @cmd", + "-5": "'-5", + "\tx": "'\tx", + "\nx": "'\nx", + } + for in, want := range tests { + if got := escapeFormula(in); got != want { + t.Errorf("escapeFormula(%q) = %q, want %q", in, got, want) + } + } +} + +func TestValidateRecordRejectsMissingMemberID(t *testing.T) { + record := store.GroupExport{GroupID: -1, Time: "t", Members: []store.Member{{Username: "x"}}} + if _, err := validateRecord(record, -1, "t"); err == nil || !strings.Contains(err.Error(), "invalid ID") { + t.Fatalf("validateRecord error = %v", err) + } +} diff --git a/internal/commands/export/csv-file.go b/internal/commands/export/csv-file.go new file mode 100644 index 0000000..7afcab1 --- /dev/null +++ b/internal/commands/export/csv-file.go @@ -0,0 +1,99 @@ +package export + +import ( + "encoding/csv" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "unicode" + + "github.com/tiennm99/export-telegram-group-members/internal/store" +) + +var csvHeader = []string{"id", "username", "first_name", "last_name"} + +// validateRecord turns members into CSV rows, rejecting a crawl whose key +// and body disagree or whose members lack an ID. +func validateRecord(record store.GroupExport, groupID int64, runTime string) ([][]string, error) { + if record.GroupID != groupID { + return nil, errors.New("Redis export group ID does not match its key") + } + if record.Time != runTime { + return nil, errors.New("Redis export time does not match its key") + } + rows := make([][]string, 0, len(record.Members)) + for i, member := range record.Members { + if member.ID == 0 { + return nil, fmt.Errorf("member %d has an invalid ID", i) + } + rows = append(rows, []string{ + strconv.FormatInt(member.ID, 10), + escapeFormula(member.Username), + escapeFormula(member.FirstName), + escapeFormula(member.LastName), + }) + } + return rows, nil +} + +// escapeFormula prefixes an apostrophe to Telegram text a spreadsheet would +// run as a formula. +func escapeFormula(value string) string { + if value == "" { + return value + } + if strings.ContainsRune("\t\r\n", rune(value[0])) { + return "'" + value + } + trimmed := strings.TrimLeftFunc(value, unicode.IsSpace) + if trimmed != "" && strings.ContainsRune("=+-@\t\r", rune(trimmed[0])) { + return "'" + value + } + return value +} + +// writeCSV writes through a temp file and renames it, so a failed export +// never leaves a half-written CSV. Member data is personal, so the directory +// is 0700 and the file 0600. +func writeCSV(dir string, groupID int64, runTime string, rows [][]string) (path string, err error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + if err := os.Chmod(dir, 0o700); err != nil { + return "", err + } + + path = filepath.Join(dir, fmt.Sprintf("%d-%s.csv", groupID, runTime)) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".") + if err != nil { + return "", err + } + defer func() { + if err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + } + }() + + if err = tmp.Chmod(0o600); err != nil { + return "", err + } + writer := csv.NewWriter(tmp) + writer.UseCRLF = true + if err = writer.Write(csvHeader); err != nil { + return "", err + } + if err = writer.WriteAll(rows); err != nil { + return "", err + } + if err = tmp.Close(); err != nil { + return "", err + } + if err = os.Rename(tmp.Name(), path); err != nil { + return "", err + } + return path, nil +} diff --git a/internal/members/diff-table.go b/internal/members/diff-table.go new file mode 100644 index 0000000..5fe31ca --- /dev/null +++ b/internal/members/diff-table.go @@ -0,0 +1,84 @@ +package members + +import ( + "fmt" + "io" + "strconv" + "strings" + "unicode/utf8" + + "github.com/tiennm99/export-telegram-group-members/internal/store" +) + +// ANSI colors for git-diff-style output. +const ( + Red = "\033[31m" + Green = "\033[32m" + Cyan = "\033[36m" + reset = "\033[0m" +) + +// Printer writes diff lines, coloring them only when Color is set (stdout is +// a terminal), so piped output stays plain. +type Printer struct { + Out io.Writer + Color bool +} + +// Line prints text in the given color, or plain when color is empty. +func (p Printer) Line(text, color string) { + if p.Color && color != "" { + text = color + text + reset + } + fmt.Fprintln(p.Out, text) +} + +// Table aligns member rows: ID, @username, and full name. +type Table struct { + widths [3]int +} + +// NewTable sizes the columns to fit every given member. +func NewTable(lists ...[]store.Member) Table { + t := Table{widths: [3]int{2, 8, 4}} + for _, list := range lists { + for _, member := range list { + for i, value := range columns(member) { + t.widths[i] = max(t.widths[i], utf8.RuneCountInString(value)) + } + } + } + return t +} + +// Row formats one member behind a one-character prefix. +func (t Table) Row(prefix string, member store.Member) string { + cols := columns(member) + row := prefix + " " + + pad(cols[0], t.widths[0]) + " " + + pad(cols[1], t.widths[1]) + " " + + pad(cols[2], t.widths[2]) + return strings.TrimRight(row, " ") +} + +func columns(member store.Member) [3]string { + username := "" + if member.Username != "" { + username = "@" + member.Username + } + var names []string + for _, part := range []string{member.FirstName, member.LastName} { + if part != "" { + names = append(names, part) + } + } + return [3]string{ + strconv.FormatInt(member.ID, 10), + username, + strings.Join(names, " "), + } +} + +func pad(value string, width int) string { + return value + strings.Repeat(" ", max(0, width-utf8.RuneCountInString(value))) +} diff --git a/internal/members/member-diff.go b/internal/members/member-diff.go new file mode 100644 index 0000000..57e55bd --- /dev/null +++ b/internal/members/member-diff.go @@ -0,0 +1,55 @@ +// Package members compares member lists and formats them as diff rows. It +// stays pure: no Redis and no network. +package members + +import ( + "sort" + + "github.com/tiennm99/export-telegram-group-members/internal/store" +) + +// Diff returns members added in after and removed from before, matched by +// Telegram member ID and sorted by ID. +func Diff(before, after []store.Member) (added, removed []store.Member) { + onlyBefore, onlyAfter, _ := Split(before, after) + return onlyAfter, onlyBefore +} + +// Split returns members only in a, only in b, and in both, matched by +// Telegram member ID and sorted by ID. Members in both come from b. +func Split(a, b []store.Member) (onlyA, onlyB, both []store.Member) { + aByID := index(a) + bByID := index(b) + + for id, member := range aByID { + if _, ok := bByID[id]; !ok { + onlyA = append(onlyA, member) + } + } + for id, member := range bByID { + if _, ok := aByID[id]; ok { + both = append(both, member) + } else { + onlyB = append(onlyB, member) + } + } + sortByID(onlyA) + sortByID(onlyB) + sortByID(both) + return onlyA, onlyB, both +} + +// index keys members by ID; ID 0 is never a real Telegram user. +func index(list []store.Member) map[int64]store.Member { + byID := make(map[int64]store.Member, len(list)) + for _, member := range list { + if member.ID != 0 { + byID[member.ID] = member + } + } + return byID +} + +func sortByID(list []store.Member) { + sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) +} diff --git a/internal/members/member-diff_test.go b/internal/members/member-diff_test.go new file mode 100644 index 0000000..516a98b --- /dev/null +++ b/internal/members/member-diff_test.go @@ -0,0 +1,73 @@ +package members + +import ( + "bytes" + "reflect" + "testing" + + "github.com/tiennm99/export-telegram-group-members/internal/store" +) + +func ids(list []store.Member) []int64 { + out := []int64{} + for _, member := range list { + out = append(out, member.ID) + } + return out +} + +func membersWithIDs(values ...int64) []store.Member { + var list []store.Member + for _, id := range values { + list = append(list, store.Member{ID: id}) + } + return list +} + +func TestSplit(t *testing.T) { + onlyA, onlyB, both := Split(membersWithIDs(3, 1, 2), membersWithIDs(4, 2, 3)) + + if !reflect.DeepEqual(ids(onlyA), []int64{1}) { + t.Errorf("onlyA = %v", ids(onlyA)) + } + if !reflect.DeepEqual(ids(onlyB), []int64{4}) { + t.Errorf("onlyB = %v", ids(onlyB)) + } + if !reflect.DeepEqual(ids(both), []int64{2, 3}) { + t.Errorf("both = %v", ids(both)) + } +} + +func TestDiff(t *testing.T) { + added, removed := Diff(membersWithIDs(1, 2, 0), membersWithIDs(2, 3)) + + if !reflect.DeepEqual(ids(added), []int64{3}) || !reflect.DeepEqual(ids(removed), []int64{1}) { + t.Fatalf("Diff added=%v removed=%v", ids(added), ids(removed)) + } +} + +func TestTableRowAlignsAndTrims(t *testing.T) { + amy := store.Member{ID: 1, Username: "amy", FirstName: "Amy", LastName: "Lê"} + bob := store.Member{ID: 22, FirstName: "Bob"} + table := NewTable([]store.Member{amy, bob}) + + if got, want := table.Row("-", amy), "- 1 @amy Amy Lê"; got != want { + t.Errorf("Row(amy) = %q, want %q", got, want) + } + if got, want := table.Row("+", bob), "+ 22 Bob"; got != want { + t.Errorf("Row(bob) = %q, want %q", got, want) + } +} + +func TestPrinterColorsOnlyWhenEnabled(t *testing.T) { + var plain, colored bytes.Buffer + Printer{Out: &plain}.Line("x", Red) + Printer{Out: &colored, Color: true}.Line("x", Red) + + if plain.String() != "x\n" { + t.Errorf("plain = %q", plain.String()) + } + if colored.String() != Red+"x"+reset+"\n" { + t.Errorf("colored = %q", colored.String()) + } +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go new file mode 100644 index 0000000..52c45ad --- /dev/null +++ b/internal/prompt/prompt.go @@ -0,0 +1,55 @@ +// Package prompt reads answers from the terminal. +package prompt + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/term" +) + +// Prompter asks questions on out and reads answers from in. +type Prompter struct { + in *bufio.Reader + fd int + tty bool + out io.Writer +} + +// New returns a Prompter. Secrets are read without echo when in is a +// terminal. +func New(in io.Reader, out io.Writer) *Prompter { + p := &Prompter{in: bufio.NewReader(in), out: out} + if f, ok := in.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + p.fd, p.tty = int(f.Fd()), true + } + return p +} + +// Line asks for one line of text and trims it. +func (p *Prompter) Line(label string) (string, error) { + fmt.Fprintf(p.out, "%s: ", label) + line, err := p.in.ReadString('\n') + if err != nil && !(errors.Is(err, io.EOF) && line != "") { + return "", fmt.Errorf("read %s: %w", strings.ToLower(label), err) + } + return strings.TrimSpace(line), nil +} + +// Secret asks for a value without echoing it on a terminal. +func (p *Prompter) Secret(label string) (string, error) { + if !p.tty { + return p.Line(label) + } + fmt.Fprintf(p.out, "%s: ", label) + raw, err := term.ReadPassword(p.fd) + fmt.Fprintln(p.out) + if err != nil { + return "", fmt.Errorf("read %s: %w", strings.ToLower(label), err) + } + return strings.TrimSpace(string(raw)), nil +} diff --git a/internal/store/app-config.go b/internal/store/app-config.go new file mode 100644 index 0000000..a845276 --- /dev/null +++ b/internal/store/app-config.go @@ -0,0 +1,67 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/redis/go-redis/v9" +) + +// AppConfig is the Telegram crawl config stored as one JSON key. +type AppConfig struct { + APIID int `json:"api_id"` + APIHash string `json:"api_hash"` + Phone string `json:"phone"` + GroupIDs []int64 `json:"group_ids"` +} + +func configKey() string { return key("config") } + +// LoadConfig reads the Telegram config and rejects missing fields. +func (s *Store) LoadConfig(ctx context.Context) (AppConfig, error) { + raw, err := s.rdb.Get(ctx, configKey()).Result() + if errors.Is(err, redis.Nil) { + return AppConfig{}, errors.New("telegram config not found in Redis. run configure first") + } + if err != nil { + return AppConfig{}, fmt.Errorf("read telegram config: %w", err) + } + + var cfg AppConfig + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return AppConfig{}, errors.New("telegram config in Redis is not valid JSON") + } + + var missing []string + if cfg.APIID == 0 { + missing = append(missing, "api_id") + } + if cfg.APIHash == "" { + missing = append(missing, "api_hash") + } + if cfg.Phone == "" { + missing = append(missing, "phone") + } + if len(cfg.GroupIDs) == 0 { + missing = append(missing, "group_ids") + } + if len(missing) > 0 { + return AppConfig{}, fmt.Errorf("telegram config missing: %s", strings.Join(missing, ", ")) + } + return cfg, nil +} + +// SaveConfig replaces the Telegram config. +func (s *Store) SaveConfig(ctx context.Context, cfg AppConfig) error { + raw, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("encode telegram config: %w", err) + } + if err := s.rdb.Set(ctx, configKey(), raw, 0).Err(); err != nil { + return fmt.Errorf("save telegram config: %w", err) + } + return nil +} diff --git a/internal/store/app-config_test.go b/internal/store/app-config_test.go new file mode 100644 index 0000000..ba2e218 --- /dev/null +++ b/internal/store/app-config_test.go @@ -0,0 +1,63 @@ +package store + +import ( + "context" + "reflect" + "strings" + "testing" +) + +func TestConfigRoundTrip(t *testing.T) { + st, _ := newTestStore(t) + ctx := context.Background() + want := AppConfig{APIID: 123, APIHash: "hash", Phone: "+15551234567", GroupIDs: []int64{-1001, -2}} + + if err := st.SaveConfig(ctx, want); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + got, err := st.LoadConfig(ctx) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("LoadConfig = %+v, want %+v", got, want) + } +} + +func TestLoadConfigReadsPythonWrittenJSON(t *testing.T) { + st, mr := newTestStore(t) + mr.Set("telegram-export:config", + `{"api_id": 123, "api_hash": "hash", "phone": "+1555", "group_ids": [-1001234567890]}`) + + got, err := st.LoadConfig(context.Background()) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if got.APIID != 123 || got.GroupIDs[0] != -1001234567890 { + t.Fatalf("LoadConfig = %+v", got) + } +} + +func TestLoadConfigErrors(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + {"missing key", "", "run configure first"}, + {"corrupt json", "{", "not valid JSON"}, + {"missing fields", `{"api_id": 1}`, "missing: api_hash, phone, group_ids"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + st, mr := newTestStore(t) + if tt.value != "" { + mr.Set("telegram-export:config", tt.value) + } + _, err := st.LoadConfig(context.Background()) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("LoadConfig error = %v, want it to contain %q", err, tt.want) + } + }) + } +} diff --git a/internal/store/group-export.go b/internal/store/group-export.go new file mode 100644 index 0000000..0c08192 --- /dev/null +++ b/internal/store/group-export.go @@ -0,0 +1,132 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +// RunTimeLayout is the yyyymmddhhmmss crawl timestamp shared by every group +// in one run. Fixed width, so timestamps sort chronologically as strings. +const RunTimeLayout = "20060102150405" + +// Member is one group member as stored in an export. +type Member struct { + ID int64 `json:"id"` + Username string `json:"username"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` +} + +// GroupExport is one group's members for one run. Each export is a single +// self-contained key, so deleting any key never corrupts another. +type GroupExport struct { + GroupID int64 `json:"group_id"` + Title string `json:"title"` + Time string `json:"time"` + Members []Member `json:"members"` +} + +// NewRunTime returns the current time as a run timestamp. +func NewRunTime() string { + return time.Now().Format(RunTimeLayout) +} + +// IsRunTime reports whether value has the yyyymmddhhmmss shape. +func IsRunTime(value string) bool { + if len(value) != len(RunTimeLayout) { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func groupExportKey(groupID int64, runTime string) string { + return key("group", strconv.FormatInt(groupID, 10), runTime) +} + +// SaveGroupExport writes one group's export with a single atomic SET. +func (s *Store) SaveGroupExport(ctx context.Context, record GroupExport) error { + raw, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("encode export: %w", err) + } + if err := s.rdb.Set(ctx, groupExportKey(record.GroupID, record.Time), raw, 0).Err(); err != nil { + return fmt.Errorf("save export for group %d: %w", record.GroupID, err) + } + return nil +} + +// GroupExportTimes returns one group's run timestamps, oldest first. +func (s *Store) GroupExportTimes(ctx context.Context, groupID int64) ([]string, error) { + pattern := groupExportKey(groupID, "*") + prefix := strings.TrimSuffix(pattern, "*") + + var times []string + iter := s.rdb.Scan(ctx, 0, pattern, 100).Iterator() + for iter.Next(ctx) { + runTime := strings.TrimPrefix(iter.Val(), prefix) + if IsRunTime(runTime) { + times = append(times, runTime) + } + } + if err := iter.Err(); err != nil { + return nil, fmt.Errorf("list exports for group %d: %w", groupID, err) + } + sort.Strings(times) + return times, nil +} + +// ErrExportNotFound means no readable export exists at that group and time. +var ErrExportNotFound = errors.New("export not found") + +// GetGroupExport returns one export, or ErrExportNotFound when it is missing +// or not valid JSON. +func (s *Store) GetGroupExport(ctx context.Context, groupID int64, runTime string) (GroupExport, error) { + raw, err := s.rdb.Get(ctx, groupExportKey(groupID, runTime)).Bytes() + if errors.Is(err, redis.Nil) { + return GroupExport{}, ErrExportNotFound + } + if err != nil { + return GroupExport{}, fmt.Errorf("read export for group %d at %s: %w", groupID, runTime, err) + } + + var record GroupExport + if err := json.Unmarshal(raw, &record); err != nil { + return GroupExport{}, ErrExportNotFound + } + return record, nil +} + +// LatestGroupExports returns up to n readable exports for a group, oldest +// first. Corrupt values and keys deleted mid-scan are skipped. +func (s *Store) LatestGroupExports(ctx context.Context, groupID int64, n int) ([]GroupExport, error) { + times, err := s.GroupExportTimes(ctx, groupID) + if err != nil { + return nil, err + } + + var records []GroupExport + for i := len(times) - 1; i >= 0 && len(records) < n; i-- { + record, err := s.GetGroupExport(ctx, groupID, times[i]) + if errors.Is(err, ErrExportNotFound) { + continue + } + if err != nil { + return nil, err + } + records = append([]GroupExport{record}, records...) + } + return records, nil +} diff --git a/internal/store/group-export_test.go b/internal/store/group-export_test.go new file mode 100644 index 0000000..e986f7b --- /dev/null +++ b/internal/store/group-export_test.go @@ -0,0 +1,91 @@ +package store + +import ( + "context" + "errors" + "reflect" + "testing" +) + +func saveExport(t *testing.T, st *Store, groupID int64, runTime string) { + t.Helper() + err := st.SaveGroupExport(context.Background(), GroupExport{ + GroupID: groupID, + Title: "Group", + Time: runTime, + Members: []Member{{ID: 1, Username: "amy"}}, + }) + if err != nil { + t.Fatalf("SaveGroupExport: %v", err) + } +} + +func TestGroupExportRoundTrip(t *testing.T) { + st, _ := newTestStore(t) + saveExport(t, st, -100, "20260724120000") + + got, err := st.GetGroupExport(context.Background(), -100, "20260724120000") + if err != nil { + t.Fatalf("GetGroupExport: %v", err) + } + if got.Title != "Group" || len(got.Members) != 1 || got.Members[0].Username != "amy" { + t.Fatalf("GetGroupExport = %+v", got) + } +} + +func TestGetGroupExportMissingOrCorrupt(t *testing.T) { + st, mr := newTestStore(t) + mr.Set("telegram-export:group:-100:20260724120000", "not json") + + for _, runTime := range []string{"20260724120000", "20990101000000"} { + _, err := st.GetGroupExport(context.Background(), -100, runTime) + if !errors.Is(err, ErrExportNotFound) { + t.Fatalf("GetGroupExport(%s) error = %v, want ErrExportNotFound", runTime, err) + } + } +} + +func TestGroupExportTimesScopesToGroupAndSorts(t *testing.T) { + st, mr := newTestStore(t) + saveExport(t, st, -100, "20260724120000") + saveExport(t, st, -100, "20260701000000") + saveExport(t, st, -1000, "20260801000000") // prefix of another group's ID + mr.Set("telegram-export:group:-100:latest", "{}") + + got, err := st.GroupExportTimes(context.Background(), -100) + if err != nil { + t.Fatalf("GroupExportTimes: %v", err) + } + want := []string{"20260701000000", "20260724120000"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("GroupExportTimes = %v, want %v", got, want) + } +} + +func TestLatestGroupExportsSkipsCorrupt(t *testing.T) { + st, mr := newTestStore(t) + saveExport(t, st, -100, "20260701000000") + saveExport(t, st, -100, "20260702000000") + mr.Set("telegram-export:group:-100:20260703000000", "{broken") + + got, err := st.LatestGroupExports(context.Background(), -100, 2) + if err != nil { + t.Fatalf("LatestGroupExports: %v", err) + } + if len(got) != 2 || got[0].Time != "20260701000000" || got[1].Time != "20260702000000" { + t.Fatalf("LatestGroupExports = %+v", got) + } +} + +func TestIsRunTime(t *testing.T) { + for value, want := range map[string]bool{ + "20260724120000": true, + "2026072412000": false, + "2026072412000a": false, + "202607241200000": false, + } { + if got := IsRunTime(value); got != want { + t.Errorf("IsRunTime(%q) = %v, want %v", value, got, want) + } + } +} diff --git a/internal/store/redis-store.go b/internal/store/redis-store.go new file mode 100644 index 0000000..5b37fae --- /dev/null +++ b/internal/store/redis-store.go @@ -0,0 +1,59 @@ +// Package store owns every Redis key the tool reads or writes. Other packages +// never import go-redis directly. +package store + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/joho/godotenv" + "github.com/redis/go-redis/v9" +) + +// keyPrefix namespaces every key. It is fixed to keep local setup to one +// REDIS_URL value. +const keyPrefix = "telegram-export" + +// Store wraps the shared Redis that holds config, session, and crawl history, +// so the tool runs on any device from just a .env file. +type Store struct { + rdb *redis.Client +} + +// OpenFromEnv loads .env, then connects to REDIS_URL. +func OpenFromEnv(ctx context.Context) (*Store, error) { + _ = godotenv.Load() + + redisURL := os.Getenv("REDIS_URL") + if redisURL == "" { + return nil, errors.New("REDIS_URL not set in .env (e.g. rediss://default:@:)") + } + return Open(ctx, redisURL) +} + +// Open connects to Redis and checks the connection. +func Open(ctx context.Context, redisURL string) (*Store, error) { + opts, err := redis.ParseURL(redisURL) + if err != nil { + return nil, fmt.Errorf("parse REDIS_URL: %w", err) + } + rdb := redis.NewClient(opts) + if err := rdb.Ping(ctx).Err(); err != nil { + _ = rdb.Close() + return nil, fmt.Errorf("connect to Redis: %w", err) + } + return &Store{rdb: rdb}, nil +} + +// Close releases the Redis connection. +func (s *Store) Close() error { + return s.rdb.Close() +} + +// key builds a namespaced Redis key; the single owner of the prefix. +func key(parts ...string) string { + return strings.Join(append([]string{keyPrefix}, parts...), ":") +} diff --git a/internal/store/session-storage.go b/internal/store/session-storage.go new file mode 100644 index 0000000..d2692de --- /dev/null +++ b/internal/store/session-storage.go @@ -0,0 +1,79 @@ +package store + +import ( + "context" + "errors" + "fmt" + + "github.com/gotd/td/session" + "github.com/redis/go-redis/v9" +) + +// The login session grants full account access; it lives only in Redis +// (behind TLS/auth), never on local disk. Deleting it forces a clean re-login. +// +// The Python version stored a Telethon StringSession under "session". This +// version keeps its own format under a separate key and only reads the old +// one once, so an existing login carries over without a new code. +func sessionKey() string { return key("session-gotd") } +func telethonSessionKey() string { return key("session") } + +// SessionStorage implements gotd's session.Storage on Redis. +type SessionStorage struct { + store *Store +} + +// SessionStorage returns the Redis-backed Telegram session storage. +func (s *Store) SessionStorage() *SessionStorage { + return &SessionStorage{store: s} +} + +// LoadSession returns the stored session, importing a Telethon session when +// no gotd session exists yet. +func (ss *SessionStorage) LoadSession(ctx context.Context) ([]byte, error) { + raw, err := ss.store.rdb.Get(ctx, sessionKey()).Bytes() + if err == nil { + return raw, nil + } + if !errors.Is(err, redis.Nil) { + return nil, fmt.Errorf("read session: %w", err) + } + + telethon, err := ss.store.rdb.Get(ctx, telethonSessionKey()).Result() + if errors.Is(err, redis.Nil) || telethon == "" { + return nil, session.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("read Telethon session: %w", err) + } + + raw, err = convertTelethonSession(ctx, telethon) + if err != nil { + // An unreadable old session should mean a fresh login, not a dead end. + return nil, session.ErrNotFound + } + if err := ss.StoreSession(ctx, raw); err != nil { + return nil, err + } + return raw, nil +} + +// StoreSession persists the session for reuse on any device. +func (ss *SessionStorage) StoreSession(ctx context.Context, data []byte) error { + if err := ss.store.rdb.Set(ctx, sessionKey(), data, 0).Err(); err != nil { + return fmt.Errorf("save session: %w", err) + } + return nil +} + +func convertTelethonSession(ctx context.Context, telethon string) ([]byte, error) { + data, err := session.TelethonSession(telethon) + if err != nil { + return nil, err + } + var mem session.StorageMemory + if err := (&session.Loader{Storage: &mem}).Save(ctx, data); err != nil { + return nil, err + } + return mem.LoadSession(ctx) +} diff --git a/internal/store/session-storage_test.go b/internal/store/session-storage_test.go new file mode 100644 index 0000000..aea009e --- /dev/null +++ b/internal/store/session-storage_test.go @@ -0,0 +1,75 @@ +package store + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "testing" + + "github.com/gotd/td/session" +) + +// telethonString builds a Telethon StringSession: version "1" plus base64 +// of DC ID, IPv4, port, and a 256-byte auth key. +func telethonString(dc byte) string { + raw := []byte{dc, 149, 154, 167, 51, 0x01, 0xbb} + raw = append(raw, bytes.Repeat([]byte{7}, 256)...) + return "1" + base64.URLEncoding.EncodeToString(raw) +} + +func TestLoadSessionNotFound(t *testing.T) { + st, _ := newTestStore(t) + + _, err := st.SessionStorage().LoadSession(context.Background()) + if !errors.Is(err, session.ErrNotFound) { + t.Fatalf("LoadSession error = %v, want session.ErrNotFound", err) + } +} + +func TestSessionRoundTrip(t *testing.T) { + st, _ := newTestStore(t) + ctx := context.Background() + + if err := st.SessionStorage().StoreSession(ctx, []byte(`{"Version":1}`)); err != nil { + t.Fatalf("StoreSession: %v", err) + } + got, err := st.SessionStorage().LoadSession(ctx) + if err != nil || string(got) != `{"Version":1}` { + t.Fatalf("LoadSession = %q, %v", got, err) + } +} + +func TestLoadSessionImportsTelethonSession(t *testing.T) { + st, mr := newTestStore(t) + ctx := context.Background() + mr.Set("telegram-export:session", telethonString(2)) + + raw, err := st.SessionStorage().LoadSession(ctx) + if err != nil { + t.Fatalf("LoadSession: %v", err) + } + data, err := (&session.Loader{Storage: st.SessionStorage()}).Load(ctx) + if err != nil { + t.Fatalf("Loader.Load: %v", err) + } + if data.DC != 2 || data.Addr != "149.154.167.51:443" { + t.Fatalf("imported session DC=%d Addr=%q", data.DC, data.Addr) + } + if stored, _ := mr.Get("telegram-export:session-gotd"); stored != string(raw) { + t.Fatal("imported session was not saved under the gotd key") + } + if old, _ := mr.Get("telegram-export:session"); old != telethonString(2) { + t.Fatal("Telethon session key was modified") + } +} + +func TestLoadSessionIgnoresBrokenTelethonSession(t *testing.T) { + st, mr := newTestStore(t) + mr.Set("telegram-export:session", "not-a-session") + + _, err := st.SessionStorage().LoadSession(context.Background()) + if !errors.Is(err, session.ErrNotFound) { + t.Fatalf("LoadSession error = %v, want session.ErrNotFound", err) + } +} diff --git a/internal/store/store-test-helpers_test.go b/internal/store/store-test-helpers_test.go new file mode 100644 index 0000000..00926a5 --- /dev/null +++ b/internal/store/store-test-helpers_test.go @@ -0,0 +1,19 @@ +package store + +import ( + "context" + "testing" + + "github.com/alicebob/miniredis/v2" +) + +func newTestStore(t *testing.T) (*Store, *miniredis.Miniredis) { + t.Helper() + mr := miniredis.RunT(t) + st, err := Open(context.Background(), "redis://"+mr.Addr()) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return st, mr +} diff --git a/internal/telegram/client.go b/internal/telegram/client.go new file mode 100644 index 0000000..fcbc720 --- /dev/null +++ b/internal/telegram/client.go @@ -0,0 +1,84 @@ +// Package telegram logs in to Telegram and reads group members. +package telegram + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/gotd/td/session" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/auth" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + + "github.com/tiennm99/export-telegram-group-members/internal/prompt" + "github.com/tiennm99/export-telegram-group-members/internal/store" +) + +// Run connects with the stored session, signs in only when that session is +// missing or expired, and calls fn while the connection is open. +func Run( + ctx context.Context, + cfg store.AppConfig, + storage session.Storage, + ask *prompt.Prompter, + fn func(ctx context.Context, api *tg.Client) error, +) error { + client := telegram.NewClient(cfg.APIID, cfg.APIHash, telegram.Options{ + SessionStorage: storage, + NoUpdates: true, + }) + return client.Run(ctx, func(ctx context.Context) error { + flow := auth.NewFlow(terminalAuth{phone: cfg.Phone, ask: ask}, auth.SendCodeOptions{}) + if err := client.Auth().IfNecessary(ctx, flow); err != nil { + return fmt.Errorf("telegram login: %w", err) + } + return fn(ctx, client.API()) + }) +} + +// terminalAuth answers the login flow from the terminal. +type terminalAuth struct { + phone string + ask *prompt.Prompter +} + +func (a terminalAuth) Phone(context.Context) (string, error) { return a.phone, nil } + +func (a terminalAuth) Password(context.Context) (string, error) { + return a.ask.Secret("Password") +} + +func (a terminalAuth) Code(context.Context, *tg.AuthSentCode) (string, error) { + return a.ask.Line("Enter code") +} + +func (a terminalAuth) AcceptTermsOfService(context.Context, tg.HelpTermsOfService) error { + return errors.New("this phone has no Telegram account; sign up in an official app first") +} + +func (a terminalAuth) SignUp(context.Context) (auth.UserInfo, error) { + return auth.UserInfo{}, errors.New("this phone has no Telegram account; sign up in an official app first") +} + +// maxFloodWait caps how long one call may sleep on FLOOD_WAIT before giving up. +const maxFloodWait = 5 * time.Minute + +// withFloodWait retries call after Telegram's requested FLOOD_WAIT delay, so +// paging a large group does not fail on rate limits. +func withFloodWait[T any](ctx context.Context, call func() (T, error)) (T, error) { + for { + result, err := call() + wait, ok := tgerr.AsFloodWait(err) + if !ok || wait > maxFloodWait { + return result, err + } + select { + case <-ctx.Done(): + return result, ctx.Err() + case <-time.After(wait + time.Second): + } + } +} diff --git a/internal/telegram/group-fetcher.go b/internal/telegram/group-fetcher.go new file mode 100644 index 0000000..5f5fb31 --- /dev/null +++ b/internal/telegram/group-fetcher.go @@ -0,0 +1,90 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/gotd/td/telegram/query/dialogs" + "github.com/gotd/td/tg" +) + +// dialogsPageSize is how many dialogs each lookup request fetches. +const dialogsPageSize = 100 + +// Fetcher resolves group IDs and fetches their members. +// +// A marked group ID alone cannot address a supergroup: Telegram also needs +// its access hash. The account's dialog list carries both, and the account +// must already be in the group to see its members, so the list is read once +// and reused for every group in a run. +type Fetcher struct { + api *tg.Client + groups map[groupRef]resolvedGroup +} + +type resolvedGroup struct { + title string + channel *tg.InputChannel +} + +// NewFetcher returns a Fetcher on an open connection. +func NewFetcher(api *tg.Client) *Fetcher { + return &Fetcher{api: api} +} + +// FetchGroup returns the group's title and members. It returns ErrNotGroup +// for a user ID. +func (f *Fetcher) FetchGroup(ctx context.Context, markedID int64, onStart func(title string)) (Group, error) { + ref, err := parseGroupID(markedID) + if err != nil { + return Group{}, err + } + if err := f.loadDialogs(ctx); err != nil { + return Group{}, err + } + resolved, ok := f.groups[ref] + if !ok { + return Group{}, fmt.Errorf("group %d not found in this account's chats", markedID) + } + + onStart(resolved.title) + group := Group{ID: markedID, Title: resolved.title} + if ref.kind == channelGroup { + group.Members, err = fetchChannelMembers(ctx, f.api, resolved.channel) + } else { + group.Members, err = fetchBasicGroupMembers(ctx, f.api, ref.id) + } + if err != nil { + return Group{}, err + } + return group, nil +} + +func (f *Fetcher) loadDialogs(ctx context.Context) error { + if f.groups != nil { + return nil + } + groups := map[groupRef]resolvedGroup{} + iter := dialogs.NewQueryBuilder(f.api).GetDialogs().BatchSize(dialogsPageSize).Iter() + for iter.Next(ctx) { + elem := iter.Value() + switch peer := elem.Peer.(type) { + case *tg.InputPeerChat: + if chat, ok := elem.Entities.Chat(peer.ChatID); ok { + groups[groupRef{kind: basicGroup, id: peer.ChatID}] = resolvedGroup{title: chat.Title} + } + case *tg.InputPeerChannel: + if channel, ok := elem.Entities.Channel(peer.ChannelID); ok { + groups[groupRef{kind: channelGroup, id: peer.ChannelID}] = resolvedGroup{ + title: channel.Title, + channel: &tg.InputChannel{ChannelID: peer.ChannelID, AccessHash: peer.AccessHash}, + } + } + } + } + if err := iter.Err(); err != nil { + return fmt.Errorf("list chats: %w", err) + } + f.groups = groups + return nil +} diff --git a/internal/telegram/group-id.go b/internal/telegram/group-id.go new file mode 100644 index 0000000..abd2dfe --- /dev/null +++ b/internal/telegram/group-id.go @@ -0,0 +1,36 @@ +package telegram + +import "errors" + +// ErrNotGroup means the ID belongs to a user, not a group or channel. +var ErrNotGroup = errors.New("not a group") + +// channelIDOffset is how Bot API style "marked" IDs encode a channel or +// supergroup: -100, i.e. -(1e12 + channel_id). +const channelIDOffset = 1_000_000_000_000 + +type groupKind int + +const ( + basicGroup groupKind = iota + channelGroup +) + +// groupRef is a parsed marked group ID. +type groupRef struct { + kind groupKind + id int64 +} + +// parseGroupID splits a marked ID (as Telethon and the Python version store +// it) into a basic group or a channel/supergroup and its raw Telegram ID. +func parseGroupID(markedID int64) (groupRef, error) { + switch { + case markedID <= -channelIDOffset: + return groupRef{kind: channelGroup, id: -markedID - channelIDOffset}, nil + case markedID < 0: + return groupRef{kind: basicGroup, id: -markedID}, nil + default: + return groupRef{}, ErrNotGroup + } +} diff --git a/internal/telegram/group-id_test.go b/internal/telegram/group-id_test.go new file mode 100644 index 0000000..cf14458 --- /dev/null +++ b/internal/telegram/group-id_test.go @@ -0,0 +1,75 @@ +package telegram + +import ( + "errors" + "testing" + + "github.com/gotd/td/tg" + + "github.com/tiennm99/export-telegram-group-members/internal/store" +) + +func TestParseGroupID(t *testing.T) { + tests := []struct { + marked int64 + want groupRef + }{ + {-1001234567890, groupRef{kind: channelGroup, id: 1234567890}}, + {-1000000000001, groupRef{kind: channelGroup, id: 1}}, + {-123456, groupRef{kind: basicGroup, id: 123456}}, + {-999999999999, groupRef{kind: basicGroup, id: 999999999999}}, + } + for _, tt := range tests { + got, err := parseGroupID(tt.marked) + if err != nil || got != tt.want { + t.Errorf("parseGroupID(%d) = %+v, %v; want %+v", tt.marked, got, err, tt.want) + } + } + + for _, userID := range []int64{0, 777} { + if _, err := parseGroupID(userID); !errors.Is(err, ErrNotGroup) { + t.Errorf("parseGroupID(%d) error = %v, want ErrNotGroup", userID, err) + } + } +} + +func TestChannelParticipantUserID(t *testing.T) { + tests := []struct { + name string + participant tg.ChannelParticipantClass + want int64 + ok bool + }{ + {"member", &tg.ChannelParticipant{UserID: 1}, 1, true}, + {"self", &tg.ChannelParticipantSelf{UserID: 2}, 2, true}, + {"admin", &tg.ChannelParticipantAdmin{UserID: 3, InviterID: 99}, 3, true}, + {"creator", &tg.ChannelParticipantCreator{UserID: 4}, 4, true}, + {"banned user", &tg.ChannelParticipantBanned{Peer: &tg.PeerUser{UserID: 5}}, 5, true}, + {"banned channel", &tg.ChannelParticipantBanned{Peer: &tg.PeerChannel{ChannelID: 6}}, 0, false}, + } + for _, tt := range tests { + got, ok := channelParticipantUserID(tt.participant) + if got != tt.want || ok != tt.ok { + t.Errorf("%s: got %d, %v; want %d, %v", tt.name, got, ok, tt.want, tt.ok) + } + } +} + +func TestMemberFromUserAndSorting(t *testing.T) { + users := usersByID([]tg.UserClass{ + &tg.User{ID: 9, Username: "zed", FirstName: "Z"}, + &tg.User{ID: 3, FirstName: "C", LastName: "D"}, + &tg.UserEmpty{ID: 5}, + }) + if len(users) != 2 { + t.Fatalf("usersByID kept %d users, want 2", len(users)) + } + + got := sortedMembers(map[int64]store.Member{ + 9: memberFromUser(users[9]), + 3: memberFromUser(users[3]), + }) + if got[0].ID != 3 || got[0].LastName != "D" || got[1].Username != "zed" { + t.Fatalf("sortedMembers = %+v", got) + } +} diff --git a/internal/telegram/group-members.go b/internal/telegram/group-members.go new file mode 100644 index 0000000..b44cf78 --- /dev/null +++ b/internal/telegram/group-members.go @@ -0,0 +1,133 @@ +package telegram + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/gotd/td/tg" + + "github.com/tiennm99/export-telegram-group-members/internal/store" +) + +// participantsPageSize is the largest page channels.getParticipants allows. +const participantsPageSize = 200 + +// Group is one fetched group with its members sorted by ID. +type Group struct { + ID int64 + Title string + Members []store.Member +} + +// fetchChannelMembers pages through a channel or supergroup's members. +func fetchChannelMembers(ctx context.Context, api *tg.Client, channel *tg.InputChannel) ([]store.Member, error) { + byID := map[int64]store.Member{} + for offset := 0; ; { + resp, err := withFloodWait(ctx, func() (tg.ChannelsChannelParticipantsClass, error) { + return api.ChannelsGetParticipants(ctx, &tg.ChannelsGetParticipantsRequest{ + Channel: channel, + Filter: &tg.ChannelParticipantsSearch{}, + Offset: offset, + Limit: participantsPageSize, + }) + }) + if err != nil { + return nil, fmt.Errorf("get participants: %w", err) + } + page, ok := resp.(*tg.ChannelsChannelParticipants) + if !ok || len(page.Participants) == 0 { + break + } + users := usersByID(page.Users) + for _, participant := range page.Participants { + if userID, ok := channelParticipantUserID(participant); ok { + if user, ok := users[userID]; ok { + byID[userID] = memberFromUser(user) + } + } + } + offset += len(page.Participants) + } + return sortedMembers(byID), nil +} + +// fetchBasicGroupMembers reads a basic group's members from its full info. +func fetchBasicGroupMembers(ctx context.Context, api *tg.Client, chatID int64) ([]store.Member, error) { + full, err := withFloodWait(ctx, func() (*tg.MessagesChatFull, error) { + return api.MessagesGetFullChat(ctx, chatID) + }) + if err != nil { + return nil, fmt.Errorf("get full chat: %w", err) + } + chatFull, ok := full.FullChat.(*tg.ChatFull) + if !ok { + return nil, errors.New("unexpected full chat type") + } + participants, ok := chatFull.Participants.(*tg.ChatParticipants) + if !ok { + return nil, errors.New("member list is hidden") + } + + users := usersByID(full.Users) + byID := map[int64]store.Member{} + for _, participant := range participants.Participants { + if user, ok := users[participant.GetUserID()]; ok { + byID[user.ID] = memberFromUser(user) + } + } + return sortedMembers(byID), nil +} + +// channelParticipantUserID returns the member's user ID. Admin entries also +// reference inviters and promoters, so users come from participants only. +func channelParticipantUserID(participant tg.ChannelParticipantClass) (int64, bool) { + switch p := participant.(type) { + case *tg.ChannelParticipant: + return p.UserID, true + case *tg.ChannelParticipantSelf: + return p.UserID, true + case *tg.ChannelParticipantAdmin: + return p.UserID, true + case *tg.ChannelParticipantCreator: + return p.UserID, true + case *tg.ChannelParticipantBanned: + if peer, ok := p.Peer.(*tg.PeerUser); ok { + return peer.UserID, true + } + case *tg.ChannelParticipantLeft: + if peer, ok := p.Peer.(*tg.PeerUser); ok { + return peer.UserID, true + } + } + return 0, false +} + +func usersByID(users []tg.UserClass) map[int64]*tg.User { + byID := make(map[int64]*tg.User, len(users)) + for _, u := range users { + if user, ok := u.(*tg.User); ok { + byID[user.ID] = user + } + } + return byID +} + +func memberFromUser(user *tg.User) store.Member { + return store.Member{ + ID: user.ID, + Username: user.Username, + FirstName: user.FirstName, + LastName: user.LastName, + } +} + +func sortedMembers(byID map[int64]store.Member) []store.Member { + list := make([]store.Member, 0, len(byID)) + for _, member := range byID { + list = append(list, member) + } + sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) + return list +} diff --git a/main.go b/main.go deleted file mode 100644 index 235e530..0000000 --- a/main.go +++ /dev/null @@ -1,165 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "os" - "strconv" - "strings" - - "github.com/celestix/gotgproto" - "github.com/celestix/gotgproto/ext" - "github.com/celestix/gotgproto/sessionMaker" - "github.com/gotd/td/tg" - "github.com/joho/godotenv" - "gorm.io/driver/sqlite" -) - -func main() { - if err := godotenv.Load(); err != nil { - log.Println("Warning: .env file not found") - } - phoneNumber, isExist := os.LookupEnv("TG_PHONE") - if !isExist { - log.Fatal("TG_PHONE not set") - } - rawAppId, isExist := os.LookupEnv("APP_ID") - if !isExist { - log.Fatal("APP_ID not set!") - return - } - appId, err := strconv.Atoi(rawAppId) - if err != nil { - log.Fatal(err) - return - } - appHash, isExist := os.LookupEnv("APP_HASH") - if !isExist { - log.Fatal("APP_HASH not set") - return - } - rawGroupIds, isExist := os.LookupEnv("GROUP_IDS") - if !isExist { - log.Fatal("GROUP_IDS not set") - return - } - groupIds, err := strToInts(rawGroupIds) - if err != nil { - log.Fatal(err) - return - } - - client, err := gotgproto.NewClient( - // Get AppID from https://my.telegram.org/apps - appId, - // Get ApiHash from https://my.telegram.org/apps - appHash, - // ClientType, as we defined above - gotgproto.ClientTypePhone(phoneNumber), - // Optional parameters of client - &gotgproto.ClientOpts{ - Session: sessionMaker.SqlSession(sqlite.Open("session.sqlite3")), - }, - ) - if err != nil { - log.Fatalln("failed to start client:", err) - } - - fmt.Printf("client (@%s) has been started...\n", client.Self.Username) - - ctx := client.CreateContext() - api := client.API() - - // Example: work with the first group ID - groupID := groupIds[0] - - inputPeer, err := ctx.ResolveInputPeerById(groupID) - if err != nil { - log.Fatalf("failed to resolve input peer: %v", err) - } - - p := inputPeer.(*tg.InputPeerChannel) - - chat, err := ctx.GetChat(groupID) - if err != nil { - log.Fatalf("failed to get chat: %v", err) - } - - // Prepare input channel - inputChannel := &tg.InputChannel{ - ChannelID: chat.GetID(), - AccessHash: p.AccessHash, - } - - // Collect unique user IDs - users := make(map[int64]struct{}) - - offset := 0 - limit := 100 - totalCount := 200 - - for offset < totalCount { - resp, err := api.ChannelsGetParticipants(context.Background(), &tg.ChannelsGetParticipantsRequest{ - Channel: inputChannel, - Filter: &tg.ChannelParticipantsRecent{}, - Offset: offset, - Limit: limit, - Hash: 0, - }) - if err != nil { - log.Fatalf("failed to get participants: %v", err) - } - - data := resp.(*tg.ChannelsChannelParticipants) - totalCount = data.Count - - for _, p := range data.Participants { - switch participant := p.(type) { - case *tg.ChannelParticipantSelf: - users[participant.UserID] = struct{}{} - case *tg.ChannelParticipant: - users[participant.UserID] = struct{}{} - case *tg.ChannelParticipantAdmin: - users[participant.UserID] = struct{}{} - case *tg.ChannelParticipantCreator: - users[participant.UserID] = struct{}{} - } - } - - offset += limit - log.Printf("Fetched %d / %d participants\n", offset, totalCount) - } - - log.Printf("Total unique participants: %d\n", len(users)) - - // Optional: convert map to slice - userIDs := make([]int64, 0, len(users)) - for id := range users { - userIDs = append(userIDs, id) - } - - log.Println("Done.") - client.Idle() -} - -func echo(ctx *ext.Context, update *ext.Update) error { - msg := update.EffectiveMessage - _, err := ctx.Reply(update, ext.ReplyTextString(msg.Text), nil) - return err -} - -func strToInts(s string) ([]int64, error) { - parts := strings.Split(s, ",") - ints := make([]int64, 0, len(parts)) - - for _, p := range parts { - v, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64) - if err != nil { - return nil, err - } - ints = append(ints, v) - } - - return ints, nil -} diff --git a/session_store.py b/session_store.py deleted file mode 100644 index 9c1f20e..0000000 --- a/session_store.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Load/save the Telethon StringSession from Redis. - -The session string grants full account access; it lives only in Redis (behind -TLS/auth), never on local disk. Deleting the key simply forces a clean re-login. -""" - -from config import key, redis_client - -_SESSION_KEY = key('session') - - -def load_session(): - """Return the stored StringSession string, or None if not yet authenticated.""" - return redis_client.get(_SESSION_KEY) - - -def save_session(session_string): - """Persist the StringSession string for reuse on any device.""" - redis_client.set(_SESSION_KEY, session_string) diff --git a/tests/test_common.py b/tests/test_common.py deleted file mode 100644 index a6c619b..0000000 --- a/tests/test_common.py +++ /dev/null @@ -1,40 +0,0 @@ -import os -import unittest -from unittest.mock import MagicMock, patch - -os.environ.setdefault('REDIS_URL', 'redis://localhost') - -import common - - -class LatestGroupExportTimeTest(unittest.TestCase): - def test_uses_latest_timestamp_from_redis_keys_without_fetching_records(self): - redis_client = MagicMock() - redis_client.scan_iter.return_value = [ - 'telegram-export:group:-100123:20260724120000', - 'telegram-export:group:-100123:20260723120000', - ] - - with patch.object(common, 'redis_client', redis_client): - run_time = common.latest_group_export_time(-100123) - - self.assertEqual(run_time, '20260724120000') - redis_client.scan_iter.assert_called_once_with( - match='telegram-export:group:-100123:*', - ) - redis_client.get.assert_not_called() - - def test_ignores_malformed_keys(self): - redis_client = MagicMock() - redis_client.scan_iter.return_value = [ - 'telegram-export:group:-100123:not-a-time', - ] - - with patch.object(common, 'redis_client', redis_client): - run_time = common.latest_group_export_time(-100123) - - self.assertIsNone(run_time) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_compare_groups.py b/tests/test_compare_groups.py deleted file mode 100644 index 9aa841b..0000000 --- a/tests/test_compare_groups.py +++ /dev/null @@ -1,110 +0,0 @@ -import io -import os -import unittest -from contextlib import redirect_stdout -from unittest.mock import MagicMock, patch - -os.environ.setdefault('REDIS_URL', 'redis://localhost') - -import common -import compare_groups -import crawl - - -class FakeChat: - def __init__(self, group_id): - self.id = group_id - self.title = f'Group {group_id}' - - -class FakeMember: - def __init__(self, member_id, username=None): - self.id = member_id - self.username = username - self.first_name = None - self.last_name = None - - -def group(group_id, member_ids): - return { - 'group_id': group_id, - 'title': f'Group {group_id}', - 'members': [{'id': i, 'username': None, 'first_name': None, 'last_name': None} - for i in member_ids], - } - - -class SplitMembersTest(unittest.TestCase): - def test_splits_by_member_id(self): - only_a, only_b, both = compare_groups.split_members( - group(1, [3, 1, 2]), group(2, [4, 2, 3]), - ) - - self.assertEqual([m['id'] for m in only_a], [1]) - self.assertEqual([m['id'] for m in only_b], [4]) - self.assertEqual([m['id'] for m in both], [2, 3]) - - -class CompareGroupsMainTest(unittest.TestCase): - def test_requires_two_group_ids(self): - with self.assertRaises(SystemExit): - compare_groups.parse_args(['-100123']) - - @patch.object(common, 'save_group_export') - @patch.object(crawl, 'save_session') - @patch.object(crawl, 'load_session', return_value='stored-session') - @patch.object(crawl, 'TelegramClient') - def test_fetches_live_and_does_not_save_exports( - self, telegram_client, load_session, save_session, save_group_export, - ): - client = MagicMock() - telegram_client.return_value = client - client.is_user_authorized.return_value = True - client.get_entity.side_effect = [FakeChat(101), FakeChat(202)] - client.get_participants.side_effect = [ - [FakeMember(2, 'bob'), FakeMember(1, 'amy')], - [FakeMember(3, 'cat'), FakeMember(2, 'bob')], - ] - app_config = {'api_id': 1, 'api_hash': 'h', 'phone': 'p', 'group_ids': [9]} - - out = io.StringIO() - with ( - patch('config.load_app_config', return_value=app_config), - patch.object(crawl, 'Chat', FakeChat), - patch.object(crawl, 'Channel', FakeChat), - patch.object(crawl, 'StringSession', return_value='session'), - redirect_stdout(out), - ): - result = compare_groups.main(['101', '202']) - - self.assertEqual(result, 0) - save_group_export.assert_not_called() - lines = out.getvalue().splitlines() - self.assertIn(' 2 @bob', lines) - self.assertIn('- 1 @amy', lines) - self.assertIn('+ 3 @cat', lines) - - @patch.object(crawl, 'save_session') - @patch.object(crawl, 'load_session', return_value='stored-session') - @patch.object(crawl, 'TelegramClient') - def test_non_group_returns_error(self, telegram_client, load_session, save_session): - client = MagicMock() - telegram_client.return_value = client - client.is_user_authorized.return_value = True - client.get_entity.return_value = object() - app_config = {'api_id': 1, 'api_hash': 'h', 'phone': 'p', 'group_ids': [9]} - - with ( - patch('config.load_app_config', return_value=app_config), - patch.object(crawl, 'StringSession', return_value='session'), - redirect_stdout(io.StringIO()), - patch('sys.stderr', io.StringIO()), - ): - result = compare_groups.main(['101', '202']) - - self.assertEqual(result, 1) - client.get_participants.assert_not_called() - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_crawl.py b/tests/test_crawl.py deleted file mode 100644 index 5964741..0000000 --- a/tests/test_crawl.py +++ /dev/null @@ -1,132 +0,0 @@ -import os -import unittest -from unittest.mock import MagicMock, call, patch - -os.environ.setdefault('REDIS_URL', 'redis://localhost') - -import crawl - - -class FakeChat: - def __init__(self, group_id): - self.id = group_id - self.title = f'Group {group_id}' - - -class FakeMember: - def __init__(self, member_id): - self.id = member_id - - -class CrawlArgumentsTest(unittest.TestCase): - def test_accepts_multiple_group_ids(self): - args = crawl.parse_args(['-100123', '-100456']) - - self.assertEqual(args.group_ids, [-100123, -100456]) - - def test_allows_no_group_ids(self): - args = crawl.parse_args([]) - - self.assertEqual(args.group_ids, []) - - def test_rejects_non_integer_group_id(self): - with ( - patch.object(crawl, 'load_app_config') as load_app_config, - patch.object(crawl, 'TelegramClient') as telegram_client, - self.assertRaises(SystemExit), - ): - crawl.main(['not-a-group']) - - load_app_config.assert_not_called() - telegram_client.assert_not_called() - - -class CrawlMainTest(unittest.TestCase): - @patch.object(crawl, 'save_group_export') - @patch.object(crawl, 'save_session') - @patch.object(crawl, 'load_session', return_value='stored-session') - @patch.object(crawl, 'load_app_config') - @patch.object(crawl, 'TelegramClient') - def test_crawls_configured_group_ids( - self, - telegram_client, - load_app_config, - load_session, - save_session, - save_group_export, - ): - load_app_config.return_value = { - 'api_id': 123, - 'api_hash': 'hash', - 'phone': '+15551234567', - 'group_ids': [101, 202], - } - client = MagicMock() - telegram_client.return_value = client - client.is_user_authorized.return_value = True - client.session.save.return_value = 'updated-session' - client.get_entity.side_effect = [FakeChat(101), FakeChat(202)] - client.get_participants.side_effect = [ - [FakeMember(2), FakeMember(1)], - [FakeMember(4), FakeMember(3)], - ] - - with ( - patch.object(crawl, 'Chat', FakeChat), - patch.object(crawl, 'Channel', FakeChat), - patch.object(crawl, 'StringSession', return_value='session'), - patch.object(crawl, 'new_run_time', return_value='20260724120000'), - ): - result = crawl.main([]) - - self.assertEqual(result, 0) - self.assertEqual(client.get_entity.call_args_list, [call(101), call(202)]) - save_session.assert_called_once_with('updated-session') - self.assertEqual(save_group_export.call_count, 2) - first_members = save_group_export.call_args_list[0].args[2] - self.assertEqual([member.id for member in first_members], [1, 2]) - - @patch.object(crawl, 'save_group_export') - @patch.object(crawl, 'save_session') - @patch.object(crawl, 'load_session', return_value='stored-session') - @patch.object(crawl, 'load_app_config') - @patch.object(crawl, 'TelegramClient') - def test_supplied_ids_override_configured_ids( - self, - telegram_client, - load_app_config, - load_session, - save_session, - save_group_export, - ): - load_app_config.return_value = { - 'api_id': 123, - 'api_hash': 'hash', - 'phone': '+15551234567', - 'group_ids': [999], - } - client = MagicMock() - telegram_client.return_value = client - client.is_user_authorized.return_value = True - client.session.save.return_value = 'updated-session' - client.get_entity.side_effect = [FakeChat(101), FakeChat(202)] - client.get_participants.side_effect = [ - [FakeMember(2), FakeMember(1)], - [FakeMember(4), FakeMember(3)], - ] - - with ( - patch.object(crawl, 'Chat', FakeChat), - patch.object(crawl, 'Channel', FakeChat), - patch.object(crawl, 'StringSession', return_value='session'), - patch.object(crawl, 'new_run_time', return_value='20260724120000'), - ): - result = crawl.main(['101', '202']) - - self.assertEqual(result, 0) - self.assertEqual(client.get_entity.call_args_list, [call(101), call(202)]) - self.assertNotIn(call(999), client.get_entity.call_args_list) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_export.py b/tests/test_export.py deleted file mode 100644 index 6657230..0000000 --- a/tests/test_export.py +++ /dev/null @@ -1,337 +0,0 @@ -import csv -import io -import types -import unittest -from contextlib import redirect_stderr, redirect_stdout -from pathlib import Path -from tempfile import TemporaryDirectory -from unittest.mock import MagicMock, patch - -import export as export_command - - -def export_record(group_id=-100123, run_time='20260724120000'): - return { - 'group_id': group_id, - 'title': 'Test Group', - 'time': run_time, - 'members': [ - { - 'id': 1, - 'username': 'alice', - 'first_name': 'Alice', - 'last_name': 'Example', - }, - { - 'id': 2, - 'username': None, - 'first_name': 'Bob', - 'last_name': None, - }, - ], - } - - -class ExportCommandTest(unittest.TestCase): - def test_exports_exact_group_and_time(self): - record = export_record() - common = types.SimpleNamespace( - get_group_export=MagicMock(return_value=record), - latest_group_export_time=MagicMock(), - ) - - with TemporaryDirectory() as temp_dir: - output_dir = Path(temp_dir) / 'output' - stdout = io.StringIO() - with ( - patch.dict('sys.modules', {'common': common}), - patch.object(export_command, 'OUTPUT_DIR', output_dir), - redirect_stdout(stdout), - ): - result = export_command.main(['-100123', '20260724120000']) - - output_path = output_dir / '-100123-20260724120000.csv' - self.assertEqual(result, 0) - common.get_group_export.assert_called_once_with( - -100123, - '20260724120000', - ) - common.latest_group_export_time.assert_not_called() - self.assertTrue(output_path.is_file()) - self.assertIn(str(output_path), stdout.getvalue()) - - with output_path.open(encoding='utf-8', newline='') as csv_file: - rows = list(csv.DictReader(csv_file)) - - self.assertEqual( - csv.DictReader(io.StringIO(output_path.read_text())).fieldnames, - export_command.CSV_FIELDS, - ) - self.assertEqual(rows[0], { - 'id': '1', - 'username': 'alice', - 'first_name': 'Alice', - 'last_name': 'Example', - }) - self.assertEqual(rows[1]['username'], '') - self.assertEqual(rows[1]['last_name'], '') - - def test_uses_latest_export_when_time_is_omitted(self): - latest = export_record(run_time='20260724120000') - common = types.SimpleNamespace( - get_group_export=MagicMock(return_value=latest), - latest_group_export_time=MagicMock( - return_value='20260724120000', - ), - ) - - with TemporaryDirectory() as temp_dir: - output_dir = Path(temp_dir) / 'output' - with ( - patch.dict('sys.modules', {'common': common}), - patch.object(export_command, 'OUTPUT_DIR', output_dir), - redirect_stdout(io.StringIO()), - ): - result = export_command.main(['-100123']) - - self.assertEqual(result, 0) - common.latest_group_export_time.assert_called_once_with(-100123) - common.get_group_export.assert_called_once_with( - -100123, - '20260724120000', - ) - self.assertTrue( - (output_dir / '-100123-20260724120000.csv').is_file(), - ) - - def test_uses_first_configured_group_when_both_args_are_omitted(self): - record = export_record(group_id=-100999) - common = types.SimpleNamespace( - get_group_export=MagicMock(return_value=record), - latest_group_export_time=MagicMock( - return_value='20260724120000', - ), - ) - config = types.SimpleNamespace( - load_app_config=MagicMock(return_value={'group_ids': [-100999]}), - ) - - with TemporaryDirectory() as temp_dir: - with ( - patch.dict('sys.modules', {'common': common, 'config': config}), - patch.object( - export_command, - 'OUTPUT_DIR', - Path(temp_dir) / 'output', - ), - redirect_stdout(io.StringIO()), - ): - result = export_command.main([]) - - self.assertEqual(result, 0) - config.load_app_config.assert_called_once_with() - common.latest_group_export_time.assert_called_once_with(-100999) - common.get_group_export.assert_called_once_with( - -100999, - '20260724120000', - ) - - def test_default_group_config_failure_returns_clean_error(self): - common = types.SimpleNamespace( - get_group_export=MagicMock(), - latest_group_export_time=MagicMock(), - ) - config = types.SimpleNamespace( - load_app_config=MagicMock( - side_effect=RuntimeError('Redis unavailable'), - ), - ) - stderr = io.StringIO() - - with ( - patch.dict('sys.modules', {'common': common, 'config': config}), - redirect_stderr(stderr), - ): - result = export_command.main([]) - - self.assertEqual(result, 1) - self.assertIn('could not read export configuration', stderr.getvalue()) - common.latest_group_export_time.assert_not_called() - common.get_group_export.assert_not_called() - - def test_common_import_failure_returns_clean_error(self): - stderr = io.StringIO() - real_import = __import__ - - def fail_common_import(name, *args, **kwargs): - if name == 'common': - raise ValueError('Redis URL is invalid') - return real_import(name, *args, **kwargs) - - with ( - patch('builtins.__import__', side_effect=fail_common_import), - redirect_stderr(stderr), - ): - result = export_command.main(['-100123', '20260724120000']) - - self.assertEqual(result, 1) - self.assertIn('could not read export configuration', stderr.getvalue()) - - def test_missing_exact_export_returns_error_without_creating_output(self): - common = types.SimpleNamespace( - get_group_export=MagicMock(return_value=None), - latest_group_export_time=MagicMock(), - ) - - with TemporaryDirectory() as temp_dir: - output_dir = Path(temp_dir) / 'output' - stderr = io.StringIO() - with ( - patch.dict('sys.modules', {'common': common}), - patch.object(export_command, 'OUTPUT_DIR', output_dir), - redirect_stderr(stderr), - ): - result = export_command.main(['-100123', '20260724120000']) - - self.assertEqual(result, 1) - self.assertFalse(output_dir.exists()) - self.assertIn('export not found', stderr.getvalue()) - - def test_missing_group_history_returns_error_without_creating_output(self): - common = types.SimpleNamespace( - get_group_export=MagicMock(), - latest_group_export_time=MagicMock(return_value=None), - ) - - with TemporaryDirectory() as temp_dir: - output_dir = Path(temp_dir) / 'output' - stderr = io.StringIO() - with ( - patch.dict('sys.modules', {'common': common}), - patch.object(export_command, 'OUTPUT_DIR', output_dir), - redirect_stderr(stderr), - ): - result = export_command.main(['-100123']) - - self.assertEqual(result, 1) - self.assertFalse(output_dir.exists()) - self.assertIn('no exports found', stderr.getvalue()) - - def test_rejects_invalid_time_before_loading_redis(self): - for invalid_time in ( - '../unsafe', - '20261301120000', - '20260230010101', - '20260724246000', - ): - with self.subTest(invalid_time=invalid_time): - with ( - patch.dict('sys.modules', {'common': None}), - self.assertRaises(SystemExit), - ): - export_command.main(['-100123', invalid_time]) - - def test_rejects_mismatched_record_without_creating_output(self): - record = export_record(group_id='../escaped') - common = types.SimpleNamespace( - get_group_export=MagicMock(return_value=record), - latest_group_export_time=MagicMock(), - ) - - with TemporaryDirectory() as temp_dir: - output_dir = Path(temp_dir) / 'output' - stderr = io.StringIO() - with ( - patch.dict('sys.modules', {'common': common}), - patch.object(export_command, 'OUTPUT_DIR', output_dir), - redirect_stderr(stderr), - ): - result = export_command.main(['-100123', '20260724120000']) - - self.assertEqual(result, 1) - self.assertFalse(output_dir.exists()) - self.assertIn('group ID does not match', stderr.getvalue()) - - def test_rejects_impossible_time_from_redis_key(self): - record = export_record(run_time='99999999999999') - common = types.SimpleNamespace( - get_group_export=MagicMock(return_value=record), - latest_group_export_time=MagicMock( - return_value='99999999999999', - ), - ) - - with TemporaryDirectory() as temp_dir: - output_dir = Path(temp_dir) / 'output' - stderr = io.StringIO() - with ( - patch.dict('sys.modules', {'common': common}), - patch.object(export_command, 'OUTPUT_DIR', output_dir), - redirect_stderr(stderr), - ): - result = export_command.main(['-100123']) - - self.assertEqual(result, 1) - self.assertFalse(output_dir.exists()) - self.assertIn('invalid crawl time', stderr.getvalue()) - - def test_rejects_malformed_members_without_replacing_existing_csv(self): - record = export_record() - record['members'] = None - common = types.SimpleNamespace( - get_group_export=MagicMock(return_value=record), - latest_group_export_time=MagicMock(), - ) - - with TemporaryDirectory() as temp_dir: - output_dir = Path(temp_dir) / 'output' - output_dir.mkdir() - output_path = output_dir / '-100123-20260724120000.csv' - output_path.write_text('existing export', encoding='utf-8') - with ( - patch.dict('sys.modules', {'common': common}), - patch.object(export_command, 'OUTPUT_DIR', output_dir), - redirect_stderr(io.StringIO()), - ): - result = export_command.main(['-100123', '20260724120000']) - - self.assertEqual(result, 1) - self.assertEqual( - output_path.read_text(encoding='utf-8'), - 'existing export', - ) - - def test_escapes_spreadsheet_formulas_and_uses_private_permissions(self): - record = export_record() - record['members'][0]['first_name'] = ' =HYPERLINK("https://example.com")' - record['members'][0]['last_name'] = '\n+SUM(1,1)' - common = types.SimpleNamespace( - get_group_export=MagicMock(return_value=record), - latest_group_export_time=MagicMock(), - ) - - with TemporaryDirectory() as temp_dir: - output_dir = Path(temp_dir) / 'output' - with ( - patch.dict('sys.modules', {'common': common}), - patch.object(export_command, 'OUTPUT_DIR', output_dir), - redirect_stdout(io.StringIO()), - ): - result = export_command.main(['-100123', '20260724120000']) - - output_path = output_dir / '-100123-20260724120000.csv' - with output_path.open(encoding='utf-8', newline='') as csv_file: - rows = list(csv.DictReader(csv_file)) - - self.assertEqual(result, 0) - self.assertEqual( - rows[0]['first_name'], - '\' =HYPERLINK("https://example.com")', - ) - self.assertEqual(rows[0]['last_name'], "'\n+SUM(1,1)") - self.assertEqual(output_dir.stat().st_mode & 0o777, 0o700) - self.assertEqual(output_path.stat().st_mode & 0o777, 0o600) - - -if __name__ == '__main__': - unittest.main()