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