293 lines
12 KiB
Python
Executable File
293 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import os
|
|
import sys
|
|
import csv
|
|
import argparse
|
|
import asyncio
|
|
import time
|
|
from getpass import getpass
|
|
from typing import Dict, Optional, Set, Tuple
|
|
|
|
from telethon import TelegramClient, functions, types
|
|
from telethon.errors import (
|
|
FloodWaitError,
|
|
PeerFloodError,
|
|
UsernameInvalidError,
|
|
UsernameNotOccupiedError,
|
|
RPCError,
|
|
)
|
|
|
|
DEFAULT_SESSION = "export_session"
|
|
|
|
# ---------- CLI ----------
|
|
|
|
def parse_args():
|
|
p = argparse.ArgumentParser(
|
|
description="Import contacts from a CSV (from export_members.py) into your Telegram Contacts."
|
|
)
|
|
p.add_argument("--csv", required=True, help="Path to CSV exported by export_members.py")
|
|
p.add_argument("--session", default=DEFAULT_SESSION, help=f"Telethon session name (default: {DEFAULT_SESSION})")
|
|
p.add_argument("--skip-bots", action="store_true", default=True, help="Skip users marked as bots (default: on)")
|
|
p.add_argument("--no-skip-bots", dest="skip_bots", action="store_false", help="Do not skip bots")
|
|
p.add_argument("--only-with-phone", action="store_true",
|
|
help="Only add rows that contain a phone number (safest and most reliable).")
|
|
p.add_argument("--dry-run", action="store_true", help="Do not write anything; just print what would happen.")
|
|
p.add_argument("--limit", type=int, default=0, help="Stop after adding this many contacts (0 = no limit).")
|
|
p.add_argument("--sleep", type=float, default=0.5, help="Delay (seconds) between adds to avoid flood (default: 0.5)")
|
|
p.add_argument("--add-phone-privacy-exception", action="store_true",
|
|
help="Set the 'add_phone_privacy_exception' flag when adding by @username/user_id.")
|
|
p.add_argument("--start-from", type=int, default=0,
|
|
help="Skip the first N data rows (0-based) to resume after a previous run.")
|
|
return p.parse_args()
|
|
|
|
# ---------- Helpers ----------
|
|
|
|
def ensure_api_creds() -> Tuple[int, str]:
|
|
api_id = os.environ.get("TELEGRAM_API_ID")
|
|
api_hash = os.environ.get("TELEGRAM_API_HASH")
|
|
if not api_id:
|
|
api_id = input("Enter TELEGRAM_API_ID: ").strip()
|
|
if not api_hash:
|
|
api_hash = getpass("Enter TELEGRAM_API_HASH (hidden): ").strip()
|
|
try:
|
|
api_id = int(api_id)
|
|
except Exception:
|
|
print("ERROR: TELEGRAM_API_ID must be an integer.", file=sys.stderr)
|
|
sys.exit(1)
|
|
return api_id, api_hash
|
|
|
|
def normalize_phone(s: Optional[str]) -> Optional[str]:
|
|
if not s:
|
|
return None
|
|
s = s.strip().replace(" ", "").replace("-", "").replace("(", "").replace(")", "")
|
|
if not s:
|
|
return None
|
|
if s[0] != "+" and s[0].isdigit():
|
|
pass
|
|
return s
|
|
|
|
def read_csv_rows(path: str):
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
reader = csv.DictReader(f)
|
|
for row in reader:
|
|
yield {
|
|
"user_id": row.get("user_id", "").strip(),
|
|
"is_bot": row.get("is_bot", "").strip(),
|
|
"username": (row.get("username") or "").strip(),
|
|
"first_name": (row.get("first_name") or "").strip(),
|
|
"last_name": (row.get("last_name") or "").strip(),
|
|
"full_name": (row.get("full_name") or "").strip(),
|
|
"phone": normalize_phone(row.get("phone")),
|
|
"lang_code": (row.get("lang_code") or "").strip(),
|
|
}
|
|
|
|
async def get_existing_contacts_sets(client: TelegramClient) -> Tuple[Set[int], Set[str], Set[str]]:
|
|
result = await client(functions.contacts.GetContactsRequest(hash=0))
|
|
ids: Set[int] = set()
|
|
usernames: Set[str] = set()
|
|
phones: Set[str] = set()
|
|
for u in result.users:
|
|
if isinstance(u, types.User):
|
|
ids.add(u.id)
|
|
if u.username:
|
|
usernames.add(u.username.lower())
|
|
if u.phone:
|
|
phones.add(normalize_phone(u.phone) or "")
|
|
return ids, usernames, phones
|
|
|
|
async def add_by_phone(client: TelegramClient, phone: str, first: str, last: str, dry: bool) -> Optional[types.User]:
|
|
ipc = types.InputPhoneContact(client_id=0, phone=phone, first_name=first or " ", last_name=last or "")
|
|
if dry:
|
|
print(f"[DRY] contacts.ImportContacts: {phone} ({first} {last})")
|
|
return None
|
|
resp = await client(functions.contacts.ImportContactsRequest(contacts=[ipc], replace=False))
|
|
return resp.users[0] if resp.users else None
|
|
|
|
async def add_by_entity(client: TelegramClient,
|
|
entity: types.TypeUser,
|
|
first: str,
|
|
last: str,
|
|
phone_for_label: str,
|
|
add_privacy: bool,
|
|
dry: bool) -> types.User:
|
|
if dry:
|
|
uname = getattr(entity, "username", None)
|
|
print(f"[DRY] contacts.AddContact: id={entity.id} @{uname or ''} ({first} {last})")
|
|
return entity
|
|
user = await client(functions.contacts.AddContactRequest(
|
|
id=entity,
|
|
first_name=first or " ",
|
|
last_name=last or "",
|
|
phone=phone_for_label or "",
|
|
add_phone_privacy_exception=bool(add_privacy),
|
|
))
|
|
return user
|
|
|
|
async def resolve_user_entity(client: TelegramClient,
|
|
username: Optional[str],
|
|
user_id: Optional[int]) -> Optional[types.User]:
|
|
if username:
|
|
try:
|
|
ent = await client.get_entity(username)
|
|
if isinstance(ent, types.User):
|
|
return ent
|
|
except (UsernameInvalidError, UsernameNotOccupiedError):
|
|
return None
|
|
except RPCError:
|
|
return None
|
|
if user_id:
|
|
try:
|
|
ent = await client.get_entity(user_id)
|
|
if isinstance(ent, types.User):
|
|
return ent
|
|
except RPCError:
|
|
return None
|
|
return None
|
|
|
|
# ---------- Main flow ----------
|
|
|
|
async def run():
|
|
args = parse_args()
|
|
|
|
# NEW: Early CSV validation (fail fast, BEFORE connecting)
|
|
csv_abs = os.path.abspath(args.csv)
|
|
try:
|
|
rows = list(read_csv_rows(csv_abs))
|
|
except FileNotFoundError:
|
|
print(f"ERROR: CSV file not found: {csv_abs}", file=sys.stderr)
|
|
sys.exit(2)
|
|
except IsADirectoryError:
|
|
print(f"ERROR: CSV path is a directory, not a file: {csv_abs}", file=sys.stderr)
|
|
sys.exit(2)
|
|
except PermissionError:
|
|
print(f"ERROR: No permission to read CSV: {csv_abs}", file=sys.stderr)
|
|
sys.exit(2)
|
|
except OSError as e:
|
|
print(f"ERROR: Could not open CSV '{csv_abs}': {e}", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
if args.start_from:
|
|
rows = rows[args.start_from:]
|
|
|
|
api_id, api_hash = ensure_api_creds()
|
|
client = TelegramClient(args.session, api_id, api_hash)
|
|
await client.start()
|
|
|
|
existing_ids, existing_usernames, existing_phones = await get_existing_contacts_sets(client)
|
|
print(f"[i] You currently have {len(existing_ids)} contacts.")
|
|
|
|
total = 0
|
|
added = 0
|
|
skipped = 0
|
|
failed = 0
|
|
|
|
for idx, row in enumerate(rows):
|
|
total += 1
|
|
uid_str = row["user_id"]
|
|
user_id = int(uid_str) if uid_str.isdigit() else None
|
|
is_bot = str(row["is_bot"]).lower() in ("1", "true", "yes")
|
|
username = row["username"]
|
|
first = row["first_name"] or (row["full_name"].split(" ")[0] if row["full_name"] else "")
|
|
last = row["last_name"] or (" ".join(row["full_name"].split(" ")[1:]) if row["full_name"] else "")
|
|
phone = row["phone"]
|
|
|
|
if args.skip_bots and is_bot:
|
|
print(f"[skip] bot user_id={uid_str} @{username}")
|
|
skipped += 1
|
|
continue
|
|
|
|
if args.only_with_phone and not phone:
|
|
print(f"[skip] no phone for user_id={uid_str} @{username}")
|
|
skipped += 1
|
|
continue
|
|
|
|
if user_id and user_id in existing_ids:
|
|
print(f"[skip] already contact: id={user_id} @{username}")
|
|
skipped += 1
|
|
continue
|
|
if username and username.lower() in existing_usernames:
|
|
print(f"[skip] already contact by username: @{username}")
|
|
skipped += 1
|
|
continue
|
|
if phone and phone in existing_phones:
|
|
print(f"[skip] already contact by phone: {phone}")
|
|
skipped += 1
|
|
continue
|
|
|
|
try:
|
|
if phone:
|
|
u = await add_by_phone(client, phone, first, last, args.dry_run)
|
|
status = "[ok]" if not args.dry_run else "[would add]"
|
|
print(f"{status} by phone: {phone} name='{first} {last}' @{username or ''}")
|
|
if not args.dry_run and u and isinstance(u, types.User):
|
|
existing_ids.add(u.id)
|
|
if u.username: existing_usernames.add(u.username.lower())
|
|
if u.phone: existing_phones.add(normalize_phone(u.phone) or "")
|
|
added += 1
|
|
else:
|
|
ent = await resolve_user_entity(client, username=username or None, user_id=user_id)
|
|
if ent:
|
|
_ = await add_by_entity(
|
|
client, ent, first, last, phone_for_label="", add_privacy=args.add_phone_privacy_exception,
|
|
dry=args.dry_run
|
|
)
|
|
status = "[ok]" if not args.dry_run else "[would add]"
|
|
print(f"{status} by entity: id={ent.id} @{getattr(ent, 'username', '')} name='{first} {last}'")
|
|
if not args.dry_run:
|
|
existing_ids.add(ent.id)
|
|
if ent.username: existing_usernames.add(ent.username.lower())
|
|
added += 1
|
|
else:
|
|
print(f"[fail] cannot resolve user (no phone, bad/unknown username/id): id={uid_str} @{username}")
|
|
failed += 1
|
|
|
|
except FloodWaitError as e:
|
|
wait_s = int(getattr(e, "seconds", 30))
|
|
print(f"[flood-wait] Telegram asked to wait {wait_s}s; sleeping…")
|
|
if args.dry_run:
|
|
print("[dry-run] continuing without waiting.")
|
|
else:
|
|
time.sleep(wait_s + 1)
|
|
try:
|
|
if phone:
|
|
_ = await add_by_phone(client, phone, first, last, args.dry_run)
|
|
print(f"[ok] retried by phone: {phone} name='{first} {last}'")
|
|
added += 1
|
|
else:
|
|
ent = await resolve_user_entity(client, username=username or None, user_id=user_id)
|
|
if ent:
|
|
_ = await add_by_entity(
|
|
client, ent, first, last, phone_for_label="", add_privacy=args.add_phone_privacy_exception,
|
|
dry=args.dry_run
|
|
)
|
|
print(f"[ok] retried by entity: id={ent.id} @{getattr(ent, 'username', '')}")
|
|
added += 1
|
|
else:
|
|
print(f"[fail] still cannot resolve after wait: id={uid_str} @{username}")
|
|
failed += 1
|
|
except PeerFloodError:
|
|
print("[fatal] PeerFloodError: Telegram is rate-limiting hard; stop and try again later.")
|
|
break
|
|
|
|
except PeerFloodError:
|
|
print("[fatal] PeerFloodError: Telegram is rate-limiting hard; stop and try again later.")
|
|
break
|
|
|
|
except RPCError as e:
|
|
print(f"[fail] RPC error for id={uid_str} @{username}: {e.__class__.__name__}: {e}")
|
|
failed += 1
|
|
|
|
if args.limit and added >= args.limit:
|
|
print(f"[i] Reached --limit={args.limit}.")
|
|
break
|
|
if args.sleep > 0 and not args.dry_run:
|
|
await asyncio.sleep(args.sleep)
|
|
|
|
print(f"\nSummary: total rows seen={total} added={added} skipped={skipped} failed={failed}")
|
|
await client.disconnect()
|
|
|
|
# ---------- Entrypoint ----------
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(run())
|