pyyapper 1.4.0__tar.gz → 1.6.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.4.0/src/pyyapper.egg-info → pyyapper-1.6.0}/PKG-INFO +1 -1
- {pyyapper-1.4.0 → pyyapper-1.6.0}/pyproject.toml +1 -1
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/__init__.py +15 -1
- pyyapper-1.6.0/src/pyyapper/_version.py +1 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/bot.py +24 -0
- pyyapper-1.6.0/src/pyyapper/components_v2.py +319 -0
- pyyapper-1.6.0/src/pyyapper/oauth.py +217 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0/src/pyyapper.egg-info}/PKG-INFO +1 -1
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper.egg-info/SOURCES.txt +3 -0
- pyyapper-1.6.0/tests/test_oauth.py +35 -0
- pyyapper-1.4.0/src/pyyapper/_version.py +0 -1
- {pyyapper-1.4.0 → pyyapper-1.6.0}/LICENSE +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/README.md +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/setup.cfg +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/buttons.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/cache.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/commands.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/config.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/embeds.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/errors.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/__init__.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/commands/__init__.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/commands/checks.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/commands/converters.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/commands/core.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/commands/errors.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/ext/tasks.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/http.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/i18n.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/metrics.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/models.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/pagination.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/presence.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/py.typed +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/wait_for.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper/webhooks.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper.egg-info/dependency_links.txt +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper.egg-info/requires.txt +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/src/pyyapper.egg-info/top_level.txt +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/tests/test_ext_commands.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/tests/test_smoke.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/tests/test_tasks.py +0 -0
- {pyyapper-1.4.0 → pyyapper-1.6.0}/tests/test_webhooks.py +0 -0
|
@@ -23,10 +23,18 @@ from .presence import Activity, Presence
|
|
|
23
23
|
from .pagination import Paginator
|
|
24
24
|
from .webhooks import Webhook
|
|
25
25
|
from .wait_for import WaiterRegistry, wait_for
|
|
26
|
+
from .oauth import OAuth2Client, OAuthError, generate_state, generate_pkce, YapperOAuth, LoginWithYapper
|
|
26
27
|
from .config import load_config, load_env_file, get_token
|
|
27
28
|
from .i18n import Translator, t as translate
|
|
28
29
|
from .metrics import Metrics
|
|
29
30
|
from .ext import commands, tasks
|
|
31
|
+
from . import components_v2 as components_v2
|
|
32
|
+
from .components_v2 import (
|
|
33
|
+
IS_COMPONENTS_V2, TextDisplay, Thumbnail, Section, MediaGallery, MediaGalleryItem,
|
|
34
|
+
Separator, FileComponent, Container, ActionRow,
|
|
35
|
+
text, thumbnail, section, gallery, separator, file_display, container, action_row,
|
|
36
|
+
row, normalize_components,
|
|
37
|
+
)
|
|
30
38
|
|
|
31
39
|
__version__ = _version.__version__
|
|
32
40
|
|
|
@@ -38,9 +46,15 @@ __all__ = [
|
|
|
38
46
|
"User", "Server", "Channel", "Category", "Member", "Message", "Role", "VoiceChannel", "picture_url",
|
|
39
47
|
"Activity", "Presence",
|
|
40
48
|
"Paginator", "Webhook", "WaiterRegistry", "wait_for",
|
|
49
|
+
"OAuth2Client", "OAuthError", "generate_state", "generate_pkce",
|
|
50
|
+
"YapperOAuth", "LoginWithYapper",
|
|
41
51
|
"load_config", "load_env_file", "get_token",
|
|
42
52
|
"Translator", "translate", "Metrics",
|
|
43
|
-
"commands", "tasks",
|
|
53
|
+
"commands", "tasks", "components_v2",
|
|
54
|
+
"IS_COMPONENTS_V2", "TextDisplay", "Thumbnail", "Section", "MediaGallery",
|
|
55
|
+
"MediaGalleryItem", "Separator", "FileComponent", "Container", "ActionRow",
|
|
56
|
+
"text", "thumbnail", "section", "gallery", "separator", "file_display",
|
|
57
|
+
"container", "action_row", "row", "normalize_components",
|
|
44
58
|
"Cog", "Command", "Group",
|
|
45
59
|
"__version__",
|
|
46
60
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.6.0"
|
|
@@ -88,6 +88,9 @@ class Context:
|
|
|
88
88
|
async def say(self, content: Any = "", **opts: Any) -> Any:
|
|
89
89
|
return await self._bot.send(self.channel_id, content, **opts)
|
|
90
90
|
|
|
91
|
+
async def send_layout(self, components: Any, **opts: Any) -> Any:
|
|
92
|
+
return await self._bot.send_layout(self.channel_id, components, **opts)
|
|
93
|
+
|
|
91
94
|
def __repr__(self) -> str:
|
|
92
95
|
return f"Context(id={self.id!r}, author={self.author!r})"
|
|
93
96
|
|
|
@@ -137,6 +140,10 @@ class InteractionContext:
|
|
|
137
140
|
kw = {**changes, **kw}
|
|
138
141
|
changes = None
|
|
139
142
|
body = {"type": "update"}
|
|
143
|
+
if "layout" in kw:
|
|
144
|
+
from .components_v2 import normalize_components
|
|
145
|
+
body["flags"] = 32768
|
|
146
|
+
body["components"] = normalize_components(kw.pop("layout"))
|
|
140
147
|
if "content" in kw:
|
|
141
148
|
c = kw.pop("content")
|
|
142
149
|
body["content"] = None if c is None else str(c)
|
|
@@ -556,6 +563,23 @@ class Bot:
|
|
|
556
563
|
def send_embed(self, channel_id: Any, embed: Any, content: str = "") -> Any:
|
|
557
564
|
return self.send(channel_id, content, embeds=[embed])
|
|
558
565
|
|
|
566
|
+
async def send_layout(self, channel_id: Any, components: Any, *, reply_to: Any = None,
|
|
567
|
+
flags: int = 32768) -> Any:
|
|
568
|
+
from .components_v2 import normalize_components
|
|
569
|
+
body: dict[str, Any] = {"flags": flags, "components": normalize_components(components)}
|
|
570
|
+
if reply_to is not None:
|
|
571
|
+
body["reply_to"] = reply_to
|
|
572
|
+
out = await self.api("POST", f"/channels/{channel_id}/messages", body)
|
|
573
|
+
msg = out.get("message") if isinstance(out, dict) else None
|
|
574
|
+
return msg if msg is not None else out
|
|
575
|
+
|
|
576
|
+
async def edit_layout(self, channel_id: Any, message_id: Any, components: Any,
|
|
577
|
+
*, flags: int = 32768) -> Any:
|
|
578
|
+
from .components_v2 import normalize_components
|
|
579
|
+
body: dict[str, Any] = {"flags": flags, "components": normalize_components(components)}
|
|
580
|
+
out = await self.api("PATCH", f"/channels/{channel_id}/messages/{message_id}", body)
|
|
581
|
+
return out.get("message") if isinstance(out, dict) else out
|
|
582
|
+
|
|
559
583
|
async def delete(self, channel_id: Any, message_id: Any) -> Any:
|
|
560
584
|
return await self.api("DELETE", f"/channels/{channel_id}/messages/{message_id}")
|
|
561
585
|
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Components V2 builders for PyYapper.
|
|
2
|
+
|
|
3
|
+
Components V2 lets you build a whole message out of layout parts: containers
|
|
4
|
+
(cards), sections with a button or thumbnail next to the text, media galleries,
|
|
5
|
+
separators, files and text displays, with button rows anywhere.
|
|
6
|
+
|
|
7
|
+
Send one with ``bot.send_layout(channel, [container(...)])`` (or
|
|
8
|
+
``ctx.send_layout(...)``), which sets ``flags: 32768`` (``IS_COMPONENTS_V2``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from .buttons import Button, Row
|
|
16
|
+
from .embeds import Embed # noqa: F401 (kept for parity)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _plain(x: Any) -> Any:
|
|
20
|
+
if hasattr(x, "to_dict"):
|
|
21
|
+
return x.to_dict()
|
|
22
|
+
if hasattr(x, "toJSON"):
|
|
23
|
+
return x.toJSON()
|
|
24
|
+
return x
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _color(c: Any) -> Optional[int]:
|
|
28
|
+
if c is None:
|
|
29
|
+
return None
|
|
30
|
+
if isinstance(c, bool):
|
|
31
|
+
return None
|
|
32
|
+
if isinstance(c, int):
|
|
33
|
+
return c
|
|
34
|
+
s = str(c).strip()
|
|
35
|
+
named = {"red": 0xED4245, "green": 0x57F287, "blue": 0x5865F2,
|
|
36
|
+
"yellow": 0xFEE75C, "purple": 0x9B59B6, "orange": 0xE67E22,
|
|
37
|
+
"grey": 0x95A5A6, "gray": 0x95A5A6, "blurple": 0x5865F2}
|
|
38
|
+
if s.lower() in named:
|
|
39
|
+
return named[s.lower()]
|
|
40
|
+
hexed = s.lstrip("#").lstrip("0x").lstrip("x")
|
|
41
|
+
if s.lower().startswith("0x"):
|
|
42
|
+
hexed = s[2:]
|
|
43
|
+
try:
|
|
44
|
+
return int(hexed, 16)
|
|
45
|
+
except ValueError:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _media(url: Any) -> dict[str, Any]:
|
|
50
|
+
if isinstance(url, dict):
|
|
51
|
+
return dict(url)
|
|
52
|
+
return {"url": str(url)}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _normalize_v2(obj: Any) -> Any:
|
|
56
|
+
"""Coerce builders/dicts to plain dicts and normalize action-row types."""
|
|
57
|
+
if hasattr(obj, "to_dict"):
|
|
58
|
+
obj = obj.to_dict()
|
|
59
|
+
if isinstance(obj, dict):
|
|
60
|
+
# action row: "row" -> 1
|
|
61
|
+
t = obj.get("type")
|
|
62
|
+
if t in ("row", "action_row"):
|
|
63
|
+
obj["type"] = 1
|
|
64
|
+
if t == "button":
|
|
65
|
+
obj["type"] = 2
|
|
66
|
+
# recurse
|
|
67
|
+
for k in ("components", "items", "accessory"):
|
|
68
|
+
if k in obj and isinstance(obj[k], list):
|
|
69
|
+
obj[k] = [_normalize_v2(x) for x in obj[k]]
|
|
70
|
+
elif k in obj and obj[k] is not None and not isinstance(obj[k], (str, int, float, bool)):
|
|
71
|
+
obj[k] = _normalize_v2(obj[k])
|
|
72
|
+
if "media" in obj and isinstance(obj["media"], (dict,)):
|
|
73
|
+
obj["media"] = _normalize_v2(obj["media"])
|
|
74
|
+
if "file" in obj and isinstance(obj["file"], (dict,)):
|
|
75
|
+
obj["file"] = _normalize_v2(obj["file"])
|
|
76
|
+
return obj
|
|
77
|
+
if isinstance(obj, (list, tuple)):
|
|
78
|
+
return [_normalize_v2(x) for x in obj]
|
|
79
|
+
return obj
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --------------------------------------------------------------------------- #
|
|
83
|
+
# Builders
|
|
84
|
+
# --------------------------------------------------------------------------- #
|
|
85
|
+
class TextDisplay:
|
|
86
|
+
"""Type 10 — a text display (markdown)."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, content: Any, *, id: Optional[int] = None) -> None:
|
|
89
|
+
self.data: dict[str, Any] = {"type": 10, "content": str(content)}
|
|
90
|
+
if id is not None:
|
|
91
|
+
self.data["id"] = id
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> dict[str, Any]:
|
|
94
|
+
return dict(self.data)
|
|
95
|
+
|
|
96
|
+
def toJSON(self) -> dict[str, Any]:
|
|
97
|
+
return self.to_dict()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Thumbnail:
|
|
101
|
+
"""Type 11 — a thumbnail (only valid as a section accessory)."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, url: Any, *, description: Optional[str] = None,
|
|
104
|
+
spoiler: bool = False, id: Optional[int] = None) -> None:
|
|
105
|
+
self.data: dict[str, Any] = {"type": 11, "media": {"url": str(url)}}
|
|
106
|
+
if description is not None:
|
|
107
|
+
self.data["description"] = str(description)
|
|
108
|
+
if spoiler:
|
|
109
|
+
self.data["spoiler"] = True
|
|
110
|
+
if id is not None:
|
|
111
|
+
self.data["id"] = id
|
|
112
|
+
|
|
113
|
+
def to_dict(self) -> dict[str, Any]:
|
|
114
|
+
return dict(self.data)
|
|
115
|
+
|
|
116
|
+
def toJSON(self) -> dict[str, Any]:
|
|
117
|
+
return self.to_dict()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Section:
|
|
121
|
+
"""Type 9 — 1 to 3 text displays plus a button or thumbnail accessory."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, *texts: Any, accessory: Any = None, id: Optional[int] = None) -> None:
|
|
124
|
+
comps = [_plain(t) if not hasattr(t, "to_dict") else t.to_dict() for t in texts]
|
|
125
|
+
# allow passing raw strings
|
|
126
|
+
norm = []
|
|
127
|
+
for t in texts:
|
|
128
|
+
if isinstance(t, str):
|
|
129
|
+
norm.append({"type": 10, "content": t})
|
|
130
|
+
else:
|
|
131
|
+
norm.append(_normalize_v2(_plain(t)))
|
|
132
|
+
self.data: dict[str, Any] = {"type": 9, "components": norm}
|
|
133
|
+
if accessory is not None:
|
|
134
|
+
self.data["accessory"] = _normalize_v2(_plain(accessory))
|
|
135
|
+
if id is not None:
|
|
136
|
+
self.data["id"] = id
|
|
137
|
+
|
|
138
|
+
def accessory(self, acc: Any) -> "Section":
|
|
139
|
+
self.data["accessory"] = _normalize_v2(_plain(acc))
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
def to_dict(self) -> dict[str, Any]:
|
|
143
|
+
return _normalize_v2(dict(self.data))
|
|
144
|
+
|
|
145
|
+
def toJSON(self) -> dict[str, Any]:
|
|
146
|
+
return self.to_dict()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class MediaGalleryItem:
|
|
150
|
+
def __init__(self, url: Any, *, description: Optional[str] = None, spoiler: bool = False) -> None:
|
|
151
|
+
self.data: dict[str, Any] = {"media": {"url": str(url)}}
|
|
152
|
+
if description is not None:
|
|
153
|
+
self.data["description"] = str(description)
|
|
154
|
+
if spoiler:
|
|
155
|
+
self.data["spoiler"] = True
|
|
156
|
+
|
|
157
|
+
def to_dict(self) -> dict[str, Any]:
|
|
158
|
+
return dict(self.data)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class MediaGallery:
|
|
162
|
+
"""Type 12 — a gallery of 1 to 10 media items."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, *items: Any, id: Optional[int] = None) -> None:
|
|
165
|
+
norm = []
|
|
166
|
+
for it in items:
|
|
167
|
+
if isinstance(it, str):
|
|
168
|
+
norm.append({"media": {"url": it}})
|
|
169
|
+
elif isinstance(it, MediaGalleryItem):
|
|
170
|
+
norm.append(it.to_dict())
|
|
171
|
+
elif isinstance(it, dict):
|
|
172
|
+
if "media" in it:
|
|
173
|
+
norm.append(dict(it))
|
|
174
|
+
else:
|
|
175
|
+
norm.append({"media": {"url": str(it)}})
|
|
176
|
+
else:
|
|
177
|
+
norm.append(_normalize_v2(_plain(it)))
|
|
178
|
+
self.data: dict[str, Any] = {"type": 12, "items": norm}
|
|
179
|
+
if id is not None:
|
|
180
|
+
self.data["id"] = id
|
|
181
|
+
|
|
182
|
+
def to_dict(self) -> dict[str, Any]:
|
|
183
|
+
return _normalize_v2(dict(self.data))
|
|
184
|
+
|
|
185
|
+
def toJSON(self) -> dict[str, Any]:
|
|
186
|
+
return self.to_dict()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Separator:
|
|
190
|
+
"""Type 14 — a divider or spacer."""
|
|
191
|
+
|
|
192
|
+
def __init__(self, *, divider: bool = True, spacing: int = 1, id: Optional[int] = None) -> None:
|
|
193
|
+
self.data: dict[str, Any] = {"type": 14, "divider": bool(divider), "spacing": int(spacing)}
|
|
194
|
+
if id is not None:
|
|
195
|
+
self.data["id"] = id
|
|
196
|
+
|
|
197
|
+
def to_dict(self) -> dict[str, Any]:
|
|
198
|
+
return dict(self.data)
|
|
199
|
+
|
|
200
|
+
def toJSON(self) -> dict[str, Any]:
|
|
201
|
+
return self.to_dict()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class FileComponent:
|
|
205
|
+
"""Type 13 — a file download card."""
|
|
206
|
+
|
|
207
|
+
def __init__(self, url: Any, *, spoiler: bool = False, id: Optional[int] = None) -> None:
|
|
208
|
+
self.data: dict[str, Any] = {"type": 13, "file": {"url": str(url)}}
|
|
209
|
+
if spoiler:
|
|
210
|
+
self.data["spoiler"] = True
|
|
211
|
+
if id is not None:
|
|
212
|
+
self.data["id"] = id
|
|
213
|
+
|
|
214
|
+
def to_dict(self) -> dict[str, Any]:
|
|
215
|
+
return dict(self.data)
|
|
216
|
+
|
|
217
|
+
def toJSON(self) -> dict[str, Any]:
|
|
218
|
+
return self.to_dict()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Container:
|
|
222
|
+
"""Type 17 — a card. ``components``: action rows, text displays, sections, galleries, separators, files."""
|
|
223
|
+
|
|
224
|
+
def __init__(self, *components: Any, accent_color: Any = None, spoiler: bool = False,
|
|
225
|
+
id: Optional[int] = None) -> None:
|
|
226
|
+
self.data: dict[str, Any] = {"type": 17, "components": [_normalize_v2(_plain(c)) for c in components]}
|
|
227
|
+
ac = _color(accent_color)
|
|
228
|
+
if ac is not None:
|
|
229
|
+
self.data["accent_color"] = ac
|
|
230
|
+
if spoiler:
|
|
231
|
+
self.data["spoiler"] = True
|
|
232
|
+
if id is not None:
|
|
233
|
+
self.data["id"] = id
|
|
234
|
+
|
|
235
|
+
def add(self, *components: Any) -> "Container":
|
|
236
|
+
self.data["components"].extend(_normalize_v2(_plain(c)) for c in components)
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def accent(self, color: Any) -> "Container":
|
|
240
|
+
ac = _color(color)
|
|
241
|
+
if ac is not None:
|
|
242
|
+
self.data["accent_color"] = ac
|
|
243
|
+
return self
|
|
244
|
+
|
|
245
|
+
def to_dict(self) -> dict[str, Any]:
|
|
246
|
+
return _normalize_v2(dict(self.data))
|
|
247
|
+
|
|
248
|
+
def toJSON(self) -> dict[str, Any]:
|
|
249
|
+
return self.to_dict()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class ActionRow:
|
|
253
|
+
"""Type 1 — an action row of up to 5 buttons (V2 canonical)."""
|
|
254
|
+
|
|
255
|
+
def __init__(self, *buttons: Any, id: Optional[int] = None) -> None:
|
|
256
|
+
btns = []
|
|
257
|
+
for b in buttons:
|
|
258
|
+
btns.extend(b if isinstance(b, list) else [b])
|
|
259
|
+
self.data: dict[str, Any] = {"type": 1, "components": [_plain(x) for x in btns]}
|
|
260
|
+
if id is not None:
|
|
261
|
+
self.data["id"] = id
|
|
262
|
+
|
|
263
|
+
def to_dict(self) -> dict[str, Any]:
|
|
264
|
+
return _normalize_v2(dict(self.data))
|
|
265
|
+
|
|
266
|
+
def toJSON(self) -> dict[str, Any]:
|
|
267
|
+
return self.to_dict()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# --------------------------------------------------------------------------- #
|
|
271
|
+
# Factory functions (discord.py / JS-style convenience)
|
|
272
|
+
# --------------------------------------------------------------------------- #
|
|
273
|
+
def text(content: Any, *, id: Optional[int] = None) -> TextDisplay:
|
|
274
|
+
return TextDisplay(content, id=id)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def thumbnail(url: Any, *, description: Optional[str] = None, spoiler: bool = False,
|
|
278
|
+
id: Optional[int] = None) -> Thumbnail:
|
|
279
|
+
return Thumbnail(url, description=description, spoiler=spoiler, id=id)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def section(*texts: Any, accessory: Any = None, id: Optional[int] = None) -> Section:
|
|
283
|
+
return Section(*texts, accessory=accessory, id=id)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def gallery(*items: Any, id: Optional[int] = None) -> MediaGallery:
|
|
287
|
+
return MediaGallery(*items, id=id)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def separator(*, divider: bool = True, spacing: int = 1, id: Optional[int] = None) -> Separator:
|
|
291
|
+
return Separator(divider=divider, spacing=spacing, id=id)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def file_display(url: Any, *, spoiler: bool = False, id: Optional[int] = None) -> FileComponent:
|
|
295
|
+
return FileComponent(url, spoiler=spoiler, id=id)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def container(*components: Any, accent_color: Any = None, spoiler: bool = False,
|
|
299
|
+
id: Optional[int] = None) -> Container:
|
|
300
|
+
return Container(*components, accent_color=accent_color, spoiler=spoiler, id=id)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def action_row(*buttons: Any, id: Optional[int] = None) -> ActionRow:
|
|
304
|
+
return ActionRow(*buttons, id=id)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# alias for Row-style usage in V2
|
|
308
|
+
row = action_row
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
IS_COMPONENTS_V2 = 32768
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def normalize_components(components: Any) -> list:
|
|
315
|
+
"""Normalize a list of V2 components to plain dicts."""
|
|
316
|
+
out = []
|
|
317
|
+
for c in (components if isinstance(components, (list, tuple)) else [components]):
|
|
318
|
+
out.append(_normalize_v2(_plain(c)))
|
|
319
|
+
return out
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""OAuth2 "Login with Yapper" client for PyYapper.
|
|
2
|
+
|
|
3
|
+
Implements the authorization-code flow (with optional PKCE) described in the
|
|
4
|
+
Yapper developer docs. This is intended for web apps that let users log in with
|
|
5
|
+
their Yapper account; it is independent of the bot API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import urllib.parse
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
import aiohttp
|
|
19
|
+
|
|
20
|
+
from .errors import YapperError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OAuthError(YapperError):
|
|
24
|
+
"""An OAuth2 request failed.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
error: OAuth2 error code (e.g. ``invalid_grant``).
|
|
28
|
+
description: Human readable description.
|
|
29
|
+
status: HTTP status code.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, error: str, description: str = "", *, status: int = 400) -> None:
|
|
33
|
+
self.error = error
|
|
34
|
+
self.description = description
|
|
35
|
+
self.status = status
|
|
36
|
+
super().__init__(f"{error}: {description}".strip(": "))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
DEFAULT_BASE = "https://www.yapper.website"
|
|
40
|
+
AUTHORIZE_PATH = "/oauth/authorize"
|
|
41
|
+
TOKEN_PATH = "/api/oauth/token"
|
|
42
|
+
ME_PATH = "/api/oauth/me"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _b64url(data: bytes) -> str:
|
|
46
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def generate_state(n: int = 32) -> str:
|
|
50
|
+
"""Generate a random, URL-safe ``state`` value."""
|
|
51
|
+
return secrets.token_urlsafe(n)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def generate_pkce(verifier_length: int = 64) -> tuple[str, str]:
|
|
55
|
+
"""Generate a (code_verifier, code_challenge) pair for PKCE (S256)."""
|
|
56
|
+
verifier = secrets.token_urlsafe(verifier_length)[:128]
|
|
57
|
+
if len(verifier) < 43:
|
|
58
|
+
verifier = verifier + secrets.token_urlsafe(43 - len(verifier))
|
|
59
|
+
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
60
|
+
return verifier, challenge
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class OAuth2Client:
|
|
64
|
+
"""A client for one Yapper OAuth2 app.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
client_id:
|
|
69
|
+
The app's client id.
|
|
70
|
+
client_secret:
|
|
71
|
+
The app's client secret (omit for PKCE/public apps).
|
|
72
|
+
redirect_uri:
|
|
73
|
+
Default redirect URI (must match one registered for the app).
|
|
74
|
+
base_url:
|
|
75
|
+
Yapper base URL. Defaults to ``https://www.yapper.website``.
|
|
76
|
+
session:
|
|
77
|
+
Optional shared :class:`aiohttp.ClientSession`.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
client_id: str,
|
|
83
|
+
client_secret: Optional[str] = None,
|
|
84
|
+
redirect_uri: Optional[str] = None,
|
|
85
|
+
*,
|
|
86
|
+
base_url: str = DEFAULT_BASE,
|
|
87
|
+
session: Optional[aiohttp.ClientSession] = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.client_id = str(client_id)
|
|
90
|
+
self.client_secret = client_secret
|
|
91
|
+
self.redirect_uri = redirect_uri
|
|
92
|
+
self.base_url = base_url.rstrip("/")
|
|
93
|
+
self._session = session
|
|
94
|
+
self._owns_session = session is None
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------ #
|
|
97
|
+
# Session management
|
|
98
|
+
# ------------------------------------------------------------------ #
|
|
99
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
100
|
+
if self._session is None or getattr(self._session, "closed", False):
|
|
101
|
+
self._session = aiohttp.ClientSession()
|
|
102
|
+
self._owns_session = True
|
|
103
|
+
return self._session
|
|
104
|
+
|
|
105
|
+
async def close(self) -> None:
|
|
106
|
+
if self._owns_session and self._session is not None and not getattr(self._session, "closed", True):
|
|
107
|
+
await self._session.close()
|
|
108
|
+
self._session = None
|
|
109
|
+
|
|
110
|
+
async def __aenter__(self) -> "OAuth2Client":
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
114
|
+
await self.close()
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------ #
|
|
117
|
+
# Authorization URL
|
|
118
|
+
# ------------------------------------------------------------------ #
|
|
119
|
+
def authorization_url(
|
|
120
|
+
self,
|
|
121
|
+
state: str,
|
|
122
|
+
*,
|
|
123
|
+
scope: str = "identify",
|
|
124
|
+
redirect_uri: Optional[str] = None,
|
|
125
|
+
code_challenge: Optional[str] = None,
|
|
126
|
+
code_challenge_method: str = "S256",
|
|
127
|
+
) -> str:
|
|
128
|
+
params: dict[str, Any] = {
|
|
129
|
+
"client_id": self.client_id,
|
|
130
|
+
"redirect_uri": redirect_uri or self.redirect_uri,
|
|
131
|
+
"response_type": "code",
|
|
132
|
+
"scope": scope,
|
|
133
|
+
"state": state,
|
|
134
|
+
}
|
|
135
|
+
if code_challenge is not None:
|
|
136
|
+
params["code_challenge"] = code_challenge
|
|
137
|
+
params["code_challenge_method"] = code_challenge_method
|
|
138
|
+
return f"{self.base_url}{AUTHORIZE_PATH}?{urllib.parse.urlencode(params)}"
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------ #
|
|
141
|
+
# Token exchange
|
|
142
|
+
# ------------------------------------------------------------------ #
|
|
143
|
+
async def exchange_code(
|
|
144
|
+
self,
|
|
145
|
+
code: str,
|
|
146
|
+
*,
|
|
147
|
+
redirect_uri: Optional[str] = None,
|
|
148
|
+
code_verifier: Optional[str] = None,
|
|
149
|
+
use_basic_auth: bool = False,
|
|
150
|
+
) -> dict[str, Any]:
|
|
151
|
+
url = f"{self.base_url}{TOKEN_PATH}"
|
|
152
|
+
body: dict[str, Any] = {
|
|
153
|
+
"grant_type": "authorization_code",
|
|
154
|
+
"code": code,
|
|
155
|
+
"redirect_uri": redirect_uri or self.redirect_uri,
|
|
156
|
+
"client_id": self.client_id,
|
|
157
|
+
}
|
|
158
|
+
if self.client_secret is not None and not use_basic_auth:
|
|
159
|
+
body["client_secret"] = self.client_secret
|
|
160
|
+
if code_verifier is not None:
|
|
161
|
+
body["code_verifier"] = code_verifier
|
|
162
|
+
|
|
163
|
+
headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
164
|
+
if use_basic_auth and self.client_secret is not None:
|
|
165
|
+
import base64 as _b64
|
|
166
|
+
|
|
167
|
+
creds = _b64.b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode()
|
|
168
|
+
headers["Authorization"] = f"Basic {creds}"
|
|
169
|
+
body.pop("client_secret", None)
|
|
170
|
+
|
|
171
|
+
session = await self._get_session()
|
|
172
|
+
async with session.post(url, data=body, headers=headers) as res:
|
|
173
|
+
raw = await res.read()
|
|
174
|
+
text = raw.decode("utf-8", errors="replace") if raw else ""
|
|
175
|
+
try:
|
|
176
|
+
payload = json.loads(text) if text else {}
|
|
177
|
+
except Exception:
|
|
178
|
+
payload = {}
|
|
179
|
+
if res.status >= 400:
|
|
180
|
+
err = payload.get("error", "invalid_request") if isinstance(payload, dict) else "invalid_request"
|
|
181
|
+
desc = payload.get("error_description", "") if isinstance(payload, dict) else ""
|
|
182
|
+
raise OAuthError(err, desc, status=res.status)
|
|
183
|
+
return payload if isinstance(payload, dict) else {}
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------ #
|
|
186
|
+
# User info
|
|
187
|
+
# ------------------------------------------------------------------ #
|
|
188
|
+
async def fetch_user(self, access_token: str) -> dict[str, Any]:
|
|
189
|
+
url = f"{self.base_url}{ME_PATH}"
|
|
190
|
+
session = await self._get_session()
|
|
191
|
+
async with session.get(url, headers={"Authorization": f"Bearer {access_token}"}) as res:
|
|
192
|
+
raw = await res.read()
|
|
193
|
+
text = raw.decode("utf-8", errors="replace") if raw else ""
|
|
194
|
+
try:
|
|
195
|
+
payload = json.loads(text) if text else {}
|
|
196
|
+
except Exception:
|
|
197
|
+
payload = {}
|
|
198
|
+
if res.status >= 400:
|
|
199
|
+
err = payload.get("error", "invalid_token") if isinstance(payload, dict) else "invalid_token"
|
|
200
|
+
desc = payload.get("error_description", "") if isinstance(payload, dict) else ""
|
|
201
|
+
raise OAuthError(err, desc, status=res.status)
|
|
202
|
+
return payload if isinstance(payload, dict) else {}
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------ #
|
|
205
|
+
# Convenience
|
|
206
|
+
# ------------------------------------------------------------------ #
|
|
207
|
+
async def login(self, code: str, **kw: Any) -> dict[str, Any]:
|
|
208
|
+
"""Exchange ``code`` and immediately fetch the user. Returns ``{token, user}``."""
|
|
209
|
+
token = await self.exchange_code(code, **kw)
|
|
210
|
+
access_token = token.get("access_token")
|
|
211
|
+
user = await self.fetch_user(access_token) if access_token else None
|
|
212
|
+
return {"token": token, "user": user}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Aliases
|
|
216
|
+
YapperOAuth = OAuth2Client
|
|
217
|
+
LoginWithYapper = OAuth2Client
|
|
@@ -7,6 +7,7 @@ src/pyyapper/bot.py
|
|
|
7
7
|
src/pyyapper/buttons.py
|
|
8
8
|
src/pyyapper/cache.py
|
|
9
9
|
src/pyyapper/commands.py
|
|
10
|
+
src/pyyapper/components_v2.py
|
|
10
11
|
src/pyyapper/config.py
|
|
11
12
|
src/pyyapper/embeds.py
|
|
12
13
|
src/pyyapper/errors.py
|
|
@@ -14,6 +15,7 @@ src/pyyapper/http.py
|
|
|
14
15
|
src/pyyapper/i18n.py
|
|
15
16
|
src/pyyapper/metrics.py
|
|
16
17
|
src/pyyapper/models.py
|
|
18
|
+
src/pyyapper/oauth.py
|
|
17
19
|
src/pyyapper/pagination.py
|
|
18
20
|
src/pyyapper/presence.py
|
|
19
21
|
src/pyyapper/py.typed
|
|
@@ -32,6 +34,7 @@ src/pyyapper/ext/commands/converters.py
|
|
|
32
34
|
src/pyyapper/ext/commands/core.py
|
|
33
35
|
src/pyyapper/ext/commands/errors.py
|
|
34
36
|
tests/test_ext_commands.py
|
|
37
|
+
tests/test_oauth.py
|
|
35
38
|
tests/test_smoke.py
|
|
36
39
|
tests/test_tasks.py
|
|
37
40
|
tests/test_webhooks.py
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from pyyapper import OAuth2Client, generate_state, generate_pkce, OAuthError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_auth_url():
|
|
7
|
+
c = OAuth2Client("cid", "secret", "https://example.com/cb")
|
|
8
|
+
url = c.authorization_url("st")
|
|
9
|
+
assert "client_id=cid" in url
|
|
10
|
+
assert "state=st" in url
|
|
11
|
+
assert "response_type=code" in url
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_pkce():
|
|
15
|
+
v, ch = generate_pkce()
|
|
16
|
+
assert len(v) >= 43
|
|
17
|
+
assert len(ch) >= 43
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_generate_state():
|
|
21
|
+
s = generate_state()
|
|
22
|
+
assert len(s) >= 32
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_oauth_error():
|
|
26
|
+
e = OAuthError("invalid_grant", "bad code", status=400)
|
|
27
|
+
assert e.error == "invalid_grant"
|
|
28
|
+
assert e.status == 400
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_async_context_manager():
|
|
32
|
+
async def main():
|
|
33
|
+
async with OAuth2Client("cid", redirect_uri="https://example.com/cb") as c:
|
|
34
|
+
assert c.client_id == "cid"
|
|
35
|
+
asyncio.run(main())
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.4.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
|
|
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
|