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
tgcaptcha/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ tgcaptcha — solve Telegram group-join captchas with pluggable solvers.
3
+
4
+ Two halves:
5
+ • the BRAIN (pure): solvers, declarative rules and the engine that picks
6
+ one — runs in the solver service, and as a fallback on the node;
7
+ • the HANDS (on the account's host): a `Runner` loop that turns a
8
+ challenge into a `Session`, asks for a decision, runs every action
9
+ past the safety policy, performs it through the framework-agnostic
10
+ `Hands` protocol (8 async methods), and loops (observe → act) until
11
+ done. Any Telegram framework can be an adapter — Telethon ships in
12
+ `tgcaptcha.executor`, the Pyrogram family in `tgcaptcha.adapters`;
13
+ anything else: `examples/diy_adapter.py`.
14
+
15
+ New solvers and rules ship to the service only; nodes never restart for
16
+ them. See README.md and docs/ADAPTERS.md.
17
+ """
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ from tgcaptcha.engine import Engine # noqa: F401
22
+ from tgcaptcha.hands import Hands, PartialHands # noqa: F401
23
+ from tgcaptcha.model import ( # noqa: F401
24
+ Account, Button, Click, Decision, Done, FetchBio, FetchImage, JoinChannel,
25
+ Message, OpenDM, Performed, SendText, Session, SetBio, SetLastName, Wait)
26
+ from tgcaptcha.policy import PolicyConfig, check # noqa: F401
27
+ from tgcaptcha.rules import RuleSet # noqa: F401
28
+ from tgcaptcha.runner import Runner # noqa: F401
29
+ from tgcaptcha.solvers import Solver # noqa: F401
@@ -0,0 +1,25 @@
1
+ """
2
+ Framework adapters. Each submodule turns one client family into the three
3
+ things tgcaptcha needs: a `to_message` wire converter, a `Hands`
4
+ implementation, and a `mount()` that wires the whole feed.
5
+
6
+ Imports are lazy: `import tgcaptcha.adapters` never requires any client
7
+ framework — the attribute access pulls the submodule in, and the submodule
8
+ itself imports the framework only where it is actually unavoidable
9
+ (`mount`). `to_message` and `PyrogramHands` are plain getattr code, so
10
+ they run (and are tested) with no framework installed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ _LAZY = ("to_message", "PyrogramHands", "mount")
16
+
17
+
18
+ def __getattr__(name: str):
19
+ if name in _LAZY:
20
+ from tgcaptcha.adapters import pyrogram as _pyro
21
+ return getattr(_pyro, name)
22
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
23
+
24
+
25
+ __all__ = list(_LAZY)
@@ -0,0 +1,508 @@
1
+ """
2
+ The Pyrogram-family adapter: pyrogram, kurigram, pyrofork, hydrogram —
3
+ one API, one adapter. Turn a client into Hands + a message feed, and let
4
+ the shared Runner (`tgcaptcha.runner`) own the loop. Semantics mirror
5
+ `TelethonHands` in `tgcaptcha.executor` one for one.
6
+
7
+ brain = Brain("http://solver:8090", token=...) # or Brain() = local
8
+ handle = mount(app, brain=brain, account=Account(me.id, me.username))
9
+ ...
10
+ handle.stop()
11
+
12
+ `mount` is the 3-line UX (detect challenges, run sessions, serialize per
13
+ chat); `PyrogramHands` and `to_message` are for hosts that drive the
14
+ Runner themselves.
15
+
16
+ This module never imports the framework at import time: `to_message` and
17
+ `PyrogramHands` are pure getattr code over whatever objects they are
18
+ handed (that is also what the offline tests exercise), and `mount` pulls
19
+ the installed family in lazily — the family's root module is derived
20
+ from the client object (`type(app).__module__`), so hydrogram mounts
21
+ through the same code as pyrogram, kurigram and pyrofork. Method-name
22
+ differences inside the family are probed at call time:
23
+ • bio write: kurigram ≥2.2 also exposes `set_profile_info`; every
24
+ family member has `update_profile(bio=...)`.
25
+ • edited group messages: kurigram routes edits to
26
+ `EditedMessageHandler` (classic pyrogram delivers them to
27
+ `MessageHandler`), so `mount` registers the edit handler only when
28
+ the installed family ships one.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import importlib
35
+ import logging
36
+ import time
37
+ import uuid
38
+ from typing import Any, Awaitable, Callable, Dict, Optional
39
+
40
+ from tgcaptcha import text as T
41
+ from tgcaptcha.client import Brain
42
+ from tgcaptcha.hands import PartialHands
43
+ from tgcaptcha.model import (Account, Button, Click, Message, SendText, Session)
44
+ from tgcaptcha.policy import PolicyConfig
45
+ from tgcaptcha.runner import InboxItem, Runner
46
+
47
+ log = logging.getLogger("tgcaptcha.adapters.pyrogram")
48
+
49
+ DelayFn = Callable[[str], float]
50
+
51
+
52
+ def _wire_text(pmsg) -> str:
53
+ """Plain family text with text-mention entities folded back in as
54
+ ``[name](tg://user?id=N)`` links — the shape the corpus and
55
+ ``T.addressed`` expect. Real family messages carry mention-by-id as
56
+ an ENTITY whose plain `.text` loses ("Jybfghhd, press …"), so the
57
+ names_account check would refuse every real challenge; fakes that
58
+ already embed the markup pass through untouched (no entities →
59
+ text as-is). Entity offsets are UTF-16 code units in the family,
60
+ not python characters."""
61
+ text = getattr(pmsg, "text", None) or ""
62
+ ents = getattr(pmsg, "entities", None) or []
63
+ if not text:
64
+ # Photo/video challenges carry their text as a CAPTION: the
65
+ # family splits it onto `.caption` / `.caption_entities`
66
+ # (Telethon's `.text` covers both; the family's does not).
67
+ text = getattr(pmsg, "caption", None) or ""
68
+ ents = getattr(pmsg, "caption_entities", None) or ents
69
+ if not ents or not text:
70
+ return text
71
+
72
+ def u16_to_py(off: int) -> int:
73
+ n = 0
74
+ for i, ch in enumerate(text):
75
+ if n >= off:
76
+ return i
77
+ n += 2 if ord(ch) > 0xFFFF else 1
78
+ return len(text)
79
+
80
+ parts, pos = [], 0
81
+ for e in ents:
82
+ if "TEXT_MENTION" not in str(getattr(e, "type", "")):
83
+ continue
84
+ # pyrogram flattens the mentioned user to `user_id`; hydrogram
85
+ # keeps a `user` object instead. Read both.
86
+ uid = getattr(e, "user_id", None)
87
+ if uid is None:
88
+ uid = getattr(getattr(e, "user", None), "id", None)
89
+ if uid is None:
90
+ continue
91
+ s, t = u16_to_py(getattr(e, "offset", 0)), \
92
+ u16_to_py(getattr(e, "offset", 0) + getattr(e, "length", 0))
93
+ if s < pos or t <= s:
94
+ continue
95
+ parts += [text[pos:s], f"[{text[s:t]}](tg://user?id={uid})"]
96
+ pos = t
97
+ parts.append(text[pos:])
98
+ return "".join(parts)
99
+
100
+
101
+ def to_message(pmsg, *, where: str = "group") -> Message:
102
+ """A pyrogram-family message as the wire model sees it. No RPC.
103
+ Works on `Message` and `EditedMessage` alike — everything is read
104
+ through getattr, so a message missing a field degrades, not crashes."""
105
+ rows = []
106
+ markup = getattr(pmsg, "reply_markup", None)
107
+ for row in (getattr(markup, "inline_keyboard", None) or []):
108
+ out = []
109
+ for b in row:
110
+ url = getattr(b, "url", None)
111
+ data = getattr(b, "callback_data", None)
112
+ kind = "callback" if data is not None else ("url" if url else "other")
113
+ out.append(Button(text=getattr(b, "text", "") or "", kind=kind,
114
+ url=(url or None) and str(url)[:300]))
115
+ rows.append(out)
116
+ sender = getattr(pmsg, "from_user", None)
117
+ date = getattr(pmsg, "date", None)
118
+ return Message(
119
+ msg_id=getattr(pmsg, "id", None),
120
+ text=_wire_text(pmsg),
121
+ buttons=rows,
122
+ where=where,
123
+ sender_id=getattr(sender, "id", None),
124
+ sender_username=getattr(sender, "username", None),
125
+ from_me=bool(getattr(pmsg, "outgoing", False)),
126
+ edited=bool(getattr(pmsg, "edit_date", None)),
127
+ has_photo=bool(getattr(pmsg, "photo", None)),
128
+ date=date.timestamp() if hasattr(date, "timestamp") else None,
129
+ )
130
+
131
+
132
+ def _family_root(app=None) -> str:
133
+ """Root module of the installed pyrogram-family package, derived from
134
+ the client object when one is at hand: a hydrogram Client's class
135
+ lives in `hydrogram`, a pyrogram / kurigram / pyrofork one in
136
+ `pyrogram`. The whole family shares one API, so only this name
137
+ differs. An unknown root that does not ship a `.handlers` module
138
+ falls back to classic pyrogram."""
139
+ if app is not None:
140
+ root = type(app).__module__.split(".", 1)[0]
141
+ if root != "pyrogram":
142
+ try:
143
+ importlib.import_module(f"{root}.handlers")
144
+ return root
145
+ except ImportError:
146
+ pass
147
+ return "pyrogram"
148
+
149
+
150
+ def _flood_wait_cls(client=None):
151
+ """The installed family's FloodWait error, or None without a family."""
152
+ try:
153
+ errors = importlib.import_module(f"{_family_root(client)}.errors")
154
+ return errors.FloodWait
155
+ except ImportError:
156
+ return None
157
+
158
+
159
+ class PyrogramHands(PartialHands):
160
+ """The Hands protocol over a pyrogram-family client. Constructed by
161
+ `mount`; usable directly by hosts that drive the Runner themselves."""
162
+
163
+ def __init__(self, client, *, label: str = "?",
164
+ join_fn: Optional[Callable[[str], Awaitable[bool]]] = None,
165
+ set_last_name_fn: Optional[Callable[[str], Awaitable[bool]]] = None,
166
+ delete_sent_after_s: float = 60.0,
167
+ inbox: Optional["asyncio.Queue[InboxItem]"] = None,
168
+ dm_mount: Optional[Callable[[Any, Callable], None]] = None):
169
+ self.client = client
170
+ self.label = label
171
+ self.join_fn = join_fn
172
+ self.set_last_name_fn = set_last_name_fn
173
+ self.delete_sent_after_s = delete_sent_after_s
174
+ self.inbox = inbox
175
+ self.dm_mount = dm_mount
176
+ self._dm_peer: Dict[str, Any] = {}
177
+ self._seen: Dict[tuple, Any] = {} # (where, msg_id) -> pmsg
178
+ self._bg: set = set()
179
+
180
+ def remember(self, where: str, pmsg) -> None:
181
+ self._seen[(where, pmsg.id)] = pmsg
182
+
183
+ def _find(self, action: Click):
184
+ where = getattr(action, "where", None)
185
+ return (self._seen.get((where, action.msg_id)) if where else
186
+ self._seen.get(("group", action.msg_id)) or
187
+ self._seen.get(("dm", action.msg_id)))
188
+
189
+ async def click(self, action, session, on_alert=None) -> bool:
190
+ pmsg = self._find(action)
191
+ if pmsg is None:
192
+ return False
193
+ # Click by LABEL on a FRESH copy of the message: gate bots
194
+ # re-render keyboards (Rose's text2 badges reshuffle rows), so the
195
+ # cached snapshot's row/col can point at a different key.
196
+ try:
197
+ fresh = await self.client.get_messages(
198
+ getattr(getattr(pmsg, "chat", None), "id", None), message_ids=pmsg.id)
199
+ if fresh is not None:
200
+ self._seen[(getattr(action, "where", None) or "group", pmsg.id)] = fresh
201
+ pmsg = fresh
202
+ except Exception:
203
+ pass
204
+ if action.label:
205
+ try:
206
+ resp = await pmsg.click(action.label)
207
+ except Exception:
208
+ resp = await pmsg.click(action.row, action.col)
209
+ else:
210
+ resp = await pmsg.click(action.row, action.col)
211
+ if on_alert is not None:
212
+ a = getattr(resp, "message", None) if resp is not None else None
213
+ if a:
214
+ on_alert(a)
215
+ return True
216
+
217
+ async def send_text(self, action: SendText, session: Session) -> Optional[Message]:
218
+ if action.where == "dm":
219
+ peer = self._dm_peer.get("peer")
220
+ if peer is None:
221
+ return None
222
+ sent = await self.client.send_message(peer, action.text)
223
+ where = "dm"
224
+ else:
225
+ chat = getattr(self, "_chat", None)
226
+ if chat is None:
227
+ chat = session.chat_id
228
+ sent = await self.client.send_message(
229
+ chat, action.text, reply_to_message_id=session.challenge.msg_id)
230
+ where = "group"
231
+ self._delete_later(chat, getattr(sent, "id", None))
232
+ m = to_message(sent, where=where)
233
+ m.from_me = True
234
+ return m
235
+
236
+ async def fetch_image(self, msg: Message, session: Session) -> Optional[bytes]:
237
+ pmsg = self._seen.get((msg.where or "group", msg.msg_id)) or \
238
+ self._seen.get(("group", msg.msg_id)) or self._seen.get(("dm", msg.msg_id))
239
+ if pmsg is None:
240
+ return None
241
+ # A fetch is observation, not a gate-bot-facing action, so a short
242
+ # bounded retry is safe here (the "no retry loops" rule exists to
243
+ # keep the bot from being poked repeatedly). Slow routes make the
244
+ # family's download_media return None intermittently; the file_id
245
+ # path is a second, independent route to the same bytes.
246
+ targets = [pmsg]
247
+ photo = getattr(pmsg, "photo", None)
248
+ if photo is not None and getattr(photo, "file_id", None):
249
+ targets.append(photo.file_id)
250
+ for attempt in range(2):
251
+ for t in targets:
252
+ try:
253
+ blob = await self.client.download_media(t, in_memory=True)
254
+ except Exception as e:
255
+ log.warning(f"[{self.label}] image download error "
256
+ f"(attempt {attempt + 1}): {e}")
257
+ blob = None
258
+ if blob is None:
259
+ continue
260
+ # The family's in-memory download hands back a BytesIO
261
+ # positioned at the END (live-proven on hydrogram):
262
+ # .read() there yields b"" while the bytes exist — read
263
+ # via getvalue() first, fall back to read() for exotic
264
+ # buffer shapes.
265
+ data = (blob.getvalue() if hasattr(blob, "getvalue")
266
+ else blob.read() if hasattr(blob, "read") else bytes(blob))
267
+ if data:
268
+ return data
269
+ if attempt == 0:
270
+ await asyncio.sleep(1.0)
271
+ return None
272
+
273
+ async def fetch_bio(self, session: Session) -> str:
274
+ me = await self.client.get_chat("me")
275
+ return getattr(me, "bio", None) or ""
276
+
277
+ async def set_bio(self, text: str, session: Session) -> bool:
278
+ try:
279
+ # kurigram ≥2.2 grew set_profile_info; the whole family keeps
280
+ # update_profile(bio=...). An empty string CLEARS the bio.
281
+ fn = getattr(self.client, "set_profile_info", None) or self.client.update_profile
282
+ await fn(bio=text or "")
283
+ except Exception as e:
284
+ # Telegram allows only a few bio edits per hour. Never
285
+ # wait-and-retry from inside a session: surface the wait and
286
+ # let the decision flow end it.
287
+ flood = _flood_wait_cls(self.client)
288
+ if flood is not None and isinstance(e, flood):
289
+ wait_s = getattr(e, "value", None)
290
+ log.warning(f"[{self.label}] bio edit rate-limited (flood wait {wait_s}s)")
291
+ return f"flood:{wait_s}"
292
+ raise
293
+ return True
294
+
295
+ async def open_dm(self, bot_username: str, start_param: str, session: Session) -> bool:
296
+ # The gate bot's own id arrived with its message, so it is reached
297
+ # by id — no ResolveUsername spent. A bot it merely links to costs
298
+ # one resolve, by username.
299
+ if bot_username.lower() == (session.gate_bot or "") and session.challenge.sender_id:
300
+ peer = session.challenge.sender_id
301
+ else:
302
+ peer = bot_username
303
+ if not self._dm_peer and self.dm_mount is not None:
304
+ self.dm_mount(peer, lambda m: self.inbox and self.inbox.put_nowait(
305
+ ("dm", to_message(m, where="dm"))))
306
+ self._dm_peer["peer"] = peer
307
+ cmd = "/start" + (f" {start_param}" if start_param else "")
308
+ await self.client.send_message(peer, cmd)
309
+ return True
310
+
311
+ async def join_channel(self, ref: str, session: Session) -> bool:
312
+ if self.join_fn is None:
313
+ return False
314
+ return bool(await self.join_fn(ref))
315
+
316
+ async def set_last_name(self, value: str, session: Session) -> bool:
317
+ if self.set_last_name_fn is None:
318
+ return False
319
+ return bool(await self.set_last_name_fn(value))
320
+
321
+ # ── internals ─────────────────────────────────────────────────────────
322
+ def set_chat(self, chat) -> None:
323
+ self._chat = chat
324
+
325
+ def _delete_later(self, chat, msg_id) -> None:
326
+ if not self.delete_sent_after_s or not msg_id:
327
+ return
328
+
329
+ async def _go():
330
+ try:
331
+ await asyncio.sleep(self.delete_sent_after_s)
332
+ await self.client.delete_messages(chat, [msg_id])
333
+ except asyncio.CancelledError:
334
+ raise
335
+ except Exception:
336
+ pass
337
+ try:
338
+ t = asyncio.get_running_loop().create_task(_go())
339
+ self._bg.add(t)
340
+ t.add_done_callback(self._bg.discard)
341
+ except RuntimeError:
342
+ pass
343
+
344
+
345
+ class _Mounted:
346
+ """What `mount` returns: the feed is live until `stop()`."""
347
+
348
+ def __init__(self, app, *, account, brain, runner, join_fn, set_last_name_fn,
349
+ delete_sent_after_s, label):
350
+ self.app = app
351
+ self.account = account
352
+ self.brain = brain
353
+ self.runner = runner
354
+ self.label = label
355
+ self._join_fn = join_fn
356
+ self._set_last_name_fn = set_last_name_fn
357
+ self._delete_sent_after_s = delete_sent_after_s
358
+ self._running: Dict[int, dict] = {} # chat_id -> session record
359
+ self._handlers = []
360
+
361
+ # ── the feed ──────────────────────────────────────────────────────────
362
+ async def on_group(self, client, pmsg) -> None:
363
+ chat_id = getattr(getattr(pmsg, "chat", None), "id", None)
364
+ rec = self._running.get(chat_id)
365
+ if rec is not None:
366
+ gate_id = rec["gate_id"]
367
+ if getattr(pmsg, "from_user", None) is not None and \
368
+ getattr(pmsg.from_user, "id", None) == gate_id:
369
+ rec["hands"].remember("group", pmsg)
370
+ rec["inbox"].put_nowait(("group", to_message(pmsg, where="group")))
371
+ return
372
+ if not self._looks_like_challenge(pmsg):
373
+ return
374
+ await self._start(client, pmsg, chat_id)
375
+
376
+ @staticmethod
377
+ def _looks_like_challenge(pmsg) -> bool:
378
+ """The same demo heuristic as examples/telethon_executor.py: an
379
+ incoming bot message carrying an inline keyboard. Whether it NAMES
380
+ this account is a separate fact (`names_account`) — the policy
381
+ refuses to act on a challenge that does not."""
382
+ sender = getattr(pmsg, "from_user", None)
383
+ # The family's User field is `is_bot` (a live hydrogram session
384
+ # exposes ONLY that — no `.bot`); `bot` is read too so hosts with
385
+ # their own user shapes keep working.
386
+ if sender is None or not (getattr(sender, "is_bot", False)
387
+ or getattr(sender, "bot", False)):
388
+ return False
389
+ markup = getattr(pmsg, "reply_markup", None)
390
+ return bool(getattr(markup, "inline_keyboard", None))
391
+
392
+ async def _start(self, client, pmsg, chat_id) -> None:
393
+ if chat_id in self._running: # one session per chat at a time
394
+ return
395
+ if self.account is None:
396
+ # Lazy: only a real challenge spends the get_me() RPC.
397
+ me = await client.get_me()
398
+ self.account = Account(getattr(me, "id", None),
399
+ getattr(me, "username", None))
400
+ m = to_message(pmsg, where="group")
401
+ ids, names = T.addressed(m.text)
402
+ named = (self.account.id in ids or
403
+ (self.account.username or "").lower() in names)
404
+ inbox: "asyncio.Queue[InboxItem]" = asyncio.Queue()
405
+ hands = PyrogramHands(client, label=self.label, join_fn=self._join_fn,
406
+ set_last_name_fn=self._set_last_name_fn,
407
+ delete_sent_after_s=self._delete_sent_after_s,
408
+ inbox=inbox)
409
+ hands.set_chat(chat_id)
410
+ hands.remember("group", pmsg)
411
+ session = Session(
412
+ session_id=uuid.uuid4().hex,
413
+ account=self.account,
414
+ challenge=m,
415
+ chat_id=chat_id,
416
+ chat_title=getattr(getattr(pmsg, "chat", None), "title", None),
417
+ names_account=named)
418
+ rec = {"inbox": inbox, "hands": hands, "gate_id": m.sender_id, "task": None,
419
+ "dm_handlers": []}
420
+
421
+ def dm_mount(peer, push):
422
+ # Same pattern as executor.py: the DM handlers are per-session
423
+ # and come off with the session. Imports follow the family the
424
+ # mounted client actually belongs to (pyrogram / hydrogram).
425
+ root = _family_root(self.app)
426
+ filters = importlib.import_module(f"{root}.filters")
427
+ handlers_mod = importlib.import_module(f"{root}.handlers")
428
+ MessageHandler = handlers_mod.MessageHandler
429
+
430
+ async def _cb(_client, dmsg):
431
+ push(dmsg)
432
+
433
+ h = MessageHandler(_cb, filters.chat(peer) & filters.incoming & ~filters.me)
434
+ self.app.add_handler(h)
435
+ rec["dm_handlers"].append(h)
436
+
437
+ hands.dm_mount = dm_mount
438
+ self._running[chat_id] = rec
439
+ rec["task"] = asyncio.get_running_loop().create_task(self._run(session, hands,
440
+ inbox, chat_id, rec))
441
+
442
+ async def _run(self, session, hands, inbox, chat_id, rec) -> None:
443
+ try:
444
+ out = await self.runner.run(session, hands, inbox)
445
+ log.info(f"[{self.label}] chat {chat_id}: {out['result']} — {out['reason']}")
446
+ finally:
447
+ for h in rec["dm_handlers"]:
448
+ try:
449
+ self.app.remove_handler(h)
450
+ except Exception:
451
+ pass
452
+ self._running.pop(chat_id, None)
453
+
454
+ # ── lifecycle ─────────────────────────────────────────────────────────
455
+ def stop(self) -> None:
456
+ """Remove every handler mount added. Running sessions finish on
457
+ their own (the Runner's deadline bounds them)."""
458
+ for h in self._handlers:
459
+ try:
460
+ self.app.remove_handler(h)
461
+ except Exception:
462
+ pass
463
+ self._handlers = []
464
+
465
+
466
+ def mount(app, *, account: Optional[Account] = None, brain: Optional[Brain] = None,
467
+ delay_fn: Optional[DelayFn] = None,
468
+ join_fn: Optional[Callable[[str], Awaitable[bool]]] = None,
469
+ set_last_name_fn: Optional[Callable[[str], Awaitable[bool]]] = None,
470
+ policy: Optional[PolicyConfig] = None,
471
+ observe_s: float = 8.0, quiet_s: float = 2.0,
472
+ session_deadline_s: float = 300.0, delete_sent_after_s: float = 60.0,
473
+ label: str = "?") -> _Mounted:
474
+ """The 3-line UX: watch the account's groups for captcha challenges
475
+ and run one session per chat through the shared Runner.
476
+
477
+ `account` may be left None — it is fetched via `app.get_me()` lazily,
478
+ when the first challenge actually appears. `delay_fn` None means no
479
+ human pause, exactly as in the Telethon executor. join_fn /
480
+ set_last_name_fn unset → those actions are refused, never improvised.
481
+ """
482
+ root = _family_root(app)
483
+ filters = importlib.import_module(f"{root}.filters")
484
+ handlers_mod = importlib.import_module(f"{root}.handlers")
485
+ MessageHandler = handlers_mod.MessageHandler
486
+
487
+ b = brain or Brain() # one Brain: Brain() = the local Engine only
488
+ mounted = _Mounted(
489
+ app, account=account, brain=b,
490
+ runner=Runner(b, policy=policy or PolicyConfig(),
491
+ delay_fn=delay_fn, observe_s=observe_s, quiet_s=quiet_s,
492
+ session_deadline_s=session_deadline_s, label=label),
493
+ join_fn=join_fn, set_last_name_fn=set_last_name_fn,
494
+ delete_sent_after_s=delete_sent_after_s, label=label)
495
+
496
+ # New group messages; edits ride along in classic pyrogram. kurigram
497
+ # splits edits into EditedMessageHandler, so register it when the
498
+ # installed family ships one — the callback and filter are identical.
499
+ flt = filters.incoming & filters.group & ~filters.me
500
+ handlers = [MessageHandler(mounted.on_group, flt)]
501
+ try:
502
+ handlers.append(handlers_mod.EditedMessageHandler(mounted.on_group, flt))
503
+ except AttributeError:
504
+ pass
505
+ for h in handlers:
506
+ app.add_handler(h)
507
+ mounted._handlers.append(h)
508
+ return mounted