pyyapper 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyyapper/buttons.py ADDED
@@ -0,0 +1,117 @@
1
+ """Button and Row builders for PyYapper.
2
+
3
+ Buttons are attached to messages via ``components`` (a list of :class:`Row`).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+
11
+ class Button:
12
+ """A clickable button.
13
+
14
+ Use the class-method shortcuts for the common cases::
15
+
16
+ Button.primary("Go", "go")
17
+ Button.link("Rules", "https://example.com")
18
+ """
19
+
20
+ STYLES = ("primary", "secondary", "success", "danger", "link")
21
+
22
+ def __init__(self, data: dict[str, Any] | None = None) -> None:
23
+ self.data: dict[str, Any] = {"type": "button", "style": "secondary"}
24
+ if data:
25
+ self.data.update(data)
26
+
27
+ # -- shortcuts ---------------------------------------------------------
28
+ @classmethod
29
+ def primary(cls, label: str, custom_id: str) -> "Button":
30
+ return cls({"style": "primary", "label": label, "custom_id": custom_id})
31
+
32
+ @classmethod
33
+ def secondary(cls, label: str, custom_id: str) -> "Button":
34
+ return cls({"style": "secondary", "label": label, "custom_id": custom_id})
35
+
36
+ @classmethod
37
+ def success(cls, label: str, custom_id: str) -> "Button":
38
+ return cls({"style": "success", "label": label, "custom_id": custom_id})
39
+
40
+ @classmethod
41
+ def danger(cls, label: str, custom_id: str) -> "Button":
42
+ return cls({"style": "danger", "label": label, "custom_id": custom_id})
43
+
44
+ @classmethod
45
+ def link(cls, label: str, url: str) -> "Button":
46
+ return cls({"style": "link", "label": label, "url": url})
47
+
48
+ # -- chainable setters -------------------------------------------------
49
+ def style(self, s: str) -> "Button":
50
+ if s not in self.STYLES:
51
+ raise ValueError(f"Unknown button style: {s!r}")
52
+ self.data["style"] = s
53
+ return self
54
+
55
+ def label(self, text: str) -> "Button":
56
+ self.data["label"] = str(text)
57
+ return self
58
+
59
+ def emoji(self, e: str) -> "Button":
60
+ self.data["emoji"] = str(e)
61
+ return self
62
+
63
+ def id(self, custom_id: str) -> "Button": # noqa: A003 - mirrors JS
64
+ self.data["custom_id"] = str(custom_id)
65
+ if "url" in self.data:
66
+ del self.data["url"]
67
+ return self
68
+
69
+ def url(self, u: str) -> "Button": # noqa: A003
70
+ self.data["style"] = "link"
71
+ self.data["url"] = str(u)
72
+ self.data.pop("custom_id", None)
73
+ return self
74
+
75
+ def disabled(self, on: bool = True) -> "Button":
76
+ self.data["disabled"] = bool(on)
77
+ return self
78
+
79
+ def to_dict(self) -> dict[str, Any]:
80
+ return dict(self.data)
81
+
82
+ def toJSON(self) -> dict[str, Any]:
83
+ return self.to_dict()
84
+
85
+ def __repr__(self) -> str: # pragma: no cover
86
+ return f"Button({self.data!r})"
87
+
88
+
89
+ class Row:
90
+ """A row of up to 5 buttons."""
91
+
92
+ def __init__(self, *buttons: Any) -> None:
93
+ self.buttons: list[Any] = []
94
+ for b in buttons:
95
+ self.buttons.extend(b if isinstance(b, list) else [b])
96
+
97
+ def add(self, *buttons: Any) -> "Row":
98
+ for b in buttons:
99
+ self.buttons.extend(b if isinstance(b, list) else [b])
100
+ return self
101
+
102
+ def to_dict(self) -> dict[str, Any]:
103
+ out = []
104
+ for b in self.buttons:
105
+ if hasattr(b, "to_dict"):
106
+ out.append(b.to_dict())
107
+ elif hasattr(b, "toJSON"):
108
+ out.append(b.toJSON())
109
+ else:
110
+ out.append(b)
111
+ return {"type": "row", "components": out}
112
+
113
+ def toJSON(self) -> dict[str, Any]:
114
+ return self.to_dict()
115
+
116
+ def __repr__(self) -> str: # pragma: no cover
117
+ return f"Row({self.buttons!r})"
pyyapper/embeds.py ADDED
@@ -0,0 +1,124 @@
1
+ """Embed (card) builder for PyYapper.
2
+
3
+ Mirrors the JS SDK's ``Embed`` class: every setter returns ``self`` so calls
4
+ chain, and :meth:`to_dict` produces the JSON payload Yapper expects.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+ _NAMED_COLORS = {
13
+ "red": 0xED4245,
14
+ "green": 0x57F287,
15
+ "blue": 0x5865F2,
16
+ "yellow": 0xFEE75C,
17
+ "purple": 0x9B59B6,
18
+ "orange": 0xE67E22,
19
+ "grey": 0x95A5A6,
20
+ "gray": 0x95A5A6,
21
+ "blurple": 0x5865F2,
22
+ }
23
+
24
+
25
+ class Embed:
26
+ """A card that can be attached to a message.
27
+
28
+ Example::
29
+
30
+ embed = (
31
+ Embed()
32
+ .title("Match result")
33
+ .description("**Blue** won 3-1.")
34
+ .color("#5865f2")
35
+ .field("MVP", "<@user-id>", inline=True)
36
+ .footer("Season 2")
37
+ )
38
+ """
39
+
40
+ def __init__(self, data: dict[str, Any] | None = None) -> None:
41
+ self.data: dict[str, Any] = dict(data) if data else {}
42
+
43
+ # -- chainable setters -------------------------------------------------
44
+ def title(self, text: str) -> "Embed":
45
+ self.data["title"] = str(text)
46
+ return self
47
+
48
+ def description(self, text: str) -> "Embed":
49
+ self.data["description"] = str(text)
50
+ return self
51
+
52
+ def url(self, url: str) -> "Embed":
53
+ self.data["url"] = str(url)
54
+ return self
55
+
56
+ def color(self, color: int | str) -> "Embed":
57
+ if isinstance(color, bool):
58
+ raise TypeError("color must be an int or str")
59
+ if isinstance(color, int):
60
+ self.data["color"] = color
61
+ return self
62
+ name = str(color).strip().lower()
63
+ if name in _NAMED_COLORS:
64
+ self.data["color"] = _NAMED_COLORS[name]
65
+ return self
66
+ hexed = name.lstrip("#").lstrip("0x").lstrip("x")
67
+ # handle 0x prefix already stripped above loosely
68
+ if name.startswith("0x"):
69
+ hexed = name[2:]
70
+ try:
71
+ self.data["color"] = int(hexed, 16)
72
+ except ValueError:
73
+ raise ValueError(f"Unknown color: {color!r}")
74
+ return self
75
+
76
+ def field(self, name: str, value: str, inline: bool = False) -> "Embed":
77
+ self.data.setdefault("fields", []).append(
78
+ {"name": str(name), "value": str(value), "inline": bool(inline)}
79
+ )
80
+ return self
81
+
82
+ def author(self, name: str, *, url: str | None = None, icon: str | None = None) -> "Embed":
83
+ payload: dict[str, Any] = {"name": str(name)}
84
+ if url is not None:
85
+ payload["url"] = str(url)
86
+ if icon is not None:
87
+ payload["icon_url"] = str(icon)
88
+ self.data["author"] = payload
89
+ return self
90
+
91
+ def footer(self, text: str, icon: str | None = None) -> "Embed":
92
+ payload: dict[str, Any] = {"text": str(text)}
93
+ if icon is not None:
94
+ payload["icon_url"] = str(icon)
95
+ self.data["footer"] = payload
96
+ return self
97
+
98
+ def thumbnail(self, url: str) -> "Embed":
99
+ self.data["thumbnail"] = {"url": str(url)}
100
+ return self
101
+
102
+ def image(self, url: str) -> "Embed":
103
+ self.data["image"] = {"url": str(url)}
104
+ return self
105
+
106
+ def timestamp(self, when: datetime | str | None = None) -> "Embed":
107
+ if when is None:
108
+ when = datetime.now(timezone.utc)
109
+ if isinstance(when, datetime):
110
+ if when.tzinfo is None:
111
+ when = when.replace(tzinfo=timezone.utc)
112
+ self.data["timestamp"] = when.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
113
+ else:
114
+ self.data["timestamp"] = str(when)
115
+ return self
116
+
117
+ def to_dict(self) -> dict[str, Any]:
118
+ return dict(self.data)
119
+
120
+ def toJSON(self) -> dict[str, Any]: # parity with JS
121
+ return self.to_dict()
122
+
123
+ def __repr__(self) -> str: # pragma: no cover
124
+ return f"Embed({self.data!r})"
pyyapper/errors.py ADDED
@@ -0,0 +1,90 @@
1
+ """Exception types raised by PyYapper.
2
+
3
+ All exceptions derive from :class:`YapperError` so callers can catch the whole
4
+ family with a single ``except``. HTTP failures are represented by
5
+ :class:`HTTPException` (and its subclasses) which carry the response ``status``,
6
+ the parsed ``error`` message and, when the server provides one, ``retry_after``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class YapperError(Exception):
13
+ """Base class for every error raised by PyYapper."""
14
+
15
+
16
+ class ConfigurationError(YapperError):
17
+ """Raised when the bot is configured incorrectly (e.g. a bad token)."""
18
+
19
+
20
+ class HTTPException(YapperError):
21
+ """An HTTP request to the Yapper API failed.
22
+
23
+ Attributes:
24
+ status: HTTP status code returned by the server.
25
+ error: Human readable error message from the server (or a fallback).
26
+ retry_after: Seconds to wait before retrying, when the server sent one.
27
+ method: HTTP method of the failed request.
28
+ path: API path of the failed request.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ status: int,
34
+ error: str = "",
35
+ *,
36
+ method: str = "",
37
+ path: str = "",
38
+ retry_after: float | None = None,
39
+ ) -> None:
40
+ self.status = status
41
+ self.error = error or f"HTTP {status}"
42
+ self.method = method
43
+ self.path = path
44
+ self.retry_after = retry_after
45
+ super().__init__(f"{method} {path} failed ({status}): {self.error}".strip())
46
+
47
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
48
+ return f"{type(self).__name__}(status={self.status}, error={self.error!r})"
49
+
50
+
51
+ class BadRequest(HTTPException):
52
+ """400 - the request body was invalid."""
53
+
54
+
55
+ class Unauthorized(HTTPException):
56
+ """401 - missing or wrong token."""
57
+
58
+
59
+ class Forbidden(HTTPException):
60
+ """403 - the bot is not in that server, or its roles do not allow it."""
61
+
62
+
63
+ class NotFound(HTTPException):
64
+ """404 - no such thing, or the bot cannot see it."""
65
+
66
+
67
+ class RateLimited(HTTPException):
68
+ """429 - slow down. ``retry_after`` says how many seconds to wait."""
69
+
70
+
71
+ class ServerError(HTTPException):
72
+ """5xx - something went wrong on Yapper's side."""
73
+
74
+
75
+ # Status code -> exception class mapping used by the HTTP layer.
76
+ STATUS_TO_EXC: dict[int, type[HTTPException]] = {
77
+ 400: BadRequest,
78
+ 401: Unauthorized,
79
+ 403: Forbidden,
80
+ 404: NotFound,
81
+ 429: RateLimited,
82
+ }
83
+
84
+
85
+ def exception_for_status(status: int) -> type[HTTPException]:
86
+ if status in STATUS_TO_EXC:
87
+ return STATUS_TO_EXC[status]
88
+ if 500 <= status < 600:
89
+ return ServerError
90
+ return HTTPException
pyyapper/http.py ADDED
@@ -0,0 +1,200 @@
1
+ """HTTP client used by PyYapper.
2
+
3
+ Wraps :mod:`aiohttp` with the Yapper-specific conventions:
4
+
5
+ * ``Authorization: Bot <token>`` on every request.
6
+ * JSON request/response bodies.
7
+ * Automatic retry of ``429`` responses that carry a ``Retry-After`` header
8
+ (up to ``retries`` times).
9
+ * Translation of HTTP error statuses into the exception types in
10
+ :mod:`pyyapper.errors`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import random
18
+ from typing import Any, Optional
19
+
20
+ import aiohttp
21
+
22
+ from .errors import (
23
+ HTTPException,
24
+ exception_for_status,
25
+ )
26
+
27
+ DEFAULT_BASE = "https://www.yapper.website/api/bot/v1"
28
+ USER_AGENT = "pyyapper/1.0"
29
+
30
+
31
+ def _backoff(n: int) -> float:
32
+ """Exponential backoff with jitter, capped at 30s."""
33
+ base = min(30_000, 1000 * (2 ** (n - 1))) / 1000.0
34
+ return base * (0.75 + random.random() * 0.5)
35
+
36
+
37
+ class HTTPClient:
38
+ def __init__(
39
+ self,
40
+ token: str,
41
+ *,
42
+ base_url: str = DEFAULT_BASE,
43
+ session: Optional[aiohttp.ClientSession] = None,
44
+ log: bool = True,
45
+ ) -> None:
46
+ if not token or not str(token).startswith("yb_"):
47
+ raise ValueError("Pass your bot token (starts with yb_). Get one in Settings -> Developer.")
48
+ self.token = str(token).strip()
49
+ self.base_url = base_url.rstrip("/")
50
+ self.log = log
51
+ self._session = session
52
+ self._owns_session = session is None
53
+
54
+ async def _get_session(self) -> aiohttp.ClientSession:
55
+ if self._session is None or self._session.closed:
56
+ self._session = aiohttp.ClientSession()
57
+ self._owns_session = True
58
+ return self._session
59
+
60
+ async def close(self) -> None:
61
+ if self._owns_session and self._session is not None and not self._session.closed:
62
+ await self._session.close()
63
+ self._session = None
64
+
65
+ async def request(
66
+ self,
67
+ method: str,
68
+ path: str,
69
+ *,
70
+ body: Any = None,
71
+ headers: Optional[dict[str, str]] = None,
72
+ timeout: float = 30.0,
73
+ retries: int = 3,
74
+ content_type: Optional[str] = None,
75
+ ) -> Any:
76
+ session = await self._get_session()
77
+ url = self.base_url + path
78
+ hdrs = {
79
+ "Authorization": f"Bot {self.token}",
80
+ "User-Agent": USER_AGENT,
81
+ }
82
+ if headers:
83
+ hdrs.update(headers)
84
+ data = None
85
+ if body is not None and not isinstance(body, (bytes, bytearray)):
86
+ # JSON body unless caller already provided bytes
87
+ if content_type is None:
88
+ content_type = "application/json"
89
+ data = json.dumps(body)
90
+ elif body is not None:
91
+ data = body
92
+ if content_type:
93
+ hdrs.setdefault("Content-Type", content_type)
94
+
95
+ for attempt in range(retries + 1):
96
+ try:
97
+ async with session.request(
98
+ method,
99
+ url,
100
+ data=data,
101
+ headers=hdrs,
102
+ timeout=aiohttp.ClientTimeout(total=timeout),
103
+ ) as res:
104
+ raw = await res.read()
105
+ text = raw.decode("utf-8", errors="replace") if raw else ""
106
+ try:
107
+ payload = json.loads(text) if text else {}
108
+ except json.JSONDecodeError:
109
+ payload = {}
110
+ if res.status < 400:
111
+ return payload
112
+ # error path
113
+ err_msg = ""
114
+ if isinstance(payload, dict):
115
+ err_msg = payload.get("error") or payload.get("message") or ""
116
+ retry_after = None
117
+ ra = res.headers.get("Retry-After")
118
+ if ra is not None:
119
+ try:
120
+ retry_after = float(ra)
121
+ except ValueError:
122
+ retry_after = None
123
+ # retry on 429 with Retry-After
124
+ if res.status == 429 and retry_after and retry_after > 0 and retry_after <= 60 and attempt < retries:
125
+ if self.log:
126
+ print(f"[pyyapper] rate limited, retrying {method} {path} in {retry_after}s")
127
+ await asyncio.sleep(retry_after)
128
+ continue
129
+ exc_cls = exception_for_status(res.status)
130
+ raise exc_cls(
131
+ res.status,
132
+ err_msg or f"{method} {path} failed ({res.status})",
133
+ method=method,
134
+ path=path,
135
+ retry_after=retry_after,
136
+ )
137
+ except (aiohttp.ClientError, asyncio.TimeoutError) as e:
138
+ if attempt < retries:
139
+ await asyncio.sleep(_backoff(attempt + 1))
140
+ continue
141
+ raise HTTPException(0, f"network error: {e}", method=method, path=path)
142
+
143
+ # Should not reach here
144
+ raise HTTPException(0, "request failed", method=method, path=path)
145
+
146
+ async def upload(
147
+ self,
148
+ method: str,
149
+ path: str,
150
+ image: Any,
151
+ content_type: Optional[str] = None,
152
+ *,
153
+ timeout: float = 60.0,
154
+ ) -> Any:
155
+ """Upload an image (bytes, path, or Blob-like)."""
156
+ import os
157
+
158
+ bytes_data = image
159
+ ct = content_type
160
+ if isinstance(image, str):
161
+ # file path
162
+ with open(image, "rb") as f:
163
+ bytes_data = f.read()
164
+ if ct is None:
165
+ ext = os.path.splitext(image)[1].lstrip(".").lower()
166
+ ct = {
167
+ "png": "image/png",
168
+ "jpg": "image/jpeg",
169
+ "jpeg": "image/jpeg",
170
+ "gif": "image/gif",
171
+ "webp": "image/webp",
172
+ }.get(ext)
173
+ elif hasattr(image, "read"):
174
+ bytes_data = image.read()
175
+ if hasattr(image, "seek"):
176
+ try:
177
+ image.seek(0)
178
+ except Exception:
179
+ pass
180
+ ct = ct or getattr(image, "content_type", None) or getattr(image, "type", None)
181
+ elif hasattr(image, "tobytes"):
182
+ bytes_data = image.tobytes() if callable(getattr(image, "tobytes")) else bytes(image)
183
+ else:
184
+ try:
185
+ bytes_data = bytes(image)
186
+ except Exception:
187
+ pass
188
+ if isinstance(bytes_data, str):
189
+ bytes_data = bytes_data.encode()
190
+ if not ct:
191
+ raise ValueError("Pass the image type too, like 'image/png'")
192
+ return await self.request(
193
+ method,
194
+ path,
195
+ body=bytes_data,
196
+ headers={"Content-Type": ct},
197
+ content_type=ct,
198
+ timeout=timeout,
199
+ retries=1,
200
+ )