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.
Files changed (63) hide show
  1. rootpy/__init__.py +393 -0
  2. rootpy/_registry_loader.py +122 -0
  3. rootpy/accounts.py +554 -0
  4. rootpy/attach.py +243 -0
  5. rootpy/auth.py +310 -0
  6. rootpy/cache.py +190 -0
  7. rootpy/client.py +1702 -0
  8. rootpy/commands.py +444 -0
  9. rootpy/data/enums.json +1 -0
  10. rootpy/data/message_schemas.json +1 -0
  11. rootpy/data/messages.json +1 -0
  12. rootpy/data/rpc_services.json +1 -0
  13. rootpy/data/services.json +1 -0
  14. rootpy/discovery.py +578 -0
  15. rootpy/dm_member.py +305 -0
  16. rootpy/domain_managers.py +677 -0
  17. rootpy/emoji.py +129 -0
  18. rootpy/enums.py +315 -0
  19. rootpy/events.py +153 -0
  20. rootpy/exceptions.py +687 -0
  21. rootpy/features.py +949 -0
  22. rootpy/gateway.py +1477 -0
  23. rootpy/generated_rpc_registry.py +18 -0
  24. rootpy/highlevel.py +1472 -0
  25. rootpy/host.py +594 -0
  26. rootpy/identifiers.py +119 -0
  27. rootpy/media.py +43 -0
  28. rootpy/media_bootstrap.py +111 -0
  29. rootpy/models.py +944 -0
  30. rootpy/object_api.py +606 -0
  31. rootpy/packet_schemas.py +181 -0
  32. rootpy/packets.py +176 -0
  33. rootpy/pagination.py +31 -0
  34. rootpy/permissions.py +237 -0
  35. rootpy/presence.py +173 -0
  36. rootpy/protocol.py +164 -0
  37. rootpy/py.typed +0 -0
  38. rootpy/raw_api.py +244 -0
  39. rootpy/responses.py +110 -0
  40. rootpy/root_exception.py +306 -0
  41. rootpy/service_facade.py +32 -0
  42. rootpy/services/__init__.py +27 -0
  43. rootpy/services/assets.py +503 -0
  44. rootpy/services/calls.py +1411 -0
  45. rootpy/services/communities.py +735 -0
  46. rootpy/services/community_admin.py +1036 -0
  47. rootpy/services/direct_messages.py +524 -0
  48. rootpy/services/discovery.py +204 -0
  49. rootpy/services/messages.py +811 -0
  50. rootpy/services/users.py +440 -0
  51. rootpy/stats.py +208 -0
  52. rootpy/structured_api.py +956 -0
  53. rootpy/structured_registry.py +61 -0
  54. rootpy/transport.py +403 -0
  55. rootpy/typed_events.py +496 -0
  56. rootpy/unread.py +546 -0
  57. rootpy/users.py +187 -0
  58. rootpy/validation.py +145 -0
  59. rootpy_client-1.31.1.dist-info/METADATA +157 -0
  60. rootpy_client-1.31.1.dist-info/RECORD +63 -0
  61. rootpy_client-1.31.1.dist-info/WHEEL +5 -0
  62. rootpy_client-1.31.1.dist-info/licenses/LICENSE +21 -0
  63. 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]