forgertele 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forgertele/__init__.py +15 -0
- forgertele/__main__.py +272 -0
- forgertele/antiban.py +108 -0
- forgertele/api.py +897 -0
- forgertele/debug.py +174 -0
- forgertele/devices.json +5811 -0
- forgertele/devices.py +449 -0
- forgertele/exception.py +362 -0
- forgertele/py.typed +0 -0
- forgertele/session.py +116 -0
- forgertele/td/__init__.py +3 -0
- forgertele/td/account.py +1493 -0
- forgertele/td/auth.py +109 -0
- forgertele/td/configs.py +446 -0
- forgertele/td/mtp.py +522 -0
- forgertele/td/qdatastream.py +802 -0
- forgertele/td/shared.py +13 -0
- forgertele/td/storage.py +590 -0
- forgertele/td/tdesktop.py +891 -0
- forgertele/tl/__init__.py +1 -0
- forgertele/tl/configs.py +39 -0
- forgertele/tl/pyrogram_bridge.py +321 -0
- forgertele/tl/shared.py +1 -0
- forgertele/tl/telethon.py +1104 -0
- forgertele/utils.py +243 -0
- forgertele-1.0.0.dist-info/METADATA +99 -0
- forgertele-1.0.0.dist-info/RECORD +31 -0
- forgertele-1.0.0.dist-info/WHEEL +5 -0
- forgertele-1.0.0.dist-info/entry_points.txt +2 -0
- forgertele-1.0.0.dist-info/licenses/LICENSE +21 -0
- forgertele-1.0.0.dist-info/top_level.txt +1 -0
forgertele/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError
|
|
2
|
+
from importlib.metadata import version as _pkg_version
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
__version__ = _pkg_version("forgertele")
|
|
6
|
+
except PackageNotFoundError:
|
|
7
|
+
__version__ = "0.0.0+unknown"
|
|
8
|
+
|
|
9
|
+
from . import td, tl
|
|
10
|
+
|
|
11
|
+
__all__ = ["td", "tl", "antiban", "session", "__version__"]
|
|
12
|
+
__author__ = "Green Forger"
|
|
13
|
+
__channel__ = "https://t.me/forgertele"
|
|
14
|
+
from . import antiban
|
|
15
|
+
from . import session
|
forgertele/__main__.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""CLI for forgertele-ng.
|
|
2
|
+
|
|
3
|
+
Three subcommands:
|
|
4
|
+
|
|
5
|
+
forgertele-ng info <tdata> — print accounts metadata (read-only)
|
|
6
|
+
forgertele-ng convert <tdata> <session.session> — convert tdata → Telethon session file
|
|
7
|
+
forgertele-ng --version
|
|
8
|
+
|
|
9
|
+
Designed to cover the 80% case in one shell command without writing Python.
|
|
10
|
+
The CLI is intentionally thin — it delegates to forgertele.td.TDesktop and
|
|
11
|
+
forgertele.tl.TelegramClient. No new wire-format code lives here.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import asyncio
|
|
17
|
+
import hashlib
|
|
18
|
+
import inspect
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from . import __version__
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _sha256_hex(b: bytes | bytearray | memoryview | None) -> str | None:
|
|
31
|
+
if b is None:
|
|
32
|
+
return None
|
|
33
|
+
return hashlib.sha256(bytes(b)).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _account_summary(account: Any, idx: int) -> dict[str, Any]:
|
|
37
|
+
info: dict[str, Any] = {"index": idx}
|
|
38
|
+
for attr in ("UserId", "MainDcId"):
|
|
39
|
+
try:
|
|
40
|
+
v = getattr(account, attr)
|
|
41
|
+
if not callable(v) and v is not None:
|
|
42
|
+
info[attr] = v
|
|
43
|
+
except Exception:
|
|
44
|
+
# Diagnostic summary should not crash the whole `info` command on
|
|
45
|
+
# a single broken account — fall through and skip this attribute.
|
|
46
|
+
pass
|
|
47
|
+
try:
|
|
48
|
+
ak = getattr(account, "authKey", None)
|
|
49
|
+
if ak is not None:
|
|
50
|
+
key_bytes = bytes(getattr(ak, "key", b"") or b"")
|
|
51
|
+
info["authKey_sha256"] = _sha256_hex(key_bytes)
|
|
52
|
+
info["authKey_dcId"] = int(getattr(ak, "dcId", 0) or 0)
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
info["authKey_error"] = repr(exc)
|
|
55
|
+
return info
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_info(args: argparse.Namespace) -> int:
|
|
59
|
+
from .exception import OpenTeleException
|
|
60
|
+
from .td import TDesktop
|
|
61
|
+
|
|
62
|
+
tdata_path = Path(args.tdata).expanduser().resolve()
|
|
63
|
+
if not tdata_path.is_dir():
|
|
64
|
+
print(f"error: tdata path is not a directory: {tdata_path}", file=sys.stderr)
|
|
65
|
+
return 2
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
td = TDesktop(str(tdata_path))
|
|
69
|
+
except OpenTeleException as exc:
|
|
70
|
+
print(f"error: TDesktop failed to load tdata at {tdata_path}: {exc}", file=sys.stderr)
|
|
71
|
+
return 3
|
|
72
|
+
if not td.isLoaded():
|
|
73
|
+
print(f"error: TDesktop failed to load tdata at {tdata_path}", file=sys.stderr)
|
|
74
|
+
return 3
|
|
75
|
+
|
|
76
|
+
accounts = list(td.accounts) if hasattr(td, "accounts") else []
|
|
77
|
+
main_idx = getattr(getattr(td, "mainAccount", None), "index", None)
|
|
78
|
+
out = {
|
|
79
|
+
"tdata_path": str(tdata_path),
|
|
80
|
+
"AppVersion": getattr(td, "AppVersion", None),
|
|
81
|
+
"mainAccount_index": main_idx,
|
|
82
|
+
"accounts_count": len(accounts),
|
|
83
|
+
"accounts": [_account_summary(a, i) for i, a in enumerate(accounts)],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if args.json:
|
|
87
|
+
print(json.dumps(out, indent=2, sort_keys=True, default=repr))
|
|
88
|
+
else:
|
|
89
|
+
print(f"tdata: {out['tdata_path']}")
|
|
90
|
+
print(f" AppVersion: {out['AppVersion']}")
|
|
91
|
+
print(f" accounts: {out['accounts_count']}")
|
|
92
|
+
print(f" main account idx: {out['mainAccount_index']}")
|
|
93
|
+
for acc in out["accounts"]:
|
|
94
|
+
print(f" └─ [{acc['index']}] UserId={acc.get('UserId')} "
|
|
95
|
+
f"DC={acc.get('MainDcId')} "
|
|
96
|
+
f"authKey_sha256={(acc.get('authKey_sha256') or '')[:16]}…")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _normalize_session_path(p: Path) -> Path:
|
|
101
|
+
"""Replicate Telethon's SQLiteSession suffix behavior so `--force` is reliable."""
|
|
102
|
+
return p if p.suffix == ".session" else p.with_suffix(p.suffix + ".session")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _publish_without_overwrite(source: Path, destination: Path) -> None:
|
|
106
|
+
"""Publish a completed session without replacing a concurrent output."""
|
|
107
|
+
try:
|
|
108
|
+
os.link(source, destination)
|
|
109
|
+
return
|
|
110
|
+
except FileExistsError:
|
|
111
|
+
raise
|
|
112
|
+
except OSError:
|
|
113
|
+
# FAT/exFAT and some network filesystems do not support hard links.
|
|
114
|
+
# Exclusive creation retains the no-overwrite guard on those volumes.
|
|
115
|
+
with source.open("rb") as incoming:
|
|
116
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
|
117
|
+
descriptor = os.open(destination, flags, 0o600)
|
|
118
|
+
try:
|
|
119
|
+
with os.fdopen(descriptor, "wb") as outgoing:
|
|
120
|
+
shutil.copyfileobj(incoming, outgoing)
|
|
121
|
+
except BaseException:
|
|
122
|
+
destination.unlink(missing_ok=True)
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def _convert_async(tdata_path: Path, session_path: Path, *, flag_use_current: bool) -> int:
|
|
127
|
+
from .api import API, CreateNewSession, UseCurrentSession
|
|
128
|
+
from .exception import OpenTeleException
|
|
129
|
+
from .td import TDesktop
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
td = TDesktop(str(tdata_path))
|
|
133
|
+
except OpenTeleException as exc:
|
|
134
|
+
print(f"error: TDesktop failed to load tdata at {tdata_path}: {exc}", file=sys.stderr)
|
|
135
|
+
return 3
|
|
136
|
+
if not td.isLoaded():
|
|
137
|
+
print(f"error: TDesktop failed to load tdata at {tdata_path}", file=sys.stderr)
|
|
138
|
+
return 3
|
|
139
|
+
|
|
140
|
+
flag = UseCurrentSession if flag_use_current else CreateNewSession
|
|
141
|
+
client = await td.ToTelethon(
|
|
142
|
+
session=str(session_path),
|
|
143
|
+
flag=flag,
|
|
144
|
+
api=API.TelegramDesktop.Generate(),
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
# Disconnect so the .session file is flushed to disk cleanly.
|
|
148
|
+
if hasattr(client, "disconnect"):
|
|
149
|
+
res = client.disconnect()
|
|
150
|
+
if inspect.isawaitable(res):
|
|
151
|
+
await res
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
print(f"error: could not finalize session file: {exc}", file=sys.stderr)
|
|
154
|
+
return 4
|
|
155
|
+
|
|
156
|
+
if not session_path.exists():
|
|
157
|
+
# Telethon appends `.session`; tolerate both.
|
|
158
|
+
alt = session_path.with_suffix(session_path.suffix + ".session") if session_path.suffix != ".session" else session_path
|
|
159
|
+
if alt.exists():
|
|
160
|
+
session_path = alt
|
|
161
|
+
else:
|
|
162
|
+
print(f"error: session file was not created at {session_path}", file=sys.stderr)
|
|
163
|
+
return 4
|
|
164
|
+
if session_path.stat().st_size == 0:
|
|
165
|
+
print(f"error: session file is empty at {session_path}", file=sys.stderr)
|
|
166
|
+
return 4
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cmd_convert(args: argparse.Namespace) -> int:
|
|
171
|
+
tdata_path = Path(args.tdata).expanduser().resolve()
|
|
172
|
+
session_path = Path(args.output).expanduser().resolve()
|
|
173
|
+
# Telethon will write to `<output>.session` even if `--output` lacks the suffix —
|
|
174
|
+
# check both forms before clobbering anything.
|
|
175
|
+
suffixed_path = _normalize_session_path(session_path)
|
|
176
|
+
|
|
177
|
+
if not tdata_path.is_dir():
|
|
178
|
+
print(f"error: tdata path is not a directory: {tdata_path}", file=sys.stderr)
|
|
179
|
+
return 2
|
|
180
|
+
if not args.force:
|
|
181
|
+
for candidate in {session_path, suffixed_path}:
|
|
182
|
+
if candidate.exists():
|
|
183
|
+
print(
|
|
184
|
+
f"error: refusing to overwrite existing {candidate}; pass --force to override",
|
|
185
|
+
file=sys.stderr,
|
|
186
|
+
)
|
|
187
|
+
return 5
|
|
188
|
+
|
|
189
|
+
# Build a fresh database even with --force: reopening an old SQLiteSession
|
|
190
|
+
# retains its entities, sent files, update state and takeout ID. Keep the
|
|
191
|
+
# old file intact until conversion and disconnect have completed.
|
|
192
|
+
try:
|
|
193
|
+
with tempfile.TemporaryDirectory(prefix=".forgertele-", dir=suffixed_path.parent) as folder:
|
|
194
|
+
temporary = Path(folder) / "converted.session"
|
|
195
|
+
temporary.touch(mode=0o600)
|
|
196
|
+
result = asyncio.run(
|
|
197
|
+
_convert_async(tdata_path, temporary, flag_use_current=args.use_current_session)
|
|
198
|
+
)
|
|
199
|
+
if result:
|
|
200
|
+
return result
|
|
201
|
+
if args.force:
|
|
202
|
+
os.replace(temporary, suffixed_path)
|
|
203
|
+
else:
|
|
204
|
+
try:
|
|
205
|
+
_publish_without_overwrite(temporary, suffixed_path)
|
|
206
|
+
except FileExistsError:
|
|
207
|
+
print(
|
|
208
|
+
f"error: refusing to overwrite existing {suffixed_path}; pass --force to override",
|
|
209
|
+
file=sys.stderr,
|
|
210
|
+
)
|
|
211
|
+
return 5
|
|
212
|
+
except OSError as exc:
|
|
213
|
+
print(f"error: cannot write session file at {suffixed_path}: {exc}", file=sys.stderr)
|
|
214
|
+
return 4
|
|
215
|
+
print(f"ok: wrote session file → {suffixed_path}")
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
220
|
+
p = argparse.ArgumentParser(
|
|
221
|
+
prog="forgertele-ng",
|
|
222
|
+
description="Convert / inspect Telegram Desktop tdata folders without Qt.",
|
|
223
|
+
)
|
|
224
|
+
p.add_argument("--version", action="version", version=f"forgertele-ng {__version__}")
|
|
225
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
226
|
+
|
|
227
|
+
p_info = sub.add_parser("info", help="Print accounts metadata for a tdata folder (read-only).")
|
|
228
|
+
p_info.add_argument("tdata", help="Path to a Telegram Desktop tdata folder.")
|
|
229
|
+
p_info.add_argument("--json", action="store_true", help="Emit JSON instead of human-friendly text.")
|
|
230
|
+
p_info.set_defaults(func=cmd_info)
|
|
231
|
+
|
|
232
|
+
p_conv = sub.add_parser("convert", help="Convert a tdata folder to a Telethon .session file.")
|
|
233
|
+
p_conv.add_argument("tdata", help="Path to a Telegram Desktop tdata folder.")
|
|
234
|
+
p_conv.add_argument("-o", "--output", required=True, help="Output .session file path.")
|
|
235
|
+
p_conv.add_argument(
|
|
236
|
+
"--use-current-session",
|
|
237
|
+
action="store_true",
|
|
238
|
+
help="Reuse the current Telegram session (default: create a new one).",
|
|
239
|
+
)
|
|
240
|
+
p_conv.add_argument("--force", action="store_true", help="Overwrite existing output file.")
|
|
241
|
+
p_conv.set_defaults(func=cmd_convert)
|
|
242
|
+
|
|
243
|
+
return p
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _ensure_utf8_stdout() -> None:
|
|
247
|
+
"""Windows consoles default to cp1251/cp437 which can't render the box-drawing
|
|
248
|
+
glyphs (└─) we use in the info output. Try to upgrade stdout to UTF-8."""
|
|
249
|
+
for stream_name in ("stdout", "stderr"):
|
|
250
|
+
stream = getattr(sys, stream_name, None)
|
|
251
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
252
|
+
if reconfigure is None:
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
256
|
+
except (AttributeError, OSError, ValueError):
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def main(argv: list[str] | None = None) -> int:
|
|
261
|
+
_ensure_utf8_stdout()
|
|
262
|
+
parser = build_parser()
|
|
263
|
+
args = parser.parse_args(argv)
|
|
264
|
+
try:
|
|
265
|
+
return int(args.func(args) or 0)
|
|
266
|
+
except KeyboardInterrupt:
|
|
267
|
+
print("interrupted", file=sys.stderr)
|
|
268
|
+
return 130
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
sys.exit(main())
|
forgertele/antiban.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_device_fingerprint(account_id: str, platform: str = "android") -> dict:
|
|
9
|
+
cache_dir = Path.home() / ".forgertele" / "fingerprints"
|
|
10
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
cache_file = cache_dir / f"{account_id}.json"
|
|
12
|
+
|
|
13
|
+
if cache_file.exists():
|
|
14
|
+
with open(cache_file) as f:
|
|
15
|
+
return json.load(f)
|
|
16
|
+
|
|
17
|
+
from .api import API
|
|
18
|
+
|
|
19
|
+
if platform == "android":
|
|
20
|
+
api = API.TelegramAndroid.Generate(account_id)
|
|
21
|
+
elif platform == "ios":
|
|
22
|
+
api = API.TelegramIOS.Generate(account_id)
|
|
23
|
+
elif platform == "desktop":
|
|
24
|
+
api = API.TelegramDesktop.Generate(account_id)
|
|
25
|
+
else:
|
|
26
|
+
api = API.TelegramAndroid.Generate(account_id)
|
|
27
|
+
|
|
28
|
+
fp = {
|
|
29
|
+
"account_id": account_id,
|
|
30
|
+
"platform": platform,
|
|
31
|
+
"device_model": api.device_model,
|
|
32
|
+
"system_version": api.system_version,
|
|
33
|
+
"app_version": api.app_version,
|
|
34
|
+
"lang_code": api.lang_code,
|
|
35
|
+
"system_lang_code": api.system_lang_code,
|
|
36
|
+
"created_at": int(time.time()),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
with open(cache_file, "w") as f:
|
|
40
|
+
json.dump(fp, f, indent=2)
|
|
41
|
+
|
|
42
|
+
return fp
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def clear_fingerprint(account_id: str) -> bool:
|
|
46
|
+
cache_file = Path.home() / ".forgertele" / "fingerprints" / f"{account_id}.json"
|
|
47
|
+
if cache_file.exists():
|
|
48
|
+
cache_file.unlink()
|
|
49
|
+
return True
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RateLimit:
|
|
54
|
+
MESSAGE_MIN = 3
|
|
55
|
+
MESSAGE_MAX = 8
|
|
56
|
+
ACTION_MIN = 1
|
|
57
|
+
ACTION_MAX = 3
|
|
58
|
+
SESSION_MIN = 10
|
|
59
|
+
SESSION_MAX = 30
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
async def wait_message():
|
|
63
|
+
import asyncio, random
|
|
64
|
+
await asyncio.sleep(random.uniform(RateLimit.MESSAGE_MIN, RateLimit.MESSAGE_MAX))
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
async def wait_action():
|
|
68
|
+
import asyncio, random
|
|
69
|
+
await asyncio.sleep(random.uniform(RateLimit.ACTION_MIN, RateLimit.ACTION_MAX))
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
async def wait_session():
|
|
73
|
+
import asyncio, random
|
|
74
|
+
await asyncio.sleep(random.uniform(RateLimit.SESSION_MIN, RateLimit.SESSION_MAX))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AccountStatus:
|
|
78
|
+
OK = "ok"
|
|
79
|
+
BANNED = "banned"
|
|
80
|
+
LIMITED = "limited"
|
|
81
|
+
DEACTIVATED = "deactivated"
|
|
82
|
+
UNKNOWN = "unknown"
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
async def check(client) -> str:
|
|
86
|
+
try:
|
|
87
|
+
me = await client.get_me()
|
|
88
|
+
if me is None:
|
|
89
|
+
return AccountStatus.DEACTIVATED
|
|
90
|
+
return AccountStatus.OK
|
|
91
|
+
except Exception as e:
|
|
92
|
+
err = str(e).lower()
|
|
93
|
+
if "banned" in err or "deactivated" in err:
|
|
94
|
+
return AccountStatus.BANNED
|
|
95
|
+
if "flood" in err or "slowmode" in err or "restricted" in err:
|
|
96
|
+
return AccountStatus.LIMITED
|
|
97
|
+
return AccountStatus.UNKNOWN
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
LATAM_LANG_CODES = ["es", "es-419", "es-PE", "es-MX", "es-CO", "es-AR"]
|
|
101
|
+
|
|
102
|
+
def random_latam_profile() -> dict:
|
|
103
|
+
import random
|
|
104
|
+
lang = random.choice(LATAM_LANG_CODES)
|
|
105
|
+
return {
|
|
106
|
+
"lang_code": lang,
|
|
107
|
+
"system_lang_code": lang,
|
|
108
|
+
}
|