diff --git a/.gitignore b/.gitignore index f585e85..3cd8ae2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ .idea/**/dictionaries .idea/**/shelf +tdatas +tdatas/* test_sessions/* sessions/* test.py diff --git a/README.md b/README.md index ada0774..29ef9db 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,10 @@

+**Automatically converts TDATA to Pyrogram session!** + **Automatically converts Telethon sessions to Pyrogram (may not be stable).** + **As long as the configuration file has the same name as the session file (see below). If you do not comply, it will not work at all πŸ™ƒ** _This script sends reactions to a new post or message in selected open groups and channels, as well as automatically subscribes to them._ @@ -23,27 +26,30 @@ _This script sends reactions to a new post or message in selected open groups an 5. `pip install -r requirements.txt` 6. Add your channel name to `config.py` 7. `mkdir sessions` -8. **Sessions must be for [pyrogram](https://github.com/pyrogram/pyrogram)!** + 8. **Sessions must be for [pyrogram](https://github.com/pyrogram/pyrogram)!** - Add the session file and its configuration file to the `/sessions` directory ( _which we created in step 7_ ). + Add the session file and its configuration file to the `/sessions` directory ( _which we created in step 7_ ). - **These two files must have the same name!** Here is an example: + **These two files must have the same name!** Here is an example: - ``` - your_dir - └───reactionbot.py - β”‚ - └───sessions - β”‚ β”‚ 8888888888.ini - β”‚ β”‚ 8888888888.session - β”‚ β”‚ 9999999999.ini - β”‚ β”‚ 9999999999.session - β”‚ β”‚ 98767242365.json - β”‚ β”‚ 98767242365.session - β”‚ β”‚ ... - β”‚ - ... - ``` + ``` + your_dir + └───reactionbot.py + β”‚ + └───sessions + β”‚ β”‚ 8888888888.ini + β”‚ β”‚ 8888888888.session + β”‚ β”‚ 9999999999.ini + β”‚ β”‚ 9999999999.session + β”‚ β”‚ 98767242365.json + β”‚ β”‚ 98767242365.session + β”‚ β”‚ ... + β”‚ + └───tdatas + β”‚ └───your_tdata + β”‚ β”‚ β”‚ ... + ... + ``` 9. `nohup python reactionbot.py &` ## Create a session file manually. diff --git a/converters/__init__.py b/converters/__init__.py new file mode 100644 index 0000000..e44c9b9 --- /dev/null +++ b/converters/__init__.py @@ -0,0 +1,5 @@ +__license__ = 'GNU Lesser General Public License v3.0 (LGPL-3.0)' +__copyright__ = 'Copyright (C) 2022-present Nikita ' + +from .telethon_to_pyrogram import SessionConvertor +from .tdata_to_telethon import convert_tdata diff --git a/converters/tdata_to_telethon.py b/converters/tdata_to_telethon.py new file mode 100644 index 0000000..f5bd1d1 --- /dev/null +++ b/converters/tdata_to_telethon.py @@ -0,0 +1,256 @@ +import io +import json +import os +import struct +import hashlib +import asyncio +import ipaddress +from typing import Union +from pathlib import Path +from base64 import urlsafe_b64encode + +import cryptg +from telethon.sync import TelegramClient +from telethon.sessions import StringSession + +from .telethon_to_pyrogram import SessionConvertor + +API_HASH = 'f2417bb89325164867e779bf8dbb34f8' +API_ID = 29702912 + +DC_TABLE = { + 1: ('149.154.175.50', 443), + 2: ('149.154.167.51', 443), + 3: ('149.154.175.100', 443), + 4: ('149.154.167.91', 443), + 5: ('149.154.171.5', 443), +} + + +class QDataStream: + def __init__(self, data): + self.stream = io.BytesIO(data) + + def read(self, n=None): + if n < 0: + n = 0 + data = self.stream.read(n) + if n != 0 and len(data) == 0: + return None + if n is not None and len(data) != n: + raise Exception('unexpected eof') + return data + + def read_buffer(self): + length_bytes = self.read(4) + if length_bytes is None: + return None + length = int.from_bytes(length_bytes, 'big', signed=True) + data = self.read(length) + if data is None: + raise Exception('unexpected eof') + return data + + def read_uint32(self): + data = self.read(4) + if data is None: + return None + return int.from_bytes(data, 'big') + + def read_uint64(self): + data = self.read(8) + if data is None: + return None + return int.from_bytes(data, 'big') + + def read_int32(self): + data = self.read(4) + if data is None: + return None + return int.from_bytes(data, 'big', signed=True) + + +def create_local_key(passcode, salt): + if passcode: + iterations = 100_000 + else: + iterations = 1 + _hash = hashlib.sha512(salt + passcode + salt).digest() + return hashlib.pbkdf2_hmac('sha512', _hash, salt, iterations, 256) + + +def prepare_aes_oldmtp(auth_key, msg_key, send): + if send: + x = 0 + else: + x = 8 + + sha1 = hashlib.sha1() + sha1.update(msg_key) + sha1.update(auth_key[x:][:32]) + a = sha1.digest() + + sha1 = hashlib.sha1() + sha1.update(auth_key[32 + x:][:16]) + sha1.update(msg_key) + sha1.update(auth_key[48 + x:][:16]) + b = sha1.digest() + + sha1 = hashlib.sha1() + sha1.update(auth_key[64 + x:][:32]) + sha1.update(msg_key) + c = sha1.digest() + + sha1 = hashlib.sha1() + sha1.update(msg_key) + sha1.update(auth_key[96 + x:][:32]) + d = sha1.digest() + + key = a[:8] + b[8:] + c[4:16] + iv = a[8:] + b[:8] + c[16:] + d[:8] + return key, iv + + +def aes_decrypt_local(ciphertext, auth_key, key_128): + key, iv = prepare_aes_oldmtp(auth_key, key_128, False) + return cryptg.decrypt_ige(ciphertext, key, iv) + + +def decrypt_local(data, key): + encrypted_key = data[:16] + data = aes_decrypt_local(data[16:], key, encrypted_key) + sha1 = hashlib.sha1() + sha1.update(data) + if encrypted_key != sha1.digest()[:16]: + raise Exception('failed to decrypt') + length = int.from_bytes(data[:4], 'little') + data = data[4:length] + return QDataStream(data) + + +def read_file(name): + with open(name, 'rb') as f: + magic = f.read(4) + if magic != b'TDF$': + raise Exception('invalid magic') + version_bytes = f.read(4) + data = f.read() + data, digest = data[:-16], data[-16:] + data_len_bytes = len(data).to_bytes(4, 'little') + md5 = hashlib.md5() + md5.update(data) + md5.update(data_len_bytes) + md5.update(version_bytes) + md5.update(magic) + digest = md5.digest() + if md5.digest() != digest: + raise Exception('invalid digest') + return QDataStream(data) + + +def read_encrypted_file(name, key): + stream = read_file(name) + encrypted_data = stream.read_buffer() + return decrypt_local(encrypted_data, key) + + +def account_data_string(index=0): + s = 'data' + if index > 0: + s += f'#{index + 1}' + md5 = hashlib.md5() + md5.update(bytes(s, 'utf-8')) + digest = md5.digest() + return digest[:8][::-1].hex().upper()[::-1] + + +def read_user_auth(directory, local_key, index=0): + name = account_data_string(index) + path = os.path.join(directory, f'{name}s') + stream = read_encrypted_file(path, local_key) + if stream.read_uint32() != 0x4B: + raise Exception('unsupported user auth config') + + stream = QDataStream(stream.read_buffer()) + user_id = stream.read_uint32() + main_dc = stream.read_uint32() + if user_id == 0xFFFFFFFF and main_dc == 0xFFFFFFFF: + user_id = stream.read_uint64() + main_dc = stream.read_uint32() + if main_dc not in DC_TABLE: + raise Exception(f'unsupported main dc: {main_dc}') + + length = stream.read_uint32() + for _ in range(length): + auth_dc = stream.read_uint32() + auth_key = stream.read(256) + if auth_dc == main_dc: + return auth_dc, auth_key + raise Exception('invalid user auth config') + + +def build_session(dc, ip, port, key): + ip_bytes = ipaddress.ip_address(ip).packed + data = struct.pack('>B4sH256s', dc, ip_bytes, port, key) + encoded_data = urlsafe_b64encode(data).decode('ascii') + return '1' + encoded_data + + +async def convert_tdata(path: Union[str, Path], work_dir: Path): + stream = read_file(os.path.join(path, 'key_datas')) + salt = stream.read_buffer() + if len(salt) != 32: + raise Exception('invalid salt length') + key_encrypted = stream.read_buffer() + info_encrypted = stream.read_buffer() + + passcode_key = create_local_key(b'', salt) + key_inner_data = decrypt_local(key_encrypted, passcode_key) + local_key = key_inner_data.read(256) + if len(local_key) != 256: + raise Exception('invalid local key') + + info_data = decrypt_local(info_encrypted, local_key) + count = info_data.read_uint32() + auth_key = [] + for _ in range(count): + index = info_data.read_uint32() + dc, key = read_user_auth(path, local_key, index) + ip, port = DC_TABLE[dc] + session = build_session(dc, ip, port, key) + auth_key.append(session) + + await convert_telethon_session_to_pyrogram(auth_key, work_dir) + + +def save_config(work_dir: Path, phone: str, config: dict): + config_path = work_dir.joinpath(phone + '.json') + with open(config_path, 'w') as config_file: + json.dump(config, config_file) + + +async def convert_telethon_session_to_pyrogram(auth_key, work_dir: Path): + session = StringSession(auth_key[0]) + async with TelegramClient(session, api_hash=API_HASH, api_id=API_ID) as client: + try: + await client.connect() + _ = await client.get_me() + except Exception as error: + raise error + + user_data = await client.get_me() + string_session = StringSession.save(client.session) + session_data = StringSession(string_session) + phone = user_data.phone + if phone is None: + raise Exception('no phone') + session_path = work_dir.joinpath(f'{phone}.session') + config = { + 'phone': phone, + 'api_id': API_ID, + 'api_hash': API_HASH, + } + save_config(work_dir, phone, config) + converter = SessionConvertor(session_path, config, work_dir) + converted_session = await converter.get_converted_sting_session(session_data, user_data) + await converter.save_pyrogram_session_file(converted_session, session_data) diff --git a/convertor.py b/converters/telethon_to_pyrogram.py similarity index 79% rename from convertor.py rename to converters/telethon_to_pyrogram.py index ebcd03c..67cf42f 100644 --- a/convertor.py +++ b/converters/telethon_to_pyrogram.py @@ -13,18 +13,20 @@ class SessionConvertor: def __init__(self, session_path: Path, config: Dict, work_dir: Path): - self.session_path = session_path + if work_dir is None: + work_dir = Path(__file__).parent.parent.joinpath('sessions') + self.session_path = session_path if session_path else work_dir self.inappropriate_sessions_path = work_dir.joinpath('unnecessary_sessions') - self.api_id = config['api_id'] - self.api_hash = config['api_hash'] + self.api_id = config['api_id'] if config else None + self.api_hash = config['api_hash'] if config else None self.work_dir = work_dir async def convert(self) -> None: """Main func""" user_data, session_data = await self.__get_data_telethon_session() - converted_sting_session = await self.__get_converted_sting_session(session_data, user_data) + converted_sting_session = await self.get_converted_sting_session(session_data, user_data) await self.move_file_to_unnecessary(self.session_path) - await self.__save_pyrogram_session_file(converted_sting_session, session_data) + await self.save_pyrogram_session_file(converted_sting_session, session_data) async def move_file_to_unnecessary(self, file_path: Path): """Move the unnecessary Telethon session file to the directory with the unnecessary sessions""" @@ -39,8 +41,8 @@ async def __get_data_telethon_session(self) -> Tuple[User, StringSession]: session_data = StringSession(string_session) return user_data, session_data - async def __save_pyrogram_session_file(self, session_string: Union[str, Coroutine[Any, Any, str]], - session_data: StringSession): + async def save_pyrogram_session_file(self, session_string: Union[str, Coroutine[Any, Any, str]], + session_data: StringSession): """Create session file for pyrogram""" async with Client(self.session_path.stem, session_string=session_string, api_id=self.api_id, api_hash=self.api_hash, workdir=self.work_dir.__str__()) as client: @@ -57,7 +59,7 @@ async def __save_pyrogram_session_file(self, session_string: Union[str, Coroutin await client.storage.save() @staticmethod - async def __get_converted_sting_session(session_data: StringSession, user_data: User) -> str: + async def get_converted_sting_session(session_data: StringSession, user_data: User) -> str: """Convert to sting session""" pack = [ Storage.SESSION_STRING_FORMAT, diff --git a/reactionbot.py b/reactionbot.py index f013252..5bdf637 100644 --- a/reactionbot.py +++ b/reactionbot.py @@ -1,4 +1,3 @@ -import sys import json import time import random @@ -17,20 +16,24 @@ from pyrogram.errors.exceptions.unauthorized_401 import UserDeactivatedBan from config import CHANNELS, POSSIBLE_KEY_NAMES, EMOJIS -from convertor import SessionConvertor +from converters import SessionConvertor, convert_tdata TRY_AGAIN_SLEEP = 20 -BASE_DIR = Path(sys.argv[0]).parent +BASE_DIR = Path(__file__).parent WORK_DIR = BASE_DIR.joinpath('sessions') +TDATAS_DIR = BASE_DIR.joinpath('tdatas') +SUCCESS_CONVERT_TDATA_DIR = TDATAS_DIR.joinpath('success') +UNSUCCESSFUL_CONVERT_TDATA_DIR = TDATAS_DIR.joinpath('unsuccessful') + BANNED_SESSIONS_DIR = WORK_DIR.joinpath('banned_sessions') UNNECESSARY_SESSIONS_DIR = WORK_DIR.joinpath('unnecessary_sessions') CONFIG_FILE_SUFFIXES = ('.ini', '.json') logging.basicConfig(filename='logs.log', level=logging.WARNING, format='%(asctime)s %(levelname)s %(message)s') -logging.info('Start reaction bot.') +logging.warning('Start reaction bot.') async def send_reaction(client: Client, message: types.Message) -> None: @@ -38,7 +41,6 @@ async def send_reaction(client: Client, message: types.Message) -> None: emoji = random.choice(EMOJIS) try: await client.send_reaction(chat_id=message.chat.id, message_id=message.id, emoji=emoji) - except ReactionInvalid: logging.warning(f'{emoji} - INVALID REACTION') except UserDeactivatedBan: @@ -52,6 +54,9 @@ async def make_work_dir() -> None: WORK_DIR.mkdir(exist_ok=True) UNNECESSARY_SESSIONS_DIR.mkdir(exist_ok=True) BANNED_SESSIONS_DIR.mkdir(exist_ok=True) + TDATAS_DIR.mkdir(exist_ok=True) + SUCCESS_CONVERT_TDATA_DIR.mkdir(exist_ok=True) + UNSUCCESSFUL_CONVERT_TDATA_DIR.mkdir(exist_ok=True) async def get_config_files_path() -> List[Path]: @@ -107,7 +112,7 @@ async def create_apps(config_files_paths: List[Path]) -> List[Tuple[Client, Dict return apps -async def try_convert(session_path: Path, config: Dict): +async def try_convert(session_path: Path, config: Dict) -> bool: """Try to convert the session if the session failed to start in Pyrogram""" convertor = SessionConvertor(session_path, config, WORK_DIR) try: @@ -120,18 +125,34 @@ async def try_convert(session_path: Path, config: Dict): if config_file_path.exists(): await convertor.move_file_to_unnecessary(config_file_path) logging.warning('Preservation of the session failed ' + session_path.stem) + return False + except Exception: + return False + else: + return True + + +def get_tdatas_paths() -> List[Path]: + """Get paths to tdata dirs""" + reserved_dirs = [SUCCESS_CONVERT_TDATA_DIR, UNSUCCESSFUL_CONVERT_TDATA_DIR] + return [path for path in TDATAS_DIR.iterdir() if path not in reserved_dirs] async def move_session_to_ban_dir(session_path: Path): """Move file to ban dir""" + if session_path.exists(): - session_path.rename(BANNED_SESSIONS_DIR.joinpath(session_path.name)) + await move_file(session_path, BANNED_SESSIONS_DIR) for suffix in CONFIG_FILE_SUFFIXES: config_file_path = session_path.with_suffix(suffix) if not config_file_path.exists(): continue - config_file_path.rename(BANNED_SESSIONS_DIR.joinpath(config_file_path.name)) + await move_file(config_file_path, BANNED_SESSIONS_DIR) + + +async def move_file(path_from: Path, path_to: Path): + path_from.rename(path_to.joinpath(path_from.name)) async def main(): @@ -146,6 +167,15 @@ async def main(): await make_work_dir() config_files = await get_config_files_path() + tdatas_paths = get_tdatas_paths() + for tdata_path in tdatas_paths: + try: + await convert_tdata(tdata_path, WORK_DIR) + except Exception: + logging.warning(traceback.format_exc()) + await move_file(tdata_path, UNSUCCESSFUL_CONVERT_TDATA_DIR) + else: + await move_file(tdata_path, SUCCESS_CONVERT_TDATA_DIR) apps = await create_apps(config_files) if not apps: @@ -158,9 +188,17 @@ async def main(): try: await app.start() except OperationalError: - await try_convert(session_file_path, config_dict) - apps.remove((app, config_dict, session_file_path)) - continue + is_converted = await try_convert(session_file_path, config_dict) + if not is_converted: + apps.remove((app, config_dict, session_file_path)) + continue + try: + app = Client(workdir=WORK_DIR.__str__(), **config_dict) + await app.start() + except Exception: + logging.warning(traceback.format_exc()) + else: + apps.append((app, config_dict, session_file_path)) except UserDeactivatedBan: await move_session_to_ban_dir(session_file_path) logging.warning('Session banned - ' + app.name) @@ -180,7 +218,11 @@ async def main(): await idle() for app, _, _ in apps: - await app.stop() + try: + logging.warning(f'Stopped - {app.name}') + await app.stop() + except ConnectionError: + pass def start(): @@ -191,7 +233,7 @@ def start(): loop.run_until_complete(main()) except Exception: logging.critical(traceback.format_exc()) - logging.info(f'Waiting {TRY_AGAIN_SLEEP} sec. before restarting the program...') + logging.warning(f'Waiting {TRY_AGAIN_SLEEP} sec. before restarting the program...') time.sleep(TRY_AGAIN_SLEEP) diff --git a/requirements.txt b/requirements.txt index 68fea9a..25f6590 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,5 @@ rsa==4.9 Telethon==1.25.4 TgCrypto==1.2.4 uvloop==0.17.0 +loguru~=0.6.0 +cryptg~=0.4.0 \ No newline at end of file