feat: port the tool to Go

Replace the Python scripts with Go commands run as go run ./cmd/<name>:
configure, crawl, compare, compare-groups, and export, each with the
same arguments and output as before.

Telegram access uses gotd/td and the code is pure Go. Redis keys and
JSON keep the Python format, so existing crawls stay readable. The
Telethon login session is converted once into a separate gotd session
key, so upgrading needs no new login code and never changes the old key.

Tests run offline with miniredis.
This commit is contained in:
tiennm99 committed 2026-10-15 14:22:22 +07:00
1 parent 73f8cf1bb3
commit d041503a47
51 files changed
+2684 -1551

No files matched your search

+1 -9
View File
@@ -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:<password>@<host>:<port>
+10 -26
View File
@@ -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/
+165 -2
View File
@@ -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<id>` for supergroups and channels,
`-<id>` 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 <group_id> <time1> <time2>
```
If `time1` and `time2` are omitted, the command compares the latest two crawls
for that group:
```bash
go run ./cmd/compare <group_id>
```
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 <group_id_a> <group_id_b>
```
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/<group-id>-<yyyymmddhhmmss>.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:<password>@<host>:<port>`) |
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:<group_id>:<yyyymmddhhmmss> -> 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.
+10
View File
@@ -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)
}
+10
View File
@@ -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)
}
+10
View File
@@ -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)
}
+10
View File
@@ -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)
}
+10
View File
@@ -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)
}
-131
View File
@@ -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())
-91
View File
@@ -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())
-76
View File
@@ -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())
-87
View File
@@ -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())
-169
View File
@@ -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())
+36 -45
View File
@@ -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
)
+88 -112
View File
@@ -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=
+73
View File
@@ -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
}
+28
View File
@@ -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")
}
}
+129
View File
@@ -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.", "")
}
}
+63
View File
@@ -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)
}
}
@@ -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 <group_a> <group_b>
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)
}
@@ -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)
}
}
}
+118
View File
@@ -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
}
@@ -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)
}
}
+93
View File
@@ -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
}
+30
View File
@@ -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())
}
}
+112
View File
@@ -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/<group-id>-<yyyymmddhhmmss>.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
}
+125
View File
@@ -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)
}
}
+99
View File
@@ -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
}
+84
View File
@@ -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)))
}
+55
View File
@@ -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 })
}
+73
View File
@@ -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())
}
}
+55
View File
@@ -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
}
+67
View File
@@ -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
}
+63
View File
@@ -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)
}
})
}
}
+132
View File
@@ -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
}
+91
View File
@@ -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)
}
}
}
+59
View File
@@ -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:<password>@<host>:<port>)")
}
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...), ":")
}
+79
View File
@@ -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)
}
+75
View File
@@ -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)
}
}
+19
View File
@@ -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
}
+84
View File
@@ -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):
}
}
}
+90
View File
@@ -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
}
+36
View File
@@ -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<channel_id>, 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
}
}
+75
View File
@@ -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)
}
}
+133
View File
@@ -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
}
-165
View File
@@ -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
}
-19
View File
@@ -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)
-40
View File
@@ -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()
-110
View File
@@ -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()
-132
View File
@@ -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()
-337
View File
@@ -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()