pyyapper 1.2.0a1__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.
Files changed (36) hide show
  1. {pyyapper-1.2.0a1/src/pyyapper.egg-info → pyyapper-1.3.0}/PKG-INFO +1 -1
  2. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/pyproject.toml +1 -1
  3. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/__init__.py +6 -3
  4. pyyapper-1.3.0/src/pyyapper/_version.py +1 -0
  5. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/bot.py +65 -6
  6. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/errors.py +5 -0
  7. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/http.py +8 -6
  8. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/models.py +74 -2
  9. pyyapper-1.3.0/src/pyyapper/presence.py +139 -0
  10. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/webhooks.py +3 -2
  11. {pyyapper-1.2.0a1 → pyyapper-1.3.0/src/pyyapper.egg-info}/PKG-INFO +1 -1
  12. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper.egg-info/SOURCES.txt +1 -0
  13. pyyapper-1.2.0a1/src/pyyapper/_version.py +0 -1
  14. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/LICENSE +0 -0
  15. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/README.md +0 -0
  16. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/setup.cfg +0 -0
  17. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/buttons.py +0 -0
  18. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/commands.py +0 -0
  19. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/embeds.py +0 -0
  20. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/ext/__init__.py +0 -0
  21. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/ext/commands/__init__.py +0 -0
  22. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/ext/commands/checks.py +0 -0
  23. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/ext/commands/converters.py +0 -0
  24. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/ext/commands/core.py +0 -0
  25. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/ext/commands/errors.py +0 -0
  26. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/ext/tasks.py +0 -0
  27. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/pagination.py +0 -0
  28. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/py.typed +0 -0
  29. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper/wait_for.py +0 -0
  30. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper.egg-info/dependency_links.txt +0 -0
  31. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper.egg-info/requires.txt +0 -0
  32. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/src/pyyapper.egg-info/top_level.txt +0 -0
  33. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/tests/test_ext_commands.py +0 -0
  34. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/tests/test_smoke.py +0 -0
  35. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/tests/test_tasks.py +0 -0
  36. {pyyapper-1.2.0a1 → pyyapper-1.3.0}/tests/test_webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyyapper
3
- Version: 1.2.0a1
3
+ Version: 1.3.0
4
4
  Summary: An intuitive, async Python library for the Yapper bot API.
5
5
  Author: PyYapper contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyyapper"
7
- version = "1.2.0a1"
7
+ version = "1.3.0"
8
8
  description = "An intuitive, async Python library for the Yapper bot API."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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", "interaction", "test",
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: Optional[str] = None) -> Any:
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
- first = await self.api("GET", "/events?after=0&wait=0")
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
- out = await self.api("GET", f"/events?after={self.last}&wait=25", timeout=45.0)
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
@@ -782,6 +832,15 @@ class Bot:
782
832
  etype = ev.get("type")
783
833
  data = ev.get("data") or {}
784
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
785
844
  ctx = Context(self, data)
786
845
  # resolve command
787
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
 
@@ -127,12 +127,14 @@ class HTTPClient:
127
127
  retry_after = float(ra)
128
128
  except ValueError:
129
129
  retry_after = None
130
- # retry on 429 with Retry-After
131
- if res.status == 429 and retry_after and retry_after > 0 and retry_after <= 60 and attempt < retries:
132
- if self.log:
133
- print(f"[pyyapper] rate limited, retrying {method} {path} in {retry_after}s")
134
- await asyncio.sleep(retry_after)
135
- continue
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
136
138
  exc_cls = exception_for_status(res.status)
137
139
  raise exc_cls(
138
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(id=data.get("id", "") or "", name=data.get("name", "") or "", raw=dict(data))
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
@@ -167,12 +167,13 @@ class Webhook:
167
167
  except (TypeError, ValueError):
168
168
  retry_after = None
169
169
  if (
170
- res.status == 429
170
+ res.status in (429, 503)
171
171
  and retry_after is not None
172
172
  and retry_after > 0
173
173
  and attempt < retries
174
174
  ):
175
- await asyncio.sleep(min(retry_after, 60.0))
175
+ cap = 60.0 if res.status == 429 else 30.0
176
+ await asyncio.sleep(min(retry_after, cap))
176
177
  continue
177
178
  exc_cls = exception_for_status(res.status)
178
179
  # RateLimited subclass expected for 429
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyyapper
3
- Version: 1.2.0a1
3
+ Version: 1.3.0
4
4
  Summary: An intuitive, async Python library for the Yapper bot API.
5
5
  Author: PyYapper contributors
6
6
  License-Expression: MIT
@@ -11,6 +11,7 @@ src/pyyapper/errors.py
11
11
  src/pyyapper/http.py
12
12
  src/pyyapper/models.py
13
13
  src/pyyapper/pagination.py
14
+ src/pyyapper/presence.py
14
15
  src/pyyapper/py.typed
15
16
  src/pyyapper/wait_for.py
16
17
  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