feat(go): rewrite in Go

This commit is contained in:
tiennm99 committed 2025-11-28 22:25:09 +07:00
1 parent e8690755e3
commit e1bf670553
8 files changed
+509 -364

No files matched your search

+32 -16
View File
@@ -1,20 +1,36 @@
# project
*.session
*.csv
output/
plans/
.idea
# python
__pycache__/
*.py[cod]
*$py.class
# env
# 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
*.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
.venv/
venv/
env/
# tooling
.idea/
.claude/
# Editor/IDE
# .idea/
# .vscode/
+2 -157
View File
@@ -1,158 +1,3 @@
# export-telegram-group-members
# Export Telegram group members
Export Telegram group members using Telethon — admin auth required for full member visibility. Session and export history are stored in **Redis**, so you can run the tool on any device from just a `.env` file (no re-login, no copying session/CSV files).
## How to use
1. Clone this repository:
```bash
git clone https://github.com/tiennm99/export-telegram-group-members.git
```
2. Install requirements:
```bash
pip install -r requirements.txt
```
3. Create a new Telegram app at [https://my.telegram.org](https://my.telegram.org) and get the `api_id` and `api_hash`.
4. Create a free Redis database (e.g. [Upstash](https://upstash.com)) and copy its `rediss://` connection URL.
5. Copy `.env.example` to `.env` and fill in `REDIS_URL`.
6. Store Telegram config in Redis:
```bash
python configure.py
```
`configure.py` prompts for `api_id`, `api_hash`, phone, and group IDs. All
commands read the stored group IDs from this Redis config.
7. Crawl the configured groups:
```bash
python crawl.py
```
Every group ID stored in the Redis config is crawled. To change the list, run
`configure.py` again.
To crawl other groups just once, pass their IDs; they replace the configured
list for that run only:
```bash
python crawl.py -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.
## Compare two crawls
Compare membership changes for one group between two saved crawls with the
git-diff-style terminal output:
```bash
python compare.py <group_id> <time1> <time2>
```
If `time1` and `time2` are omitted, the command compares the latest two crawls
for that group:
```bash
python compare.py <group_id>
```
If `group_id` is also omitted, the command uses the first group in the stored
configuration:
```bash
python compare.py
```
## Compare two groups
Fetch two groups live from Telegram and compare their members by Telegram ID:
```bash
python compare_groups.py <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.py` does.
## Export a crawl to CSV
Export one group's saved crawl from Redis:
```bash
python export.py [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
python export.py -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.
## 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.py`.
## 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 -> StringSession string (login)
telegram-export:group:<group_id>:<yyyymmddhhmmss> -> one group's export as JSON:
{ group_id, title, time,
members: [{ id, username, first_name, last_name }] }
```
Each group is written as its own key per run; all groups in one run share the same `yyyymmddhhmmss` timestamp. Read the history programmatically:
```python
from common import list_exports
for rec in list_exports(): # sorted by (time, group_id)
print(rec['time'], rec['group_id'], rec['title'], len(rec['members']), 'members')
```
## Rate limits and visibility notes
- Telegram limits how fast you can fetch participants; large groups may take longer.
- For **supergroups**, only admins can retrieve the full member list — regular members see a partial list or get an error.
- For private groups where you are not a member, access will be denied.
## Security
- The session string grants **full access to your Telegram account**. It lives only in Redis (use a TLS `rediss://` URL) and is never written to disk or committed to git.
- `.env` is git-ignored. Never commit your `REDIS_URL` or session string.
## License
Apache-2.0 — see [LICENSE](LICENSE).
*In 2025, I rewrote this project using Go. The original Python version of this project can be found at `feature/python` branch.*
-128
View File
@@ -1,128 +0,0 @@
"""Export persistence in Redis.
Each group's members are stored per export as one self-contained JSON key:
<prefix>:group:<group_id>:<yyyymmddhhmmss>
-> {group_id, title, time, members:[{id,username,first_name,last_name}]}
All groups in a single run share one timestamp. No key references another, so
deleting any key can never corrupt another's state. Writing a group export is a
single atomic SET; listing scans the group export keys.
"""
import json
from datetime import datetime
from config import key, redis_client
def new_run_time():
"""Timestamp shared by every group in one run: yyyymmddhhmmss."""
return datetime.now().strftime('%Y%m%d%H%M%S')
def member_dict(member):
return {
'id': member.id,
'username': member.username,
'first_name': member.first_name,
'last_name': member.last_name,
}
def save_group_export(group_id, title, members, run_time):
"""Atomically persist one group's members for a run as a single JSON value."""
record = {
'group_id': group_id,
'title': title,
'time': run_time,
'members': [member_dict(m) for m in members],
}
redis_client.set(
key('group', str(group_id), run_time),
json.dumps(record, ensure_ascii=False),
)
def list_exports():
"""Return all group-export records, sorted by (time, group_id).
Tolerates keys deleted mid-scan and corrupt/non-JSON values (skips them).
"""
records = []
for export_key in redis_client.scan_iter(match=key('group', '*', '*')):
if not _is_group_export_key(export_key):
continue
raw = redis_client.get(export_key)
if raw is None: # deleted between scan and get
continue
try:
records.append(json.loads(raw))
except (ValueError, TypeError): # corrupt / non-JSON value: skip, don't abort
continue
records.sort(key=lambda r: (r.get('time', ''), r.get('group_id', 0)))
return records
def list_group_exports(group_id):
"""Return export records for one group, sorted by run time."""
group_id = str(group_id)
return [
record for record in list_exports()
if str(record.get('group_id')) == group_id
]
def latest_group_export_time(group_id):
"""Return the latest Redis-key timestamp for one group, or None."""
latest_time = None
for export_key in redis_client.scan_iter(
match=key('group', str(group_id), '*'),
):
if not _is_group_export_key(export_key):
continue
run_time = export_key.rsplit(':', 1)[-1]
if latest_time is None or run_time > latest_time:
latest_time = run_time
return latest_time
def get_group_export(group_id, run_time):
"""Return a group export at one run time, or None when missing."""
raw = redis_client.get(key('group', str(group_id), run_time))
if raw is None:
return None
try:
return json.loads(raw)
except (ValueError, TypeError):
return None
def diff_group_members(before_record, after_record):
"""Compare two group export records by Telegram member id."""
before_members = _member_index(before_record)
after_members = _member_index(after_record)
before_ids = set(before_members)
after_ids = set(after_members)
added = [after_members[member_id] for member_id in sorted(after_ids - before_ids)]
removed = [before_members[member_id] for member_id in sorted(before_ids - after_ids)]
return added, removed
def _member_index(record):
return {
int(member['id']): member
for member in record.get('members', [])
if member.get('id') is not None
}
def _is_run_time(value):
return len(value) == 14 and value.isdigit()
def _is_group_export_key(export_key):
parts = export_key.split(':')
return len(parts) == 4 and _is_run_time(parts[3])
-60
View File
@@ -1,60 +0,0 @@
import os
import json
import redis
from dotenv import load_dotenv
load_dotenv()
# Shared Redis holds the Telegram session + export history so the tool runs on
# any device from just .env. The prefix is fixed to keep local setup minimal.
redis_url = os.getenv('REDIS_URL')
if not redis_url:
raise SystemExit('REDIS_URL not set in .env (e.g. rediss://default:<password>@<host>:<port>)')
redis_prefix = 'telegram-export'
redis_client = redis.from_url(redis_url, decode_responses=True)
def key(*parts):
"""Build a namespaced Redis key. Single source of the prefix (DRY)."""
return ':'.join([redis_prefix, *parts])
def load_app_config():
"""Load Telegram crawl config from Redis."""
raw = redis_client.get(key('config'))
if not raw:
raise SystemExit(
'telegram config not found in Redis. run configure.py first.'
)
try:
config = json.loads(raw)
except ValueError as exc:
raise SystemExit('telegram config in Redis is not valid JSON.') from exc
missing = [
name for name in ('api_id', 'api_hash', 'phone', 'group_ids')
if not config.get(name)
]
if missing:
raise SystemExit(f'telegram config missing: {", ".join(missing)}')
return {
'api_id': int(config['api_id']),
'api_hash': str(config['api_hash']),
'phone': str(config['phone']),
'group_ids': [int(group_id) for group_id in config['group_ids']],
}
def save_app_config(api_id, api_hash, phone, group_ids):
"""Save Telegram crawl config to Redis."""
record = {
'api_id': int(api_id),
'api_hash': str(api_hash),
'phone': str(phone),
'group_ids': [int(group_id) for group_id in group_ids],
}
redis_client.set(key('config'), json.dumps(record, ensure_ascii=False))
return record
+27
View File
@@ -0,0 +1,27 @@
module github.com/tiennm99/export-telegram-group-members
go 1.21
require github.com/gotd/td v0.98.0
require (
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-faster/jx v1.2.0 // indirect
github.com/go-faster/xor v1.0.0 // indirect
github.com/gotd/ige v0.2.2 // indirect
github.com/gotd/neo v0.1.5 // indirect
github.com/klauspost/compress v1.17.7 // indirect
github.com/segmentio/asm v1.2.1 // indirect
go.opentelemetry.io/otel v1.23.1 // indirect
go.opentelemetry.io/otel/trace v1.23.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.17.0 // indirect
nhooyr.io/websocket v1.8.10 // indirect
rsc.io/qr v0.2.0 // indirect
)
+56
View File
@@ -0,0 +1,56 @@
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
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/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.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI=
github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE=
github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
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.98.0 h1:WNS6ob/Nl1OBskXiOO/JNxCO2j3zGG2oKqm/ELHDjQY=
github.com/gotd/td v0.98.0/go.mod h1:Px8r98qWVHjQ3l68PVBCJfK1+Y9ZaAIkflqx0mqKcAg=
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
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/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/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/otel v1.23.1 h1:Za4UzOqJYS+MUczKI320AtqZHZb7EqxO00jAHE0jmQY=
go.opentelemetry.io/otel v1.23.1/go.mod h1:Td0134eafDLcTS4y+zQ26GE8u3dEuRBiBCTUIRHaikA=
go.opentelemetry.io/otel/trace v1.23.1 h1:4LrmmEd8AU2rFvU1zegmvqW7+kWarxtNOPyeL6HmYY8=
go.opentelemetry.io/otel/trace v1.23.1/go.mod h1:4IpnpJFwr1mo/6HL8XIPJaE9y0+u1KcVmuW7dwFSVrI=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
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.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/exp v0.0.0-20230116083435-1de6713980de h1:DBWn//IJw30uYCgERoxCg84hWtA97F4wMiKOIh00Uf0=
golang.org/x/exp v0.0.0-20230116083435-1de6713980de/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+392
View File
@@ -0,0 +1,392 @@
package main
import (
"bufio"
"context"
"encoding/csv"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/auth"
"github.com/gotd/td/tg"
)
// Config holds the application configuration
type Config struct {
Phone string
APIId int
APIHash string
GroupNames []string
}
// Member represents a Telegram group member
type Member struct {
ID int64
Username string
FirstName string
LastName string
}
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func run() error {
// Load configuration
config := loadConfig()
ctx := context.Background()
// Setup client options
opts := telegram.Options{
SessionStorage: &telegram.FileSessionStorage{
Path: "session.json",
},
}
client := telegram.NewClient(config.APIId, config.APIHash, opts)
return client.Run(ctx, func(ctx context.Context) error {
// Authenticate
if err := authenticate(ctx, client, config.Phone); err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
// Export group members
if err := exportGroupMembers(ctx, client, config.GroupNames); err != nil {
return fmt.Errorf("failed to export members: %w", err)
}
fmt.Println("\nExport completed successfully!")
return nil
})
}
// loadConfig loads configuration - modify these values
func loadConfig() *Config {
return &Config{
// TODO: Set your Telegram credentials here
Phone: "+1234567890", // Your phone number
APIId: 123456789, // Your API ID from https://my.telegram.org
APIHash: "your_api_hash_here", // Your API Hash from https://my.telegram.org
// Define groups to export
GroupNames: []string{
"ZingPlay Game Studios",
"ZPS HCM",
"ZPS HCM - Xin nghỉ (phép/đi trễ)",
},
}
}
// authenticate handles the Telegram authentication flow
func authenticate(ctx context.Context, client *telegram.Client, phone string) error {
api := client.API()
flow := auth.NewFlow(
&terminalAuth{phone: phone},
auth.SendCodeOptions{},
)
if err := client.Auth().IfNecessary(ctx, flow); err != nil {
return err
}
// Get current user to confirm authentication
user, err := api.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUserSelf{}})
if err != nil {
return fmt.Errorf("failed to get current user: %w", err)
}
if len(user) > 0 {
if u, ok := user[0].(*tg.User); ok {
fmt.Printf("Authenticated as: %s %s\n", u.FirstName, u.LastName)
}
}
return nil
}
// terminalAuth implements auth flow for terminal input
type terminalAuth struct {
phone string
}
func (a *terminalAuth) Phone(_ context.Context) (string, error) {
return a.phone, nil
}
func (a *terminalAuth) Password(_ context.Context) (string, error) {
fmt.Print("Enter 2FA password: ")
password, err := readLine()
if err != nil {
return "", err
}
return strings.TrimSpace(password), nil
}
func (a *terminalAuth) Code(_ context.Context, _ *tg.AuthSentCode) (string, error) {
fmt.Print("Enter verification code: ")
code, err := readLine()
if err != nil {
return "", err
}
return strings.TrimSpace(code), nil
}
func (a *terminalAuth) AcceptTermsOfService(_ context.Context, tos tg.HelpTermsOfService) error {
return nil
}
func (a *terminalAuth) SignUp(_ context.Context) (auth.UserInfo, error) {
return auth.UserInfo{}, fmt.Errorf("signup not supported")
}
// readLine reads a line from stdin
func readLine() (string, error) {
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(line), nil
}
// exportGroupMembers exports members from specified groups
func exportGroupMembers(ctx context.Context, client *telegram.Client, groupNames []string) error {
api := client.API()
// Create timestamped directory for exports
timestamp := time.Now().Format("2006-01-02 15-04-05")
exportDir := timestamp
if err := os.MkdirAll(exportDir, 0755); err != nil {
return fmt.Errorf("failed to create export directory: %w", err)
}
fmt.Printf("\nExporting to directory: %s\n\n", exportDir)
// Get all dialogs
dialogs, err := getDialogs(ctx, api)
if err != nil {
return fmt.Errorf("failed to get dialogs: %w", err)
}
// Filter and process groups
groupsExported := 0
for _, dialog := range dialogs {
// Check if it's a channel/group
peer, ok := dialog.Peer.(*tg.PeerChannel)
if !ok {
continue
}
// Get channel info
var channelName string
for _, chat := range dialog.Chats {
if channel, ok := chat.(*tg.Channel); ok && channel.ID == peer.ChannelID {
channelName = channel.Title
break
}
}
// Check if this group should be exported
if !contains(groupNames, channelName) {
continue
}
fmt.Printf("Exporting group: %s\n", channelName)
// Get members
members, err := getChannelMembers(ctx, api, peer.ChannelID)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to get members for %s: %v\n", channelName, err)
continue
}
// Sort members by ID
sort.Slice(members, func(i, j int) bool {
return members[i].ID < members[j].ID
})
// Export to CSV
filename := sanitizeFilename(channelName) + ".csv"
filepath := filepath.Join(exportDir, filename)
if err := exportToCSV(filepath, members); err != nil {
fmt.Fprintf(os.Stderr, "Failed to export %s: %v\n", channelName, err)
continue
}
fmt.Printf(" -> Exported %d members to %s\n", len(members), filename)
groupsExported++
}
if groupsExported == 0 {
fmt.Println("No matching groups found to export.")
}
return nil
}
// getDialogs retrieves all dialogs (conversations)
func getDialogs(ctx context.Context, api *tg.Client) ([]*tg.Dialog, error) {
var allDialogs []*tg.Dialog
var offsetDate int
var offsetID int
var offsetPeer tg.InputPeerClass = &tg.InputPeerEmpty{}
for {
dialogs, err := api.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{
OffsetDate: offsetDate,
OffsetID: offsetID,
OffsetPeer: offsetPeer,
Limit: 100,
})
if err != nil {
return nil, err
}
var dialogsList []*tg.Dialog
switch d := dialogs.(type) {
case *tg.MessagesDialogs:
dialogsList = d.Dialogs
allDialogs = append(allDialogs, d.Dialogs...)
// Last page reached
return allDialogs, nil
case *tg.MessagesDialogsSlice:
dialogsList = d.Dialogs
allDialogs = append(allDialogs, d.Dialogs...)
if len(d.Dialogs) == 0 {
return allDialogs, nil
}
default:
return allDialogs, nil
}
// Update offset for next iteration
if len(dialogsList) > 0 {
lastDialog := dialogsList[len(dialogsList)-1]
offsetDate = lastDialog.TopMessage
offsetID = lastDialog.TopMessage
offsetPeer = lastDialog.Peer
} else {
break
}
}
return allDialogs, nil
}
// getChannelMembers retrieves all members from a channel
func getChannelMembers(ctx context.Context, api *tg.Client, channelID int64) ([]Member, error) {
// Get channel input
channel := &tg.InputChannel{
ChannelID: channelID,
}
var members []Member
var offset int
for {
participants, err := api.ChannelsGetParticipants(ctx, &tg.ChannelsGetParticipantsRequest{
Channel: channel,
Filter: &tg.ChannelParticipantsRecent{},
Offset: offset,
Limit: 200,
})
if err != nil {
return nil, err
}
channelParticipants, ok := participants.(*tg.ChannelsChannelParticipants)
if !ok {
break
}
if len(channelParticipants.Users) == 0 {
break
}
// Extract member data
for _, u := range channelParticipants.Users {
if user, ok := u.(*tg.User); ok {
member := Member{
ID: user.ID,
FirstName: user.FirstName,
LastName: user.LastName,
}
if user.Username != "" {
member.Username = user.Username
}
members = append(members, member)
}
}
offset += len(channelParticipants.Users)
// Check if we've reached the end
if len(channelParticipants.Users) < 200 {
break
}
}
return members, nil
}
// exportToCSV exports members to a CSV file
func exportToCSV(filename string, members []Member) error {
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
// Write header
if err := writer.Write([]string{"id", "username", "first_name", "last_name"}); err != nil {
return err
}
// Write member data
for _, member := range members {
record := []string{
strconv.FormatInt(member.ID, 10),
member.Username,
member.FirstName,
member.LastName,
}
if err := writer.Write(record); err != nil {
return err
}
}
return nil
}
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(name string) string {
// Remove characters that are invalid in Windows filenames
invalidChars := regexp.MustCompile(`[<>:"/\\|?*]`)
return invalidChars.ReplaceAllString(name, "")
}
// contains checks if a string is in a slice
func contains(slice []string, str string) bool {
for _, s := range slice {
if s == str {
return true
}
}
return false
}
-3
View File
@@ -1,3 +0,0 @@
python-dotenv==1.2.2
redis[hiredis]==5.2.1
Telethon==1.42.0