tgcaptcha-solver 0.1.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.
Files changed (44) hide show
  1. tgcaptcha/__init__.py +29 -0
  2. tgcaptcha/adapters/__init__.py +25 -0
  3. tgcaptcha/adapters/pyrogram.py +508 -0
  4. tgcaptcha/adapters/tdlib.py +684 -0
  5. tgcaptcha/client.py +87 -0
  6. tgcaptcha/engine.py +122 -0
  7. tgcaptcha/executor.py +453 -0
  8. tgcaptcha/hands.py +123 -0
  9. tgcaptcha/model.py +245 -0
  10. tgcaptcha/policy.py +254 -0
  11. tgcaptcha/replay.py +156 -0
  12. tgcaptcha/rules.py +184 -0
  13. tgcaptcha/runner.py +312 -0
  14. tgcaptcha/server.py +188 -0
  15. tgcaptcha/solvers/__init__.py +50 -0
  16. tgcaptcha/solvers/bio.py +180 -0
  17. tgcaptcha/solvers/builtin.py +162 -0
  18. tgcaptcha/solvers/dm.py +171 -0
  19. tgcaptcha/solvers/emoji.py +119 -0
  20. tgcaptcha/solvers/gates.py +287 -0
  21. tgcaptcha/solvers/image.py +267 -0
  22. tgcaptcha/solvers/image_digits.py +216 -0
  23. tgcaptcha/solvers/image_text_pick.py +379 -0
  24. tgcaptcha/solvers/ocr.py +153 -0
  25. tgcaptcha/solvers/qa.py +127 -0
  26. tgcaptcha/solvers/rose.py +334 -0
  27. tgcaptcha/solvers/success.py +70 -0
  28. tgcaptcha/solvers/video.py +204 -0
  29. tgcaptcha/tests/__init__.py +0 -0
  30. tgcaptcha/tests/test_bio.py +431 -0
  31. tgcaptcha/tests/test_core.py +961 -0
  32. tgcaptcha/tests/test_image_digits.py +263 -0
  33. tgcaptcha/tests/test_image_pick.py +353 -0
  34. tgcaptcha/tests/test_pyro_adapter.py +498 -0
  35. tgcaptcha/tests/test_runner.py +187 -0
  36. tgcaptcha/tests/test_success.py +117 -0
  37. tgcaptcha/tests/test_tdlib_adapter.py +737 -0
  38. tgcaptcha/tests/test_video.py +127 -0
  39. tgcaptcha/text.py +398 -0
  40. tgcaptcha_solver-0.1.0.dist-info/METADATA +387 -0
  41. tgcaptcha_solver-0.1.0.dist-info/RECORD +44 -0
  42. tgcaptcha_solver-0.1.0.dist-info/WHEEL +5 -0
  43. tgcaptcha_solver-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. tgcaptcha_solver-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,684 @@
1
+ """
2
+ The TDLib adapter (the `aiotdlib` package): turn an aiotdlib Client into
3
+ Hands + a message feed, and let the shared Runner (`tgcaptcha.runner`)
4
+ own the loop. Semantics mirror `TelethonHands` / `PyrogramHands` one for
5
+ one.
6
+
7
+ from aiotdlib import Client
8
+ brain = Brain("http://solver:8090", token=...) # or Brain() = local
9
+ handle = mount(Client(api_id=..., api_hash=...), brain=brain)
10
+ ...
11
+ handle.stop()
12
+
13
+ `mount` is the 3-line UX (detect challenges, run sessions, serialize per
14
+ chat); `TDLibHands` and `to_message` are for hosts that drive the Runner
15
+ themselves.
16
+
17
+ Install burden: aiotdlib's wheels bundle libtdjson on Linux and macOS
18
+ arm64, so `pip install tgcaptcha[tdlib]` just works there. Everywhere
19
+ else (other BSDs, macOS x86_64, exotic archs) TDLib must be compiled by
20
+ hand and pointed at through aiotdlib's `library_path` setting — budget
21
+ an hour and a big compiler before the first run.
22
+
23
+ Upstream caveat: aiotdlib is a thin asyncio skin over TDLib's generated
24
+ JSON API and follows TDLib's rename-happy release cadence — this adapter
25
+ was verified against 0.27.6, where message edits arrive as TWO update
26
+ kinds (`updateMessageEdited` and `updateMessageContent`). A future
27
+ rename surfaces here as one RPC raising inside a session (clean
28
+ failure, recorded by the Runner), never as silently wrong behavior.
29
+
30
+ This module never imports aiotdlib at import time — `to_message` and
31
+ `TDLibHands` are pure getattr code over whatever objects they are
32
+ handed (that is also what the offline tests exercise), and the few
33
+ aiotdlib types that must be CONSTRUCTED (the callback payload, the
34
+ error class) are imported lazily inside the methods that need them.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import asyncio
40
+ import logging
41
+ import re
42
+ import uuid
43
+ from typing import Any, Awaitable, Callable, Dict, Optional
44
+
45
+ from tgcaptcha import text as T
46
+ from tgcaptcha.client import Brain
47
+ from tgcaptcha.hands import PartialHands
48
+ from tgcaptcha.model import (Account, Button, Click, Message, SendSticker,
49
+ SendText, Session)
50
+ from tgcaptcha.policy import PolicyConfig
51
+ from tgcaptcha.runner import InboxItem, Runner
52
+
53
+ log = logging.getLogger("tgcaptcha.adapters.tdlib")
54
+
55
+ DelayFn = Callable[[str], float]
56
+
57
+
58
+ def _api_of(client):
59
+ """aiotdlib splits its surface in two: the ergonomic helpers
60
+ (`send_text`, `send_sticker`, `get_user`, ...) live on the Client,
61
+ the raw TDLib wrappers (`get_message`, `get_callback_query_answer`,
62
+ `set_bio`, ...) on `client.api`. Hands and the mount call through
63
+ this resolver so a fake in the offline tests can simply implement
64
+ everything flat on one object."""
65
+ return getattr(client, "api", None) or client
66
+
67
+
68
+ def _first_username(user) -> Optional[str]:
69
+ """TDLib `User` has NO `.username` field — usernames moved into
70
+ `usernames.active_usernames` (Optional; a user may have none, and
71
+ the first active one is what every other framework calls "the"
72
+ username)."""
73
+ active = getattr(getattr(user, "usernames", None), "active_usernames", None)
74
+ return active[0] if active else None
75
+
76
+
77
+ def _utf16_slice(text: str, offset, length) -> str:
78
+ """Slice `text` by a TDLib entity window. Entity offsets/lengths
79
+ count UTF-16 code units, not Python characters — slicing the str
80
+ directly lands in the middle of an emoji-prefixed challenge and
81
+ reads the wrong segment."""
82
+ if offset is None or length is None:
83
+ return ""
84
+ try:
85
+ return text.encode("utf-16-le")[offset * 2:(offset + length) * 2] \
86
+ .decode("utf-16-le")
87
+ except Exception:
88
+ return ""
89
+
90
+
91
+ def to_message(tdmsg, *, where: str = "group", sender=None) -> Message:
92
+ """An aiotdlib/TDLib message as the wire model sees it. No RPC.
93
+
94
+ `sender` may hand in the TDLib `User` the feed already fetched:
95
+ TDLib messages carry only a `sender_id` UNION (never a username),
96
+ so the username — when one is needed — is read off that User via
97
+ `_first_username`. Everything is read through getattr, so a message
98
+ missing a field degrades, not crashes."""
99
+ rows = []
100
+ markup = getattr(tdmsg, "reply_markup", None)
101
+ for row in (getattr(markup, "rows", None) or []):
102
+ out = []
103
+ for b in row:
104
+ btype = getattr(b, "type_", None) or getattr(b, "type", None)
105
+ data = getattr(btype, "data", None) # callback payload
106
+ url = getattr(btype, "url", None) # url target
107
+ kind = "callback" if data is not None else ("url" if url else "other")
108
+ out.append(Button(text=getattr(b, "text", "") or "", kind=kind,
109
+ url=(url or None) and str(url)[:300]))
110
+ rows.append(out)
111
+ content = getattr(tdmsg, "content", None)
112
+ body = getattr(content, "text", None) or getattr(content, "caption", None)
113
+ date = getattr(tdmsg, "date", None)
114
+ if isinstance(date, (int, float)):
115
+ wire_date = float(date) if date else None # TDLib dates are Int32
116
+ else:
117
+ wire_date = date.timestamp() if hasattr(date, "timestamp") else None
118
+ return Message(
119
+ msg_id=getattr(tdmsg, "id", None),
120
+ text=getattr(body, "text", None) or "",
121
+ buttons=rows,
122
+ where=where,
123
+ sender_id=getattr(getattr(tdmsg, "sender_id", None), "user_id", None),
124
+ sender_username=_first_username(sender) if sender is not None else None,
125
+ from_me=bool(getattr(tdmsg, "is_outgoing", False)),
126
+ edited=bool(getattr(tdmsg, "edit_date", 0)), # Int32: 0 = never
127
+ has_photo=bool(getattr(content, "photo", None)),
128
+ date=wire_date,
129
+ )
130
+
131
+
132
+ def _addressed(tdmsg, plain_text: str):
133
+ """Who the challenge names, TDLib edition: `T.addressed` over the
134
+ plain text PLUS its entities. TDLib keeps mentions OUT of the text
135
+ (a user mention renders as the display name, its id lives on a
136
+ `textEntityTypeMentionName` entity; an @name is a `...Mention`
137
+ entity), so the text alone would find nothing and every session
138
+ would start with names_account=False — which the policy then
139
+ refuses. Returns `(ids, names)` exactly like `T.addressed`."""
140
+ ids, names = T.addressed(plain_text)
141
+ body = getattr(getattr(tdmsg, "content", None), "text", None)
142
+ for ent in (getattr(body, "entities", None) or []):
143
+ etype = getattr(ent, "type_", None) or getattr(ent, "type", None)
144
+ uid = getattr(etype, "user_id", None) # mentionName entity
145
+ if uid is not None:
146
+ ids.add(uid)
147
+ elif getattr(etype, "ID", "") == "textEntityTypeMention":
148
+ seg = _utf16_slice(plain_text, getattr(ent, "offset", None),
149
+ getattr(ent, "length", None))
150
+ if seg.startswith("@"):
151
+ names.add(seg[1:].lower())
152
+ return ids, names
153
+
154
+
155
+ def _td_error():
156
+ """aiotdlib's AioTDLibError (attributes `.code` / `.message`),
157
+ imported lazily so this module imports without aiotdlib installed —
158
+ the offline tests hand in a stub package through sys.modules."""
159
+ from aiotdlib.api.errors.error import AioTDLibError
160
+ return AioTDLibError
161
+
162
+
163
+ def _callback_payload(data):
164
+ """CallbackQueryPayloadData(data=...) — the one TDLib object
165
+ `click` must construct. Lazy import for the same reason as
166
+ `_td_error`."""
167
+ from aiotdlib.api.types.all import CallbackQueryPayloadData
168
+ return CallbackQueryPayloadData(data=data)
169
+
170
+
171
+ class TDLibHands(PartialHands):
172
+ """The Hands protocol over an aiotdlib Client. Constructed by
173
+ `mount`; usable directly by hosts that drive the Runner
174
+ themselves."""
175
+
176
+ def __init__(self, client, *, label: str = "?",
177
+ join_fn: Optional[Callable[[str], Awaitable[bool]]] = None,
178
+ set_last_name_fn: Optional[Callable[[str], Awaitable[bool]]] = None,
179
+ delete_sent_after_s: float = 60.0,
180
+ default_sticker: str = "",
181
+ inbox: Optional["asyncio.Queue[InboxItem]"] = None,
182
+ dm_mount: Optional[Callable[[Any, Callable], None]] = None):
183
+ self.client = client
184
+ self.label = label
185
+ self.join_fn = join_fn
186
+ self.set_last_name_fn = set_last_name_fn
187
+ self.delete_sent_after_s = delete_sent_after_s
188
+ # No default ("") → send_sticker returns None, exactly like a
189
+ # TelethonHands built without one: the send-a-sticker gates
190
+ # never name a sticker, so the executor's own default is the
191
+ # only way that lane can ever fire.
192
+ self.default_sticker = default_sticker
193
+ self.inbox = inbox
194
+ self.dm_mount = dm_mount
195
+ self._dm_peer: Dict[str, Any] = {}
196
+ self._seen: Dict[tuple, Any] = {} # (where, msg_id) -> tdmsg
197
+ self._bg: set = set()
198
+
199
+ def remember(self, where: str, tdmsg) -> None:
200
+ self._seen[(where, tdmsg.id)] = tdmsg
201
+
202
+ def _find(self, action: Click):
203
+ where = getattr(action, "where", None)
204
+ return (self._seen.get((where, action.msg_id)) if where else
205
+ self._seen.get(("group", action.msg_id)) or
206
+ self._seen.get(("dm", action.msg_id)))
207
+
208
+ async def click(self, action, session, on_alert=None) -> bool:
209
+ tdmsg = self._find(action)
210
+ if tdmsg is None:
211
+ return False
212
+ api = _api_of(self.client)
213
+ chat_id = getattr(tdmsg, "chat_id", None)
214
+ # Click by LABEL on a FRESH copy of the message: gate bots
215
+ # re-render keyboards (Rose's text2 badges reshuffle rows), and
216
+ # a TDLib edit update never carries the whole message anyway.
217
+ try:
218
+ fresh = await api.get_message(chat_id, tdmsg.id)
219
+ if fresh is not None:
220
+ self._seen[(getattr(action, "where", None) or "group", tdmsg.id)] = fresh
221
+ tdmsg = fresh
222
+ except Exception:
223
+ pass
224
+ rows = getattr(getattr(tdmsg, "reply_markup", None), "rows", None) or []
225
+ if action.label:
226
+ btn = next((b for row in rows for b in row
227
+ if (getattr(b, "text", "") or "") == action.label), None)
228
+ if btn is None: # label vanished on a re-render: row/col
229
+ btn = self._at(rows, action.row, action.col)
230
+ else:
231
+ btn = self._at(rows, action.row, action.col)
232
+ data = getattr(getattr(btn, "type_", None), "data", None) \
233
+ if btn is not None else None
234
+ if data is None:
235
+ # A URL button (or no such button at all). URL buttons in
236
+ # TDLib are client-side navigation: there is NO server-side
237
+ # press to send, so the action simply fails — never faked.
238
+ return False
239
+ try:
240
+ answer = await api.get_callback_query_answer(
241
+ chat_id, tdmsg.id, payload=_callback_payload(data))
242
+ except Exception as e:
243
+ # TDLib error 502 = the press WAS delivered but the bot did
244
+ # not answer its callback query in time. That is success
245
+ # for us (and there is no alert text to report).
246
+ try:
247
+ err_cls = _td_error()
248
+ except ImportError:
249
+ err_cls = None
250
+ if err_cls is not None and isinstance(e, err_cls) and \
251
+ getattr(e, "code", None) == 502:
252
+ return True
253
+ raise
254
+ if on_alert is not None:
255
+ a = getattr(answer, "text", None)
256
+ if a:
257
+ on_alert(a)
258
+ return True
259
+
260
+ async def send_text(self, action: SendText, session: Session) -> Optional[Message]:
261
+ if action.where == "dm":
262
+ peer = self._dm_peer.get("chat_id")
263
+ if peer is None:
264
+ return None
265
+ sent = await self.client.send_text(peer, action.text)
266
+ where = "dm"
267
+ else:
268
+ chat = getattr(self, "_chat", None)
269
+ if chat is None:
270
+ chat = session.chat_id
271
+ sent = await self.client.send_text(
272
+ chat, action.text, reply_to_message_id=session.challenge.msg_id)
273
+ where = "group"
274
+ self._delete_later(chat, getattr(sent, "id", None))
275
+ m = to_message(sent, where=where)
276
+ m.from_me = True
277
+ return m
278
+
279
+ async def send_sticker(self, action: SendSticker,
280
+ session: Session) -> Optional[Message]:
281
+ """One sticker — a reply to the challenge in the group, or into
282
+ the gate bot's DM. The action may carry the sticker (`file_id`:
283
+ a TDLib file id or a local path — aiotdlib's send_sticker takes
284
+ both); empty falls back to this hands' `default_sticker` (the
285
+ send-a-sticker gates never name one). No default configured →
286
+ not supported, same as PartialHands."""
287
+ target = action.file_id or self.default_sticker
288
+ if not target:
289
+ return None
290
+ if action.where == "dm":
291
+ peer = self._dm_peer.get("chat_id")
292
+ if peer is None:
293
+ return None
294
+ sent = await self.client.send_sticker(peer, target)
295
+ where = "dm"
296
+ else:
297
+ chat = getattr(self, "_chat", None) or session.chat_id
298
+ sent = await self.client.send_sticker(
299
+ chat, target, reply_to_message_id=session.challenge.msg_id)
300
+ where = "group"
301
+ self._delete_later(chat, getattr(sent, "id", None))
302
+ m = to_message(sent, where=where)
303
+ m.from_me = True
304
+ return m
305
+
306
+ async def fetch_image(self, msg: Message, session: Session) -> Optional[bytes]:
307
+ tdmsg = self._seen.get((msg.where or "group", msg.msg_id)) or \
308
+ self._seen.get(("group", msg.msg_id)) or self._seen.get(("dm", msg.msg_id))
309
+ if tdmsg is None:
310
+ return None
311
+ sizes = getattr(getattr(getattr(tdmsg, "content", None),
312
+ "photo", None), "sizes", None) or []
313
+ if not sizes:
314
+ return None
315
+ fid = getattr(getattr(sizes[-1], "photo", None), "id", None)
316
+ if fid is None:
317
+ return None
318
+ # synchronous=True blocks until the file is on disk (priority
319
+ # 32 = near the front of TDLib's download queue); the returned
320
+ # File only carries a LOCAL path, so the bytes are read from it.
321
+ f = await _api_of(self.client).download_file(
322
+ fid, priority=32, offset=0, limit=0, synchronous=True)
323
+ path = getattr(getattr(f, "local", None), "path", None)
324
+ if not path:
325
+ return None
326
+ with open(path, "rb") as fh:
327
+ return fh.read()
328
+
329
+ async def fetch_bio(self, session: Session) -> str:
330
+ me = await _api_of(self.client).get_me()
331
+ full = await self.client.get_user_full_info(me.id)
332
+ # CAREFUL: UserFullInfo.bio is Optional[FormattedText], NOT a
333
+ # plain str — None (no bio) must become "", never "None".
334
+ return getattr(getattr(full, "bio", None), "text", None) or ""
335
+
336
+ async def set_bio(self, text: str, session: Session) -> bool:
337
+ try:
338
+ await _api_of(self.client).set_bio(bio=text or "")
339
+ except Exception as e:
340
+ # Telegram allows only a few bio edits per hour. TDLib
341
+ # surfaces the limit as error 429 with "retry after N" in
342
+ # the message. Never wait-and-retry from inside a session:
343
+ # surface the wait and let the decision flow end it.
344
+ try:
345
+ err_cls = _td_error()
346
+ except ImportError:
347
+ err_cls = None
348
+ if err_cls is not None and isinstance(e, err_cls) and \
349
+ getattr(e, "code", None) == 429:
350
+ m = re.search(r"retry after (\d+)",
351
+ str(getattr(e, "message", "") or ""))
352
+ wait_s = m.group(1) if m else 0
353
+ log.warning(f"[{self.label}] bio edit rate-limited "
354
+ f"(retry after {wait_s}s)")
355
+ return f"flood:{wait_s}"
356
+ raise
357
+ return True
358
+
359
+ async def open_dm(self, bot_username: str, start_param: str,
360
+ session: Session) -> bool:
361
+ api = _api_of(self.client)
362
+ # The gate bot's own id arrived with its message, so the DM is
363
+ # opened straight by id — no searchPublicChat spent. A bot the
364
+ # gate merely links to costs one resolve, by username.
365
+ if bot_username.lower() == (session.gate_bot or "") and \
366
+ session.challenge.sender_id:
367
+ bot_id = session.challenge.sender_id
368
+ else:
369
+ bot_id = getattr(await api.search_public_chat(bot_username), "id", None)
370
+ if bot_id is None:
371
+ return False
372
+ dm_chat = getattr(await api.create_private_chat(bot_id), "id", None)
373
+ if not self._dm_peer and self.dm_mount is not None:
374
+ self.dm_mount(dm_chat, self._push_dm)
375
+ self._dm_peer["chat_id"] = dm_chat
376
+ # The official deep-link entry point: /start <param> as a bot
377
+ # start message (plain /start text also works, but this one is
378
+ # what the deep-link flows expect).
379
+ await api.send_bot_start_message(bot_id, dm_chat, start_param or "")
380
+ return True
381
+
382
+ async def join_channel(self, ref: str, session: Session) -> bool:
383
+ # Native joins — TDLib has them built in, so unlike the Telethon
384
+ # and Pyrogram adapters no host callback is required (a join_fn
385
+ # still wins when the host wants its own join path).
386
+ if self.join_fn is not None:
387
+ return bool(await self.join_fn(ref))
388
+ try:
389
+ api = _api_of(self.client)
390
+ if "://" in ref:
391
+ await api.join_chat_by_invite_link(ref)
392
+ else:
393
+ chat_id = getattr(await api.search_public_chat(ref.lstrip("@")),
394
+ "id", None)
395
+ if chat_id is None:
396
+ return False
397
+ await api.join_chat(chat_id)
398
+ except Exception as e:
399
+ log.info(f"[{self.label}] join {ref!r} failed: "
400
+ f"{type(e).__name__}: {str(e)[:100]}")
401
+ return False
402
+ return True
403
+
404
+ async def set_last_name(self, value: str, session: Session) -> bool:
405
+ # Native too: set_name always requires first_name, ours is
406
+ # passed through untouched. A host callback still wins.
407
+ if self.set_last_name_fn is not None:
408
+ return bool(await self.set_last_name_fn(value))
409
+ try:
410
+ me = await _api_of(self.client).get_me()
411
+ await _api_of(self.client).set_name(
412
+ first_name=getattr(me, "first_name", "") or "",
413
+ last_name=value or "")
414
+ except Exception:
415
+ return False
416
+ return True
417
+
418
+ # ── internals ─────────────────────────────────────────────────────────
419
+ def _push_dm(self, tdmsg, edited: bool = False) -> None:
420
+ """The DM feeder handed to dm_mount — the DM twin of _Mounted's
421
+ group feed (edits arrive flagged, the Runner dedups)."""
422
+ if self.inbox is None:
423
+ return
424
+ m = to_message(tdmsg, where="dm")
425
+ if edited:
426
+ m.edited = True
427
+ self.inbox.put_nowait(("dm", m))
428
+
429
+ def set_chat(self, chat) -> None:
430
+ self._chat = chat
431
+
432
+ @staticmethod
433
+ def _at(rows, row, col):
434
+ try:
435
+ return rows[row][col]
436
+ except IndexError:
437
+ return None
438
+
439
+ def _delete_later(self, chat, msg_id) -> None:
440
+ if not self.delete_sent_after_s or not msg_id:
441
+ return
442
+
443
+ async def _go():
444
+ try:
445
+ await asyncio.sleep(self.delete_sent_after_s)
446
+ await _api_of(self.client).delete_messages(chat, [msg_id])
447
+ except asyncio.CancelledError:
448
+ raise
449
+ except Exception:
450
+ pass
451
+ try:
452
+ t = asyncio.get_running_loop().create_task(_go())
453
+ self._bg.add(t)
454
+ t.add_done_callback(self._bg.discard)
455
+ except RuntimeError:
456
+ pass
457
+
458
+
459
+ class _Mounted:
460
+ """What `mount` returns: the feed is live until `stop()`."""
461
+
462
+ def __init__(self, client, *, account, brain, runner, join_fn,
463
+ set_last_name_fn, delete_sent_after_s, default_sticker, label):
464
+ self.client = client
465
+ self.account = account
466
+ self.brain = brain
467
+ self.runner = runner
468
+ self.label = label
469
+ self._join_fn = join_fn
470
+ self._set_last_name_fn = set_last_name_fn
471
+ self._delete_sent_after_s = delete_sent_after_s
472
+ self._default_sticker = default_sticker
473
+ self._running: Dict[Any, dict] = {} # chat_id -> session record
474
+ self._handlers = [] # (handler obj, update type)
475
+
476
+ # ── the feed ──────────────────────────────────────────────────────────
477
+ async def on_group(self, client, update) -> None:
478
+ tdmsg = getattr(update, "message", None)
479
+ chat_id = getattr(tdmsg, "chat_id", None)
480
+ # TDLib ids say what a chat is: private chats (a bot DM!) are
481
+ # positive, groups and supergroups negative. Only groups feed
482
+ # the challenge detector; DMs arrive through the per-session
483
+ # dm_mount below, so this early-out also stops a gate bot's DM
484
+ # from being double-fed.
485
+ if tdmsg is None or chat_id is None or chat_id >= 0:
486
+ return
487
+ rec = self._running.get(chat_id)
488
+ if rec is not None:
489
+ self._feed(rec, tdmsg, "group")
490
+ return
491
+ gate = await self._gate_user(client, tdmsg)
492
+ if gate is None:
493
+ return
494
+ await self._start(client, tdmsg, chat_id, gate)
495
+
496
+ async def on_edit(self, client, update) -> None:
497
+ # BOTH edit flavors land here: updateMessageEdited carries only
498
+ # metadata, updateMessageContent only the new content — neither
499
+ # is a whole message, so the full one is re-fetched (the cached
500
+ # copy is stale by definition) and pushed with edited=True. The
501
+ # Runner dedups by (where, msg_id).
502
+ chat_id = getattr(update, "chat_id", None)
503
+ msg_id = getattr(update, "message_id", None)
504
+ rec = self._running.get(chat_id)
505
+ if rec is None or msg_id is None:
506
+ return
507
+ try:
508
+ tdmsg = await _api_of(client).get_message(chat_id, msg_id)
509
+ except Exception:
510
+ return
511
+ if tdmsg is not None:
512
+ self._feed(rec, tdmsg, "group", edited=True)
513
+
514
+ def _feed(self, rec, tdmsg, where: str, *, edited: bool = False) -> None:
515
+ """One gate-bot message onto its session's inbox — the only
516
+ feeder. Anything the gate bot did not say is dropped here."""
517
+ uid = getattr(getattr(tdmsg, "sender_id", None), "user_id", None)
518
+ if uid is None or uid != rec["gate_id"]:
519
+ return
520
+ rec["hands"].remember(where, tdmsg)
521
+ m = to_message(tdmsg, where=where, sender=rec.get("gate_user"))
522
+ if edited:
523
+ m.edited = True
524
+ rec["inbox"].put_nowait((where, m))
525
+
526
+ @staticmethod
527
+ async def _gate_user(client, tdmsg):
528
+ """The demo heuristic from examples/telethon_executor.py, TDLib
529
+ edition: an incoming BOT message carrying an inline keyboard.
530
+ A TDLib message carries no bot flag (only a sender union), so
531
+ the keyboard is checked first — free — and only then is one
532
+ getUser spent. Returns the gate bot's `User` (the session wants
533
+ its username later), or None. Whether it NAMES this account is
534
+ a separate fact (`names_account`) — the policy refuses to act
535
+ on a challenge that does not."""
536
+ if getattr(tdmsg, "is_outgoing", False):
537
+ return None
538
+ if not getattr(getattr(tdmsg, "reply_markup", None), "rows", None):
539
+ return None
540
+ uid = getattr(getattr(tdmsg, "sender_id", None), "user_id", None)
541
+ if uid is None:
542
+ return None
543
+ try:
544
+ user = await client.get_user(uid)
545
+ except Exception:
546
+ return None
547
+ return user if getattr(getattr(user, "type_", None), "ID", "") \
548
+ == "userTypeBot" else None
549
+
550
+ async def _start(self, client, tdmsg, chat_id, gate_user) -> None:
551
+ if chat_id in self._running: # one session per chat at a time
552
+ return
553
+ if self.account is None:
554
+ # Lazy: only a real challenge spends the get_me() RPC.
555
+ me = await _api_of(client).get_me()
556
+ self.account = Account(getattr(me, "id", None), _first_username(me))
557
+ m = to_message(tdmsg, where="group", sender=gate_user)
558
+ ids, names = _addressed(tdmsg, m.text)
559
+ named = (self.account.id in ids or
560
+ (self.account.username or "").lower() in names)
561
+ inbox: "asyncio.Queue[InboxItem]" = asyncio.Queue()
562
+ hands = TDLibHands(client, label=self.label, join_fn=self._join_fn,
563
+ set_last_name_fn=self._set_last_name_fn,
564
+ delete_sent_after_s=self._delete_sent_after_s,
565
+ default_sticker=self._default_sticker, inbox=inbox)
566
+ hands.set_chat(chat_id)
567
+ hands.remember("group", tdmsg)
568
+ chat_title = None
569
+ try: # a TDLib message knows its chat only by id
570
+ chat_title = getattr(await client.get_chat(chat_id), "title", None)
571
+ except Exception:
572
+ pass
573
+ session = Session(
574
+ session_id=uuid.uuid4().hex,
575
+ account=self.account,
576
+ challenge=m,
577
+ chat_id=chat_id,
578
+ chat_title=chat_title,
579
+ names_account=named)
580
+ rec = {"inbox": inbox, "hands": hands, "gate_id": m.sender_id,
581
+ "gate_user": gate_user, "task": None, "dm_handlers": []}
582
+
583
+ def dm_mount(dm_chat_id, push):
584
+ # Same pattern as executor.py: the DM handlers are
585
+ # per-session and come off with the session. aiotdlib has
586
+ # no chat filters, so the dm chat id is checked inside each
587
+ # callback; edits are re-fetched, exactly like on_edit.
588
+ from aiotdlib.api import API # lazy, same as mount()
589
+ t = API.Types
590
+
591
+ async def _on_new(_client, update):
592
+ dmsg = getattr(update, "message", None)
593
+ if getattr(dmsg, "chat_id", None) == dm_chat_id:
594
+ push(dmsg)
595
+
596
+ async def _on_edit(_client, update):
597
+ if getattr(update, "chat_id", None) != dm_chat_id:
598
+ return
599
+ try:
600
+ dmsg = await _api_of(client).get_message(
601
+ dm_chat_id, update.message_id)
602
+ except Exception:
603
+ return
604
+ if dmsg is not None:
605
+ push(dmsg, edited=True)
606
+
607
+ for ut, cb in ((t.UPDATE_NEW_MESSAGE, _on_new),
608
+ (t.UPDATE_MESSAGE_EDITED, _on_edit),
609
+ (t.UPDATE_MESSAGE_CONTENT, _on_edit)):
610
+ rec["dm_handlers"].append((client.add_event_handler(cb, ut), ut))
611
+
612
+ hands.dm_mount = dm_mount
613
+ self._running[chat_id] = rec
614
+ rec["task"] = asyncio.get_running_loop().create_task(self._run(session, hands,
615
+ inbox, chat_id, rec))
616
+
617
+ async def _run(self, session, hands, inbox, chat_id, rec) -> None:
618
+ try:
619
+ out = await self.runner.run(session, hands, inbox)
620
+ log.info(f"[{self.label}] chat {chat_id}: {out['result']} — {out['reason']}")
621
+ return out
622
+ finally:
623
+ for h, ut in rec["dm_handlers"]:
624
+ try:
625
+ self.client.remove_event_handler(h, ut)
626
+ except Exception:
627
+ pass
628
+ self._running.pop(chat_id, None)
629
+
630
+ # ── lifecycle ─────────────────────────────────────────────────────────
631
+ def stop(self) -> None:
632
+ """Remove every handler mount added. Running sessions finish on
633
+ their own (the Runner's deadline bounds them)."""
634
+ for h, ut in self._handlers:
635
+ try:
636
+ self.client.remove_event_handler(h, ut)
637
+ except Exception:
638
+ pass
639
+ self._handlers = []
640
+
641
+
642
+ def mount(client, *, account: Optional[Account] = None,
643
+ brain: Optional[Brain] = None,
644
+ delay_fn: Optional[DelayFn] = None,
645
+ join_fn: Optional[Callable[[str], Awaitable[bool]]] = None,
646
+ set_last_name_fn: Optional[Callable[[str], Awaitable[bool]]] = None,
647
+ policy: Optional[PolicyConfig] = None,
648
+ observe_s: float = 8.0, quiet_s: float = 2.0,
649
+ session_deadline_s: float = 300.0, delete_sent_after_s: float = 60.0,
650
+ default_sticker: str = "", label: str = "?") -> _Mounted:
651
+ """The 3-line UX: watch the account's groups for captcha challenges
652
+ and run one session per chat through the shared Runner.
653
+
654
+ `account` may be left None — it is fetched via `api.get_me()`
655
+ lazily, when the first challenge actually appears. `delay_fn` None
656
+ means no human pause, exactly as in the Telethon executor. Unlike
657
+ the Telethon/Pyrogram adapters, join_channel and set_last_name are
658
+ NATIVE here (TDLib ships joinChatByInviteLink and setName) — the
659
+ host callbacks still win when given. `default_sticker` is the
660
+ executor's own sticker for the send-a-sticker gates, which never
661
+ name one.
662
+ """
663
+ from aiotdlib.api import API # the one aiotdlib import: update names
664
+ t = API.Types
665
+
666
+ b = brain or Brain() # one Brain: Brain() = the local Engine only
667
+ mounted = _Mounted(
668
+ client, account=account, brain=b,
669
+ runner=Runner(b, policy=policy or PolicyConfig(),
670
+ delay_fn=delay_fn, observe_s=observe_s, quiet_s=quiet_s,
671
+ session_deadline_s=session_deadline_s, label=label),
672
+ join_fn=join_fn, set_last_name_fn=set_last_name_fn,
673
+ delete_sent_after_s=delete_sent_after_s, default_sticker=default_sticker,
674
+ label=label)
675
+
676
+ # New group messages, plus BOTH edit flavors (TDLib splits edits
677
+ # into updateMessageEdited / updateMessageContent; both re-fetch in
678
+ # on_edit). add_event_handler returns the Handler object
679
+ # remove_event_handler wants, so it is kept.
680
+ for ut, cb in ((t.UPDATE_NEW_MESSAGE, mounted.on_group),
681
+ (t.UPDATE_MESSAGE_EDITED, mounted.on_edit),
682
+ (t.UPDATE_MESSAGE_CONTENT, mounted.on_edit)):
683
+ mounted._handlers.append((client.add_event_handler(cb, ut), ut))
684
+ return mounted