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
|
@@ -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
|