#!/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())