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.
- tgcaptcha/__init__.py +29 -0
- tgcaptcha/adapters/__init__.py +25 -0
- tgcaptcha/adapters/pyrogram.py +508 -0
- tgcaptcha/adapters/tdlib.py +684 -0
- tgcaptcha/client.py +87 -0
- tgcaptcha/engine.py +122 -0
- tgcaptcha/executor.py +453 -0
- tgcaptcha/hands.py +123 -0
- tgcaptcha/model.py +245 -0
- tgcaptcha/policy.py +254 -0
- tgcaptcha/replay.py +156 -0
- tgcaptcha/rules.py +184 -0
- tgcaptcha/runner.py +312 -0
- tgcaptcha/server.py +188 -0
- tgcaptcha/solvers/__init__.py +50 -0
- tgcaptcha/solvers/bio.py +180 -0
- tgcaptcha/solvers/builtin.py +162 -0
- tgcaptcha/solvers/dm.py +171 -0
- tgcaptcha/solvers/emoji.py +119 -0
- tgcaptcha/solvers/gates.py +287 -0
- tgcaptcha/solvers/image.py +267 -0
- tgcaptcha/solvers/image_digits.py +216 -0
- tgcaptcha/solvers/image_text_pick.py +379 -0
- tgcaptcha/solvers/ocr.py +153 -0
- tgcaptcha/solvers/qa.py +127 -0
- tgcaptcha/solvers/rose.py +334 -0
- tgcaptcha/solvers/success.py +70 -0
- tgcaptcha/solvers/video.py +204 -0
- tgcaptcha/tests/__init__.py +0 -0
- tgcaptcha/tests/test_bio.py +431 -0
- tgcaptcha/tests/test_core.py +961 -0
- tgcaptcha/tests/test_image_digits.py +263 -0
- tgcaptcha/tests/test_image_pick.py +353 -0
- tgcaptcha/tests/test_pyro_adapter.py +498 -0
- tgcaptcha/tests/test_runner.py +187 -0
- tgcaptcha/tests/test_success.py +117 -0
- tgcaptcha/tests/test_tdlib_adapter.py +737 -0
- tgcaptcha/tests/test_video.py +127 -0
- tgcaptcha/text.py +398 -0
- tgcaptcha_solver-0.1.0.dist-info/METADATA +387 -0
- tgcaptcha_solver-0.1.0.dist-info/RECORD +44 -0
- tgcaptcha_solver-0.1.0.dist-info/WHEEL +5 -0
- tgcaptcha_solver-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|