rootpy-client 1.31.1__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.
- rootpy/__init__.py +393 -0
- rootpy/_registry_loader.py +122 -0
- rootpy/accounts.py +554 -0
- rootpy/attach.py +243 -0
- rootpy/auth.py +310 -0
- rootpy/cache.py +190 -0
- rootpy/client.py +1702 -0
- rootpy/commands.py +444 -0
- rootpy/data/enums.json +1 -0
- rootpy/data/message_schemas.json +1 -0
- rootpy/data/messages.json +1 -0
- rootpy/data/rpc_services.json +1 -0
- rootpy/data/services.json +1 -0
- rootpy/discovery.py +578 -0
- rootpy/dm_member.py +305 -0
- rootpy/domain_managers.py +677 -0
- rootpy/emoji.py +129 -0
- rootpy/enums.py +315 -0
- rootpy/events.py +153 -0
- rootpy/exceptions.py +687 -0
- rootpy/features.py +949 -0
- rootpy/gateway.py +1477 -0
- rootpy/generated_rpc_registry.py +18 -0
- rootpy/highlevel.py +1472 -0
- rootpy/host.py +594 -0
- rootpy/identifiers.py +119 -0
- rootpy/media.py +43 -0
- rootpy/media_bootstrap.py +111 -0
- rootpy/models.py +944 -0
- rootpy/object_api.py +606 -0
- rootpy/packet_schemas.py +181 -0
- rootpy/packets.py +176 -0
- rootpy/pagination.py +31 -0
- rootpy/permissions.py +237 -0
- rootpy/presence.py +173 -0
- rootpy/protocol.py +164 -0
- rootpy/py.typed +0 -0
- rootpy/raw_api.py +244 -0
- rootpy/responses.py +110 -0
- rootpy/root_exception.py +306 -0
- rootpy/service_facade.py +32 -0
- rootpy/services/__init__.py +27 -0
- rootpy/services/assets.py +503 -0
- rootpy/services/calls.py +1411 -0
- rootpy/services/communities.py +735 -0
- rootpy/services/community_admin.py +1036 -0
- rootpy/services/direct_messages.py +524 -0
- rootpy/services/discovery.py +204 -0
- rootpy/services/messages.py +811 -0
- rootpy/services/users.py +440 -0
- rootpy/stats.py +208 -0
- rootpy/structured_api.py +956 -0
- rootpy/structured_registry.py +61 -0
- rootpy/transport.py +403 -0
- rootpy/typed_events.py +496 -0
- rootpy/unread.py +546 -0
- rootpy/users.py +187 -0
- rootpy/validation.py +145 -0
- rootpy_client-1.31.1.dist-info/METADATA +157 -0
- rootpy_client-1.31.1.dist-info/RECORD +63 -0
- rootpy_client-1.31.1.dist-info/WHEEL +5 -0
- rootpy_client-1.31.1.dist-info/licenses/LICENSE +21 -0
- rootpy_client-1.31.1.dist-info/top_level.txt +1 -0
rootpy/accounts.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Creating test accounts on a whitelisted email domain.
|
|
2
|
+
|
|
3
|
+
Root gates signup behind a Cloudflare Turnstile challenge. That challenge is
|
|
4
|
+
the platform's control over automated signup, so this module doesn't try to
|
|
5
|
+
defeat it: you solve it yourself and pass the resulting token in. Everything
|
|
6
|
+
around it -- naming, the signup call, verification, bookkeeping -- is handled
|
|
7
|
+
here.
|
|
8
|
+
|
|
9
|
+
from rootpy.accounts import AccountFactory
|
|
10
|
+
|
|
11
|
+
factory = AccountFactory(email_pattern="hello-{tag}@example.com")
|
|
12
|
+
account = await factory.create(turnstile_token=token)
|
|
13
|
+
print(account.username, account.email)
|
|
14
|
+
factory.save("accounts.json") # keep the credentials somewhere safe
|
|
15
|
+
|
|
16
|
+
Because the accounts share one email pattern, whoever runs the platform can
|
|
17
|
+
find and remove them in one sweep -- which is usually the condition attached
|
|
18
|
+
to permission for this sort of thing.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import secrets
|
|
26
|
+
import string
|
|
27
|
+
from dataclasses import asdict, dataclass, field
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
from .client import RootClient
|
|
33
|
+
from .exceptions import RootError
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger("rootpy.accounts")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AlreadyCreatedError(RootError):
|
|
39
|
+
"""Raised when a signup succeeded without needing a challenge.
|
|
40
|
+
|
|
41
|
+
Not really an error -- the account exists and is recorded; this just tells
|
|
42
|
+
the two-step flow there's nothing to solve. ``.account`` is the result.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, account) -> None:
|
|
46
|
+
super().__init__(
|
|
47
|
+
f"account {account.username!r} was created without a challenge"
|
|
48
|
+
)
|
|
49
|
+
self.account = account
|
|
50
|
+
|
|
51
|
+
_USERNAME_ALPHABET = string.ascii_lowercase + string.digits
|
|
52
|
+
_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "!@#$%^&*-_"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TurnstileChallenge(str):
|
|
56
|
+
"""The challenge URL -- and the signup it belongs to.
|
|
57
|
+
|
|
58
|
+
This is a ``str``, so it behaves exactly like the URL everywhere you'd
|
|
59
|
+
use one::
|
|
60
|
+
|
|
61
|
+
challenge_url = await factory.create_return_turnstile(username="bob")
|
|
62
|
+
token = my_solver(challenge_url) # just a string
|
|
63
|
+
|
|
64
|
+
But it also remembers the details of the attempt that produced it
|
|
65
|
+
(``username``, ``password``, ``email``, ``device_id``). Those have to be
|
|
66
|
+
identical on the retry, so passing this object back to
|
|
67
|
+
:meth:`AccountFactory.create_with_turnstile` is the safe way to finish:
|
|
68
|
+
|
|
69
|
+
account = await factory.create_with_turnstile(
|
|
70
|
+
turnstile_token=token, challenge=challenge_url,
|
|
71
|
+
)
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
username: str = ""
|
|
75
|
+
password: str = ""
|
|
76
|
+
email: str = ""
|
|
77
|
+
device_id: str = ""
|
|
78
|
+
tag: str = ""
|
|
79
|
+
action: str = ""
|
|
80
|
+
|
|
81
|
+
def __new__(cls, url: str, **context):
|
|
82
|
+
obj = super().__new__(cls, url or "")
|
|
83
|
+
for key, value in context.items():
|
|
84
|
+
setattr(obj, key, value)
|
|
85
|
+
return obj
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def challenge_url(self) -> str:
|
|
89
|
+
"""The URL itself, if you'd rather be explicit."""
|
|
90
|
+
return str(self)
|
|
91
|
+
|
|
92
|
+
def context(self) -> dict:
|
|
93
|
+
"""The signup details that must be reused on the retry."""
|
|
94
|
+
return {
|
|
95
|
+
"username": self.username,
|
|
96
|
+
"password": self.password,
|
|
97
|
+
"email": self.email,
|
|
98
|
+
"device_id": self.device_id,
|
|
99
|
+
"tag": self.tag,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def describe(self) -> str:
|
|
103
|
+
parts = [f"challenge for {self.username!r} <{self.email}>"]
|
|
104
|
+
from urllib.parse import parse_qs, urlparse
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
params = {k: v[0] for k, v in parse_qs(urlparse(str(self)).query).items()}
|
|
108
|
+
if "action" in params:
|
|
109
|
+
parts.append(f"action={params['action']}")
|
|
110
|
+
if "cdata" in params:
|
|
111
|
+
parts.append(f"cdata={params['cdata'][:16]}...")
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
return " | ".join(parts)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class CreatedAccount:
|
|
119
|
+
"""Credentials and identifiers for an account this factory made."""
|
|
120
|
+
|
|
121
|
+
username: str
|
|
122
|
+
password: str
|
|
123
|
+
email: str
|
|
124
|
+
user_id: Optional[str] = None
|
|
125
|
+
token: Optional[str] = None
|
|
126
|
+
created_at: str = field(
|
|
127
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
128
|
+
)
|
|
129
|
+
verified: bool = False
|
|
130
|
+
device_id: Optional[str] = None
|
|
131
|
+
note: str = ""
|
|
132
|
+
|
|
133
|
+
def redacted(self) -> dict:
|
|
134
|
+
"""A copy safe to print or log -- no password, no token."""
|
|
135
|
+
data = asdict(self)
|
|
136
|
+
data["password"] = "***"
|
|
137
|
+
data["token"] = "***" if self.token else None
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AccountFactory:
|
|
142
|
+
"""Creates accounts on one email domain and remembers what it made.
|
|
143
|
+
|
|
144
|
+
email_pattern:
|
|
145
|
+
Must contain ``{tag}``, e.g. ``"hello-{tag}@example.com"``. Keeping
|
|
146
|
+
every account on one recognisable pattern is what makes them easy to
|
|
147
|
+
find and remove later.
|
|
148
|
+
username_prefix:
|
|
149
|
+
Optional prefix so the accounts are identifiable in the UI too.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
email_pattern: str,
|
|
155
|
+
*,
|
|
156
|
+
username_prefix: str = "",
|
|
157
|
+
transport=None,
|
|
158
|
+
proxy: Optional[str] = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
if "{tag}" not in email_pattern:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
"email_pattern must contain {tag}, e.g. 'hello-{tag}@example.com'"
|
|
163
|
+
)
|
|
164
|
+
self.email_pattern = email_pattern
|
|
165
|
+
self.username_prefix = username_prefix
|
|
166
|
+
self.transport = transport
|
|
167
|
+
self.proxy = proxy
|
|
168
|
+
self.accounts: list[CreatedAccount] = []
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------ #
|
|
171
|
+
def _routing(self) -> dict:
|
|
172
|
+
"""How this factory reaches Root -- forwarded only when it is set.
|
|
173
|
+
|
|
174
|
+
These two are classmethods with no client behind them, so without this
|
|
175
|
+
they build a bare transport and go direct: the one request that proves
|
|
176
|
+
you own an address, sent around the proxy the signup used. Passing the
|
|
177
|
+
keys only when they have values keeps a factory with no proxy calling
|
|
178
|
+
exactly as it always did.
|
|
179
|
+
"""
|
|
180
|
+
routing = {}
|
|
181
|
+
if self.proxy is not None:
|
|
182
|
+
routing["proxy"] = self.proxy
|
|
183
|
+
if self.transport is not None:
|
|
184
|
+
routing["transport"] = self.transport
|
|
185
|
+
return routing
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _tag(length: int = 8) -> str:
|
|
189
|
+
return "".join(secrets.choice(_USERNAME_ALPHABET) for _ in range(length))
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def make_password(length: int = 24) -> str:
|
|
193
|
+
"""A random password. Long, because nobody types these."""
|
|
194
|
+
return "".join(secrets.choice(_PASSWORD_ALPHABET) for _ in range(length))
|
|
195
|
+
|
|
196
|
+
def make_username(self, tag: Optional[str] = None) -> str:
|
|
197
|
+
tag = tag or self._tag()
|
|
198
|
+
name = f"{self.username_prefix}{tag}" if self.username_prefix else tag
|
|
199
|
+
return name[:32]
|
|
200
|
+
|
|
201
|
+
def make_email(self, tag: str) -> str:
|
|
202
|
+
return self.email_pattern.format(tag=tag)
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------ #
|
|
205
|
+
async def create(
|
|
206
|
+
self,
|
|
207
|
+
*,
|
|
208
|
+
turnstile_token: Optional[str] = None,
|
|
209
|
+
username: Optional[str] = None,
|
|
210
|
+
password: Optional[str] = None,
|
|
211
|
+
tag: Optional[str] = None,
|
|
212
|
+
note: str = "",
|
|
213
|
+
keep_client: bool = False,
|
|
214
|
+
device_id: Optional[str] = None,
|
|
215
|
+
):
|
|
216
|
+
"""Create one account.
|
|
217
|
+
|
|
218
|
+
turnstile_token:
|
|
219
|
+
The Cloudflare Turnstile token for this signup. Solve the
|
|
220
|
+
challenge yourself (see the Account creation guide) and pass it here --
|
|
221
|
+
this library does not and will not solve it for you, and tokens
|
|
222
|
+
are single-use and short-lived, so one per account.
|
|
223
|
+
|
|
224
|
+
Pass None to find out what the server wants: signup is attempted
|
|
225
|
+
without one and Root raises
|
|
226
|
+
:class:`~rootpy.exceptions.TurnstileRequired` carrying the real
|
|
227
|
+
challenge URL.
|
|
228
|
+
|
|
229
|
+
Returns ``(CreatedAccount, client)`` when ``keep_client`` is True,
|
|
230
|
+
otherwise just the :class:`CreatedAccount`.
|
|
231
|
+
"""
|
|
232
|
+
# No local gate on the token: let Root answer. If it wants a challenge
|
|
233
|
+
# it raises TurnstileRequired with the real challenge URL, which is far
|
|
234
|
+
# more useful than a guess -- and if a whitelisted domain ever doesn't
|
|
235
|
+
# need one, this just works.
|
|
236
|
+
turnstile_token = (turnstile_token or "").strip() or None
|
|
237
|
+
|
|
238
|
+
tag = tag or self._tag()
|
|
239
|
+
username = username or self.make_username(tag)
|
|
240
|
+
password = password or self.make_password()
|
|
241
|
+
email = self.make_email(tag)
|
|
242
|
+
# One device id per account, reused if a Turnstile retry is needed --
|
|
243
|
+
# Root binds the challenge to the request, and a new device id makes
|
|
244
|
+
# the retry look like a different signup.
|
|
245
|
+
if device_id is None:
|
|
246
|
+
from .identifiers import create_desktop_device_guid
|
|
247
|
+
|
|
248
|
+
device_id = create_desktop_device_guid()
|
|
249
|
+
|
|
250
|
+
log.info("creating account %s <%s>", username, email)
|
|
251
|
+
client = await RootClient.create_account(
|
|
252
|
+
username=username,
|
|
253
|
+
password=password,
|
|
254
|
+
email=email,
|
|
255
|
+
turnstile_token=turnstile_token,
|
|
256
|
+
device_id=device_id,
|
|
257
|
+
transport=self.transport,
|
|
258
|
+
proxy=self.proxy,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
account = CreatedAccount(
|
|
262
|
+
username=username,
|
|
263
|
+
password=password,
|
|
264
|
+
email=email,
|
|
265
|
+
user_id=getattr(client.user, "id", None) or client.user_id,
|
|
266
|
+
token=getattr(client.session, "token", None),
|
|
267
|
+
device_id=device_id,
|
|
268
|
+
note=note,
|
|
269
|
+
)
|
|
270
|
+
self.accounts.append(account)
|
|
271
|
+
log.info("created %s (%s)", account.username, account.user_id)
|
|
272
|
+
|
|
273
|
+
if keep_client:
|
|
274
|
+
return account, client
|
|
275
|
+
await client.close()
|
|
276
|
+
return account
|
|
277
|
+
|
|
278
|
+
# ------------------------------------------------------------------ #
|
|
279
|
+
# Two-step signup: get the challenge, solve it however you like, finish.
|
|
280
|
+
# ------------------------------------------------------------------ #
|
|
281
|
+
async def create_return_turnstile(
|
|
282
|
+
self,
|
|
283
|
+
*,
|
|
284
|
+
username: Optional[str] = None,
|
|
285
|
+
password: Optional[str] = None,
|
|
286
|
+
tag: Optional[str] = None,
|
|
287
|
+
device_id: Optional[str] = None,
|
|
288
|
+
) -> TurnstileChallenge:
|
|
289
|
+
"""STEP 1 -- attempt a signup and return the challenge URL.
|
|
290
|
+
|
|
291
|
+
challenge_url = await factory.create_return_turnstile(username="bob")
|
|
292
|
+
|
|
293
|
+
This deliberately sends a signup WITHOUT a token so Root issues a
|
|
294
|
+
challenge, and hands you back the URL it named. Solve it however you
|
|
295
|
+
like, then pass the token to :meth:`create_with_turnstile`.
|
|
296
|
+
|
|
297
|
+
The returned value is a plain string (the URL) that also carries the
|
|
298
|
+
username, password, email and device id of this attempt. Those must be
|
|
299
|
+
identical on the retry, so hand the object straight back rather than
|
|
300
|
+
re-deriving them.
|
|
301
|
+
|
|
302
|
+
Raises :class:`AlreadyCreatedError` if the account was created without
|
|
303
|
+
a challenge (some accounts don't get one), and the account is recorded
|
|
304
|
+
as normal.
|
|
305
|
+
"""
|
|
306
|
+
tag = tag or self._tag()
|
|
307
|
+
username = username or self.make_username(tag)
|
|
308
|
+
password = password or self.make_password()
|
|
309
|
+
email = self.make_email(tag)
|
|
310
|
+
if device_id is None:
|
|
311
|
+
from .identifiers import create_desktop_device_guid
|
|
312
|
+
|
|
313
|
+
device_id = create_desktop_device_guid()
|
|
314
|
+
|
|
315
|
+
from .exceptions import TurnstileRequired
|
|
316
|
+
|
|
317
|
+
log.info("requesting challenge for %s <%s>", username, email)
|
|
318
|
+
try:
|
|
319
|
+
client = await RootClient.create_account(
|
|
320
|
+
username=username,
|
|
321
|
+
password=password,
|
|
322
|
+
email=email,
|
|
323
|
+
device_id=device_id,
|
|
324
|
+
transport=self.transport,
|
|
325
|
+
proxy=self.proxy,
|
|
326
|
+
)
|
|
327
|
+
except TurnstileRequired as exc:
|
|
328
|
+
challenge = TurnstileChallenge(
|
|
329
|
+
exc.challenge_url or "",
|
|
330
|
+
username=username, password=password, email=email,
|
|
331
|
+
device_id=device_id, tag=tag,
|
|
332
|
+
action=getattr(exc, "action", "") or "",
|
|
333
|
+
)
|
|
334
|
+
log.info("challenge issued: %s", challenge.describe())
|
|
335
|
+
return challenge
|
|
336
|
+
|
|
337
|
+
# No challenge was required -- the account already exists now.
|
|
338
|
+
account = self._record(client, username, password, email, device_id,
|
|
339
|
+
note="created without a challenge")
|
|
340
|
+
await client.close()
|
|
341
|
+
raise AlreadyCreatedError(account)
|
|
342
|
+
|
|
343
|
+
async def create_with_turnstile(
|
|
344
|
+
self,
|
|
345
|
+
*,
|
|
346
|
+
turnstile_token: str,
|
|
347
|
+
challenge: Optional[TurnstileChallenge] = None,
|
|
348
|
+
username: Optional[str] = None,
|
|
349
|
+
password: Optional[str] = None,
|
|
350
|
+
email: Optional[str] = None,
|
|
351
|
+
device_id: Optional[str] = None,
|
|
352
|
+
note: str = "",
|
|
353
|
+
keep_client: bool = False,
|
|
354
|
+
):
|
|
355
|
+
"""STEP 3 -- finish the signup with a solved token.
|
|
356
|
+
|
|
357
|
+
account = await factory.create_with_turnstile(
|
|
358
|
+
turnstile_token=token, challenge=challenge_url,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
Pass the object from :meth:`create_return_turnstile` as ``challenge``
|
|
362
|
+
and everything else is filled in for you. If you'd rather be explicit,
|
|
363
|
+
supply ``username``/``password``/``email``/``device_id`` yourself --
|
|
364
|
+
but they MUST match the attempt that produced the challenge, or Root
|
|
365
|
+
issues a fresh one and your token is never checked.
|
|
366
|
+
"""
|
|
367
|
+
if not turnstile_token:
|
|
368
|
+
raise ValueError("turnstile_token is required")
|
|
369
|
+
|
|
370
|
+
if challenge is not None and hasattr(challenge, "context"):
|
|
371
|
+
context = challenge.context()
|
|
372
|
+
username = username or context["username"]
|
|
373
|
+
password = password or context["password"]
|
|
374
|
+
email = email or context["email"]
|
|
375
|
+
device_id = device_id or context["device_id"]
|
|
376
|
+
|
|
377
|
+
missing = [
|
|
378
|
+
name for name, value in (
|
|
379
|
+
("username", username), ("password", password),
|
|
380
|
+
("email", email), ("device_id", device_id),
|
|
381
|
+
) if not value
|
|
382
|
+
]
|
|
383
|
+
if missing:
|
|
384
|
+
raise ValueError(
|
|
385
|
+
"missing " + ", ".join(missing) + " -- pass challenge=<the "
|
|
386
|
+
"object from create_return_turnstile()>, or supply them "
|
|
387
|
+
"explicitly. They must match the attempt the challenge came "
|
|
388
|
+
"from."
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
log.info("completing signup for %s with a %d-char token",
|
|
392
|
+
username, len(turnstile_token))
|
|
393
|
+
client = await RootClient.create_account(
|
|
394
|
+
username=username,
|
|
395
|
+
password=password,
|
|
396
|
+
email=email,
|
|
397
|
+
turnstile_token=turnstile_token,
|
|
398
|
+
device_id=device_id,
|
|
399
|
+
transport=self.transport,
|
|
400
|
+
proxy=self.proxy,
|
|
401
|
+
)
|
|
402
|
+
account = self._record(client, username, password, email, device_id,
|
|
403
|
+
note=note)
|
|
404
|
+
if keep_client:
|
|
405
|
+
return account, client
|
|
406
|
+
await client.close()
|
|
407
|
+
return account
|
|
408
|
+
|
|
409
|
+
def _record(self, client, username, password, email, device_id, note=""):
|
|
410
|
+
account = CreatedAccount(
|
|
411
|
+
username=username,
|
|
412
|
+
password=password,
|
|
413
|
+
email=email,
|
|
414
|
+
user_id=getattr(client.user, "id", None) or client.user_id,
|
|
415
|
+
token=getattr(client.session, "token", None),
|
|
416
|
+
device_id=device_id,
|
|
417
|
+
note=note,
|
|
418
|
+
)
|
|
419
|
+
self.accounts.append(account)
|
|
420
|
+
log.info("created %s (%s)", account.username, account.user_id)
|
|
421
|
+
return account
|
|
422
|
+
|
|
423
|
+
async def send_verification(self, account, *,
|
|
424
|
+
turnstile_token: Optional[str] = None) -> None:
|
|
425
|
+
"""Ask Root to email a verification code to this account.
|
|
426
|
+
|
|
427
|
+
The code goes to the account's address -- with a pattern like
|
|
428
|
+
``service-{tag}@example.com`` that's your own mail server, so how you
|
|
429
|
+
read it is up to you (IMAP, a catch-all webhook, an API).
|
|
430
|
+
|
|
431
|
+
This can raise :class:`~rootpy.exceptions.TurnstileRequired`: Root
|
|
432
|
+
gates the resend behind its own challenge (``action=resend_verification``),
|
|
433
|
+
separate from the one you solved at signup. Solve it and call again
|
|
434
|
+
with ``turnstile_token=``.
|
|
435
|
+
"""
|
|
436
|
+
await RootClient.send_email_verification(
|
|
437
|
+
token=account.token, turnstile_token=turnstile_token,
|
|
438
|
+
**self._routing(),
|
|
439
|
+
)
|
|
440
|
+
log.info("verification code sent to %s", account.email)
|
|
441
|
+
|
|
442
|
+
async def verify(self, account, code: str) -> bool:
|
|
443
|
+
"""Complete verification with the code from the email.
|
|
444
|
+
|
|
445
|
+
Returns True on success; the account's ``verified`` flag is updated.
|
|
446
|
+
"""
|
|
447
|
+
code = (code or "").strip()
|
|
448
|
+
if not code:
|
|
449
|
+
raise ValueError("a verification code is required")
|
|
450
|
+
await RootClient.verify_email(
|
|
451
|
+
code, token=account.token, **self._routing())
|
|
452
|
+
account.verified = True
|
|
453
|
+
log.info("verified %s", account.email)
|
|
454
|
+
return True
|
|
455
|
+
|
|
456
|
+
async def create_and_verify(
|
|
457
|
+
self,
|
|
458
|
+
*,
|
|
459
|
+
turnstile_token: Optional[str] = None,
|
|
460
|
+
code_provider,
|
|
461
|
+
timeout: float = 120.0,
|
|
462
|
+
poll_interval: float = 5.0,
|
|
463
|
+
resend: bool = False,
|
|
464
|
+
**kwargs,
|
|
465
|
+
):
|
|
466
|
+
"""Create an account, then verify it once the code arrives.
|
|
467
|
+
|
|
468
|
+
``code_provider`` is your own function that returns the verification
|
|
469
|
+
code for an address (or None if it hasn't arrived yet)::
|
|
470
|
+
|
|
471
|
+
async def read_code(email: str):
|
|
472
|
+
# however you read your mail -- IMAP, a webhook store, an API
|
|
473
|
+
return await my_mailbox.latest_code(email)
|
|
474
|
+
|
|
475
|
+
account = await factory.create_and_verify(
|
|
476
|
+
turnstile_token=token, code_provider=read_code,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
It's deliberately your function: the mail side is your infrastructure,
|
|
480
|
+
and this library shouldn't guess at it.
|
|
481
|
+
|
|
482
|
+
resend:
|
|
483
|
+
Signup itself already emails the code, so by default this just
|
|
484
|
+
waits for that one. Asking for another is a *separate* Turnstile
|
|
485
|
+
challenge (``action=resend_verification``) -- the signup token
|
|
486
|
+
doesn't satisfy it -- so on a fresh account the resend usually
|
|
487
|
+
just raises :class:`~rootpy.exceptions.TurnstileRequired` and
|
|
488
|
+
throws away credentials that were already minted. Pass
|
|
489
|
+
``resend=True`` only if the first email genuinely never arrived,
|
|
490
|
+
and expect to solve that challenge.
|
|
491
|
+
"""
|
|
492
|
+
import asyncio
|
|
493
|
+
import inspect
|
|
494
|
+
|
|
495
|
+
account = await self.create(turnstile_token=turnstile_token, **kwargs)
|
|
496
|
+
if resend:
|
|
497
|
+
await self.send_verification(account)
|
|
498
|
+
|
|
499
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
500
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
501
|
+
result = code_provider(account.email)
|
|
502
|
+
if inspect.isawaitable(result):
|
|
503
|
+
result = await result
|
|
504
|
+
if result:
|
|
505
|
+
await self.verify(account, result)
|
|
506
|
+
return account
|
|
507
|
+
await asyncio.sleep(poll_interval)
|
|
508
|
+
|
|
509
|
+
log.warning(
|
|
510
|
+
"no verification code for %s within %.0fs -- account created but "
|
|
511
|
+
"unverified; call factory.verify(account, code) later",
|
|
512
|
+
account.email, timeout,
|
|
513
|
+
)
|
|
514
|
+
return account
|
|
515
|
+
|
|
516
|
+
# ------------------------------------------------------------------ #
|
|
517
|
+
def unverified(self) -> list:
|
|
518
|
+
"""Accounts that were created but never verified."""
|
|
519
|
+
return [a for a in self.accounts if not a.verified]
|
|
520
|
+
|
|
521
|
+
# ------------------------------------------------------------------ #
|
|
522
|
+
def save(self, path, *, include_secrets: bool = True) -> str:
|
|
523
|
+
"""Write the created accounts to a JSON file.
|
|
524
|
+
|
|
525
|
+
These are real credentials -- keep the file out of version control
|
|
526
|
+
and off anything public. ``include_secrets=False`` writes a redacted
|
|
527
|
+
copy suitable for sharing.
|
|
528
|
+
"""
|
|
529
|
+
target = Path(path).expanduser()
|
|
530
|
+
payload = [
|
|
531
|
+
asdict(account) if include_secrets else account.redacted()
|
|
532
|
+
for account in self.accounts
|
|
533
|
+
]
|
|
534
|
+
target.write_text(
|
|
535
|
+
json.dumps(payload, indent=2), encoding="utf-8"
|
|
536
|
+
)
|
|
537
|
+
if include_secrets:
|
|
538
|
+
log.warning(
|
|
539
|
+
"%s contains passwords and tokens -- keep it private", target
|
|
540
|
+
)
|
|
541
|
+
return str(target)
|
|
542
|
+
|
|
543
|
+
def load(self, path) -> list:
|
|
544
|
+
"""Read accounts back from a file written by :meth:`save`."""
|
|
545
|
+
target = Path(path).expanduser()
|
|
546
|
+
if not target.exists():
|
|
547
|
+
return []
|
|
548
|
+
data = json.loads(target.read_text(encoding="utf-8"))
|
|
549
|
+
self.accounts = [CreatedAccount(**entry) for entry in data]
|
|
550
|
+
return self.accounts
|
|
551
|
+
|
|
552
|
+
def emails(self) -> list:
|
|
553
|
+
"""Every email this factory has used -- useful for cleanup requests."""
|
|
554
|
+
return [account.email for account in self.accounts]
|