From 71a77767ed3889e9ddc66fa9350e2dc664127815 Mon Sep 17 00:00:00 2001 From: CacucoH Date: Mon, 16 Feb 2026 21:10:09 +0300 Subject: [PATCH 1/3] Feat: + Fixed file format + Added pipeline + Updated TODO --- .github/workflows/pipe.yml | 69 +++++++ Makefile | 10 + TODO | 9 +- src/classes/channel.py | 33 ++-- src/classes/generic.py | 31 +-- src/classes/group.py | 7 +- src/classes/user.py | 6 +- src/common/common_api_commands.py | 224 ++++++++++++++-------- src/common/local_commands.py | 25 +-- src/scan_modules/channels/channel_scan.py | 82 +++++--- src/scan_modules/groups/group_scan.py | 104 ++++++---- src/visuals.py | 65 +++++-- 12 files changed, 453 insertions(+), 212 deletions(-) create mode 100644 .github/workflows/pipe.yml create mode 100644 Makefile diff --git a/.github/workflows/pipe.yml b/.github/workflows/pipe.yml new file mode 100644 index 0000000..60915a3 --- /dev/null +++ b/.github/workflows/pipe.yml @@ -0,0 +1,69 @@ +name: Based Sigma pipeline +aura + +on: + push: + branches: [dev] # All checks on dev + pull_request: + branches: [dev] # Qual checks on pull + +jobs: + lint: + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install ruff + run: pipx install ruff + + - name: Run ruff check + run: | + ruff check src + ruff format --check src + + semgrep: + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run semgrep + uses: returntocorp/semgrep-action@v1 + with: + config: auto + continue-on-error: false + + quality-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Check dev=True in src/shared.py + run: | + if grep -q "dev=True" src/shared.py; then + echo "❌ dev=True found!" + exit 1 + else + echo "✅ No dev=True found" + fi + + # - name: Install mypy + # run: pipx install mypy + + # - name: Run mypy + # run: | + # if [ "${{ github.event_name }}" = "pull_request" ]; then + # git diff --name-only origin/${{ github.base_ref }} | grep '\.py$' | xargs mypy || mypy src/ + # else + # mypy src/ + # fi \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e25d4d0 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +# Only check style +check: + ruff check src/ + +# Format +format: + ruff format src/ && isort src/ + +dev-check: + @grep -q "dev=True" src/shared.py && (echo "❌ dev=True found!" && exit 1) || echo "✅ OK" \ No newline at end of file diff --git a/TODO b/TODO index 15ee92c..9a78ee2 100644 --- a/TODO +++ b/TODO @@ -1,3 +1,8 @@ Ver 1.0: -+ Сделать функционал связи пользователей и каналов -+ Рекурсивно обнаруживать каналы пользователей в целевом канале \ No newline at end of file ++ Сделать функционал связи пользователей и каналов ✅ ++ Рекурсивно обнаруживать каналы пользователей в целевом канале ✅ + +Ver 2.0: ++ Сделать resume файл ++ Сделать .exe для винды ++ Рефактор кода для уменьшения цикл. сложности (< 10) \ No newline at end of file diff --git a/src/classes/channel.py b/src/classes/channel.py index f11817d..fb440cb 100644 --- a/src/classes/channel.py +++ b/src/classes/channel.py @@ -1,27 +1,28 @@ from src.classes.generic import BasicRecord + class ChannelRecord(BasicRecord): def __init__( - self, - channelId: int, - channelUsername: str, - channelTitle: str, - creatorName: str, - totalParticipants: int = -1, - totalMessages: int = -1, - linkedChat: int | str | None = None - ): + self, + channelId: int, + channelUsername: str, + channelTitle: str, + creatorName: str, + totalParticipants: int = -1, + totalMessages: int = -1, + linkedChat: int | str | None = None, + ): super().__init__( id=channelId, - username=channelUsername, - title=channelTitle, - creatorUsername=creatorName, - totalParticipants=totalParticipants, - totalMessages=totalMessages, + username=channelUsername, + title=channelTitle, + creatorUsername=creatorName, + totalParticipants=totalParticipants, + totalMessages=totalMessages, linkedChat=linkedChat, isChannel=True, - isSupergroup=False + isSupergroup=False, ) - + async def checkAdminPresence(self, userId: int | str) -> bool: return True if userId in self.members else False diff --git a/src/classes/generic.py b/src/classes/generic.py index 22cc6f0..d5e0040 100644 --- a/src/classes/generic.py +++ b/src/classes/generic.py @@ -1,18 +1,19 @@ from src.classes.user import UserRecord + class BasicRecord: def __init__( - self, - id: int, - username: str, - title: str, - creatorUsername: str, - totalParticipants: int = -1, - totalMessages: int = -1, - linkedChat: int | str | None = None, - isChannel: bool = True, - isSupergroup: bool = False - ): + self, + id: int, + username: str, + title: str, + creatorUsername: str, + totalParticipants: int = -1, + totalMessages: int = -1, + linkedChat: int | str | None = None, + isChannel: bool = True, + isSupergroup: bool = False, + ): self.id = id self.title = title self.usernamme = username @@ -34,7 +35,7 @@ def addUser(self, userId: int | str, user) -> None: if not self.getUser(userId): self.members[userId] = user self.membersFound += 1 - + def addAdmin(self, admId, admin) -> None: if not self.getAdmin(admId): self.admins[admId] = admin @@ -43,10 +44,10 @@ def addAdmin(self, admId, admin) -> None: def getUser(self, userId: int | str): return self.members.get(userId) - + def getAdmin(self, userId: int | str): return self.admins.get(userId) - + def addSubChannel(self, userName: str, subchannelRecord): self.subchannels[userName] = subchannelRecord @@ -61,4 +62,4 @@ def __eq__(self, other): return self.id == other.id if isinstance(other, int): return self.id == other - return False \ No newline at end of file + return False diff --git a/src/classes/group.py b/src/classes/group.py index 4c8b84d..b7a71b9 100644 --- a/src/classes/group.py +++ b/src/classes/group.py @@ -1,5 +1,6 @@ from src.classes.generic import BasicRecord + class GroupRecord(BasicRecord): def __init__( self, @@ -22,7 +23,7 @@ def __init__( totalMessages=total_messages, linkedChat=None, # Groups do not have linked chats like channels isChannel=False, - isSupergroup=is_supergroup + isSupergroup=is_supergroup, ) self.description = description @@ -32,7 +33,7 @@ async def add_member(self, user_id: int | str, user_obj) -> None: if user_id not in self.members: self.members[user_id] = user_obj self.members_found += 1 - + @DeprecationWarning def add_admin(self, user_id: int | str, participant_obj) -> None: - self.admins[user_id] = participant_obj \ No newline at end of file + self.admins[user_id] = participant_obj diff --git a/src/classes/user.py b/src/classes/user.py index ba3b56a..8c48716 100644 --- a/src/classes/user.py +++ b/src/classes/user.py @@ -2,7 +2,7 @@ class UserRecord: def __init__(self, user): self.id = user.id self.username = user.username - self.first_name = user.first_name or '' + self.first_name = user.first_name or "" self.full_name = f"{self.first_name} {user.last_name or ''}".strip() self.is_bot = user.bot self.is_verified = user.verified @@ -11,11 +11,11 @@ def __init__(self, user): self.is_scam = user.scam self.is_fake = user.fake self.lang_code = user.lang_code - self.emoji_status = getattr(user.emoji_status, 'document_id', None) + self.emoji_status = getattr(user.emoji_status, "document_id", None) self.phone = user.phone self.adminInChannel = set() self.capturedMessages = {} - + def __repr__(self): return f"" diff --git a/src/common/common_api_commands.py b/src/common/common_api_commands.py index dfa2ee7..ea13cb3 100644 --- a/src/common/common_api_commands.py +++ b/src/common/common_api_commands.py @@ -1,72 +1,101 @@ """ - ## Contains common API commands for Telegram client operations. - These commands are used across different modules to interact with Telegram API. +## Contains common API commands for Telegram client operations. +These commands are used across different modules to interact with Telegram API. """ -import os -import re + import asyncio import logging -from tqdm.asyncio import tqdm +import os +import re from telethon import TelegramClient -from telethon.tl.functions.users import GetFullUserRequest from telethon.tl.functions.messages import ImportChatInviteRequest +from telethon.tl.functions.users import GetFullUserRequest from telethon.tl.patched import Message -from telethon.tl.types import UserFull, Channel, Chat, User, ChannelForbidden +from telethon.tl.types import Channel, ChannelForbidden, Chat, User, UserFull +from tqdm.asyncio import tqdm logger = logging.getLogger(__name__) -MAX_DEPTH = int(os.getenv('MAX_DEPTH', 5)) -FLOOD_WAIT = float(os.getenv('SAFE_FLOOD_TIME', 1)) # Время ожидания между запросами, по умолчанию 0.5 секунды -USER_SEARCH_LIMIT = int(os.getenv('USER_SEARCH_LIMIT', 50)) -API_MESSAGES_PER_REQUEST = int(os.getenv('API_MESSAGES_PER_REQUEST')) -ADMIN_MAX_PROBING = int(os.getenv('ADMIN_MAX_PROBING')) -MAX_PARTICIPANTS_GROUP = int(os.getenv('MAX_PARTICIPANTS_GROUP', 666)) # Максимальное количество участников в группе для обработки -MAX_PARTICIPANTS_CHANNEL = int(os.getenv('MAX_PARTICIPANTS_CHANNEL', 1000)) # Максимальное количество участников для обработки -API_MAX_USERS_REQUEST = int(os.getenv('API_MAX_USERS_REQUEST', 200)) # Максимальное количество пользователей, получаемых за один запрос -MAX_USERS_SCAN_ITERATIONS = int(os.getenv('MAX_USERS_SCAN_ITERATIONS', 5)) +MAX_DEPTH = int(os.getenv("MAX_DEPTH", 5)) +FLOOD_WAIT = float( + os.getenv("SAFE_FLOOD_TIME", 1) +) # Время ожидания между запросами, по умолчанию 0.5 секунды +USER_SEARCH_LIMIT = int(os.getenv("USER_SEARCH_LIMIT", 50)) +API_MESSAGES_PER_REQUEST = int(os.getenv("API_MESSAGES_PER_REQUEST")) +ADMIN_MAX_PROBING = int(os.getenv("ADMIN_MAX_PROBING")) +MAX_PARTICIPANTS_GROUP = int( + os.getenv("MAX_PARTICIPANTS_GROUP", 666) +) # Максимальное количество участников в группе для обработки +MAX_PARTICIPANTS_CHANNEL = int( + os.getenv("MAX_PARTICIPANTS_CHANNEL", 1000) +) # Максимальное количество участников для обработки +API_MAX_USERS_REQUEST = int( + os.getenv("API_MAX_USERS_REQUEST", 200) +) # Максимальное количество пользователей, получаемых за один запрос +MAX_USERS_SCAN_ITERATIONS = int(os.getenv("MAX_USERS_SCAN_ITERATIONS", 5)) from src.classes.channel import ChannelRecord -from src.classes.user import UserRecord from src.classes.group import GroupRecord +from src.classes.user import UserRecord -async def startScanningProcess(client: TelegramClient, chatId: str | int, trackUsers: set[str] = set(), - banned_usernames: set[str] = set()) -> list[ChannelRecord]: +async def startScanningProcess( + client: TelegramClient, + chatId: str | int, + trackUsers: set[str] = set(), + banned_usernames: set[str] = set(), +) -> list[ChannelRecord]: from src.scan_modules.channels.channel_scan import channelScanRecursion from src.scan_modules.groups.group_scan import getChatUsers - + totalChannels: list[ChannelRecord] = [] - + # We've got link try: - chatObj = await client.get_entity(chatId) + chatObj = await client.get_entity(chatId) except Exception as e: logging.error(e) - tqdm.write(f"Cannot get {chatId} entity. Ensure you're provide existing ID and you have joined this chat if it's private") + tqdm.write( + f"Cannot get {chatId} entity. Ensure you're provide existing ID and you have joined this chat if it's private" + ) return if not chatObj: - tqdm.write(f"[!] Bruhhh cannot obtain any info about @{chatId}. Double check ID or username") + tqdm.write( + f"[!] Bruhhh cannot obtain any info about @{chatId}. Double check ID or username" + ) return if isinstance(chatObj, Channel) and not chatObj.megagroup: tqdm.write(f"[i] Scanning channel @{chatObj.username} ({chatObj.id})") - channelInstance, _ = await channelScanRecursion(client, chatObj, trackUsers=trackUsers, banned_usernames=banned_usernames) + channelInstance, _ = await channelScanRecursion( + client, chatObj, trackUsers=trackUsers, banned_usernames=banned_usernames + ) if channelInstance: totalChannels.append(channelInstance) - + # If chat is presented: # Scan chat for channels -> recursively scan all found channels elif isinstance(chatObj, Chat) or (isinstance(chatObj, Channel)): tqdm.write(f"[i] Scanning chat {chatObj.title} ({chatObj.id})") isSupergroup = isinstance(chatObj, Channel) and chatObj.megagroup - groupInstance: GroupRecord = await getChatUsers(client, chatObj, trackUsers=trackUsers, - banned_usernames=banned_usernames, supergroup=isSupergroup) + groupInstance: GroupRecord = await getChatUsers( + client, + chatObj, + trackUsers=trackUsers, + banned_usernames=banned_usernames, + supergroup=isSupergroup, + ) for subchannelId in groupInstance.subchannels.values(): channelObj = await client.get_entity(subchannelId) - channelInstance, isBlocked = await channelScanRecursion(client, channelObj, trackUsers=trackUsers, banned_usernames=banned_usernames) - + channelInstance, isBlocked = await channelScanRecursion( + client, + channelObj, + trackUsers=trackUsers, + banned_usernames=banned_usernames, + ) + if channelInstance: totalChannels.append(channelInstance) @@ -76,34 +105,47 @@ async def startScanningProcess(client: TelegramClient, chatId: str | int, trackU return totalChannels -async def get_channel_from_user(client: TelegramClient, username: str, current_channel_id: int, user: User | None = None) -> int | str | None: +async def get_channel_from_user( + client: TelegramClient, + username: str, + current_channel_id: int, + user: User | None = None, +) -> int | str | None: """ - ## Obtains a channel ID from a user's profile or bio - Returns the channel ID if found, otherwise None + ## Obtains a channel ID from a user's profile or bio + Returns the channel ID if found, otherwise None """ try: if not user: user = await client.get_entity(username) - - await asyncio.sleep(0.05) # Задержка для избежания превышения частоты запросов API + + await asyncio.sleep( + 0.05 + ) # Задержка для избежания превышения частоты запросов API full_user: UserFull = await client(GetFullUserRequest(user)) - - personal_channel_id = getattr(full_user.full_user, 'personal_channel_id', None) - bio = getattr(full_user.full_user, 'about') + + personal_channel_id = getattr(full_user.full_user, "personal_channel_id", None) + bio = getattr(full_user.full_user, "about") if personal_channel_id == current_channel_id: - tqdm.write(f"[i] Пользователь @{username} уже связан с каналом ID: {current_channel_id}") + tqdm.write( + f"[i] Пользователь @{username} уже связан с каналом ID: {current_channel_id}" + ) return None - + # Search in profile if personal_channel_id: - tqdm.write(f"[🎉] У пользователя @{username} прикреплён канал. ID: {personal_channel_id}") + tqdm.write( + f"[🎉] У пользователя @{username} прикреплён канал. ID: {personal_channel_id}" + ) return personal_channel_id # Search in bio elif bio: - channel_in_bio = re.match(r'(https:\/\/)?t\.me\/[a-z0-9]+', bio) + channel_in_bio = re.match(r"(https:\/\/)?t\.me\/[a-z0-9]+", bio) if channel_in_bio: - tqdm.write(f"[🎉] У пользователя @{username} канал в коментах. ID: {channel_in_bio.group(0)}") + tqdm.write( + f"[🎉] У пользователя @{username} канал в коментах. ID: {channel_in_bio.group(0)}" + ) return channel_in_bio.group(0) except Exception as e: tqdm.write(f"[!] Error while trying to get channel from user @{username}: {e}") @@ -112,8 +154,8 @@ async def get_channel_from_user(client: TelegramClient, username: str, current_c async def scanForAdmins(client: TelegramClient, channelId: str | int) -> set[str]: """ - ## Scans the specified channel for admin signatures in messages - Returns a list of admin usernames found in the channel + ## Scans the specified channel for admin signatures in messages + Returns a list of admin usernames found in the channel """ admins = set() message: Message @@ -121,9 +163,13 @@ async def scanForAdmins(client: TelegramClient, channelId: str | int) -> set[str adminFoundMessage = False counter = 1 - maxMessages = API_MESSAGES_PER_REQUEST*ADMIN_MAX_PROBING + maxMessages = API_MESSAGES_PER_REQUEST * ADMIN_MAX_PROBING tqdm.write("[i] Probing channel for admin signatures") - async for message in tqdm(client.iter_messages(channelId, wait_time=FLOOD_WAIT, limit=maxMessages), total=maxMessages, desc="Searching for admins"): + async for message in tqdm( + client.iter_messages(channelId, wait_time=FLOOD_WAIT, limit=maxMessages), + total=maxMessages, + desc="Searching for admins", + ): adminName = message.post_author if adminName: admins.add(adminName) @@ -135,7 +181,7 @@ async def scanForAdmins(client: TelegramClient, channelId: str | int) -> set[str break adminFoundMessage = True tqdm.write("[i] Admin signatures found, continuing probing!!!") - + counter += 1 if admins: @@ -143,32 +189,46 @@ async def scanForAdmins(client: TelegramClient, channelId: str | int) -> set[str return admins -async def getUsersByComments(client: TelegramClient, chatRecord: GroupRecord | ChannelRecord, targetUsers: set[str], banned_usernames: set[str] = [], - totalMessages: int = 0, participantsCount: int = 0) -> list[dict[UserRecord], dict[int, int]]: +async def getUsersByComments( + client: TelegramClient, + chatRecord: GroupRecord | ChannelRecord, + targetUsers: set[str], + banned_usernames: set[str] = [], + totalMessages: int = 0, + participantsCount: int = 0, +) -> list[dict[UserRecord], dict[int, int]]: """ - ### Generic method to get users from comments in a channel or chat - Returns dict of `UserRecord` instances and a dict of subchannels found. + ### Generic method to get users from comments in a channel or chat + Returns dict of `UserRecord` instances and a dict of subchannels found. """ try: message: Message originalPostId = None thisChatId = chatRecord.id - chatToScan = chatRecord.linkedChat if isinstance(chatRecord, ChannelRecord) else thisChatId + chatToScan = ( + chatRecord.linkedChat + if isinstance(chatRecord, ChannelRecord) + else thisChatId + ) async for message in tqdm( - client.iter_messages(chatToScan, wait_time=FLOOD_WAIT, reverse=True), - total=totalMessages, desc="Scanning channel messages"): + client.iter_messages(chatToScan, wait_time=FLOOD_WAIT, reverse=True), + total=totalMessages, + desc="Scanning channel messages", + ): if not message.sender: continue - - sender = message.sender # Optimized + + sender = message.sender # Optimized senderId: int = sender.id try: senderUsername: str = sender.username except Exception as e: # Skip to avoid error if isinstance(sender, ChannelForbidden): - logging.warning(f"[!] Skipping... megagroup? {senderId} {sender.title}") + logging.warning( + f"[!] Skipping... megagroup? {senderId} {sender.title}" + ) continue logging.warning(f"[!] Username not found for {senderId}") @@ -179,25 +239,27 @@ async def getUsersByComments(client: TelegramClient, chatRecord: GroupRecord | C logging.debug(f"[!] User @{senderId} is deleted? Skipping anyway...") continue - if senderUsername in banned_usernames or \ - str(senderId) in banned_usernames: - logging.debug(f"[i] User @{senderUsername} and their (potential) channel is banned from scanning. Skipping...") + if senderUsername in banned_usernames or str(senderId) in banned_usernames: + logging.debug( + f"[i] User @{senderUsername} and their (potential) channel is banned from scanning. Skipping..." + ) continue # If user asked to track some channel subs # TODO: if user is not present comment wouldnt be saved # На самом деле мне слшиком похцй это делать если хотите киньте PullReq :) - if senderUsername in targetUsers or \ - str(senderId) in targetUsers: + if senderUsername in targetUsers or str(senderId) in targetUsers: user: UserRecord = chatRecord.getUser(senderId) if not user: user = UserRecord(sender) chatRecord.addUser(senderId, user) - msgDate = message.date.strftime('%Y-%m-%d %H:%M:%S') - text = message.text or '' + msgDate = message.date.strftime("%Y-%m-%d %H:%M:%S") + text = message.text or "" link = await makeLink(message.id, chatRecord, originalPostId) - user.capturedMessages[f"{msgDate} : {link}"] = f"{text[:100]} {'...' if len(text) > 100 else ''}" + user.capturedMessages[f"{msgDate} : {link}"] = ( + f"{text[:100]} {'...' if len(text) > 100 else ''}" + ) continue @@ -206,8 +268,8 @@ async def getUsersByComments(client: TelegramClient, chatRecord: GroupRecord | C if message.forward: originalPostId = message.forward.channel_post continue - - # Check if user is already present in channel or we deal not with user + + # Check if user is already present in channel or we deal not with user if chatRecord.getUser(senderId) or not isinstance(sender, User): continue @@ -218,14 +280,18 @@ async def getUsersByComments(client: TelegramClient, chatRecord: GroupRecord | C if subChannId: chatRecord.addSubChannel(senderUsername, subChannId) user.adminInChannel.add(subChannId) - + chatRecord.addUser(senderId, user) # elif isinstance(sender, Channel): # Admin found # prefix = "[+] New admin found:" # await channelInstance.addAdmin(sender.) - tqdm.write(f"[+] New user found: {user.full_name} (@{senderUsername or '---'})") - tqdm.write(f"\n[i] Users found: {chatRecord.membersFound}/{participantsCount}\n{'-' * 64}") + tqdm.write( + f"[+] New user found: {user.full_name} (@{senderUsername or '---'})" + ) + tqdm.write( + f"\n[i] Users found: {chatRecord.membersFound}/{participantsCount}\n{'-' * 64}" + ) except Exception as e: tqdm.write(f"Ошибка при получении пользователей из комментариев: {e}") @@ -233,18 +299,22 @@ async def getUsersByComments(client: TelegramClient, chatRecord: GroupRecord | C finally: return chatRecord - -async def makeLink(messageId: int, chat: ChannelRecord | GroupRecord, originalPostId: int | None = None) -> str: + +async def makeLink( + messageId: int, chat: ChannelRecord | GroupRecord, originalPostId: int | None = None +) -> str: """ - ## Generates a link to the public/private channel/group/supergroup. - Returns a string with the link + ## Generates a link to the public/private channel/group/supergroup. + Returns a string with the link """ if chat.isChannel: if not chat.isSupergroup: - return f"https://t.me/{chat.usernamme}/{originalPostId}/?comment={messageId}" + return ( + f"https://t.me/{chat.usernamme}/{originalPostId}/?comment={messageId}" + ) username = chat.usernamme if not username: return f"https://t.me/c/{chat.id}/{messageId}" - return f"https://t.me/{username}/{messageId}" \ No newline at end of file + return f"https://t.me/{username}/{messageId}" diff --git a/src/common/local_commands.py b/src/common/local_commands.py index 4c3e769..d8b9cd9 100644 --- a/src/common/local_commands.py +++ b/src/common/local_commands.py @@ -1,17 +1,18 @@ """ - A helper module for processing events that are not related to communication with Telegram API +A helper module for processing events that are not related to communication with Telegram API """ -import os import logging - +import os from src.classes.user import UserRecord -DIRECTORIES = ['logs', 'session', 'config', 'reports'] +DIRECTORIES = ["logs", "session", "config", "reports"] -async def matchAdminsByNames(channelUsers: dict[int, UserRecord], potentialAdmins: set[str]) -> dict[int, UserRecord]: +async def matchAdminsByNames( + channelUsers: dict[int, UserRecord], potentialAdmins: set[str] +) -> dict[int, UserRecord]: foundAdmins = {} user: UserRecord for adminName in potentialAdmins: @@ -26,25 +27,25 @@ async def matchAdminsByNames(channelUsers: dict[int, UserRecord], potentialAdmin # Too much candidates if matchedCounter > 2: continue - + for adm in tempArray: foundAdmins[adm.id] = adm - + return foundAdmins def _prepareWorkspace(): """ - Prepare workspace for the application. - This function creates necessary directories if not found + Prepare workspace for the application. + This function creates necessary directories if not found """ for directory in DIRECTORIES: - if not os.path.exists(f'./{directory}'): + if not os.path.exists(f"./{directory}"): try: - os.makedirs(f'./{directory}') + os.makedirs(f"./{directory}") except OSError as e: logging.error(f"Error creating directory {directory}: {e}") exit(1) logging.info(f"Created {directory} directory") else: - logging.debug(f"{directory} directory already exists") \ No newline at end of file + logging.debug(f"{directory} directory already exists") diff --git a/src/scan_modules/channels/channel_scan.py b/src/scan_modules/channels/channel_scan.py index fd92e5e..f1def71 100644 --- a/src/scan_modules/channels/channel_scan.py +++ b/src/scan_modules/channels/channel_scan.py @@ -1,24 +1,32 @@ import logging -from tqdm.asyncio import tqdm from telethon import TelegramClient, errors from telethon.tl.functions.channels import GetFullChannelRequest from telethon.tl.patched import Message -from telethon.tl.types import User, ChatFull +from telethon.tl.types import ChatFull, User +from tqdm.asyncio import tqdm from src.classes.channel import ChannelRecord from src.classes.user import UserRecord -from src.common.local_commands import matchAdminsByNames from src.common.common_api_commands import * +from src.common.local_commands import matchAdminsByNames -async def channelScanRecursion(client: TelegramClient, channelObj: Channel, currentDepth: int = 1, channelInstance: ChannelRecord | None = None, - creatorId: int | None = None, trackUsers: set[str] = [], banned_usernames: set[str] = [], isBlocked: bool = False) -> list[ChannelRecord, bool]: +async def channelScanRecursion( + client: TelegramClient, + channelObj: Channel, + currentDepth: int = 1, + channelInstance: ChannelRecord | None = None, + creatorId: int | None = None, + trackUsers: set[str] = [], + banned_usernames: set[str] = [], + isBlocked: bool = False, +) -> list[ChannelRecord, bool]: """ - ## Рекурсивно сканирует подканалы и добавляет их пользователей в основной канал. - ### Returns: - - A Channel instance (ChannelRecord) - - Are you blocked by telegram API (bool) + ## Рекурсивно сканирует подканалы и добавляет их пользователей в основной канал. + ### Returns: + - A Channel instance (ChannelRecord) + - Are you blocked by telegram API (bool) """ channelInstance: ChannelRecord = None try: @@ -28,21 +36,25 @@ async def channelScanRecursion(client: TelegramClient, channelObj: Channel, curr tqdm.write(message) logging.info(message) return None, False - + if not channelInstance: - channelInstance: ChannelRecord = await getUsersFromChannelComments(client, channelObj, trackUsers, banned_usernames) + channelInstance: ChannelRecord = await getUsersFromChannelComments( + client, channelObj, trackUsers, banned_usernames + ) if channelInstance.totalParticipants > MAX_PARTICIPANTS_CHANNEL: message = f"[i] Skipping {channelInstance.title} ({channelInstance.usernamme}). Participants exceed maximum value {channelInstance.totalParticipants} > {MAX_DEPTH}" tqdm.write(message) logging.info(message) return channelInstance - + if creatorId: channelInstance.creatorName = creatorId admins: set[str] = await scanForAdmins(client, channelId) - channelInstance.admins = await matchAdminsByNames(channelInstance.members, admins) + channelInstance.admins = await matchAdminsByNames( + channelInstance.members, admins + ) # На первой итерации необходимо указать админов канала (если найдены) if currentDepth == 1: @@ -53,10 +65,12 @@ async def channelScanRecursion(client: TelegramClient, channelObj: Channel, curr if not channelInstance or not channelInstance.subchannels: tqdm.write(f"[i] No subchannels for @{channelInstance.usernamme} =(((") return channelInstance - + for username, subchannelId in channelInstance.subchannels.items(): subChanObj: Channel = await client.get_entity(subchannelId) - subtree, isBlocked = await channelScanRecursion(client, subChanObj, currentDepth=currentDepth + 1, creatorId=username) + subtree, isBlocked = await channelScanRecursion( + client, subChanObj, currentDepth=currentDepth + 1, creatorId=username + ) if subtree: channelInstance.subchannels[username] = subtree @@ -72,27 +86,43 @@ async def channelScanRecursion(client: TelegramClient, channelObj: Channel, curr return channelInstance, isBlocked -async def getUsersFromChannelComments(client: TelegramClient, channelObj: Channel, targetUsers: set[str], - banned_usernames: set[str] = []) -> list[UserRecord]: +async def getUsersFromChannelComments( + client: TelegramClient, + channelObj: Channel, + targetUsers: set[str], + banned_usernames: set[str] = [], +) -> list[UserRecord]: try: channelInstance = await getChannelInfo(client, channelObj) if not channelInstance.linkedChat: - tqdm.write(f"[!] У канала @{channelInstance.usernamme} нет привязанного чата с комментариями.") + tqdm.write( + f"[!] У канала @{channelInstance.usernamme} нет привязанного чата с комментариями." + ) return channelInstance - channelInstance = await getUsersByComments(client, channelInstance, targetUsers=targetUsers, banned_usernames=banned_usernames, - totalMessages=channelInstance.totalMessages, participantsCount=channelInstance.totalParticipants) + channelInstance = await getUsersByComments( + client, + channelInstance, + targetUsers=targetUsers, + banned_usernames=banned_usernames, + totalMessages=channelInstance.totalMessages, + participantsCount=channelInstance.totalParticipants, + ) except Exception as e: - tqdm.write(f"Ошибка при получении пользователей из комментариев канала @{channelInstance.usernamme}: {e}") + tqdm.write( + f"Ошибка при получении пользователей из комментариев канала @{channelInstance.usernamme}: {e}" + ) logging.error(e) finally: - return channelInstance + return channelInstance async def getChannelInfo(client: TelegramClient, channelObj: Channel) -> ChannelRecord: # Получаем объект канала - tqdm.write(f"--- Gathering info from {channelObj.title} (@{channelObj.username}) ---") + tqdm.write( + f"--- Gathering info from {channelObj.title} (@{channelObj.username}) ---" + ) # Получаем полную информацию о канале (ищем привязанный чат) full_channel: ChatFull = await client(GetFullChannelRequest(channelObj)) @@ -106,10 +136,10 @@ async def getChannelInfo(client: TelegramClient, channelObj: Channel) -> Channel channelId=channelObj.id, channelUsername=channelObj.username, channelTitle=channelObj.title, - creatorName=channelObj.username or 'Unknown', + creatorName=channelObj.username or "Unknown", totalParticipants=participants, totalMessages=approx_total_messages, - linkedChat=linked_chat + linkedChat=linked_chat, ) - return channelInstance \ No newline at end of file + return channelInstance diff --git a/src/scan_modules/groups/group_scan.py b/src/scan_modules/groups/group_scan.py index 4ef558c..f5fbacf 100644 --- a/src/scan_modules/groups/group_scan.py +++ b/src/scan_modules/groups/group_scan.py @@ -1,67 +1,91 @@ import logging -from tqdm.asyncio import tqdm from telethon import TelegramClient -from telethon.tl.functions.channels import GetFullChannelRequest, GetParticipantsRequest -from telethon.tl.types import ChatFull, Chat, ChannelParticipantsAdmins, User +from telethon.tl.functions.channels import (GetFullChannelRequest, + GetParticipantsRequest) +from telethon.tl.types import ChannelParticipantsAdmins, Chat, ChatFull, User +from tqdm.asyncio import tqdm from src.classes.group import GroupRecord from src.classes.user import UserRecord -from src.common.common_api_commands import getUsersByComments, get_channel_from_user -from src.common.common_api_commands import USER_SEARCH_LIMIT, API_MAX_USERS_REQUEST, MAX_USERS_SCAN_ITERATIONS +from src.common.common_api_commands import (API_MAX_USERS_REQUEST, + MAX_USERS_SCAN_ITERATIONS, + USER_SEARCH_LIMIT, + get_channel_from_user, + getUsersByComments) from src.visuals import visualize_group_record -async def getChatUsers(client: TelegramClient, chatObj: Chat, trackUsers: set[str] = [], - banned_usernames: set[str] = [], supergroup = False) -> GroupRecord | None: +async def getChatUsers( + client: TelegramClient, + chatObj: Chat, + trackUsers: set[str] = [], + banned_usernames: set[str] = [], + supergroup=False, +) -> GroupRecord | None: try: groupInstance: GroupRecord = await getGroupInfo(client, chatObj, supergroup) -# if groupInstance.totalMembers > MAX_PARTICIPANTS_GROUP: -# tqdm.write(f"[!] Too many members in @{groupInstance.title} > {MAX_PARTICIPANTS_GROUP}). \ -# You may set up max count in .env file (up to 10000)") -# return groupInstance - + # if groupInstance.totalMembers > MAX_PARTICIPANTS_GROUP: + # tqdm.write(f"[!] Too many members in @{groupInstance.title} > {MAX_PARTICIPANTS_GROUP}). \ + # You may set up max count in .env file (up to 10000)") + # return groupInstance + if not groupInstance.isSupergroup: - groupInstance = await getUsersByComments(client, groupInstance, trackUsers, banned_usernames, participantsCount=groupInstance.totalParticipants) + groupInstance = await getUsersByComments( + client, + groupInstance, + trackUsers, + banned_usernames, + participantsCount=groupInstance.totalParticipants, + ) else: groupInstance = await scanUsersFromSupergroup(client, groupInstance) visualize_group_record(groupInstance) - + except Exception as e: - tqdm.write(f"Ошибка при получении пользователей из комментариев группы @{groupInstance.id}: {e}") + tqdm.write( + f"Ошибка при получении пользователей из комментариев группы @{groupInstance.id}: {e}" + ) logging.error(e) finally: return groupInstance - -async def getGroupInfo(client: TelegramClient, chat: Chat, supergroup: bool) -> list[ChatFull, GroupRecord]: + +async def getGroupInfo( + client: TelegramClient, chat: Chat, supergroup: bool +) -> list[ChatFull, GroupRecord]: """ - ## Obtains information about a group or supergroup. - Returns a `GroupRecord` instance with details about the group. + ## Obtains information about a group or supergroup. + Returns a `GroupRecord` instance with details about the group. """ if not supergroup: groupInstance = GroupRecord( group_id=chat.id, - group_username=getattr(chat, 'username', None), # If private there is no username + group_username=getattr( + chat, "username", None + ), # If private there is no username group_title=chat.title, total_members=chat.participants_count, - is_supergroup=supergroup + is_supergroup=supergroup, ) - + else: full: ChatFull = await client(GetFullChannelRequest(chat)) groupInstance = GroupRecord( group_id=chat.id, - group_username=getattr(chat, 'username', None), + group_username=getattr(chat, "username", None), group_title=chat.title, - creator_id=(full.full_chat.creator_user_id - if hasattr(full.full_chat, 'creator_user_id') else None), + creator_id=( + full.full_chat.creator_user_id + if hasattr(full.full_chat, "creator_user_id") + else None + ), creator_name=None, # можно получить через get_participants(filter=ChannelParticipantsCreator) total_members=full.full_chat.participants_count, total_messages=full.full_chat.read_inbox_max_id or full.full_chat.pts, is_supergroup=supergroup, - description=full.full_chat.about + description=full.full_chat.about, ) return groupInstance @@ -69,8 +93,8 @@ async def getGroupInfo(client: TelegramClient, chat: Chat, supergroup: bool) -> async def scanUsersFromSupergroup(client: TelegramClient, groupInstance: GroupRecord): """ - ## Scans the specified supergroup for users. - Returns a tuple of sets containing users and admins found in the group. + ## Scans the specified supergroup for users. + Returns a tuple of sets containing users and admins found in the group. """ groupId = groupInstance.id sender: User @@ -78,21 +102,25 @@ async def scanUsersFromSupergroup(client: TelegramClient, groupInstance: GroupRe if sender.bot: continue userR = UserRecord(sender) - userChannel = await get_channel_from_user(client, sender.username, groupId, sender) + userChannel = await get_channel_from_user( + client, sender.username, groupId, sender + ) if userChannel: userR.adminInChannel.add(userChannel) groupInstance.addSubChannel(sender.id, userChannel) groupInstance.addUser(sender.id, userR) - - result = await client(GetParticipantsRequest( - channel=groupId, - filter=ChannelParticipantsAdmins(), - offset=0, - limit=API_MAX_USERS_REQUEST*MAX_USERS_SCAN_ITERATIONS, - hash=0 - )) + + result = await client( + GetParticipantsRequest( + channel=groupId, + filter=ChannelParticipantsAdmins(), + offset=0, + limit=API_MAX_USERS_REQUEST * MAX_USERS_SCAN_ITERATIONS, + hash=0, + ) + ) for sender in result.users: groupInstance.addAdmin(sender.id, UserRecord(sender)) tqdm.write(f"[+] Users found: {groupInstance.totalParticipants}") - return groupInstance \ No newline at end of file + return groupInstance diff --git a/src/visuals.py b/src/visuals.py index 0c41566..681cc39 100644 --- a/src/visuals.py +++ b/src/visuals.py @@ -1,16 +1,18 @@ import os + +from rich import box +from rich import print as rprint from rich.console import Console -from rich.table import Table from rich.panel import Panel -from rich.tree import Tree +from rich.table import Table from rich.text import Text -from rich import box +from rich.tree import Tree from telethon.tl.types import User -from rich import print as rprint from src.classes.channel import ChannelRecord from src.classes.group import GroupRecord from src.classes.user import UserRecord + REPORT_DIR = "reports" @@ -23,8 +25,8 @@ def visualize_channel_record(record: ChannelRecord): table.add_row("Channel ID", str(record.id)) table.add_row("Creator", record.creatorName) - table.add_row("Total Participants", '~' + str(record.totalParticipants)) - table.add_row("Total Messages", '~' + str(record.totalMessages)) + table.add_row("Total Participants", "~" + str(record.totalParticipants)) + table.add_row("Total Messages", "~" + str(record.totalMessages)) # table.add_row("members Found", str(record.membersFound)) table.add_row("Admins Found", str(len(record.admins))) table.add_row("Subchannels", str(len(record.subchannels))) @@ -36,17 +38,19 @@ def visualize_channel_record(record: ChannelRecord): def createSubchannelsTree(record: ChannelRecord, root: bool = True) -> Tree: if not isinstance(record, ChannelRecord): return - + prefix = "🌐" if root else "📎" - tree = Tree(f"{prefix} [bold]{record.title}[/] ({record.usernamme}) by [green]@{record.creatorName}[/] ({record.membersFound}/{record.totalParticipants} пользователей)") + tree = Tree( + f"{prefix} [bold]{record.title}[/] ({record.usernamme}) by [green]@{record.creatorName}[/] ({record.membersFound}/{record.totalParticipants} пользователей)" + ) tree = output_user_info(tree, record.members.values(), record.id) - + for _, subchannel in record.subchannels.items(): subtree = createSubchannelsTree(subchannel, root=False) if subtree: tree.add(subtree) - + return tree @@ -63,15 +67,30 @@ def visualize_group_record(group: GroupRecord): group_table.add_row("ID:", str(group.id)) group_table.add_row("Creator:", group.creatorName or "N/A") - group_table.add_row("Total Members:", str(group.totalParticipants if group.totalParticipants != -1 else group.membersFound)) + group_table.add_row( + "Total Members:", + str( + group.totalParticipants + if group.totalParticipants != -1 + else group.membersFound + ), + ) group_table.add_row("Admins Found:", str(len(group.admins))) - group_table.add_row("Messages:", str(group.totalMessages if group.totalMessages != -1 else "N/A")) + group_table.add_row( + "Messages:", str(group.totalMessages if group.totalMessages != -1 else "N/A") + ) group_table.add_row("Supergroup:", "✅" if group.isSupergroup else "❌") if group.description: group_table.add_row("Description:", group.description.strip()) - bTable = Panel(group_table, title=title_text, expand=False, border_style="green", box=box.ROUNDED) + bTable = Panel( + group_table, + title=title_text, + expand=False, + border_style="green", + box=box.ROUNDED, + ) console.print(bTable) tree = Tree(f"👥 Users") @@ -96,19 +115,25 @@ def visualize_subchannels_tree(record: ChannelRecord): def writeOutputToFile(filename: str, data) -> bool: filepath = os.path.join(REPORT_DIR, filename) - with open(filepath, 'a') as targetFile: + with open(filepath, "a") as targetFile: rprint(data, file=targetFile) def output_user_info(tree: Tree, users: set[UserRecord], currentChatId: int) -> Tree: user: UserRecord for user in users: - phoneNum = ' | [bold red]' + user.phone + '[/]' if user.phone else '' - admin = ' | [bold red]admin[/]' if currentChatId in user.adminInChannel else '' - hasChannel = '' + phoneNum = " | [bold red]" + user.phone + "[/]" if user.phone else "" + admin = " | [bold red]admin[/]" if currentChatId in user.adminInChannel else "" + hasChannel = "" if not admin: - hasChannel = f' | [green]adm in {len(user.adminInChannel)} chat(s)[/]' if user.adminInChannel else '' - user_branch = tree.add(f"👤 {user.id} | @{user.username} | {user.full_name}{admin}{phoneNum}{hasChannel}") + hasChannel = ( + f" | [green]adm in {len(user.adminInChannel)} chat(s)[/]" + if user.adminInChannel + else "" + ) + user_branch = tree.add( + f"👤 {user.id} | @{user.username} | {user.full_name}{admin}{phoneNum}{hasChannel}" + ) for link, comment in user.capturedMessages.items(): user_branch.add(f"💬 [blue]{link}[/] | {comment}") - return tree \ No newline at end of file + return tree From 305a68f7281c206f436e7645f5f77b04b41e5680 Mon Sep 17 00:00:00 2001 From: CacucoH Date: Mon, 16 Feb 2026 21:32:00 +0300 Subject: [PATCH 2/3] Now project is python package --- .gitignore | 3 +- Makefile | 2 +- main.py | 79 --- poetry.lock | 580 ++++++++++++++++++++++ pyproject.toml | 40 ++ src/classes/channel.py | 2 +- src/classes/generic.py | 2 +- src/classes/group.py | 2 +- src/common/common_api_commands.py | 19 +- src/common/local_commands.py | 2 +- src/scan_modules/channels/channel_scan.py | 21 +- src/scan_modules/groups/group_scan.py | 8 +- src/telestalker/__init__.py | 2 + src/telestalker/main.py | 98 ++++ src/{ => visuals}/visuals.py | 9 +- 15 files changed, 757 insertions(+), 112 deletions(-) delete mode 100644 main.py create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 src/telestalker/__init__.py create mode 100644 src/telestalker/main.py rename src/{ => visuals}/visuals.py (95%) diff --git a/.gitignore b/.gitignore index 1517f3c..3c17445 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ research session/ **__pycache__** reports -**/.env \ No newline at end of file +**/.env +dist/ \ No newline at end of file diff --git a/Makefile b/Makefile index e25d4d0..970650d 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ check: # Format format: - ruff format src/ && isort src/ + ruff format src/ && isort src/ && ruff check --fix dev-check: @grep -q "dev=True" src/shared.py && (echo "❌ dev=True found!" && exit 1) || echo "✅ OK" \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index d4c1e2c..0000000 --- a/main.py +++ /dev/null @@ -1,79 +0,0 @@ -import os -import logging -import uvloop -import argparse - -from dotenv import load_dotenv -from telethon import TelegramClient -from telethon.tl.functions.users import GetFullUserRequest -from telethon.sessions import StringSession - -# Загрузка переменных окружения -load_dotenv('./config/.env') - -from src.common import common_api_commands -from src import visuals - -# Настройка логов -logging.basicConfig( - level=logging.DEBUG, - format="[%(levelname)s] - %(asctime)s - %(message)s", - datefmt="%Y/%m/%d %H:%M:%S", - filename=f"./logs/log.log", - filemode="w" -) - -# Оптимизация asyncio -uvloop.install() - -api_id = int(os.getenv('api_id')) -api_hash = os.getenv('api_hash') -session_name = os.getenv('name') - -client = TelegramClient('./session/' + session_name, api_id, api_hash) - - -def defineArgs() -> argparse.Namespace: - parser = argparse.ArgumentParser( - prog='teleStalker', - description='Searches for users in channels and their subchannels recursively. Makes OSINT process much easier and saves your time', - epilog='') - parser.add_argument('-c', '--chat', required=True, help="Specify target chat. It may be group or channel, provide ID/Invite link/Username (without \"@\" symbol)") - parser.add_argument('-u', '--users', help="If you want, you may specify username, usernames or user IDs set to search for comments (space separated, w/o \"@\" symbol). Would not work with supergroups", nargs="+") - parser.add_argument('-r', '--recursion-depth', help="Specify how large our recursion tree would be. Optimal values are 2-3. By default scans only given channel") - parser.add_argument('-e', '--exclude', help="Exlude user from scanning by their USERNAME. You may specify multiple usernames to exclude (space separated, w/o \"@\" symbol). Would not work with supergroups", nargs="+") - args = parser.parse_args() - - return args - -async def main(): - args = defineArgs() - recursionDepth = args.recursion_depth - if recursionDepth: - os.environ['MAX_DEPTH'] = recursionDepth - - users = set() - exclude = set() - - users = [] - if args.users: - users = set(args.users) - - if args.exclude: - exclude = set(args.exclude) - - async with client: - print(f"> Started TeleSlaker") - allChannels = await common_api_commands.startScanningProcess(client, args.chat, trackUsers=users, banned_usernames=exclude) - - if not allChannels: - print(f"[!] No channels found or scanned") - return - - for channel in allChannels: - visuals.visualize_channel_record(channel) - visuals.visualize_subchannels_tree(channel) -try: - client.loop.run_until_complete(main()) -except KeyboardInterrupt: - print("[!] Interrupted by user") \ No newline at end of file diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..5e90fd2 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,580 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "platform_system == \"Windows\""} + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "librt" +version = "0.8.0" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:db63cf3586a24241e89ca1ce0b56baaec9d371a328bd186c529b27c914c9a1ef"}, + {file = "librt-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ba9d9e60651615bc614be5e21a82cdb7b1769a029369cf4b4d861e4f19686fb6"}, + {file = "librt-0.8.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb4b3ad543084ed79f186741470b251b9d269cd8b03556f15a8d1a99a64b7de5"}, + {file = "librt-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d2720335020219197380ccfa5c895f079ac364b4c429e96952cd6509934d8eb"}, + {file = "librt-0.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726305d3e53419d27fc8cdfcd3f9571f0ceae22fa6b5ea1b3662c2e538f833e"}, + {file = "librt-0.8.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3d107f603b5ee7a79b6aa6f166551b99b32fb4a5303c4dfcb4222fc6a0335e"}, + {file = "librt-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41064a0c07b4cc7a81355ccc305cb097d6027002209ffca51306e65ee8293630"}, + {file = "librt-0.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c6e4c10761ddbc0d67d2f6e2753daf99908db85d8b901729bf2bf5eaa60e0567"}, + {file = "librt-0.8.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ba581acad5ac8f33e2ff1746e8a57e001b47c6721873121bf8bbcf7ba8bd3aa4"}, + {file = "librt-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bdab762e2c0b48bab76f1a08acb3f4c77afd2123bedac59446aeaaeed3d086cf"}, + {file = "librt-0.8.0-cp310-cp310-win32.whl", hash = "sha256:6a3146c63220d814c4a2c7d6a1eacc8d5c14aed0ff85115c1dfea868080cd18f"}, + {file = "librt-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:bbebd2bba5c6ae02907df49150e55870fdd7440d727b6192c46b6f754723dde9"}, + {file = "librt-0.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ce33a9778e294507f3a0e3468eccb6a698b5166df7db85661543eca1cfc5369"}, + {file = "librt-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8070aa3368559de81061ef752770d03ca1f5fc9467d4d512d405bd0483bfffe6"}, + {file = "librt-0.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:20f73d4fecba969efc15cdefd030e382502d56bb6f1fc66b580cce582836c9fa"}, + {file = "librt-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a512c88900bdb1d448882f5623a0b1ad27ba81a9bd75dacfe17080b72272ca1f"}, + {file = "librt-0.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:015e2dde6e096d27c10238bf9f6492ba6c65822dfb69d2bf74c41a8e88b7ddef"}, + {file = "librt-0.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c25a131013eadd3c600686a0c0333eb2896483cbc7f65baa6a7ee761017aef9"}, + {file = "librt-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:21b14464bee0b604d80a638cf1ee3148d84ca4cc163dcdcecb46060c1b3605e4"}, + {file = "librt-0.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:05a3dd3f116747f7e1a2b475ccdc6fb637fd4987126d109e03013a79d40bf9e6"}, + {file = "librt-0.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fa37f99bff354ff191c6bcdffbc9d7cdd4fc37faccfc9be0ef3a4fd5613977da"}, + {file = "librt-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1566dbb9d1eb0987264c9b9460d212e809ba908d2f4a3999383a84d765f2f3f1"}, + {file = "librt-0.8.0-cp311-cp311-win32.whl", hash = "sha256:70defb797c4d5402166787a6b3c66dfb3fa7f93d118c0509ffafa35a392f4258"}, + {file = "librt-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:db953b675079884ffda33d1dca7189fb961b6d372153750beb81880384300817"}, + {file = "librt-0.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:75d1a8cab20b2043f03f7aab730551e9e440adc034d776f15f6f8d582b0a5ad4"}, + {file = "librt-0.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:17269dd2745dbe8e42475acb28e419ad92dfa38214224b1b01020b8cac70b645"}, + {file = "librt-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f4617cef654fca552f00ce5ffdf4f4b68770f18950e4246ce94629b789b92467"}, + {file = "librt-0.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5cb11061a736a9db45e3c1293cfcb1e3caf205912dfa085734ba750f2197ff9a"}, + {file = "librt-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4bb00bd71b448f16749909b08a0ff16f58b079e2261c2e1000f2bbb2a4f0a45"}, + {file = "librt-0.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95a719a049f0eefaf1952673223cf00d442952273cbd20cf2ed7ec423a0ef58d"}, + {file = "librt-0.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bd32add59b58fba3439d48d6f36ac695830388e3da3e92e4fc26d2d02670d19c"}, + {file = "librt-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f764b2424cb04524ff7a486b9c391e93f93dc1bd8305b2136d25e582e99aa2f"}, + {file = "librt-0.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f04ca50e847abc486fa8f4107250566441e693779a5374ba211e96e238f298b9"}, + {file = "librt-0.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9ab3a3475a55b89b87ffd7e6665838e8458e0b596c22e0177e0f961434ec474a"}, + {file = "librt-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e36a8da17134ffc29373775d88c04832f9ecfab1880470661813e6c7991ef79"}, + {file = "librt-0.8.0-cp312-cp312-win32.whl", hash = "sha256:4eb5e06ebcc668677ed6389164f52f13f71737fc8be471101fa8b4ce77baeb0c"}, + {file = "librt-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:0a33335eb59921e77c9acc05d0e654e4e32e45b014a4d61517897c11591094f8"}, + {file = "librt-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:24a01c13a2a9bdad20997a4443ebe6e329df063d1978bbe2ebbf637878a46d1e"}, + {file = "librt-0.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7f820210e21e3a8bf8fde2ae3c3d10106d4de9ead28cbfdf6d0f0f41f5b12fa1"}, + {file = "librt-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4831c44b8919e75ca0dfb52052897c1ef59fdae19d3589893fbd068f1e41afbf"}, + {file = "librt-0.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:88c6e75540f1f10f5e0fc5e87b4b6c290f0e90d1db8c6734f670840494764af8"}, + {file = "librt-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9646178cd794704d722306c2c920c221abbf080fede3ba539d5afdec16c46dad"}, + {file = "librt-0.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e1af31a710e17891d9adf0dbd9a5fcd94901a3922a96499abdbf7ce658f4e01"}, + {file = "librt-0.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:507e94f4bec00b2f590fbe55f48cd518a208e2474a3b90a60aa8f29136ddbada"}, + {file = "librt-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f1178e0de0c271231a660fbef9be6acdfa1d596803464706862bef6644cc1cae"}, + {file = "librt-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:71fc517efc14f75c2f74b1f0a5d5eb4a8e06aa135c34d18eaf3522f4a53cd62d"}, + {file = "librt-0.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0583aef7e9a720dd40f26a2ad5a1bf2ccbb90059dac2b32ac516df232c701db3"}, + {file = "librt-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d0f76fc73480d42285c609c0ea74d79856c160fa828ff9aceab574ea4ecfd7b"}, + {file = "librt-0.8.0-cp313-cp313-win32.whl", hash = "sha256:e79dbc8f57de360f0ed987dc7de7be814b4803ef0e8fc6d3ff86e16798c99935"}, + {file = "librt-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:25b3e667cbfc9000c4740b282df599ebd91dbdcc1aa6785050e4c1d6be5329ab"}, + {file = "librt-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:e9a3a38eb4134ad33122a6d575e6324831f930a771d951a15ce232e0237412c2"}, + {file = "librt-0.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:421765e8c6b18e64d21c8ead315708a56fc24f44075059702e421d164575fdda"}, + {file = "librt-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:48f84830a8f8ad7918afd743fd7c4eb558728bceab7b0e38fd5a5cf78206a556"}, + {file = "librt-0.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f09d4884f882baa39a7e36bbf3eae124c4ca2a223efb91e567381d1c55c6b06"}, + {file = "librt-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:693697133c3b32aa9b27f040e3691be210e9ac4d905061859a9ed519b1d5a376"}, + {file = "librt-0.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5512aae4648152abaf4d48b59890503fcbe86e85abc12fb9b096fe948bdd816"}, + {file = "librt-0.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:995d24caa6bbb34bcdd4a41df98ac6d1af637cfa8975cb0790e47d6623e70e3e"}, + {file = "librt-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b9aef96d7593584e31ef6ac1eb9775355b0099fee7651fae3a15bc8657b67b52"}, + {file = "librt-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4f6e975377fbc4c9567cb33ea9ab826031b6c7ec0515bfae66a4fb110d40d6da"}, + {file = "librt-0.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:daae5e955764be8fd70a93e9e5133c75297f8bce1e802e1d3683b98f77e1c5ab"}, + {file = "librt-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7bd68cebf3131bb920d5984f75fe302d758db33264e44b45ad139385662d7bc3"}, + {file = "librt-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1e6811cac1dcb27ca4c74e0ca4a5917a8e06db0d8408d30daee3a41724bfde7a"}, + {file = "librt-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:178707cda89d910c3b28bf5aa5f69d3d4734e0f6ae102f753ad79edef83a83c7"}, + {file = "librt-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3e8b77b5f54d0937b26512774916041756c9eb3e66f1031971e626eea49d0bf4"}, + {file = "librt-0.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:789911e8fa40a2e82f41120c936b1965f3213c67f5a483fc5a41f5839a05dcbb"}, + {file = "librt-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2b37437e7e4ef5e15a297b36ba9e577f73e29564131d86dd75875705e97402b5"}, + {file = "librt-0.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:671a6152edf3b924d98a5ed5e6982ec9cb30894085482acadce0975f031d4c5c"}, + {file = "librt-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8992ca186a1678107b0af3d0c9303d8c7305981b9914989b9788319ed4d89546"}, + {file = "librt-0.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:001e5330093d887b8b9165823eca6c5c4db183fe4edea4fdc0680bbac5f46944"}, + {file = "librt-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d920789eca7ef71df7f31fd547ec0d3002e04d77f30ba6881e08a630e7b2c30e"}, + {file = "librt-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:82fb4602d1b3e303a58bfe6165992b5a78d823ec646445356c332cd5f5bbaa61"}, + {file = "librt-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4d3e38797eb482485b486898f89415a6ab163bc291476bd95712e42cf4383c05"}, + {file = "librt-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a905091a13e0884701226860836d0386b88c72ce5c2fdfba6618e14c72be9f25"}, + {file = "librt-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:375eda7acfce1f15f5ed56cfc960669eefa1ec8732e3e9087c3c4c3f2066759c"}, + {file = "librt-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:2ccdd20d9a72c562ffb73098ac411de351b53a6fbb3390903b2d33078ef90447"}, + {file = "librt-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:25e82d920d4d62ad741592fcf8d0f3bda0e3fc388a184cb7d2f566c681c5f7b9"}, + {file = "librt-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:92249938ab744a5890580d3cb2b22042f0dce71cdaa7c1369823df62bedf7cbc"}, + {file = "librt-0.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4b705f85311ee76acec5ee70806990a51f0deb519ea0c29c1d1652d79127604d"}, + {file = "librt-0.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7ce0a8cb67e702dcb06342b2aaaa3da9fb0ddc670417879adfa088b44cf7b3b6"}, + {file = "librt-0.8.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:aaadec87f45a3612b6818d1db5fbfe93630669b7ee5d6bdb6427ae08a1aa2141"}, + {file = "librt-0.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56901f1eec031396f230db71c59a01d450715cbbef9856bf636726994331195d"}, + {file = "librt-0.8.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b055bb3abaf69abed25743d8fc1ab691e4f51a912ee0a6f9a6c84f4bbddb283d"}, + {file = "librt-0.8.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ef3bd856373cf8e7382402731f43bfe978a8613b4039e49e166e1e0dc590216"}, + {file = "librt-0.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2e0ffe88ebb5962f8fb0ddcbaaff30f1ea06a79501069310e1e030eafb1ad787"}, + {file = "librt-0.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:82e61cd1c563745ad495387c3b65806bfd453badb4adbc019df3389dddee1bf6"}, + {file = "librt-0.8.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:667e2513cf69bfd1e1ed9a00d6c736d5108714ec071192afb737987955888a25"}, + {file = "librt-0.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6b6caff69e25d80c269b1952be8493b4d94ef745f438fa619d7931066bdd26de"}, + {file = "librt-0.8.0-cp39-cp39-win32.whl", hash = "sha256:02a9fe85410cc9bef045e7cb7fd26fdde6669e6d173f99df659aa7f6335961e9"}, + {file = "librt-0.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:de076eaba208d16efb5962f99539867f8e2c73480988cb513fcf1b5dbb0c9dcf"}, + {file = "librt-0.8.0.tar.gz", hash = "sha256:cb74cdcbc0103fc988e04e5c58b0b31e8e5dd2babb9182b6f9490488eb36324b"}, +] + +[[package]] +name = "mando" +version = "0.7.1" +description = "Create Python CLI apps with little to no effort at all!" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a"}, + {file = "mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500"}, +] + +[package.dependencies] +six = "*" + +[package.extras] +restructuredtext = ["rst2ansi"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mypy" +version = "1.19.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, + {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"}, + {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"}, + {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"}, + {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"}, + {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"}, + {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"}, + {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"}, + {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"}, + {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"}, + {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"}, + {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"}, + {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"}, + {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"}, + {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"}, + {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"}, +] + +[package.dependencies] +librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "packaging" +version = "26.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, + {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] +tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pyaes" +version = "1.6.1" +description = "Pure-Python Implementation of the AES block-cipher and common modes of operation" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f"}, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pygments" +version = "2.19.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, + {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d"}, + {file = "python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "radon" +version = "6.0.1" +description = "Code Metrics in Python" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859"}, + {file = "radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5"}, +] + +[package.dependencies] +colorama = {version = ">=0.4.1", markers = "python_version > \"3.4\""} +mando = ">=0.6,<0.8" + +[package.extras] +toml = ["tomli (>=2.0.1)"] + +[[package]] +name = "rich" +version = "14.0.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, + {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "rsa" +version = "4.9.1" +description = "Pure-Python RSA implementation" +optional = false +python-versions = "<4,>=3.6" +groups = ["main"] +files = [ + {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, + {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "ruff" +version = "0.6.9" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd"}, + {file = "ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec"}, + {file = "ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039"}, + {file = "ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d"}, + {file = "ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117"}, + {file = "ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93"}, + {file = "ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2"}, +] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "telethon" +version = "1.40.0" +description = "Full-featured Telegram client library for Python 3" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "Telethon-1.40.0-py3-none-any.whl", hash = "sha256:146fd4cb2a7afa66bc67f9c2167756096a37b930f65711a3e7399ec9874dcfa7"}, + {file = "telethon-1.40.0-py3-none-any.whl", hash = "sha256:1aebaca04fd8410968816645bdbcc0baeff55429b6d6bec37e647417bb8e8a2c"}, + {file = "telethon-1.40.0.tar.gz", hash = "sha256:40e83326877a2e68b754d4b6d0d1ca5ac924110045b039e02660f2d67add97db"}, +] + +[package.dependencies] +pyaes = "*" +rsa = "*" + +[package.extras] +cryptg = ["cryptg"] + +[[package]] +name = "tqdm" +version = "4.67.1" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, + {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"}, + {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"}, + {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26"}, + {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb"}, + {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f"}, + {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c"}, + {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8"}, + {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0"}, + {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e"}, + {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb"}, + {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6"}, + {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d"}, + {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c"}, + {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2"}, + {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d"}, + {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc"}, + {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb"}, + {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f"}, + {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281"}, + {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af"}, + {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6"}, + {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816"}, + {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc"}, + {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553"}, + {file = "uvloop-0.21.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:17df489689befc72c39a08359efac29bbee8eee5209650d4b9f34df73d22e414"}, + {file = "uvloop-0.21.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc09f0ff191e61c2d592a752423c767b4ebb2986daa9ed62908e2b1b9a9ae206"}, + {file = "uvloop-0.21.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0ce1b49560b1d2d8a2977e3ba4afb2414fb46b86a1b64056bc4ab929efdafbe"}, + {file = "uvloop-0.21.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e678ad6fe52af2c58d2ae3c73dc85524ba8abe637f134bf3564ed07f555c5e79"}, + {file = "uvloop-0.21.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:460def4412e473896ef179a1671b40c039c7012184b627898eea5072ef6f017a"}, + {file = "uvloop-0.21.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:10da8046cc4a8f12c91a1c39d1dd1585c41162a15caaef165c2174db9ef18bdc"}, + {file = "uvloop-0.21.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c097078b8031190c934ed0ebfee8cc5f9ba9642e6eb88322b9958b649750f72b"}, + {file = "uvloop-0.21.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:46923b0b5ee7fc0020bef24afe7836cb068f5050ca04caf6b487c513dc1a20b2"}, + {file = "uvloop-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53e420a3afe22cdcf2a0f4846e377d16e718bc70103d7088a4f7623567ba5fb0"}, + {file = "uvloop-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88cb67cdbc0e483da00af0b2c3cdad4b7c61ceb1ee0f33fe00e09c81e3a6cb75"}, + {file = "uvloop-0.21.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:221f4f2a1f46032b403bf3be628011caf75428ee3cc204a22addf96f586b19fd"}, + {file = "uvloop-0.21.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d1f581393673ce119355d56da84fe1dd9d2bb8b3d13ce792524e1607139feff"}, + {file = "uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3"}, +] + +[package.extras] +dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["aiohttp (>=3.10.5)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.12" +content-hash = "18eac7c8307d4d3746a9924343ae751c5fe34e6a70c231d92755f91465777478" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7978be5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[tool.poetry] +name = "TeleStalker" +version = "0.1.0" +description = "You want to gather info about people and their relations with others, but everytime this process requires damn big amount of time? Well, we're in the same boat. That is why I tried to (at least somehow) optimize and automatize this annoying process. Hope that helps =P" +authors = ["xpyhgejlb@gmail.com"] +license = "MIT" +readme = "README.md" +packages = [{include = "telestalker", from = "src"}] # ВАЖНО! + +[tool.poetry.dependencies] +python = "^3.12" +python-dotenv = "1.1.0" +markdown-it-py = "3.0.0" +mdurl = "0.1.2" +pyaes = "1.6.1" +pyasn1 = "0.6.1" +Pygments = "2.19.1" +rich = "14.0.0" +rsa = "4.9.1" +Telethon = "1.40.0" +tqdm = "4.67.1" +uvloop = "0.21.0" + +[tool.poetry.group.dev.dependencies] +ruff = ">=0.6.9" +mypy = ">=1.11.0" +pytest = ">=8.3.0" +radon = ">=6.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "B", "C4", "C9", "I", "UP"] +ignore = ["E501"] \ No newline at end of file diff --git a/src/classes/channel.py b/src/classes/channel.py index fb440cb..6ff72d5 100644 --- a/src/classes/channel.py +++ b/src/classes/channel.py @@ -1,4 +1,4 @@ -from src.classes.generic import BasicRecord +from classes.generic import BasicRecord class ChannelRecord(BasicRecord): diff --git a/src/classes/generic.py b/src/classes/generic.py index d5e0040..5e4681c 100644 --- a/src/classes/generic.py +++ b/src/classes/generic.py @@ -1,4 +1,4 @@ -from src.classes.user import UserRecord +from classes.user import UserRecord class BasicRecord: diff --git a/src/classes/group.py b/src/classes/group.py index b7a71b9..aff17b7 100644 --- a/src/classes/group.py +++ b/src/classes/group.py @@ -1,4 +1,4 @@ -from src.classes.generic import BasicRecord +from classes.generic import BasicRecord class GroupRecord(BasicRecord): diff --git a/src/common/common_api_commands.py b/src/common/common_api_commands.py index ea13cb3..e9f9827 100644 --- a/src/common/common_api_commands.py +++ b/src/common/common_api_commands.py @@ -8,13 +8,20 @@ import os import re +from dotenv import load_dotenv from telethon import TelegramClient -from telethon.tl.functions.messages import ImportChatInviteRequest from telethon.tl.functions.users import GetFullUserRequest from telethon.tl.patched import Message from telethon.tl.types import Channel, ChannelForbidden, Chat, User, UserFull from tqdm.asyncio import tqdm +from classes.channel import ChannelRecord +from classes.group import GroupRecord +from classes.user import UserRecord + +# Загрузка переменных окружения +load_dotenv("./config/.env") + logger = logging.getLogger(__name__) MAX_DEPTH = int(os.getenv("MAX_DEPTH", 5)) FLOOD_WAIT = float( @@ -34,10 +41,6 @@ ) # Максимальное количество пользователей, получаемых за один запрос MAX_USERS_SCAN_ITERATIONS = int(os.getenv("MAX_USERS_SCAN_ITERATIONS", 5)) -from src.classes.channel import ChannelRecord -from src.classes.group import GroupRecord -from src.classes.user import UserRecord - async def startScanningProcess( client: TelegramClient, @@ -45,8 +48,8 @@ async def startScanningProcess( trackUsers: set[str] = set(), banned_usernames: set[str] = set(), ) -> list[ChannelRecord]: - from src.scan_modules.channels.channel_scan import channelScanRecursion - from src.scan_modules.groups.group_scan import getChatUsers + from scan_modules.channels.channel_scan import channelScanRecursion + from scan_modules.groups.group_scan import getChatUsers totalChannels: list[ChannelRecord] = [] @@ -223,7 +226,7 @@ async def getUsersByComments( senderId: int = sender.id try: senderUsername: str = sender.username - except Exception as e: + except Exception: # Skip to avoid error if isinstance(sender, ChannelForbidden): logging.warning( diff --git a/src/common/local_commands.py b/src/common/local_commands.py index d8b9cd9..d78b1e6 100644 --- a/src/common/local_commands.py +++ b/src/common/local_commands.py @@ -5,7 +5,7 @@ import logging import os -from src.classes.user import UserRecord +from classes.user import UserRecord DIRECTORIES = ["logs", "session", "config", "reports"] diff --git a/src/scan_modules/channels/channel_scan.py b/src/scan_modules/channels/channel_scan.py index f1def71..9fa2f51 100644 --- a/src/scan_modules/channels/channel_scan.py +++ b/src/scan_modules/channels/channel_scan.py @@ -1,15 +1,16 @@ import logging -from telethon import TelegramClient, errors +from telethon import TelegramClient from telethon.tl.functions.channels import GetFullChannelRequest -from telethon.tl.patched import Message -from telethon.tl.types import ChatFull, User +from telethon.tl.types import ChatFull from tqdm.asyncio import tqdm -from src.classes.channel import ChannelRecord -from src.classes.user import UserRecord -from src.common.common_api_commands import * -from src.common.local_commands import matchAdminsByNames +from classes.channel import ChannelRecord +from classes.user import UserRecord +from common.common_api_commands import (MAX_DEPTH, + MAX_PARTICIPANTS_CHANNEL, Channel, + getUsersByComments, scanForAdmins) +from common.local_commands import matchAdminsByNames async def channelScanRecursion( @@ -79,9 +80,9 @@ async def channelScanRecursion( break except KeyboardInterrupt: - tqdm.write("[!] Прерывание пользователем") - except: - tqdm.write("[!] API запросы на сегодня исчерпаны") + tqdm.write("[!] Caught CTRL+C") + except Exception: + tqdm.write("[!] API requests exhausted") return channelInstance, True return channelInstance, isBlocked diff --git a/src/scan_modules/groups/group_scan.py b/src/scan_modules/groups/group_scan.py index f5fbacf..b7111a8 100644 --- a/src/scan_modules/groups/group_scan.py +++ b/src/scan_modules/groups/group_scan.py @@ -6,14 +6,14 @@ from telethon.tl.types import ChannelParticipantsAdmins, Chat, ChatFull, User from tqdm.asyncio import tqdm -from src.classes.group import GroupRecord -from src.classes.user import UserRecord -from src.common.common_api_commands import (API_MAX_USERS_REQUEST, +from classes.group import GroupRecord +from classes.user import UserRecord +from common.common_api_commands import (API_MAX_USERS_REQUEST, MAX_USERS_SCAN_ITERATIONS, USER_SEARCH_LIMIT, get_channel_from_user, getUsersByComments) -from src.visuals import visualize_group_record +from visuals.visuals import visualize_group_record async def getChatUsers( diff --git a/src/telestalker/__init__.py b/src/telestalker/__init__.py new file mode 100644 index 0000000..111c5b6 --- /dev/null +++ b/src/telestalker/__init__.py @@ -0,0 +1,2 @@ +"""TeleStalker - Telegram stalker bot""" +__version__ = "0.1.0" \ No newline at end of file diff --git a/src/telestalker/main.py b/src/telestalker/main.py new file mode 100644 index 0000000..26ce29d --- /dev/null +++ b/src/telestalker/main.py @@ -0,0 +1,98 @@ +import os +import logging +import uvloop +import argparse + +from telethon import TelegramClient + +from common import common_api_commands +from visuals import visuals + +# Настройка логов +logging.basicConfig( + level=logging.DEBUG, + format="[%(levelname)s] - %(asctime)s - %(message)s", + datefmt="%Y/%m/%d %H:%M:%S", + filename="./logs/log.log", + filemode="w", +) + +# Оптимизация asyncio +uvloop.install() + +api_id = int(os.getenv("api_id")) +api_hash = os.getenv("api_hash") +session_name = os.getenv("name") + +client = TelegramClient("./session/" + session_name, api_id, api_hash) + + +def defineArgs() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="teleStalker", + description="Searches for users in channels and their subchannels recursively. Makes OSINT process much easier and saves your time", + epilog="", + ) + parser.add_argument( + "-c", + "--chat", + required=True, + help='Specify target chat. It may be group or channel, provide ID/Invite link/Username (without "@" symbol)', + ) + parser.add_argument( + "-u", + "--users", + help='If you want, you may specify username, usernames or user IDs set to search for comments (space separated, w/o "@" symbol). Would not work with supergroups', + nargs="+", + ) + parser.add_argument( + "-r", + "--recursion-depth", + help="Specify how large our recursion tree would be. Optimal values are 2-3. By default scans only given channel", + ) + parser.add_argument( + "-e", + "--exclude", + help='Exlude user from scanning by their USERNAME. You may specify multiple usernames to exclude (space separated, w/o "@" symbol). Would not work with supergroups', + nargs="+", + ) + args = parser.parse_args() + + return args + + +async def main(): + args = defineArgs() + recursionDepth = args.recursion_depth + if recursionDepth: + os.environ["MAX_DEPTH"] = recursionDepth + + users = set() + exclude = set() + + users = [] + if args.users: + users = set(args.users) + + if args.exclude: + exclude = set(args.exclude) + + async with client: + print("> Started TeleSlaker") + allChannels = await common_api_commands.startScanningProcess( + client, args.chat, trackUsers=users, banned_usernames=exclude + ) + + if not allChannels: + print("[!] No channels found or scanned") + return + + for channel in allChannels: + visuals.visualize_channel_record(channel) + visuals.visualize_subchannels_tree(channel) + + +try: + client.loop.run_until_complete(main()) +except KeyboardInterrupt: + print("[!] Interrupted by user") diff --git a/src/visuals.py b/src/visuals/visuals.py similarity index 95% rename from src/visuals.py rename to src/visuals/visuals.py index 681cc39..8ba83a6 100644 --- a/src/visuals.py +++ b/src/visuals/visuals.py @@ -7,11 +7,10 @@ from rich.table import Table from rich.text import Text from rich.tree import Tree -from telethon.tl.types import User -from src.classes.channel import ChannelRecord -from src.classes.group import GroupRecord -from src.classes.user import UserRecord +from classes.channel import ChannelRecord +from classes.group import GroupRecord +from classes.user import UserRecord REPORT_DIR = "reports" @@ -93,7 +92,7 @@ def visualize_group_record(group: GroupRecord): ) console.print(bTable) - tree = Tree(f"👥 Users") + tree = Tree("👥 Users") tree = output_user_info(tree, group.members.values(), group.id) console.print(tree) From 217d4e33d768d9d587d9c48645c5397f4f1f3446 Mon Sep 17 00:00:00 2001 From: CacucoH Date: Mon, 16 Feb 2026 22:07:59 +0300 Subject: [PATCH 3/3] Code fixed && refactored --- .github/workflows/pipe.yml | 11 +- pyproject.toml | 5 +- src/common/common_api_commands.py | 158 ++++++++++++---------- src/scan_modules/channels/channel_scan.py | 20 +-- src/scan_modules/groups/group_scan.py | 23 ++-- src/telestalker/__init__.py | 3 +- src/telestalker/main.py | 6 +- 7 files changed, 123 insertions(+), 103 deletions(-) diff --git a/.github/workflows/pipe.yml b/.github/workflows/pipe.yml index 60915a3..d29df92 100644 --- a/.github/workflows/pipe.yml +++ b/.github/workflows/pipe.yml @@ -47,15 +47,10 @@ jobs: uses: actions/setup-python@v5 with: python-version: '3.12' - - - name: Check dev=True in src/shared.py + + - name: Code complexity (max 15) run: | - if grep -q "dev=True" src/shared.py; then - echo "❌ dev=True found!" - exit 1 - else - echo "✅ No dev=True found" - fi + radon cc src/ | awk '$2 > 13 {exit 1}' # FAIL при > 13 # - name: Install mypy # run: pipx install mypy diff --git a/pyproject.toml b/pyproject.toml index 7978be5..e5b70b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,4 +37,7 @@ target-version = "py312" [tool.ruff.lint] select = ["E", "F", "B", "C4", "C9", "I", "UP"] -ignore = ["E501"] \ No newline at end of file +ignore = ["E501"] + +[tool.ruff.lint.mccabe] # Max complexity is 15 +max-complexity = 15 \ No newline at end of file diff --git a/src/common/common_api_commands.py b/src/common/common_api_commands.py index e9f9827..2c80f23 100644 --- a/src/common/common_api_commands.py +++ b/src/common/common_api_commands.py @@ -45,13 +45,15 @@ async def startScanningProcess( client: TelegramClient, chatId: str | int, - trackUsers: set[str] = set(), - banned_usernames: set[str] = set(), + trackUsers: set[str] = None, + banned_usernames: set[str] = None, ) -> list[ChannelRecord]: from scan_modules.channels.channel_scan import channelScanRecursion from scan_modules.groups.group_scan import getChatUsers totalChannels: list[ChannelRecord] = [] + trackUsers = trackUsers or set() + banned_usernames = banned_usernames or set() # We've got link try: @@ -128,7 +130,7 @@ async def get_channel_from_user( full_user: UserFull = await client(GetFullUserRequest(user)) personal_channel_id = getattr(full_user.full_user, "personal_channel_id", None) - bio = getattr(full_user.full_user, "about") + bio = full_user.full_user.about if personal_channel_id == current_channel_id: tqdm.write( @@ -196,7 +198,7 @@ async def getUsersByComments( client: TelegramClient, chatRecord: GroupRecord | ChannelRecord, targetUsers: set[str], - banned_usernames: set[str] = [], + banned_usernames: set[str] = None, totalMessages: int = 0, participantsCount: int = 0, ) -> list[dict[UserRecord], dict[int, int]]: @@ -206,7 +208,7 @@ async def getUsersByComments( """ try: message: Message - originalPostId = None + # originalPostId = None thisChatId = chatRecord.id chatToScan = ( chatRecord.linkedChat @@ -222,86 +224,100 @@ async def getUsersByComments( if not message.sender: continue - sender = message.sender # Optimized - senderId: int = sender.id - try: - senderUsername: str = sender.username - except Exception: - # Skip to avoid error - if isinstance(sender, ChannelForbidden): - logging.warning( - f"[!] Skipping... megagroup? {senderId} {sender.title}" - ) - continue - - logging.warning(f"[!] Username not found for {senderId}") - senderUsername = senderId - - # Dont waste API calls on deleted users - if not senderUsername: - logging.debug(f"[!] User @{senderId} is deleted? Skipping anyway...") + res = await search( + message, client, thisChatId, chatRecord, banned_usernames, targetUsers + ) + if not res: continue - if senderUsername in banned_usernames or str(senderId) in banned_usernames: - logging.debug( - f"[i] User @{senderUsername} and their (potential) channel is banned from scanning. Skipping..." - ) - continue + user, senderUsername = res[0], res[1] + tqdm.write( + f"[+] New user found: {user.full_name} (@{senderUsername or '---'})" + ) - # If user asked to track some channel subs - # TODO: if user is not present comment wouldnt be saved - # На самом деле мне слшиком похцй это делать если хотите киньте PullReq :) - if senderUsername in targetUsers or str(senderId) in targetUsers: - user: UserRecord = chatRecord.getUser(senderId) - if not user: - user = UserRecord(sender) - chatRecord.addUser(senderId, user) - - msgDate = message.date.strftime("%Y-%m-%d %H:%M:%S") - text = message.text or "" - link = await makeLink(message.id, chatRecord, originalPostId) - user.capturedMessages[f"{msgDate} : {link}"] = ( - f"{text[:100]} {'...' if len(text) > 100 else ''}" - ) + tqdm.write( + f"\n[i] Users found: {chatRecord.membersFound}/{participantsCount}\n{'-' * 64}" + ) - continue + except Exception as e: + tqdm.write(f"Ошибка при получении пользователей из комментариев: {e}") + logging.error(e) - # Drain messages buffer if we met post from channel and continue - if senderId == thisChatId: - if message.forward: - originalPostId = message.forward.channel_post - continue + return chatRecord - # Check if user is already present in channel or we deal not with user - if chatRecord.getUser(senderId) or not isinstance(sender, User): - continue - user = UserRecord(sender) +async def search( + message: Message, + client, + thisChatId: int, + chatRecord: GroupRecord | ChannelRecord, + banned_usernames: set[str], + targetUsers: set[str], + originalPostId: int = None, +): + sender = message.sender # Optimized + senderId: int = sender.id + senderUsername: str = sender.get("username") + if not senderUsername: + # Skip to avoid error + if isinstance(sender, ChannelForbidden): + logging.warning(f"[!] Skipping... megagroup? {senderId} {sender.title}") + return None + + # Dont waste API calls on deleted users + logging.debug(f"[!] User @{senderId} is deleted? Skipping anyway...") + senderUsername = senderId - # Check if the user has a channel; If so add them - subChannId = await get_channel_from_user(client, senderUsername, thisChatId) - if subChannId: - chatRecord.addSubChannel(senderUsername, subChannId) - user.adminInChannel.add(subChannId) + return None + if senderUsername in banned_usernames or str(senderId) in banned_usernames: + logging.debug( + f"[i] User @{senderUsername} and their (potential) channel is banned from scanning. Skipping..." + ) + return None + + # If user asked to track some channel subs + # TODO: if user is not present comment wouldnt be saved + # На самом деле мне слшиком похцй это делать если хотите киньте PullReq :) + if senderUsername in targetUsers or str(senderId) in targetUsers: + user: UserRecord = chatRecord.getUser(senderId) + if not user: + user = UserRecord(sender) chatRecord.addUser(senderId, user) - # elif isinstance(sender, Channel): # Admin found - # prefix = "[+] New admin found:" - # await channelInstance.addAdmin(sender.) - tqdm.write( - f"[+] New user found: {user.full_name} (@{senderUsername or '---'})" - ) - tqdm.write( - f"\n[i] Users found: {chatRecord.membersFound}/{participantsCount}\n{'-' * 64}" + msgDate = message.date.strftime("%Y-%m-%d %H:%M:%S") + text = message.text or "" + link = await makeLink(message.id, chatRecord, originalPostId) + user.capturedMessages[f"{msgDate} : {link}"] = ( + f"{text[:100]} {'...' if len(text) > 100 else ''}" ) - except Exception as e: - tqdm.write(f"Ошибка при получении пользователей из комментариев: {e}") - logging.error(e) + return None + + # Drain messages buffer if we met post from channel and continue + if senderId == thisChatId: + if message.forward: + originalPostId = message.forward.channel_post + return None + + # Check if user is already present in channel or we deal not with user + if chatRecord.getUser(senderId) or not isinstance(sender, User): + return None + + user = UserRecord(sender) + + # Check if the user has a channel; If so add them + subChannId = await get_channel_from_user(client, senderUsername, thisChatId) + if subChannId: + chatRecord.addSubChannel(senderUsername, subChannId) + user.adminInChannel.add(subChannId) + + chatRecord.addUser(senderId, user) + # elif isinstance(sender, Channel): # Admin found + # prefix = "[+] New admin found:" + # await channelInstance.addAdmin(sender.) - finally: - return chatRecord + return user, senderUsername async def makeLink( diff --git a/src/scan_modules/channels/channel_scan.py b/src/scan_modules/channels/channel_scan.py index 9fa2f51..682573a 100644 --- a/src/scan_modules/channels/channel_scan.py +++ b/src/scan_modules/channels/channel_scan.py @@ -7,9 +7,13 @@ from classes.channel import ChannelRecord from classes.user import UserRecord -from common.common_api_commands import (MAX_DEPTH, - MAX_PARTICIPANTS_CHANNEL, Channel, - getUsersByComments, scanForAdmins) +from common.common_api_commands import ( + MAX_DEPTH, + MAX_PARTICIPANTS_CHANNEL, + Channel, + getUsersByComments, + scanForAdmins, +) from common.local_commands import matchAdminsByNames @@ -19,8 +23,8 @@ async def channelScanRecursion( currentDepth: int = 1, channelInstance: ChannelRecord | None = None, creatorId: int | None = None, - trackUsers: set[str] = [], - banned_usernames: set[str] = [], + trackUsers: set[str] = None, + banned_usernames: set[str] = None, isBlocked: bool = False, ) -> list[ChannelRecord, bool]: """ @@ -91,7 +95,7 @@ async def getUsersFromChannelComments( client: TelegramClient, channelObj: Channel, targetUsers: set[str], - banned_usernames: set[str] = [], + banned_usernames: set[str] = None, ) -> list[UserRecord]: try: channelInstance = await getChannelInfo(client, channelObj) @@ -115,8 +119,8 @@ async def getUsersFromChannelComments( f"Ошибка при получении пользователей из комментариев канала @{channelInstance.usernamme}: {e}" ) logging.error(e) - finally: - return channelInstance + + return channelInstance async def getChannelInfo(client: TelegramClient, channelObj: Channel) -> ChannelRecord: diff --git a/src/scan_modules/groups/group_scan.py b/src/scan_modules/groups/group_scan.py index b7111a8..165a585 100644 --- a/src/scan_modules/groups/group_scan.py +++ b/src/scan_modules/groups/group_scan.py @@ -1,26 +1,27 @@ import logging from telethon import TelegramClient -from telethon.tl.functions.channels import (GetFullChannelRequest, - GetParticipantsRequest) +from telethon.tl.functions.channels import GetFullChannelRequest, GetParticipantsRequest from telethon.tl.types import ChannelParticipantsAdmins, Chat, ChatFull, User from tqdm.asyncio import tqdm from classes.group import GroupRecord from classes.user import UserRecord -from common.common_api_commands import (API_MAX_USERS_REQUEST, - MAX_USERS_SCAN_ITERATIONS, - USER_SEARCH_LIMIT, - get_channel_from_user, - getUsersByComments) +from common.common_api_commands import ( + API_MAX_USERS_REQUEST, + MAX_USERS_SCAN_ITERATIONS, + USER_SEARCH_LIMIT, + get_channel_from_user, + getUsersByComments, +) from visuals.visuals import visualize_group_record async def getChatUsers( client: TelegramClient, chatObj: Chat, - trackUsers: set[str] = [], - banned_usernames: set[str] = [], + trackUsers: set[str] = None, + banned_usernames: set[str] = None, supergroup=False, ) -> GroupRecord | None: try: @@ -48,8 +49,8 @@ async def getChatUsers( f"Ошибка при получении пользователей из комментариев группы @{groupInstance.id}: {e}" ) logging.error(e) - finally: - return groupInstance + + return groupInstance async def getGroupInfo( diff --git a/src/telestalker/__init__.py b/src/telestalker/__init__.py index 111c5b6..f398639 100644 --- a/src/telestalker/__init__.py +++ b/src/telestalker/__init__.py @@ -1,2 +1,3 @@ """TeleStalker - Telegram stalker bot""" -__version__ = "0.1.0" \ No newline at end of file + +__version__ = "0.1.0" diff --git a/src/telestalker/main.py b/src/telestalker/main.py index 26ce29d..b6e1617 100644 --- a/src/telestalker/main.py +++ b/src/telestalker/main.py @@ -1,8 +1,8 @@ -import os -import logging -import uvloop import argparse +import logging +import os +import uvloop from telethon import TelegramClient from common import common_api_commands