diff --git a/README.md b/README.md index c3f4905..5e6675c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ PHONE_NUMBER= If you don't create this file, you can also provide these 3 values when calling the tool, or even be prompted for them interactively. ## Usage + +### Search by phone number The tool accepts a comma-separated list of phone numbers to check, you can pass this when you call the tool, or interactively. See the examples below: @@ -62,14 +64,32 @@ telegram-phone-number-checker telegram-phone-number-checker --api-id YOUR_API_KEY --api-hash YOUR_API_HASH --api-phone-number YOUR_PHONE_NUMBER --phone-numbers +1234567890 ``` +### Search by Username +You can also search by Telegram username(s): + +```bash +# single username +telegram-phone-number-checker --usernames john + +# multiple usernames +telegram-phone-number-checker --usernames john,jane + +# combine phone numbers and usernames +telegram-phone-number-checker --phone-numbers +1234567890 --usernames johndoe + +# with profile photo download +telegram-phone-number-checker --usernames johndoe --download-profile-photos +``` + The result will be written to the console but also written as JSON to a `results.json` file, you can write it to another file by adding `--output your_filename.json` to the command. -For each phone number, you can expect the following possible responses: +For each phone number or username, you can expect the following possible responses: 1. If available, you will receive the Telegram Username, Name, and ID that are connected with this number. 2. 'no username detected'. This means that it looks like the number was used to create a Telegram account but the user did not choose a Telegram Username. It is optional to create a Username on Telegram. 3. 'ERROR: no response, the user does not exist or has blocked contact adding.': There can be several reasons for this response. Either the phone number has not been used to create a Telegram account. Or: The phone number is connected to a Telegram account but the user has restricted the option to find him/her via the phone number. -4. Or: another error occurred. +4. For username searches: The username may not exist, or it may belong to a channel/group rather than a user account. In the latter case, an output such as '@group_name is a channel or supergroup ('Group'), not a user account. This tool is for searching user accounts only' +5. Or: another error occurred. ## Development diff --git a/telegram_phone_number_checker/main.py b/telegram_phone_number_checker/main.py index 0c12427..3e8d066 100644 --- a/telegram_phone_number_checker/main.py +++ b/telegram_phone_number_checker/main.py @@ -75,7 +75,7 @@ async def get_names( { "id": user.id, "username": user.username, - "usernames": user.usernames, + "usernames": [u.username for u in (user.usernames or [])] if user.usernames else None, "first_name": user.first_name, "last_name": user.last_name, "fake": user.fake, @@ -137,6 +137,104 @@ async def get_names( return result +async def get_user_by_username( + client: TelegramClient, username: str, download_profile_photos: bool = False +) -> dict: + """Take in a username and returns the associated user information if the user exists. + + Uses Telegram's get_entity to look up users by their username. + --- + client, TelegramClient : Telegram client used to generate API call(s) + username, str : Username to search (with or without @ symbol, e.g. 'username' or '@username') + download_profile_photos, bool : Flag for whether to download a profile's associated account photo; defaults to False. + """ + result = {} + # Remove @ symbol if present + clean_username = username.lstrip('@') + logging.info(f"Checking username: @{clean_username} ...") + + try: + # Get entity by username + entity = await client.get_entity(clean_username) + + # Check if it's a User (not a Channel or Chat) + if isinstance(entity, types.User): + result.update( + { + "id": entity.id, + "username": entity.username, + "usernames": [u.username for u in (entity.usernames or [])] if entity.usernames else None, + "first_name": entity.first_name, + "last_name": entity.last_name, + "fake": entity.fake, + "verified": entity.verified, + "premium": entity.premium, + "mutual_contact": entity.mutual_contact, + "bot": entity.bot, + "bot_chat_history": entity.bot_chat_history, + "restricted": entity.restricted, + "restriction_reason": entity.restriction_reason, + "user_was_online": get_human_readable_user_status(entity.status), + "phone": entity.phone, + } + ) + + if download_profile_photos is True: + try: + photo_output_path = Path(f"{entity.id}_{clean_username}_photo.jpeg") + logging.info( + "Attempting to download profile photo for @%s (%s)", + clean_username, + str(entity.id), + ) + photo = await client.download_profile_photo( + entity, file=photo_output_path, download_big=True + ) + if photo is not None: + logging.info("Downloaded photo at '%s'", photo) + else: + logging.info( + "No photo found for @%s (%s)", clean_username, str(entity.id) + ) + except Exception as e: + logging.exception( + "---\nUnable to download profile photo for @%s. Exception provided below.\n---\n%s\n---\n", + clean_username, + str(e), + ) + elif isinstance(entity, types.Channel): + result.update( + { + "error": f"@{clean_username} is a channel or supergroup ('{entity.title}'), not a user account. This tool is for searching user accounts only." + } + ) + elif isinstance(entity, types.Chat): + result.update( + { + "error": f"@{clean_username} is a group chat ('{entity.title}'), not a user account. This tool is for searching user accounts only." + } + ) + else: + result.update( + { + "error": f"@{clean_username} returned an unexpected entity type: {type(entity).__name__}" + } + ) + + except errors.UsernameNotOccupiedError: + result.update({"error": f"Username @{clean_username} does not exist on Telegram."}) + except errors.UsernameInvalidError: + result.update({"error": f"Username @{clean_username} is invalid."}) + except ValueError as e: + result.update({"error": f"Could not find username @{clean_username}: {e}"}) + except Exception as e: + result.update({"error": f"Unexpected error while searching for @{clean_username}: {e}."}) + raise + + logging.info("Done.") + return result + + async def validate_users( client: TelegramClient, phone_numbers: str, download_profile_photos: bool ) -> dict: @@ -157,6 +255,26 @@ async def validate_users( return result +async def validate_usernames( + client: TelegramClient, usernames: str, download_profile_photos: bool +) -> dict: + """ + Take in a string of comma separated usernames and try to get the user information associated with each username. + """ + if not usernames or not len(usernames): + usernames = input("Enter the usernames to check, separated by commas: ") + result = {} + username_list = [re.sub(r"\s+", "", u, flags=re.UNICODE) for u in usernames.split(",")] + try: + for username in username_list: + if username not in result: + result[username] = await get_user_by_username(client, username, download_profile_photos) + except Exception as e: + logging.error(e) + raise + return result + + async def login( api_id: str | None, api_hash: str | None, phone_number: str | None ) -> TelegramClient: @@ -200,6 +318,12 @@ def show_results(output: str, res: dict) -> None: help="List of phone numbers to check, separated by commas", type=str, ) +@click.option( + "--usernames", + "-u", + help="List of usernames to check, separated by commas (e.g. 'username' or '@username')", + type=str, +) @click.option( "--api-id", help="Your Telegram app api_id", @@ -241,6 +365,7 @@ def show_results(output: str, res: dict) -> None: ) def main_entrypoint( phone_numbers: str, + usernames: str, api_id: str, api_hash: str, api_phone_number: str, @@ -248,7 +373,7 @@ def main_entrypoint( download_profile_photos: bool, ) -> None: """ - Check to see if one or more phone numbers belong to a valid Telegram account. + Check to see if one or more phone numbers or usernames belong to a valid Telegram account. \b Prerequisites: @@ -281,6 +406,7 @@ def main_entrypoint( asyncio.run( run_program( phone_numbers, + usernames, api_id, api_hash, api_phone_number, @@ -292,6 +418,7 @@ def main_entrypoint( async def run_program( phone_numbers: str, + usernames: str, api_id: str, api_hash: str, api_phone_number: str, @@ -302,10 +429,34 @@ async def run_program( Get all args passed from Click parser, pass them into the script. """ client = await login(api_id, api_hash, api_phone_number) - res = await validate_users(client, phone_numbers, download_profile_photos) - show_results(output, res) + + results = {} + + # Search by phone numbers if provided + if phone_numbers: + phone_results = await validate_users(client, phone_numbers, download_profile_photos) + results.update(phone_results) + + # Search by usernames if provided + if usernames: + username_results = await validate_usernames(client, usernames, download_profile_photos) + results.update(username_results) + + # If neither provided, prompt for input + if not phone_numbers and not usernames: + choice = input("Search by (p)hone numbers or (u)sernames? [p/u]: ").lower() + if choice == 'u': + usernames = input("Enter the usernames to check, separated by commas: ") + username_results = await validate_usernames(client, usernames, download_profile_photos) + results.update(username_results) + else: + phone_numbers = input("Enter the phone numbers to check, separated by commas: ") + phone_results = await validate_users(client, phone_numbers, download_profile_photos) + results.update(phone_results) + + show_results(output, results) client.disconnect() if __name__ == "__main__": - main_entrypoint() + main_entrypoint() \ No newline at end of file