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 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
+ }