pyyapper 1.1.0__tar.gz → 1.3.0__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.1.0/src/pyyapper.egg-info → pyyapper-1.3.0}/PKG-INFO +1 -1
- {pyyapper-1.1.0 → pyyapper-1.3.0}/pyproject.toml +1 -1
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/__init__.py +6 -3
- pyyapper-1.3.0/src/pyyapper/_version.py +1 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/bot.py +78 -8
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/errors.py +5 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/http.py +16 -7
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/models.py +74 -2
- pyyapper-1.3.0/src/pyyapper/presence.py +139 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/webhooks.py +11 -3
- {pyyapper-1.1.0 → pyyapper-1.3.0/src/pyyapper.egg-info}/PKG-INFO +1 -1
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper.egg-info/SOURCES.txt +1 -0
- pyyapper-1.1.0/src/pyyapper/_version.py +0 -1
- {pyyapper-1.1.0 → pyyapper-1.3.0}/LICENSE +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/README.md +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/setup.cfg +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/buttons.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/commands.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/embeds.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/ext/__init__.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/ext/commands/__init__.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/ext/commands/checks.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/ext/commands/converters.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/ext/commands/core.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/ext/commands/errors.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/ext/tasks.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/pagination.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/py.typed +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper/wait_for.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper.egg-info/dependency_links.txt +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper.egg-info/requires.txt +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/src/pyyapper.egg-info/top_level.txt +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/tests/test_ext_commands.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/tests/test_smoke.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/tests/test_tasks.py +0 -0
- {pyyapper-1.1.0 → pyyapper-1.3.0}/tests/test_webhooks.py +0 -0
|
@@ -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.0"
|
|
@@ -24,8 +24,8 @@ from .buttons import Button, Row
|
|
|
24
24
|
# Event names that are valid.
|
|
25
25
|
EVENTS = {
|
|
26
26
|
"message", "command", "command_error", "member_join", "member_leave",
|
|
27
|
-
"reaction_add", "reaction_remove", "
|
|
28
|
-
"ready", "error",
|
|
27
|
+
"reaction_add", "reaction_remove", "voice_join", "voice_leave",
|
|
28
|
+
"interaction", "test", "ready", "error",
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
# Helper to coerce embeds/buttons to dicts.
|
|
@@ -170,14 +170,22 @@ class Bot:
|
|
|
170
170
|
|
|
171
171
|
def __init__(
|
|
172
172
|
self,
|
|
173
|
-
token: str,
|
|
173
|
+
token: Optional[str] = None,
|
|
174
174
|
*,
|
|
175
175
|
base_url: str = "https://www.yapper.website/api/bot/v1",
|
|
176
176
|
prefixes: Optional[list[str]] = None,
|
|
177
177
|
log: bool = True,
|
|
178
|
+
ignore_bots: bool = True,
|
|
179
|
+
max_age: Optional[int] = None,
|
|
178
180
|
) -> None:
|
|
181
|
+
if token is None:
|
|
182
|
+
import os
|
|
183
|
+
token = os.environ.get("YAPPER_BOT_TOKEN")
|
|
179
184
|
self.token = str(token).strip() if token else ""
|
|
180
185
|
self.base_url = base_url
|
|
186
|
+
self.ignore_bots = bool(ignore_bots)
|
|
187
|
+
self.max_age = max_age
|
|
188
|
+
self._presence = None
|
|
181
189
|
self.prefixes = prefixes if prefixes is not None else ["/", "!"]
|
|
182
190
|
self.log = log
|
|
183
191
|
|
|
@@ -485,9 +493,20 @@ class Bot:
|
|
|
485
493
|
async def set_banner(self, image: Any, type: Optional[str] = None) -> Any:
|
|
486
494
|
return await self.http.upload("PUT", "/me/banner", image, type)
|
|
487
495
|
|
|
488
|
-
async def set_status(self, status: str = "online", activity:
|
|
496
|
+
async def set_status(self, status: str = "online", activity: Any = None) -> Any:
|
|
497
|
+
from .presence import Presence, Activity
|
|
498
|
+
if isinstance(activity, Activity):
|
|
499
|
+
activity = activity.to_text()
|
|
500
|
+
elif isinstance(activity, Presence):
|
|
501
|
+
status = activity.to_status()
|
|
502
|
+
activity = activity.to_activity()
|
|
489
503
|
return await self.api("PUT", "/me/status", {"status": status, "activity": activity})
|
|
490
504
|
|
|
505
|
+
async def set_presence(self, *, status: str = "online", activity: Any = None) -> Any:
|
|
506
|
+
from .presence import Presence
|
|
507
|
+
self._presence = Presence(status=status, activity=activity)
|
|
508
|
+
return await self.set_status(status=status, activity=activity)
|
|
509
|
+
|
|
491
510
|
# ------------------------------------------------------------------ #
|
|
492
511
|
# Messages advanced
|
|
493
512
|
# ------------------------------------------------------------------ #
|
|
@@ -588,6 +607,25 @@ class Bot:
|
|
|
588
607
|
"user_id": user_id, "minutes": minutes, "reason": reason,
|
|
589
608
|
})
|
|
590
609
|
|
|
610
|
+
# ------------------------------------------------------------------ #
|
|
611
|
+
# Voice
|
|
612
|
+
# ------------------------------------------------------------------ #
|
|
613
|
+
async def voice(self, server_id: Any) -> list:
|
|
614
|
+
from .models import VoiceChannel
|
|
615
|
+
out = await self.api("GET", f"/servers/{server_id}/voice")
|
|
616
|
+
voice = out.get("voice", []) if isinstance(out, dict) else []
|
|
617
|
+
return [VoiceChannel.from_dict(v) for v in (voice or [])]
|
|
618
|
+
|
|
619
|
+
async def move_member(self, server_id: Any, user_id: Any, channel_id: Any) -> Any:
|
|
620
|
+
return await self.api("POST", f"/servers/{server_id}/voice/move", {
|
|
621
|
+
"user_id": user_id, "channel_id": channel_id,
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
async def disconnect_member(self, server_id: Any, user_id: Any) -> Any:
|
|
625
|
+
return await self.api("POST", f"/servers/{server_id}/voice/disconnect", {
|
|
626
|
+
"user_id": user_id,
|
|
627
|
+
})
|
|
628
|
+
|
|
591
629
|
# ------------------------------------------------------------------ #
|
|
592
630
|
# Channels management
|
|
593
631
|
# ------------------------------------------------------------------ #
|
|
@@ -664,7 +702,10 @@ class Bot:
|
|
|
664
702
|
try:
|
|
665
703
|
self.me = User.from_dict(await self.api("GET", "/me"))
|
|
666
704
|
await self.api("PUT", "/commands", {"commands": cmd_list})
|
|
667
|
-
|
|
705
|
+
q = "/events?after=0&wait=0"
|
|
706
|
+
if self.max_age is not None:
|
|
707
|
+
q += f"&max_age={int(self.max_age)}"
|
|
708
|
+
first = await self.api("GET", q)
|
|
668
709
|
break
|
|
669
710
|
except Exception as e:
|
|
670
711
|
status = getattr(e, "status", None)
|
|
@@ -684,6 +725,12 @@ class Bot:
|
|
|
684
725
|
print(f"[pyyapper] {self.me.username} is online"
|
|
685
726
|
+ (f" with {len(cmd_list)} command{'s' if len(cmd_list) != 1 else ''}" if cmd_list else ""))
|
|
686
727
|
await self._emit("ready", self.me)
|
|
728
|
+
# re-apply saved presence
|
|
729
|
+
if getattr(self, "_presence", None) is not None:
|
|
730
|
+
try:
|
|
731
|
+
await self.set_presence(status=self._presence.status, activity=self._presence.activity)
|
|
732
|
+
except Exception:
|
|
733
|
+
pass
|
|
687
734
|
# start registered tasks
|
|
688
735
|
try:
|
|
689
736
|
from .ext.tasks import register_task, cancel_all_tasks
|
|
@@ -700,7 +747,10 @@ class Bot:
|
|
|
700
747
|
failures = 0
|
|
701
748
|
while self.running:
|
|
702
749
|
try:
|
|
703
|
-
|
|
750
|
+
q = f"/events?after={self.last}&wait=25"
|
|
751
|
+
if self.max_age is not None:
|
|
752
|
+
q += f"&max_age={int(self.max_age)}"
|
|
753
|
+
out = await self.api("GET", q, timeout=45.0)
|
|
704
754
|
failures = 0
|
|
705
755
|
except Exception as e:
|
|
706
756
|
failures += 1
|
|
@@ -752,14 +802,25 @@ class Bot:
|
|
|
752
802
|
# Internals
|
|
753
803
|
# ------------------------------------------------------------------ #
|
|
754
804
|
async def _emit(self, event: str, payload: Any) -> None:
|
|
755
|
-
|
|
805
|
+
fns = list(self.handlers.get(event, []))
|
|
806
|
+
if not fns:
|
|
807
|
+
return
|
|
808
|
+
|
|
809
|
+
async def _run(fn: Callable) -> None:
|
|
756
810
|
try:
|
|
757
811
|
if asyncio.iscoroutinefunction(fn):
|
|
758
812
|
await fn(payload)
|
|
759
813
|
else:
|
|
760
814
|
fn(payload)
|
|
761
815
|
except Exception as e: # noqa: BLE001
|
|
762
|
-
|
|
816
|
+
try:
|
|
817
|
+
await self._fail(e)
|
|
818
|
+
except Exception:
|
|
819
|
+
pass
|
|
820
|
+
|
|
821
|
+
# Dispatch handlers concurrently to reduce latency when several
|
|
822
|
+
# listeners are registered for the same event. Order is not guaranteed.
|
|
823
|
+
await asyncio.gather(*(_run(fn) for fn in fns), return_exceptions=True)
|
|
763
824
|
|
|
764
825
|
async def _fail(self, e: Exception) -> None:
|
|
765
826
|
if self.handlers.get("error"):
|
|
@@ -771,6 +832,15 @@ class Bot:
|
|
|
771
832
|
etype = ev.get("type")
|
|
772
833
|
data = ev.get("data") or {}
|
|
773
834
|
if etype == "message":
|
|
835
|
+
if self.ignore_bots and data.get("author_bot"):
|
|
836
|
+
# skip messages from other bots/webhooks
|
|
837
|
+
if self._waiters is not None:
|
|
838
|
+
try:
|
|
839
|
+
ctx = Context(self, data)
|
|
840
|
+
self._waiters.dispatch("message", ctx)
|
|
841
|
+
except Exception:
|
|
842
|
+
pass
|
|
843
|
+
return
|
|
774
844
|
ctx = Context(self, data)
|
|
775
845
|
# resolve command
|
|
776
846
|
name = data.get("command")
|
|
@@ -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
|
|
|
@@ -53,7 +53,14 @@ class HTTPClient:
|
|
|
53
53
|
|
|
54
54
|
async def _get_session(self) -> aiohttp.ClientSession:
|
|
55
55
|
if self._session is None or self._session.closed:
|
|
56
|
-
|
|
56
|
+
connector = aiohttp.TCPConnector(
|
|
57
|
+
limit=100,
|
|
58
|
+
limit_per_host=30,
|
|
59
|
+
ttl_dns_cache=300,
|
|
60
|
+
enable_cleanup_closed=True,
|
|
61
|
+
keepalive_timeout=30,
|
|
62
|
+
)
|
|
63
|
+
self._session = aiohttp.ClientSession(connector=connector)
|
|
57
64
|
self._owns_session = True
|
|
58
65
|
return self._session
|
|
59
66
|
|
|
@@ -120,12 +127,14 @@ class HTTPClient:
|
|
|
120
127
|
retry_after = float(ra)
|
|
121
128
|
except ValueError:
|
|
122
129
|
retry_after = None
|
|
123
|
-
# retry on 429 with Retry-After
|
|
124
|
-
if res.status
|
|
125
|
-
if
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
130
|
+
# retry on 429 / 503 with Retry-After
|
|
131
|
+
if res.status in (429, 503) and retry_after and retry_after > 0 and attempt < retries:
|
|
132
|
+
cap = 60 if res.status == 429 else 30
|
|
133
|
+
if retry_after <= cap:
|
|
134
|
+
if self.log:
|
|
135
|
+
print(f"[pyyapper] {res.status} retrying {method} {path} in {retry_after}s")
|
|
136
|
+
await asyncio.sleep(retry_after)
|
|
137
|
+
continue
|
|
129
138
|
exc_cls = exception_for_status(res.status)
|
|
130
139
|
raise exc_cls(
|
|
131
140
|
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
|
+
)
|
|
@@ -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
|
|
@@ -96,7 +96,14 @@ class Webhook:
|
|
|
96
96
|
# ------------------------------------------------------------------ #
|
|
97
97
|
async def _get_session(self) -> aiohttp.ClientSession:
|
|
98
98
|
if self._session is None or getattr(self._session, "closed", False):
|
|
99
|
-
|
|
99
|
+
connector = aiohttp.TCPConnector(
|
|
100
|
+
limit=100,
|
|
101
|
+
limit_per_host=30,
|
|
102
|
+
ttl_dns_cache=300,
|
|
103
|
+
enable_cleanup_closed=True,
|
|
104
|
+
keepalive_timeout=30,
|
|
105
|
+
)
|
|
106
|
+
self._session = aiohttp.ClientSession(connector=connector)
|
|
100
107
|
self._owns_session = True
|
|
101
108
|
return self._session
|
|
102
109
|
|
|
@@ -160,12 +167,13 @@ class Webhook:
|
|
|
160
167
|
except (TypeError, ValueError):
|
|
161
168
|
retry_after = None
|
|
162
169
|
if (
|
|
163
|
-
res.status
|
|
170
|
+
res.status in (429, 503)
|
|
164
171
|
and retry_after is not None
|
|
165
172
|
and retry_after > 0
|
|
166
173
|
and attempt < retries
|
|
167
174
|
):
|
|
168
|
-
|
|
175
|
+
cap = 60.0 if res.status == 429 else 30.0
|
|
176
|
+
await asyncio.sleep(min(retry_after, cap))
|
|
169
177
|
continue
|
|
170
178
|
exc_cls = exception_for_status(res.status)
|
|
171
179
|
# RateLimited subclass expected for 429
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.1.0"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|