pyyapper 1.2.0a1__tar.gz → 1.3.2__tar.gz
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.
- {pyyapper-1.2.0a1/src/pyyapper.egg-info → pyyapper-1.3.2}/PKG-INFO +3 -1
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/pyproject.toml +2 -1
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/__init__.py +6 -3
- pyyapper-1.3.2/src/pyyapper/_version.py +1 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/bot.py +179 -24
- pyyapper-1.3.2/src/pyyapper/cache.py +52 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/errors.py +5 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/commands/core.py +7 -4
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/tasks.py +3 -2
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/http.py +31 -9
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/models.py +74 -2
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/pagination.py +5 -1
- pyyapper-1.3.2/src/pyyapper/presence.py +139 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/wait_for.py +2 -1
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/webhooks.py +31 -6
- {pyyapper-1.2.0a1 → pyyapper-1.3.2/src/pyyapper.egg-info}/PKG-INFO +3 -1
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper.egg-info/SOURCES.txt +2 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper.egg-info/requires.txt +3 -0
- pyyapper-1.2.0a1/src/pyyapper/_version.py +0 -1
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/LICENSE +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/README.md +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/setup.cfg +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/buttons.py +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/commands.py +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/embeds.py +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/__init__.py +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/commands/__init__.py +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/commands/checks.py +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/commands/converters.py +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/ext/commands/errors.py +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper/py.typed +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper.egg-info/dependency_links.txt +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/src/pyyapper.egg-info/top_level.txt +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/tests/test_ext_commands.py +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/tests/test_smoke.py +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/tests/test_tasks.py +0 -0
- {pyyapper-1.2.0a1 → pyyapper-1.3.2}/tests/test_webhooks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyyapper
|
|
3
|
-
Version: 1.2
|
|
3
|
+
Version: 1.3.2
|
|
4
4
|
Summary: An intuitive, async Python library for the Yapper bot API.
|
|
5
5
|
Author: PyYapper contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -31,6 +31,8 @@ Requires-Dist: twine; extra == "dev"
|
|
|
31
31
|
Provides-Extra: docs
|
|
32
32
|
Requires-Dist: mkdocs; extra == "docs"
|
|
33
33
|
Requires-Dist: mkdocs-material; extra == "docs"
|
|
34
|
+
Provides-Extra: speed
|
|
35
|
+
Requires-Dist: orjson; extra == "speed"
|
|
34
36
|
Dynamic: license-file
|
|
35
37
|
|
|
36
38
|
# PyYapper
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyyapper"
|
|
7
|
-
version = "1.2
|
|
7
|
+
version = "1.3.2"
|
|
8
8
|
description = "An intuitive, async Python library for the Yapper bot API."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -33,6 +33,7 @@ dependencies = [
|
|
|
33
33
|
[project.optional-dependencies]
|
|
34
34
|
dev = ["pytest", "pytest-asyncio", "build", "twine"]
|
|
35
35
|
docs = ["mkdocs", "mkdocs-material"]
|
|
36
|
+
speed = ["orjson"]
|
|
36
37
|
|
|
37
38
|
[project.urls]
|
|
38
39
|
Homepage = "https://github.com/pyyapper/pyyapper"
|
|
@@ -14,10 +14,12 @@ from .errors import (
|
|
|
14
14
|
NotFound,
|
|
15
15
|
RateLimited,
|
|
16
16
|
ServerError,
|
|
17
|
+
ServiceUnavailable,
|
|
17
18
|
)
|
|
18
19
|
from .models import (
|
|
19
|
-
User, Server, Channel, Category, Member, Message, Role,
|
|
20
|
+
User, Server, Channel, Category, Member, Message, Role, VoiceChannel, picture_url,
|
|
20
21
|
)
|
|
22
|
+
from .presence import Activity, Presence
|
|
21
23
|
from .pagination import Paginator
|
|
22
24
|
from .webhooks import Webhook
|
|
23
25
|
from .wait_for import WaiterRegistry, wait_for
|
|
@@ -29,8 +31,9 @@ __all__ = [
|
|
|
29
31
|
"Bot", "Context", "InteractionContext",
|
|
30
32
|
"Embed", "Button", "Row",
|
|
31
33
|
"YapperError", "ConfigurationError", "HTTPException",
|
|
32
|
-
"BadRequest", "Unauthorized", "Forbidden", "NotFound", "RateLimited", "ServerError",
|
|
33
|
-
"User", "Server", "Channel", "Category", "Member", "Message", "Role",
|
|
34
|
+
"BadRequest", "Unauthorized", "Forbidden", "NotFound", "RateLimited", "ServerError", "ServiceUnavailable",
|
|
35
|
+
"User", "Server", "Channel", "Category", "Member", "Message", "Role", "VoiceChannel", "picture_url",
|
|
36
|
+
"Activity", "Presence",
|
|
34
37
|
"Paginator", "Webhook", "WaiterRegistry", "wait_for",
|
|
35
38
|
"commands", "tasks",
|
|
36
39
|
"Cog", "Command", "Group",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.3.2"
|
|
@@ -17,15 +17,18 @@ from .models import (
|
|
|
17
17
|
Role,
|
|
18
18
|
Server,
|
|
19
19
|
User,
|
|
20
|
+
VoiceChannel,
|
|
20
21
|
)
|
|
22
|
+
from .cache import TTLCache, MISSING as _CACHE_MISSING
|
|
23
|
+
from .presence import Activity, Presence
|
|
21
24
|
from .embeds import Embed
|
|
22
25
|
from .buttons import Button, Row
|
|
23
26
|
|
|
24
27
|
# Event names that are valid.
|
|
25
28
|
EVENTS = {
|
|
26
29
|
"message", "command", "command_error", "member_join", "member_leave",
|
|
27
|
-
"reaction_add", "reaction_remove", "
|
|
28
|
-
"ready", "error",
|
|
30
|
+
"reaction_add", "reaction_remove", "voice_join", "voice_leave",
|
|
31
|
+
"interaction", "test", "ready", "error",
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
# Helper to coerce embeds/buttons to dicts.
|
|
@@ -170,14 +173,25 @@ class Bot:
|
|
|
170
173
|
|
|
171
174
|
def __init__(
|
|
172
175
|
self,
|
|
173
|
-
token: str,
|
|
176
|
+
token: Optional[str] = None,
|
|
174
177
|
*,
|
|
175
178
|
base_url: str = "https://www.yapper.website/api/bot/v1",
|
|
176
179
|
prefixes: Optional[list[str]] = None,
|
|
177
180
|
log: bool = True,
|
|
181
|
+
ignore_bots: bool = True,
|
|
182
|
+
max_age: Optional[int] = None,
|
|
183
|
+
cache_ttl: float = 0.0,
|
|
178
184
|
) -> None:
|
|
185
|
+
if token is None:
|
|
186
|
+
import os
|
|
187
|
+
token = os.environ.get("YAPPER_BOT_TOKEN")
|
|
179
188
|
self.token = str(token).strip() if token else ""
|
|
180
189
|
self.base_url = base_url
|
|
190
|
+
self.ignore_bots = bool(ignore_bots)
|
|
191
|
+
self.max_age = max_age
|
|
192
|
+
self._presence = None
|
|
193
|
+
self._cache = TTLCache(default_ttl=cache_ttl)
|
|
194
|
+
self.cache_ttl = cache_ttl
|
|
181
195
|
self.prefixes = prefixes if prefixes is not None else ["/", "!"]
|
|
182
196
|
self.log = log
|
|
183
197
|
|
|
@@ -430,27 +444,71 @@ class Bot:
|
|
|
430
444
|
# ------------------------------------------------------------------ #
|
|
431
445
|
# Servers / channels / members
|
|
432
446
|
# ------------------------------------------------------------------ #
|
|
433
|
-
async def servers(self) -> list[Server]:
|
|
447
|
+
async def servers(self, *, cache_ttl: Optional[float] = None, force: bool = False) -> list[Server]:
|
|
448
|
+
key = ("servers",)
|
|
449
|
+
_eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
450
|
+
if not force and _eff_ttl and _eff_ttl > 0:
|
|
451
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
452
|
+
cached = self._cache.get(key)
|
|
453
|
+
if cached is not _CACHE_MISSING:
|
|
454
|
+
return cached
|
|
434
455
|
out = await self.api("GET", "/servers")
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
456
|
+
res = [Server.from_dict(s) for s in (out.get("servers") or [])]
|
|
457
|
+
if cache_ttl != 0:
|
|
458
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
459
|
+
self._cache.set(key, res, ttl)
|
|
460
|
+
return res
|
|
461
|
+
|
|
462
|
+
async def channels(self, server_id: Any = None, *, type: Optional[str] = None, cache_ttl: Optional[float] = None, force: bool = False) -> list[Channel]:
|
|
463
|
+
key = ("channels", server_id, type)
|
|
464
|
+
_eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
465
|
+
if not force and _eff_ttl and _eff_ttl > 0:
|
|
466
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
467
|
+
cached = self._cache.get(key)
|
|
468
|
+
if cached is not _CACHE_MISSING:
|
|
469
|
+
return cached
|
|
438
470
|
q = f"?type={type}" if type else ""
|
|
439
471
|
path = f"/servers/{server_id}/channels{q}" if server_id else f"/channels{q}"
|
|
440
472
|
out = await self.api("GET", path)
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
473
|
+
res = [Channel.from_dict(c) for c in (out.get("channels") or [])]
|
|
474
|
+
if cache_ttl != 0:
|
|
475
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
476
|
+
self._cache.set(key, res, ttl)
|
|
477
|
+
return res
|
|
478
|
+
|
|
479
|
+
async def categories(self, server_id: Any, *, cache_ttl: Optional[float] = None, force: bool = False) -> list[Category]:
|
|
480
|
+
key = ("categories", server_id)
|
|
481
|
+
_eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
482
|
+
if not force and _eff_ttl and _eff_ttl > 0:
|
|
483
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
484
|
+
cached = self._cache.get(key)
|
|
485
|
+
if cached is not _CACHE_MISSING:
|
|
486
|
+
return cached
|
|
444
487
|
out = await self.api("GET", f"/servers/{server_id}/channels")
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
488
|
+
res = [Category.from_dict(c) for c in (out.get("categories") or [])]
|
|
489
|
+
if cache_ttl != 0:
|
|
490
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
491
|
+
self._cache.set(key, res, ttl)
|
|
492
|
+
return res
|
|
493
|
+
|
|
494
|
+
async def channel(self, channel_id: Any, *, cache_ttl: Optional[float] = None, force: bool = False) -> Optional[Channel]:
|
|
495
|
+
key = ("channel", channel_id)
|
|
496
|
+
_eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
497
|
+
if not force and _eff_ttl and _eff_ttl > 0:
|
|
498
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
499
|
+
cached = self._cache.get(key)
|
|
500
|
+
if cached is not _CACHE_MISSING:
|
|
501
|
+
return cached
|
|
448
502
|
try:
|
|
449
503
|
out = await self.api("GET", f"/channels/{channel_id}")
|
|
450
504
|
except Exception:
|
|
451
505
|
return None
|
|
452
506
|
c = out.get("channel") if isinstance(out, dict) else None
|
|
453
|
-
|
|
507
|
+
res = Channel.from_dict(c) if c else None
|
|
508
|
+
if cache_ttl != 0:
|
|
509
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
510
|
+
self._cache.set(key, res, ttl)
|
|
511
|
+
return res
|
|
454
512
|
|
|
455
513
|
async def members(self, server_id: Any, after: Any = None) -> list[Member]:
|
|
456
514
|
path = f"/servers/{server_id}/members?limit=200"
|
|
@@ -463,10 +521,21 @@ class Bot:
|
|
|
463
521
|
out = await self.api("GET", f"/channels/{channel_id}/messages?limit={limit}")
|
|
464
522
|
return [Message.from_dict(m) for m in (out.get("messages") or [])]
|
|
465
523
|
|
|
466
|
-
async def user(self, user_id: Any) -> User:
|
|
524
|
+
async def user(self, user_id: Any, *, cache_ttl: Optional[float] = None, force: bool = False) -> User:
|
|
525
|
+
key = ("user", user_id)
|
|
526
|
+
_eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
527
|
+
if not force and _eff_ttl and _eff_ttl > 0:
|
|
528
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
529
|
+
cached = self._cache.get(key)
|
|
530
|
+
if cached is not _CACHE_MISSING:
|
|
531
|
+
return cached
|
|
467
532
|
out = await self.api("GET", f"/users/{user_id}")
|
|
468
533
|
u = out.get("user") if isinstance(out, dict) else out
|
|
469
|
-
|
|
534
|
+
res = User.from_dict(u)
|
|
535
|
+
if cache_ttl != 0:
|
|
536
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
537
|
+
self._cache.set(key, res, ttl)
|
|
538
|
+
return res
|
|
470
539
|
|
|
471
540
|
# ------------------------------------------------------------------ #
|
|
472
541
|
# Profile
|
|
@@ -485,9 +554,18 @@ class Bot:
|
|
|
485
554
|
async def set_banner(self, image: Any, type: Optional[str] = None) -> Any:
|
|
486
555
|
return await self.http.upload("PUT", "/me/banner", image, type)
|
|
487
556
|
|
|
488
|
-
async def set_status(self, status: str = "online", activity:
|
|
557
|
+
async def set_status(self, status: str = "online", activity: Any = None) -> Any:
|
|
558
|
+
if isinstance(activity, Activity):
|
|
559
|
+
activity = activity.to_text()
|
|
560
|
+
elif isinstance(activity, Presence):
|
|
561
|
+
status = activity.to_status()
|
|
562
|
+
activity = activity.to_activity()
|
|
489
563
|
return await self.api("PUT", "/me/status", {"status": status, "activity": activity})
|
|
490
564
|
|
|
565
|
+
async def set_presence(self, *, status: str = "online", activity: Any = None) -> Any:
|
|
566
|
+
self._presence = Presence(status=status, activity=activity)
|
|
567
|
+
return await self.set_status(status=status, activity=activity)
|
|
568
|
+
|
|
491
569
|
# ------------------------------------------------------------------ #
|
|
492
570
|
# Messages advanced
|
|
493
571
|
# ------------------------------------------------------------------ #
|
|
@@ -548,13 +626,35 @@ class Bot:
|
|
|
548
626
|
# ------------------------------------------------------------------ #
|
|
549
627
|
# Moderation
|
|
550
628
|
# ------------------------------------------------------------------ #
|
|
551
|
-
async def server(self, server_id: Any) -> Any:
|
|
629
|
+
async def server(self, server_id: Any, *, cache_ttl: Optional[float] = None, force: bool = False) -> Any:
|
|
630
|
+
key = ("server", server_id)
|
|
631
|
+
_eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
632
|
+
if not force and _eff_ttl and _eff_ttl > 0:
|
|
633
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
634
|
+
cached = self._cache.get(key)
|
|
635
|
+
if cached is not _CACHE_MISSING:
|
|
636
|
+
return cached
|
|
552
637
|
out = await self.api("GET", f"/servers/{server_id}")
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
638
|
+
res = out.get("server") if isinstance(out, dict) else out
|
|
639
|
+
if cache_ttl != 0:
|
|
640
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
641
|
+
self._cache.set(key, res, ttl)
|
|
642
|
+
return res
|
|
643
|
+
|
|
644
|
+
async def roles(self, server_id: Any, *, cache_ttl: Optional[float] = None, force: bool = False) -> list[Role]:
|
|
645
|
+
key = ("roles", server_id)
|
|
646
|
+
_eff_ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
647
|
+
if not force and _eff_ttl and _eff_ttl > 0:
|
|
648
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
649
|
+
cached = self._cache.get(key)
|
|
650
|
+
if cached is not _CACHE_MISSING:
|
|
651
|
+
return cached
|
|
556
652
|
out = await self.api("GET", f"/servers/{server_id}/roles")
|
|
557
|
-
|
|
653
|
+
res = [Role.from_dict(r) for r in (out.get("roles") or [])]
|
|
654
|
+
if cache_ttl != 0:
|
|
655
|
+
ttl = self._cache.default_ttl if cache_ttl is None else cache_ttl
|
|
656
|
+
self._cache.set(key, res, ttl)
|
|
657
|
+
return res
|
|
558
658
|
|
|
559
659
|
async def member(self, server_id: Any, user_id: Any) -> Member:
|
|
560
660
|
out = await self.api("GET", f"/servers/{server_id}/members/{user_id}")
|
|
@@ -588,6 +688,24 @@ class Bot:
|
|
|
588
688
|
"user_id": user_id, "minutes": minutes, "reason": reason,
|
|
589
689
|
})
|
|
590
690
|
|
|
691
|
+
# ------------------------------------------------------------------ #
|
|
692
|
+
# Voice
|
|
693
|
+
# ------------------------------------------------------------------ #
|
|
694
|
+
async def voice(self, server_id: Any) -> list:
|
|
695
|
+
out = await self.api("GET", f"/servers/{server_id}/voice")
|
|
696
|
+
voice = out.get("voice", []) if isinstance(out, dict) else []
|
|
697
|
+
return [VoiceChannel.from_dict(v) for v in (voice or [])]
|
|
698
|
+
|
|
699
|
+
async def move_member(self, server_id: Any, user_id: Any, channel_id: Any) -> Any:
|
|
700
|
+
return await self.api("POST", f"/servers/{server_id}/voice/move", {
|
|
701
|
+
"user_id": user_id, "channel_id": channel_id,
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
async def disconnect_member(self, server_id: Any, user_id: Any) -> Any:
|
|
705
|
+
return await self.api("POST", f"/servers/{server_id}/voice/disconnect", {
|
|
706
|
+
"user_id": user_id,
|
|
707
|
+
})
|
|
708
|
+
|
|
591
709
|
# ------------------------------------------------------------------ #
|
|
592
710
|
# Channels management
|
|
593
711
|
# ------------------------------------------------------------------ #
|
|
@@ -664,7 +782,10 @@ class Bot:
|
|
|
664
782
|
try:
|
|
665
783
|
self.me = User.from_dict(await self.api("GET", "/me"))
|
|
666
784
|
await self.api("PUT", "/commands", {"commands": cmd_list})
|
|
667
|
-
|
|
785
|
+
q = "/events?after=0&wait=0"
|
|
786
|
+
if self.max_age is not None:
|
|
787
|
+
q += f"&max_age={int(self.max_age)}"
|
|
788
|
+
first = await self.api("GET", q)
|
|
668
789
|
break
|
|
669
790
|
except Exception as e:
|
|
670
791
|
status = getattr(e, "status", None)
|
|
@@ -684,6 +805,12 @@ class Bot:
|
|
|
684
805
|
print(f"[pyyapper] {self.me.username} is online"
|
|
685
806
|
+ (f" with {len(cmd_list)} command{'s' if len(cmd_list) != 1 else ''}" if cmd_list else ""))
|
|
686
807
|
await self._emit("ready", self.me)
|
|
808
|
+
# re-apply saved presence
|
|
809
|
+
if getattr(self, "_presence", None) is not None:
|
|
810
|
+
try:
|
|
811
|
+
await self.set_presence(status=self._presence.status, activity=self._presence.activity)
|
|
812
|
+
except Exception:
|
|
813
|
+
pass
|
|
687
814
|
# start registered tasks
|
|
688
815
|
try:
|
|
689
816
|
from .ext.tasks import register_task, cancel_all_tasks
|
|
@@ -700,7 +827,10 @@ class Bot:
|
|
|
700
827
|
failures = 0
|
|
701
828
|
while self.running:
|
|
702
829
|
try:
|
|
703
|
-
|
|
830
|
+
q = f"/events?after={self.last}&wait=25"
|
|
831
|
+
if self.max_age is not None:
|
|
832
|
+
q += f"&max_age={int(self.max_age)}"
|
|
833
|
+
out = await self.api("GET", q, timeout=45.0)
|
|
704
834
|
failures = 0
|
|
705
835
|
except Exception as e:
|
|
706
836
|
failures += 1
|
|
@@ -748,6 +878,19 @@ class Bot:
|
|
|
748
878
|
def stop(self) -> None:
|
|
749
879
|
self.running = False
|
|
750
880
|
|
|
881
|
+
def invalidate_cache(self, key: Any = None) -> None:
|
|
882
|
+
"""Drop cached read results. ``key=None`` clears everything."""
|
|
883
|
+
self._cache.invalidate(key)
|
|
884
|
+
|
|
885
|
+
async def send_many(self, channel_id: Any, contents: Any, *, concurrency: int = 5, **opts: Any) -> list:
|
|
886
|
+
sem = asyncio.Semaphore(max(1, int(concurrency)))
|
|
887
|
+
|
|
888
|
+
async def _one(c: Any) -> Any:
|
|
889
|
+
async with sem:
|
|
890
|
+
return await self.send(channel_id, c, **opts)
|
|
891
|
+
|
|
892
|
+
return list(await asyncio.gather(*(_one(c) for c in (contents or [])), return_exceptions=True))
|
|
893
|
+
|
|
751
894
|
# ------------------------------------------------------------------ #
|
|
752
895
|
# Internals
|
|
753
896
|
# ------------------------------------------------------------------ #
|
|
@@ -768,6 +911,9 @@ class Bot:
|
|
|
768
911
|
except Exception:
|
|
769
912
|
pass
|
|
770
913
|
|
|
914
|
+
if len(fns) == 1:
|
|
915
|
+
await _run(fns[0])
|
|
916
|
+
return
|
|
771
917
|
# Dispatch handlers concurrently to reduce latency when several
|
|
772
918
|
# listeners are registered for the same event. Order is not guaranteed.
|
|
773
919
|
await asyncio.gather(*(_run(fn) for fn in fns), return_exceptions=True)
|
|
@@ -782,6 +928,15 @@ class Bot:
|
|
|
782
928
|
etype = ev.get("type")
|
|
783
929
|
data = ev.get("data") or {}
|
|
784
930
|
if etype == "message":
|
|
931
|
+
if self.ignore_bots and data.get("author_bot"):
|
|
932
|
+
# skip messages from other bots/webhooks
|
|
933
|
+
if self._waiters is not None:
|
|
934
|
+
try:
|
|
935
|
+
ctx = Context(self, data)
|
|
936
|
+
self._waiters.dispatch("message", ctx)
|
|
937
|
+
except Exception:
|
|
938
|
+
pass
|
|
939
|
+
return
|
|
785
940
|
ctx = Context(self, data)
|
|
786
941
|
# resolve command
|
|
787
942
|
name = data.get("command")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Tiny TTL cache used by PyYapper for read-heavy API calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
_MISSING = object()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TTLCache:
|
|
12
|
+
"""A simple in-memory TTL cache.
|
|
13
|
+
|
|
14
|
+
Keys may be any hashable value. ``get`` returns the sentinel
|
|
15
|
+
:data:`MISSING` when a key is absent or expired.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, default_ttl: float = 15.0) -> None:
|
|
19
|
+
self._store: dict[Any, tuple[Any, float]] = {}
|
|
20
|
+
self.default_ttl = float(default_ttl)
|
|
21
|
+
|
|
22
|
+
def get(self, key: Any) -> Any:
|
|
23
|
+
entry = self._store.get(key)
|
|
24
|
+
if entry is None:
|
|
25
|
+
return _MISSING
|
|
26
|
+
value, expiry = entry
|
|
27
|
+
if time.monotonic() >= expiry:
|
|
28
|
+
self._store.pop(key, None)
|
|
29
|
+
return _MISSING
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
def set(self, key: Any, value: Any, ttl: Optional[float] = None) -> None:
|
|
33
|
+
if ttl is None:
|
|
34
|
+
ttl = self.default_ttl
|
|
35
|
+
if ttl and ttl > 0:
|
|
36
|
+
self._store[key] = (value, time.monotonic() + float(ttl))
|
|
37
|
+
|
|
38
|
+
def invalidate(self, key: Any = None) -> None:
|
|
39
|
+
if key is None:
|
|
40
|
+
self._store.clear()
|
|
41
|
+
else:
|
|
42
|
+
self._store.pop(key, None)
|
|
43
|
+
|
|
44
|
+
def invalidate_prefix(self, prefix: Any) -> None:
|
|
45
|
+
for k in [k for k in list(self._store.keys()) if isinstance(k, tuple) and k and k[0] == prefix]:
|
|
46
|
+
self._store.pop(k, None)
|
|
47
|
+
|
|
48
|
+
def size(self) -> int:
|
|
49
|
+
return len(self._store)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
MISSING = _MISSING
|
|
@@ -72,6 +72,10 @@ class ServerError(HTTPException):
|
|
|
72
72
|
"""5xx - something went wrong on Yapper's side."""
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
class ServiceUnavailable(ServerError):
|
|
76
|
+
"""503 - Yapper is busy; retry after Retry-After."""
|
|
77
|
+
|
|
78
|
+
|
|
75
79
|
# Status code -> exception class mapping used by the HTTP layer.
|
|
76
80
|
STATUS_TO_EXC: dict[int, type[HTTPException]] = {
|
|
77
81
|
400: BadRequest,
|
|
@@ -79,6 +83,7 @@ STATUS_TO_EXC: dict[int, type[HTTPException]] = {
|
|
|
79
83
|
403: Forbidden,
|
|
80
84
|
404: NotFound,
|
|
81
85
|
429: RateLimited,
|
|
86
|
+
503: ServiceUnavailable,
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
|
|
@@ -376,10 +376,13 @@ def remove_cog(bot, name):
|
|
|
376
376
|
import asyncio
|
|
377
377
|
# best-effort: if event loop running, schedule; else run
|
|
378
378
|
try:
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
loop.
|
|
382
|
-
|
|
379
|
+
try:
|
|
380
|
+
loop = asyncio.get_running_loop()
|
|
381
|
+
if loop.is_running():
|
|
382
|
+
loop.create_task(res)
|
|
383
|
+
except RuntimeError:
|
|
384
|
+
loop = asyncio.new_event_loop()
|
|
385
|
+
asyncio.set_event_loop(loop)
|
|
383
386
|
loop.run_until_complete(res)
|
|
384
387
|
except Exception:
|
|
385
388
|
pass
|
|
@@ -132,9 +132,10 @@ class Loop:
|
|
|
132
132
|
try:
|
|
133
133
|
loop = asyncio.get_running_loop()
|
|
134
134
|
except RuntimeError:
|
|
135
|
-
# Fallback:
|
|
135
|
+
# Fallback: create a new event loop (cross-platform).
|
|
136
136
|
try:
|
|
137
|
-
loop = asyncio.
|
|
137
|
+
loop = asyncio.new_event_loop()
|
|
138
|
+
asyncio.set_event_loop(loop)
|
|
138
139
|
except RuntimeError:
|
|
139
140
|
return None
|
|
140
141
|
try:
|
|
@@ -15,6 +15,25 @@ from __future__ import annotations
|
|
|
15
15
|
import asyncio
|
|
16
16
|
import json
|
|
17
17
|
import random
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import orjson as _orjson
|
|
21
|
+
|
|
22
|
+
def _json_loads(data):
|
|
23
|
+
return _orjson.loads(data)
|
|
24
|
+
|
|
25
|
+
def _json_dumps(obj):
|
|
26
|
+
return _orjson.dumps(obj)
|
|
27
|
+
|
|
28
|
+
_HAS_ORJSON = True
|
|
29
|
+
except ImportError: # pragma: no cover
|
|
30
|
+
_HAS_ORJSON = False
|
|
31
|
+
|
|
32
|
+
def _json_loads(data):
|
|
33
|
+
return json.loads(data)
|
|
34
|
+
|
|
35
|
+
def _json_dumps(obj):
|
|
36
|
+
return json.dumps(obj).encode()
|
|
18
37
|
from typing import Any, Optional
|
|
19
38
|
|
|
20
39
|
import aiohttp
|
|
@@ -93,7 +112,8 @@ class HTTPClient:
|
|
|
93
112
|
# JSON body unless caller already provided bytes
|
|
94
113
|
if content_type is None:
|
|
95
114
|
content_type = "application/json"
|
|
96
|
-
|
|
115
|
+
dumped = _json_dumps(body)
|
|
116
|
+
data = dumped if isinstance(dumped, (bytes, bytearray)) else dumped.encode()
|
|
97
117
|
elif body is not None:
|
|
98
118
|
data = body
|
|
99
119
|
if content_type:
|
|
@@ -111,8 +131,8 @@ class HTTPClient:
|
|
|
111
131
|
raw = await res.read()
|
|
112
132
|
text = raw.decode("utf-8", errors="replace") if raw else ""
|
|
113
133
|
try:
|
|
114
|
-
payload =
|
|
115
|
-
except
|
|
134
|
+
payload = _json_loads(text) if text else {}
|
|
135
|
+
except Exception:
|
|
116
136
|
payload = {}
|
|
117
137
|
if res.status < 400:
|
|
118
138
|
return payload
|
|
@@ -127,12 +147,14 @@ class HTTPClient:
|
|
|
127
147
|
retry_after = float(ra)
|
|
128
148
|
except ValueError:
|
|
129
149
|
retry_after = None
|
|
130
|
-
# retry on 429 with Retry-After
|
|
131
|
-
if res.status
|
|
132
|
-
if
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
150
|
+
# retry on 429 / 503 with Retry-After
|
|
151
|
+
if res.status in (429, 503) and retry_after and retry_after > 0 and attempt < retries:
|
|
152
|
+
cap = 60 if res.status == 429 else 30
|
|
153
|
+
if retry_after <= cap:
|
|
154
|
+
if self.log:
|
|
155
|
+
print(f"[pyyapper] {res.status} retrying {method} {path} in {retry_after}s")
|
|
156
|
+
await asyncio.sleep(retry_after)
|
|
157
|
+
continue
|
|
136
158
|
exc_cls = exception_for_status(res.status)
|
|
137
159
|
raise exc_cls(
|
|
138
160
|
res.status,
|
|
@@ -43,6 +43,16 @@ class _Model:
|
|
|
43
43
|
return out
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def picture_url(bucket: str, path: Any) -> Optional[str]:
|
|
47
|
+
"""Build a full picture URL from a path returned by the API."""
|
|
48
|
+
if not path:
|
|
49
|
+
return None
|
|
50
|
+
p = str(path)
|
|
51
|
+
if p.startswith("/"):
|
|
52
|
+
return "https://www.yapper.website" + p
|
|
53
|
+
return f"https://files.yapper.website/{bucket}/{p}"
|
|
54
|
+
|
|
55
|
+
|
|
46
56
|
@dataclass
|
|
47
57
|
class User(_Model):
|
|
48
58
|
id: str = ""
|
|
@@ -51,10 +61,21 @@ class User(_Model):
|
|
|
51
61
|
bio: str = ""
|
|
52
62
|
avatar_url: Optional[str] = None
|
|
53
63
|
banner_url: Optional[str] = None
|
|
64
|
+
avatar_path: Optional[str] = None
|
|
65
|
+
banner_path: Optional[str] = None
|
|
66
|
+
badges: list[Any] = field(default_factory=list)
|
|
54
67
|
created_at: Optional[str] = None
|
|
55
68
|
bot: Optional[dict[str, Any]] = None
|
|
56
69
|
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
57
70
|
|
|
71
|
+
@property
|
|
72
|
+
def avatar(self) -> Optional[str]:
|
|
73
|
+
return self.avatar_url or picture_url("avatars", self.avatar_path)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def banner(self) -> Optional[str]:
|
|
77
|
+
return self.banner_url or picture_url("banners", self.banner_path)
|
|
78
|
+
|
|
58
79
|
@classmethod
|
|
59
80
|
def from_dict(cls, data: dict[str, Any] | None) -> "User":
|
|
60
81
|
data = data or {}
|
|
@@ -65,6 +86,9 @@ class User(_Model):
|
|
|
65
86
|
bio=data.get("bio", "") or "",
|
|
66
87
|
avatar_url=data.get("avatar_url"),
|
|
67
88
|
banner_url=data.get("banner_url"),
|
|
89
|
+
avatar_path=data.get("avatar_path"),
|
|
90
|
+
banner_path=data.get("banner_path"),
|
|
91
|
+
badges=list(data.get("badges") or []),
|
|
68
92
|
created_at=data.get("created_at"),
|
|
69
93
|
bot=data.get("bot"),
|
|
70
94
|
raw=dict(data),
|
|
@@ -75,12 +99,26 @@ class User(_Model):
|
|
|
75
99
|
class Server(_Model):
|
|
76
100
|
id: str = ""
|
|
77
101
|
name: str = ""
|
|
102
|
+
icon_path: Optional[str] = None
|
|
103
|
+
owner_id: Optional[str] = None
|
|
104
|
+
member_count: Optional[int] = None
|
|
78
105
|
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
79
106
|
|
|
107
|
+
@property
|
|
108
|
+
def icon(self) -> Optional[str]:
|
|
109
|
+
return picture_url("icons", self.icon_path)
|
|
110
|
+
|
|
80
111
|
@classmethod
|
|
81
112
|
def from_dict(cls, data: dict[str, Any] | None) -> "Server":
|
|
82
113
|
data = data or {}
|
|
83
|
-
return cls(
|
|
114
|
+
return cls(
|
|
115
|
+
id=data.get("id", "") or "",
|
|
116
|
+
name=data.get("name", "") or "",
|
|
117
|
+
icon_path=data.get("icon_path"),
|
|
118
|
+
owner_id=data.get("owner_id"),
|
|
119
|
+
member_count=data.get("member_count"),
|
|
120
|
+
raw=dict(data),
|
|
121
|
+
)
|
|
84
122
|
|
|
85
123
|
|
|
86
124
|
@dataclass
|
|
@@ -166,13 +204,25 @@ class Message(_Model):
|
|
|
166
204
|
author: Optional[str] = None
|
|
167
205
|
content: str = ""
|
|
168
206
|
attachment: Optional[str] = None
|
|
207
|
+
attachment_path: Optional[str] = None
|
|
169
208
|
reply_to_id: Optional[str] = None
|
|
170
209
|
created_at: Optional[str] = None
|
|
210
|
+
edited_at: Optional[str] = None
|
|
211
|
+
pinned_at: Optional[str] = None
|
|
171
212
|
mentioned: bool = False
|
|
213
|
+
author_bot: bool = False
|
|
214
|
+
embeds: list[Any] = field(default_factory=list)
|
|
215
|
+
components: list[Any] = field(default_factory=list)
|
|
172
216
|
command: Optional[str] = None
|
|
173
217
|
args: Optional[str] = None
|
|
174
218
|
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
175
219
|
|
|
220
|
+
@property
|
|
221
|
+
def attachment_url(self) -> Optional[str]:
|
|
222
|
+
if self.attachment:
|
|
223
|
+
return self.attachment
|
|
224
|
+
return picture_url("attachments", self.attachment_path)
|
|
225
|
+
|
|
176
226
|
@classmethod
|
|
177
227
|
def from_dict(cls, data: dict[str, Any] | None) -> "Message":
|
|
178
228
|
data = data or {}
|
|
@@ -185,9 +235,15 @@ class Message(_Model):
|
|
|
185
235
|
author=data.get("author"),
|
|
186
236
|
content=data.get("content", "") or "",
|
|
187
237
|
attachment=data.get("attachment"),
|
|
238
|
+
attachment_path=data.get("attachment_path"),
|
|
188
239
|
reply_to_id=data.get("reply_to_id"),
|
|
189
240
|
created_at=data.get("created_at"),
|
|
241
|
+
edited_at=data.get("edited_at"),
|
|
242
|
+
pinned_at=data.get("pinned_at"),
|
|
190
243
|
mentioned=bool(data.get("mentioned", False)),
|
|
244
|
+
author_bot=bool(data.get("author_bot", False)),
|
|
245
|
+
embeds=list(data.get("embeds") or []),
|
|
246
|
+
components=list(data.get("components") or []),
|
|
191
247
|
command=data.get("command"),
|
|
192
248
|
args=data.get("args"),
|
|
193
249
|
raw=dict(data),
|
|
@@ -203,4 +259,20 @@ class Role(_Model):
|
|
|
203
259
|
@classmethod
|
|
204
260
|
def from_dict(cls, data: dict[str, Any] | None) -> "Role":
|
|
205
261
|
data = data or {}
|
|
206
|
-
return cls(id=data.get("id", "") or "", name=data.get("name", "") or "", raw=dict(data))
|
|
262
|
+
return cls(id=data.get("id", "") or "", name=data.get("name", "") or "", raw=dict(data))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass
|
|
266
|
+
class VoiceChannel(_Model):
|
|
267
|
+
channel_id: str = ""
|
|
268
|
+
user_ids: list[str] = field(default_factory=list)
|
|
269
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
270
|
+
|
|
271
|
+
@classmethod
|
|
272
|
+
def from_dict(cls, data: dict[str, Any] | None) -> "VoiceChannel":
|
|
273
|
+
data = data or {}
|
|
274
|
+
return cls(
|
|
275
|
+
channel_id=data.get("channel_id", "") or "",
|
|
276
|
+
user_ids=list(data.get("user_ids") or []),
|
|
277
|
+
raw=dict(data),
|
|
278
|
+
)
|
|
@@ -62,7 +62,11 @@ class Paginator:
|
|
|
62
62
|
return [Row(*btns)]
|
|
63
63
|
|
|
64
64
|
async def _wait(self) -> Any:
|
|
65
|
-
|
|
65
|
+
try:
|
|
66
|
+
loop = asyncio.get_running_loop()
|
|
67
|
+
except RuntimeError:
|
|
68
|
+
loop = asyncio.new_event_loop()
|
|
69
|
+
asyncio.set_event_loop(loop)
|
|
66
70
|
self._future = loop.create_future()
|
|
67
71
|
try:
|
|
68
72
|
return await asyncio.wait_for(self._future, timeout=self.timeout)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Presence / activity helpers for PyYapper bots.
|
|
2
|
+
|
|
3
|
+
Yapper's API accepts a status (``online``/``idle``/``dnd``) and a single
|
|
4
|
+
activity line (up to 60 characters). This module adds a small, discord.py-style
|
|
5
|
+
``Activity`` builder on top of that, including an activity *type* (playing,
|
|
6
|
+
listening, watching, ...) that is rendered into the activity line. Extra
|
|
7
|
+
"rich" fields (details, state, timestamps, assets) are stored locally so they
|
|
8
|
+
are available when Yapper supports them, and are serialized into the activity
|
|
9
|
+
string in the meantime.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
ACTIVITY_TYPES = ("playing", "streaming", "listening", "watching", "competing", "custom")
|
|
19
|
+
|
|
20
|
+
_PREFIX = {
|
|
21
|
+
"playing": "Playing",
|
|
22
|
+
"streaming": "Streaming",
|
|
23
|
+
"listening": "Listening to",
|
|
24
|
+
"watching": "Watching",
|
|
25
|
+
"competing": "Competing in",
|
|
26
|
+
"custom": "",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Activity:
|
|
32
|
+
"""A rich activity descriptor.
|
|
33
|
+
|
|
34
|
+
Example::
|
|
35
|
+
|
|
36
|
+
act = Activity.playing("chess")
|
|
37
|
+
act = Activity.listening("lofi beats")
|
|
38
|
+
act = Activity.custom("Moderating")
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name: str = ""
|
|
42
|
+
type: str = "playing"
|
|
43
|
+
state: Optional[str] = None
|
|
44
|
+
details: Optional[str] = None
|
|
45
|
+
url: Optional[str] = None
|
|
46
|
+
start: Optional[datetime] = None
|
|
47
|
+
end: Optional[datetime] = None
|
|
48
|
+
large_image: Optional[str] = None
|
|
49
|
+
small_image: Optional[str] = None
|
|
50
|
+
large_text: Optional[str] = None
|
|
51
|
+
small_text: Optional[str] = None
|
|
52
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
# -- factories --------------------------------------------------------
|
|
55
|
+
@classmethod
|
|
56
|
+
def playing(cls, name: str, **kw: Any) -> "Activity":
|
|
57
|
+
return cls(name=name, type="playing", **kw)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def streaming(cls, name: str, url: Optional[str] = None, **kw: Any) -> "Activity":
|
|
61
|
+
return cls(name=name, type="streaming", url=url, **kw)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def listening(cls, name: str, **kw: Any) -> "Activity":
|
|
65
|
+
return cls(name=name, type="listening", **kw)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def watching(cls, name: str, **kw: Any) -> "Activity":
|
|
69
|
+
return cls(name=name, type="watching", **kw)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def competing(cls, name: str, **kw: Any) -> "Activity":
|
|
73
|
+
return cls(name=name, type="competing", **kw)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def custom(cls, text: str, **kw: Any) -> "Activity":
|
|
77
|
+
return cls(name=text, type="custom", **kw)
|
|
78
|
+
|
|
79
|
+
# -- serialization ----------------------------------------------------
|
|
80
|
+
def to_text(self) -> str:
|
|
81
|
+
"""Render to the 60-char activity line Yapper accepts."""
|
|
82
|
+
prefix = _PREFIX.get(self.type, "")
|
|
83
|
+
base = self.name.strip() if self.name else ""
|
|
84
|
+
if self.type == "custom":
|
|
85
|
+
text = base
|
|
86
|
+
elif prefix and base:
|
|
87
|
+
text = f"{prefix} {base}"
|
|
88
|
+
else:
|
|
89
|
+
text = base
|
|
90
|
+
# Append state/details if room (Yapper caps at 60).
|
|
91
|
+
if self.state and len(text) + len(self.state) + 3 <= 60:
|
|
92
|
+
text = f"{text} — {self.state}"
|
|
93
|
+
return text[:60]
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict[str, Any]:
|
|
96
|
+
out: dict[str, Any] = {"type": self.type, "name": self.name}
|
|
97
|
+
if self.state is not None:
|
|
98
|
+
out["state"] = self.state
|
|
99
|
+
if self.details is not None:
|
|
100
|
+
out["details"] = self.details
|
|
101
|
+
if self.url is not None:
|
|
102
|
+
out["url"] = self.url
|
|
103
|
+
if self.start is not None:
|
|
104
|
+
out["start"] = self.start.isoformat()
|
|
105
|
+
if self.end is not None:
|
|
106
|
+
out["end"] = self.end.isoformat()
|
|
107
|
+
if self.large_image is not None:
|
|
108
|
+
out["large_image"] = self.large_image
|
|
109
|
+
if self.small_image is not None:
|
|
110
|
+
out["small_image"] = self.small_image
|
|
111
|
+
if self.large_text is not None:
|
|
112
|
+
out["large_text"] = self.large_text
|
|
113
|
+
if self.small_text is not None:
|
|
114
|
+
out["small_text"] = self.small_text
|
|
115
|
+
out.update(self.extra)
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
def to_api(self) -> str:
|
|
119
|
+
return self.to_text()
|
|
120
|
+
|
|
121
|
+
def __str__(self) -> str:
|
|
122
|
+
return self.to_text()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class Presence:
|
|
127
|
+
"""Status + activity pair."""
|
|
128
|
+
status: str = "online"
|
|
129
|
+
activity: Optional[Any] = None
|
|
130
|
+
|
|
131
|
+
def to_status(self) -> str:
|
|
132
|
+
return self.status
|
|
133
|
+
|
|
134
|
+
def to_activity(self) -> Optional[str]:
|
|
135
|
+
if self.activity is None:
|
|
136
|
+
return None
|
|
137
|
+
if isinstance(self.activity, Activity):
|
|
138
|
+
return self.activity.to_text()
|
|
139
|
+
return str(self.activity)[:60] if self.activity is not None else None
|
|
@@ -104,7 +104,8 @@ class WaiterRegistry:
|
|
|
104
104
|
try:
|
|
105
105
|
loop = asyncio.get_running_loop()
|
|
106
106
|
except RuntimeError:
|
|
107
|
-
loop = asyncio.
|
|
107
|
+
loop = asyncio.new_event_loop()
|
|
108
|
+
asyncio.set_event_loop(loop)
|
|
108
109
|
future = loop.create_future()
|
|
109
110
|
waiter = self.add(event, check, future)
|
|
110
111
|
try:
|
|
@@ -10,6 +10,25 @@ from __future__ import annotations
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import json
|
|
12
12
|
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import orjson as _orjson
|
|
16
|
+
|
|
17
|
+
def _json_loads(data):
|
|
18
|
+
return _orjson.loads(data)
|
|
19
|
+
|
|
20
|
+
def _json_dumps(obj):
|
|
21
|
+
return _orjson.dumps(obj)
|
|
22
|
+
|
|
23
|
+
_HAS_ORJSON = True
|
|
24
|
+
except ImportError: # pragma: no cover
|
|
25
|
+
_HAS_ORJSON = False
|
|
26
|
+
|
|
27
|
+
def _json_loads(data):
|
|
28
|
+
return json.loads(data)
|
|
29
|
+
|
|
30
|
+
def _json_dumps(obj):
|
|
31
|
+
return json.dumps(obj).encode()
|
|
13
32
|
from urllib.parse import urlparse
|
|
14
33
|
|
|
15
34
|
import aiohttp
|
|
@@ -134,11 +153,16 @@ class Webhook:
|
|
|
134
153
|
session = await self._get_session()
|
|
135
154
|
for attempt in range(retries + 1):
|
|
136
155
|
try:
|
|
156
|
+
if json_body is None:
|
|
157
|
+
data_body = None
|
|
158
|
+
else:
|
|
159
|
+
dumped = _json_dumps(json_body)
|
|
160
|
+
data_body = dumped if isinstance(dumped, (bytes, bytearray)) else dumped.encode()
|
|
137
161
|
kwargs = dict(
|
|
138
162
|
params=params,
|
|
139
|
-
|
|
163
|
+
data=data_body,
|
|
140
164
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
|
141
|
-
headers={"User-Agent": USER_AGENT},
|
|
165
|
+
headers={"User-Agent": USER_AGENT, "Content-Type": "application/json"} if data_body is not None else {"User-Agent": USER_AGENT},
|
|
142
166
|
)
|
|
143
167
|
if hasattr(session, "request"):
|
|
144
168
|
cm = session.request(method, url, **kwargs)
|
|
@@ -151,8 +175,8 @@ class Webhook:
|
|
|
151
175
|
raw = await res.read()
|
|
152
176
|
text = raw.decode("utf-8", errors="replace") if raw else ""
|
|
153
177
|
try:
|
|
154
|
-
payload =
|
|
155
|
-
except
|
|
178
|
+
payload = _json_loads(text) if text else None
|
|
179
|
+
except Exception:
|
|
156
180
|
payload = None
|
|
157
181
|
if res.status < 400:
|
|
158
182
|
return payload
|
|
@@ -167,12 +191,13 @@ class Webhook:
|
|
|
167
191
|
except (TypeError, ValueError):
|
|
168
192
|
retry_after = None
|
|
169
193
|
if (
|
|
170
|
-
res.status
|
|
194
|
+
res.status in (429, 503)
|
|
171
195
|
and retry_after is not None
|
|
172
196
|
and retry_after > 0
|
|
173
197
|
and attempt < retries
|
|
174
198
|
):
|
|
175
|
-
|
|
199
|
+
cap = 60.0 if res.status == 429 else 30.0
|
|
200
|
+
await asyncio.sleep(min(retry_after, cap))
|
|
176
201
|
continue
|
|
177
202
|
exc_cls = exception_for_status(res.status)
|
|
178
203
|
# RateLimited subclass expected for 429
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyyapper
|
|
3
|
-
Version: 1.2
|
|
3
|
+
Version: 1.3.2
|
|
4
4
|
Summary: An intuitive, async Python library for the Yapper bot API.
|
|
5
5
|
Author: PyYapper contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -31,6 +31,8 @@ Requires-Dist: twine; extra == "dev"
|
|
|
31
31
|
Provides-Extra: docs
|
|
32
32
|
Requires-Dist: mkdocs; extra == "docs"
|
|
33
33
|
Requires-Dist: mkdocs-material; extra == "docs"
|
|
34
|
+
Provides-Extra: speed
|
|
35
|
+
Requires-Dist: orjson; extra == "speed"
|
|
34
36
|
Dynamic: license-file
|
|
35
37
|
|
|
36
38
|
# PyYapper
|
|
@@ -5,12 +5,14 @@ src/pyyapper/__init__.py
|
|
|
5
5
|
src/pyyapper/_version.py
|
|
6
6
|
src/pyyapper/bot.py
|
|
7
7
|
src/pyyapper/buttons.py
|
|
8
|
+
src/pyyapper/cache.py
|
|
8
9
|
src/pyyapper/commands.py
|
|
9
10
|
src/pyyapper/embeds.py
|
|
10
11
|
src/pyyapper/errors.py
|
|
11
12
|
src/pyyapper/http.py
|
|
12
13
|
src/pyyapper/models.py
|
|
13
14
|
src/pyyapper/pagination.py
|
|
15
|
+
src/pyyapper/presence.py
|
|
14
16
|
src/pyyapper/py.typed
|
|
15
17
|
src/pyyapper/wait_for.py
|
|
16
18
|
src/pyyapper/webhooks.py
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.2.0a1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|