Files
telegramscripts/import_contacts.py
2025-08-20 12:46:30 +02:00

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())