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/__init__.py +48 -0
- pyyapper/_version.py +1 -0
- pyyapper/bot.py +769 -0
- pyyapper/buttons.py +117 -0
- pyyapper/embeds.py +124 -0
- pyyapper/errors.py +90 -0
- pyyapper/http.py +200 -0
- pyyapper/models.py +206 -0
- pyyapper/py.typed +0 -0
- pyyapper-1.0.0.dist-info/METADATA +108 -0
- pyyapper-1.0.0.dist-info/RECORD +14 -0
- pyyapper-1.0.0.dist-info/WHEEL +5 -0
- pyyapper-1.0.0.dist-info/licenses/LICENSE +21 -0
- pyyapper-1.0.0.dist-info/top_level.txt +1 -0
pyyapper/bot.py
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
"""The :class:`Bot` class - the heart of PyYapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Callable, Optional
|
|
9
|
+
|
|
10
|
+
from .errors import ConfigurationError, Unauthorized
|
|
11
|
+
from .http import HTTPClient, _backoff
|
|
12
|
+
from .models import (
|
|
13
|
+
Category,
|
|
14
|
+
Channel,
|
|
15
|
+
Member,
|
|
16
|
+
Message,
|
|
17
|
+
Role,
|
|
18
|
+
Server,
|
|
19
|
+
User,
|
|
20
|
+
)
|
|
21
|
+
from .embeds import Embed
|
|
22
|
+
from .buttons import Button, Row
|
|
23
|
+
|
|
24
|
+
# Event names that are valid.
|
|
25
|
+
EVENTS = {
|
|
26
|
+
"message", "command", "member_join", "member_leave",
|
|
27
|
+
"reaction_add", "reaction_remove", "interaction", "test",
|
|
28
|
+
"ready", "error",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Helper to coerce embeds/buttons to dicts.
|
|
32
|
+
def _plain(x: Any) -> Any:
|
|
33
|
+
if hasattr(x, "to_dict"):
|
|
34
|
+
return x.to_dict()
|
|
35
|
+
if hasattr(x, "toJSON"):
|
|
36
|
+
return x.toJSON()
|
|
37
|
+
return x
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _list(v: Any) -> Any:
|
|
41
|
+
if v is None:
|
|
42
|
+
return None
|
|
43
|
+
if isinstance(v, (list, tuple)):
|
|
44
|
+
return [_plain(x) for x in v]
|
|
45
|
+
return [_plain(v)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Context:
|
|
49
|
+
"""Wraps a message event, adding helpers like ``reply`` / ``say``.
|
|
50
|
+
|
|
51
|
+
A ``Context`` is what command callbacks and ``on_message`` handlers receive.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, bot: "Bot", data: dict[str, Any]) -> None:
|
|
55
|
+
self._bot = bot
|
|
56
|
+
self.raw = dict(data)
|
|
57
|
+
self.id = data.get("id", "")
|
|
58
|
+
self.channel_id = data.get("channel_id")
|
|
59
|
+
self.channel_name = data.get("channel_name")
|
|
60
|
+
self.server_id = data.get("server_id")
|
|
61
|
+
self.author_id = data.get("author_id", "")
|
|
62
|
+
self.author = data.get("author")
|
|
63
|
+
self.content = data.get("content", "") or ""
|
|
64
|
+
self.attachment = data.get("attachment")
|
|
65
|
+
self.reply_to_id = data.get("reply_to_id")
|
|
66
|
+
self.created_at = data.get("created_at")
|
|
67
|
+
self.mentioned = bool(data.get("mentioned", False))
|
|
68
|
+
# command info (filled in by Bot.handle for commands)
|
|
69
|
+
self.command: Optional[str] = data.get("command")
|
|
70
|
+
self.args: str = data.get("args") or ""
|
|
71
|
+
self.argv: list[str] = self.args.split() if self.args else []
|
|
72
|
+
# message model
|
|
73
|
+
self.message = Message.from_dict(data)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def author_mention(self) -> str:
|
|
77
|
+
return f"<@{self.author_id}>"
|
|
78
|
+
|
|
79
|
+
async def reply(self, content: Any = "", **opts: Any) -> Any:
|
|
80
|
+
return await self._bot.send(
|
|
81
|
+
self.channel_id, content, reply_to=self.id, **opts
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def say(self, content: Any = "", **opts: Any) -> Any:
|
|
85
|
+
return await self._bot.send(self.channel_id, content, **opts)
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return f"Context(id={self.id!r}, author={self.author!r})"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class InteractionContext:
|
|
92
|
+
"""Wraps a button-click (interaction) event."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, bot: "Bot", data: dict[str, Any]) -> None:
|
|
95
|
+
self._bot = bot
|
|
96
|
+
self.raw = dict(data)
|
|
97
|
+
self.id = data.get("id", "")
|
|
98
|
+
self.token = data.get("token", "")
|
|
99
|
+
self.type = data.get("type", "button")
|
|
100
|
+
self.custom_id = data.get("custom_id", "")
|
|
101
|
+
self.message_id = data.get("message_id")
|
|
102
|
+
self.channel_id = data.get("channel_id")
|
|
103
|
+
self.server_id = data.get("server_id")
|
|
104
|
+
self.user_id = data.get("user_id", "")
|
|
105
|
+
self.username = data.get("username", "")
|
|
106
|
+
self.expires_at = data.get("expires_at")
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def user_mention(self) -> str:
|
|
110
|
+
return f"<@{self.user_id}>"
|
|
111
|
+
|
|
112
|
+
async def respond(self, kind: str, **opts: Any) -> Any:
|
|
113
|
+
body: dict[str, Any] = {"type": kind}
|
|
114
|
+
if "content" in opts and opts["content"] is not None:
|
|
115
|
+
body["content"] = "" if opts["content"] is None else str(opts["content"])
|
|
116
|
+
if "embeds" in opts:
|
|
117
|
+
body["embeds"] = _list(opts["embeds"])
|
|
118
|
+
if "components" in opts:
|
|
119
|
+
body["components"] = _list(opts["components"])
|
|
120
|
+
# allow passing content positionally via first kw
|
|
121
|
+
return await self._bot.api(
|
|
122
|
+
"POST", f"/interactions/{self.id}/{self.token}/callback", body
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def reply(self, content: Any = "", **opts: Any) -> Any:
|
|
126
|
+
return await self.respond("reply", content=content, **opts)
|
|
127
|
+
|
|
128
|
+
async def ephemeral(self, content: Any = "", **opts: Any) -> Any:
|
|
129
|
+
return await self.respond("ephemeral", content=content, **opts)
|
|
130
|
+
|
|
131
|
+
async def update(self, changes: Any = None, **kw: Any) -> Any:
|
|
132
|
+
if isinstance(changes, dict):
|
|
133
|
+
kw = {**changes, **kw}
|
|
134
|
+
changes = None
|
|
135
|
+
body = {"type": "update"}
|
|
136
|
+
if "content" in kw:
|
|
137
|
+
c = kw.pop("content")
|
|
138
|
+
body["content"] = None if c is None else str(c)
|
|
139
|
+
if "embeds" in kw:
|
|
140
|
+
body["embeds"] = _list(kw.pop("embeds"))
|
|
141
|
+
if "components" in kw:
|
|
142
|
+
body["components"] = _list(kw.pop("components"))
|
|
143
|
+
for k, v in kw.items():
|
|
144
|
+
body[k] = v
|
|
145
|
+
return await self._bot.api(
|
|
146
|
+
"POST", f"/interactions/{self.id}/{self.token}/callback", body
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
async def ack(self) -> Any:
|
|
150
|
+
return await self.respond("ack")
|
|
151
|
+
|
|
152
|
+
def __repr__(self) -> str:
|
|
153
|
+
return f"InteractionContext(custom_id={self.custom_id!r}, user={self.username!r})"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Bot:
|
|
157
|
+
"""A Yapper bot.
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
token:
|
|
162
|
+
Bot token (starts with ``yb_``).
|
|
163
|
+
base_url:
|
|
164
|
+
Override the API base URL.
|
|
165
|
+
prefixes:
|
|
166
|
+
Command prefixes in addition to slash commands. Defaults to ``['/', '!']``.
|
|
167
|
+
log:
|
|
168
|
+
Whether to print log lines. Defaults to ``True``.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
token: str,
|
|
174
|
+
*,
|
|
175
|
+
base_url: str = "https://www.yapper.website/api/bot/v1",
|
|
176
|
+
prefixes: Optional[list[str]] = None,
|
|
177
|
+
log: bool = True,
|
|
178
|
+
) -> None:
|
|
179
|
+
self.token = str(token).strip() if token else ""
|
|
180
|
+
self.base_url = base_url
|
|
181
|
+
self.prefixes = prefixes if prefixes is not None else ["/", "!"]
|
|
182
|
+
self.log = log
|
|
183
|
+
|
|
184
|
+
self.http = HTTPClient(self.token, base_url=base_url, log=log)
|
|
185
|
+
# convenience: keep .api working
|
|
186
|
+
self.handlers: dict[str, list[Callable]] = {}
|
|
187
|
+
self.commands: dict[str, dict[str, Any]] = {}
|
|
188
|
+
self.buttons: list[dict[str, Any]] = []
|
|
189
|
+
self.last: int = 0
|
|
190
|
+
self.running: bool = False
|
|
191
|
+
self.me: Optional[User] = None
|
|
192
|
+
self._task: Optional[asyncio.Task] = None
|
|
193
|
+
|
|
194
|
+
# Lazy validation happens in start(); keep construction permissive for tests.
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------ #
|
|
197
|
+
# Registration
|
|
198
|
+
# ------------------------------------------------------------------ #
|
|
199
|
+
def on(self, event: str, fn: Optional[Callable] = None) -> Any:
|
|
200
|
+
"""Register an event handler.
|
|
201
|
+
|
|
202
|
+
Can be used as a decorator::
|
|
203
|
+
|
|
204
|
+
@bot.on("message")
|
|
205
|
+
async def handle(msg):
|
|
206
|
+
...
|
|
207
|
+
|
|
208
|
+
or directly::
|
|
209
|
+
|
|
210
|
+
bot.on("message", handle)
|
|
211
|
+
"""
|
|
212
|
+
if event not in EVENTS:
|
|
213
|
+
raise ValueError(f"Unknown event {event!r}. Valid: {sorted(EVENTS)}")
|
|
214
|
+
if fn is None:
|
|
215
|
+
def decorator(f: Callable) -> Callable:
|
|
216
|
+
self.handlers.setdefault(event, []).append(f)
|
|
217
|
+
return f
|
|
218
|
+
return decorator
|
|
219
|
+
self.handlers.setdefault(event, []).append(fn)
|
|
220
|
+
return fn
|
|
221
|
+
|
|
222
|
+
def event(self, fn: Optional[Callable] = None) -> Any:
|
|
223
|
+
"""Decorator that registers a handler based on its name.
|
|
224
|
+
|
|
225
|
+
The function name must start with ``on_`` (e.g. ``on_ready``,
|
|
226
|
+
``on_message``). The remainder is used as the event name, with
|
|
227
|
+
underscores accepted (``on_member_join`` -> ``member_join``).
|
|
228
|
+
"""
|
|
229
|
+
def _register(f):
|
|
230
|
+
name = f.__name__
|
|
231
|
+
if not name.startswith("on_"):
|
|
232
|
+
raise ValueError("event handler name must start with 'on_'")
|
|
233
|
+
event = name[3:]
|
|
234
|
+
if event not in EVENTS:
|
|
235
|
+
raise ValueError(f"Unknown event {event!r}")
|
|
236
|
+
self.handlers.setdefault(event, []).append(f)
|
|
237
|
+
return f
|
|
238
|
+
if fn is None:
|
|
239
|
+
return _register
|
|
240
|
+
return _register(fn)
|
|
241
|
+
|
|
242
|
+
def command(
|
|
243
|
+
self,
|
|
244
|
+
name: Any = None,
|
|
245
|
+
description: Any = "",
|
|
246
|
+
fn: Optional[Callable] = None,
|
|
247
|
+
*,
|
|
248
|
+
aliases: Optional[list[str]] = None,
|
|
249
|
+
) -> Any:
|
|
250
|
+
"""Register a slash/prefix command.
|
|
251
|
+
|
|
252
|
+
Decorator form::
|
|
253
|
+
|
|
254
|
+
@bot.command("ping", description="Is the bot alive?")
|
|
255
|
+
async def ping(ctx):
|
|
256
|
+
await ctx.reply("pong")
|
|
257
|
+
|
|
258
|
+
Direct form::
|
|
259
|
+
|
|
260
|
+
bot.command("ping", "Is the bot alive?", handler)
|
|
261
|
+
"""
|
|
262
|
+
# Bare decorator usage: bot.command(fn)
|
|
263
|
+
if callable(name) and description == "" and fn is None:
|
|
264
|
+
f = name
|
|
265
|
+
name = f.__name__
|
|
266
|
+
|
|
267
|
+
def _reg0():
|
|
268
|
+
clean = str(name).lower().lstrip("/!").strip()
|
|
269
|
+
if not re.fullmatch(r"[a-z0-9_-]{1,32}", clean):
|
|
270
|
+
raise ValueError(f'Bad command name "{name}": letters, numbers, - and _ only')
|
|
271
|
+
self.commands[clean] = {"description": "", "fn": f}
|
|
272
|
+
_reg0()
|
|
273
|
+
return f
|
|
274
|
+
|
|
275
|
+
# Resolve when description is actually the handler
|
|
276
|
+
if callable(description) and fn is None:
|
|
277
|
+
fn = description
|
|
278
|
+
description = ""
|
|
279
|
+
|
|
280
|
+
def _register(n, d, f):
|
|
281
|
+
clean = f.__name__.lower().lstrip("/!").strip() if n is None else str(n).lower().lstrip("/!").strip()
|
|
282
|
+
if not re.fullmatch(r"[a-z0-9_-]{1,32}", clean):
|
|
283
|
+
raise ValueError(f'Bad command name "{n}": letters, numbers, - and _ only')
|
|
284
|
+
self.commands[clean] = {"description": str(d or "")[:100], "fn": f}
|
|
285
|
+
for a in (aliases or []):
|
|
286
|
+
self.commands[str(a).lower().lstrip("/!").strip()] = {
|
|
287
|
+
"description": str(d or "")[:100], "fn": f,
|
|
288
|
+
}
|
|
289
|
+
return f
|
|
290
|
+
|
|
291
|
+
if fn is not None:
|
|
292
|
+
return _register(name, description, fn)
|
|
293
|
+
|
|
294
|
+
desc = description
|
|
295
|
+
|
|
296
|
+
def decorator(f: Callable) -> Callable:
|
|
297
|
+
n = name if name is not None else f.__name__
|
|
298
|
+
return _register(n, desc, f)
|
|
299
|
+
|
|
300
|
+
return decorator
|
|
301
|
+
|
|
302
|
+
def button(self, match: Any, fn: Optional[Callable] = None) -> Any:
|
|
303
|
+
"""Register a handler for button clicks.
|
|
304
|
+
|
|
305
|
+
``match`` can be a string (exact custom_id) or a compiled/str regex.
|
|
306
|
+
Decorator form supported.
|
|
307
|
+
"""
|
|
308
|
+
import re as _re
|
|
309
|
+
|
|
310
|
+
# Bare decorator: bot.button(fn) with no match -> match everything
|
|
311
|
+
if callable(match) and fn is None:
|
|
312
|
+
f = match
|
|
313
|
+
matcher = _re.compile(".*")
|
|
314
|
+
|
|
315
|
+
def _reg():
|
|
316
|
+
self.buttons.append({"match": matcher, "fn": f})
|
|
317
|
+
_reg()
|
|
318
|
+
return f
|
|
319
|
+
|
|
320
|
+
if isinstance(match, str):
|
|
321
|
+
# Allow "/pattern/" or "re:pattern" to mean regex
|
|
322
|
+
s = match
|
|
323
|
+
if s.startswith("/") and s.endswith("/") and len(s) > 2:
|
|
324
|
+
matcher = _re.compile(s[1:-1])
|
|
325
|
+
elif s.startswith("re:"):
|
|
326
|
+
matcher = _re.compile(s[3:])
|
|
327
|
+
else:
|
|
328
|
+
matcher = s
|
|
329
|
+
elif hasattr(match, "pattern"):
|
|
330
|
+
matcher = match
|
|
331
|
+
else:
|
|
332
|
+
matcher = _re.compile(str(match))
|
|
333
|
+
|
|
334
|
+
if fn is None:
|
|
335
|
+
def decorator(f: Callable) -> Callable:
|
|
336
|
+
self.buttons.append({"match": matcher, "fn": f})
|
|
337
|
+
return f
|
|
338
|
+
return decorator
|
|
339
|
+
self.buttons.append({"match": matcher, "fn": fn})
|
|
340
|
+
return fn
|
|
341
|
+
|
|
342
|
+
on_button = button # alias
|
|
343
|
+
|
|
344
|
+
# ------------------------------------------------------------------ #
|
|
345
|
+
# HTTP shortcuts
|
|
346
|
+
# ------------------------------------------------------------------ #
|
|
347
|
+
async def api(self, method: str, path: str, body: Any = None, **kw: Any) -> Any:
|
|
348
|
+
return await self.http.request(method, path, body=body, **kw)
|
|
349
|
+
|
|
350
|
+
# ------------------------------------------------------------------ #
|
|
351
|
+
# Messaging
|
|
352
|
+
# ------------------------------------------------------------------ #
|
|
353
|
+
async def send(
|
|
354
|
+
self,
|
|
355
|
+
channel_id: Any,
|
|
356
|
+
content: Any = "",
|
|
357
|
+
*,
|
|
358
|
+
reply_to: Optional[str] = None,
|
|
359
|
+
embeds: Any = None,
|
|
360
|
+
components: Any = None,
|
|
361
|
+
**opts: Any,
|
|
362
|
+
) -> Any:
|
|
363
|
+
body: dict[str, Any] = {"content": "" if content is None else str(content)}
|
|
364
|
+
if reply_to is not None:
|
|
365
|
+
body["reply_to"] = reply_to
|
|
366
|
+
if embeds is not None:
|
|
367
|
+
body["embeds"] = _list(embeds)
|
|
368
|
+
if components is not None:
|
|
369
|
+
body["components"] = _list(components)
|
|
370
|
+
# merge opts
|
|
371
|
+
for k, v in opts.items():
|
|
372
|
+
if k in ("embeds", "components"):
|
|
373
|
+
body[k] = _list(v)
|
|
374
|
+
else:
|
|
375
|
+
body[k] = v
|
|
376
|
+
out = await self.api("POST", f"/channels/{channel_id}/messages", body)
|
|
377
|
+
msg = out.get("message") if isinstance(out, dict) else None
|
|
378
|
+
return msg if msg is not None else out
|
|
379
|
+
|
|
380
|
+
def send_embed(self, channel_id: Any, embed: Any, content: str = "") -> Any:
|
|
381
|
+
return self.send(channel_id, content, embeds=[embed])
|
|
382
|
+
|
|
383
|
+
async def delete(self, channel_id: Any, message_id: Any) -> Any:
|
|
384
|
+
return await self.api("DELETE", f"/channels/{channel_id}/messages/{message_id}")
|
|
385
|
+
|
|
386
|
+
# ------------------------------------------------------------------ #
|
|
387
|
+
# Servers / channels / members
|
|
388
|
+
# ------------------------------------------------------------------ #
|
|
389
|
+
async def servers(self) -> list[Server]:
|
|
390
|
+
out = await self.api("GET", "/servers")
|
|
391
|
+
return [Server.from_dict(s) for s in (out.get("servers") or [])]
|
|
392
|
+
|
|
393
|
+
async def channels(self, server_id: Any = None, *, type: Optional[str] = None) -> list[Channel]:
|
|
394
|
+
q = f"?type={type}" if type else ""
|
|
395
|
+
path = f"/servers/{server_id}/channels{q}" if server_id else f"/channels{q}"
|
|
396
|
+
out = await self.api("GET", path)
|
|
397
|
+
return [Channel.from_dict(c) for c in (out.get("channels") or [])]
|
|
398
|
+
|
|
399
|
+
async def categories(self, server_id: Any) -> list[Category]:
|
|
400
|
+
out = await self.api("GET", f"/servers/{server_id}/channels")
|
|
401
|
+
return [Category.from_dict(c) for c in (out.get("categories") or [])]
|
|
402
|
+
|
|
403
|
+
async def channel(self, channel_id: Any) -> Optional[Channel]:
|
|
404
|
+
try:
|
|
405
|
+
out = await self.api("GET", f"/channels/{channel_id}")
|
|
406
|
+
except Exception:
|
|
407
|
+
return None
|
|
408
|
+
c = out.get("channel") if isinstance(out, dict) else None
|
|
409
|
+
return Channel.from_dict(c) if c else None
|
|
410
|
+
|
|
411
|
+
async def members(self, server_id: Any, after: Any = None) -> list[Member]:
|
|
412
|
+
path = f"/servers/{server_id}/members?limit=200"
|
|
413
|
+
if after:
|
|
414
|
+
path += f"&after={after}"
|
|
415
|
+
out = await self.api("GET", path)
|
|
416
|
+
return [Member.from_dict(m) for m in (out.get("members") or [])]
|
|
417
|
+
|
|
418
|
+
async def messages(self, channel_id: Any, limit: int = 50) -> list[Message]:
|
|
419
|
+
out = await self.api("GET", f"/channels/{channel_id}/messages?limit={limit}")
|
|
420
|
+
return [Message.from_dict(m) for m in (out.get("messages") or [])]
|
|
421
|
+
|
|
422
|
+
async def user(self, user_id: Any) -> User:
|
|
423
|
+
out = await self.api("GET", f"/users/{user_id}")
|
|
424
|
+
u = out.get("user") if isinstance(out, dict) else out
|
|
425
|
+
return User.from_dict(u)
|
|
426
|
+
|
|
427
|
+
# ------------------------------------------------------------------ #
|
|
428
|
+
# Profile
|
|
429
|
+
# ------------------------------------------------------------------ #
|
|
430
|
+
async def set_avatar(self, image: Any, type: Optional[str] = None) -> Any:
|
|
431
|
+
return await self.http.upload("PUT", "/me/avatar", image, type)
|
|
432
|
+
|
|
433
|
+
async def set_profile(self, *, display_name: Optional[str] = None, bio: Optional[str] = None) -> Any:
|
|
434
|
+
body: dict[str, Any] = {}
|
|
435
|
+
if display_name is not None:
|
|
436
|
+
body["display_name"] = display_name
|
|
437
|
+
if bio is not None:
|
|
438
|
+
body["bio"] = bio
|
|
439
|
+
return await self.api("PATCH", "/me", body)
|
|
440
|
+
|
|
441
|
+
async def set_banner(self, image: Any, type: Optional[str] = None) -> Any:
|
|
442
|
+
return await self.http.upload("PUT", "/me/banner", image, type)
|
|
443
|
+
|
|
444
|
+
async def set_status(self, status: str = "online", activity: Optional[str] = None) -> Any:
|
|
445
|
+
return await self.api("PUT", "/me/status", {"status": status, "activity": activity})
|
|
446
|
+
|
|
447
|
+
# ------------------------------------------------------------------ #
|
|
448
|
+
# Messages advanced
|
|
449
|
+
# ------------------------------------------------------------------ #
|
|
450
|
+
async def edit(
|
|
451
|
+
self,
|
|
452
|
+
channel_id: Any,
|
|
453
|
+
message_id: Any,
|
|
454
|
+
content: Any = None,
|
|
455
|
+
*,
|
|
456
|
+
embeds: Any = None,
|
|
457
|
+
components: Any = None,
|
|
458
|
+
) -> Any:
|
|
459
|
+
body: dict[str, Any] = {}
|
|
460
|
+
if content is not None:
|
|
461
|
+
body["content"] = str(content)
|
|
462
|
+
if embeds is not None:
|
|
463
|
+
body["embeds"] = _list(embeds)
|
|
464
|
+
if components is not None:
|
|
465
|
+
body["components"] = _list(components)
|
|
466
|
+
out = await self.api("PATCH", f"/channels/{channel_id}/messages/{message_id}", body)
|
|
467
|
+
return out.get("message") if isinstance(out, dict) else out
|
|
468
|
+
|
|
469
|
+
async def message(self, channel_id: Any, message_id: Any) -> Any:
|
|
470
|
+
return await self.api("GET", f"/channels/{channel_id}/messages/{message_id}")
|
|
471
|
+
|
|
472
|
+
async def messages_after(self, channel_id: Any, after: Any, limit: int = 50) -> list[Message]:
|
|
473
|
+
if hasattr(after, "isoformat"):
|
|
474
|
+
ts = after.isoformat()
|
|
475
|
+
else:
|
|
476
|
+
ts = str(after)
|
|
477
|
+
from urllib.parse import quote
|
|
478
|
+
out = await self.api(
|
|
479
|
+
"GET", f"/channels/{channel_id}/messages?limit={limit}&after={quote(ts, safe='')}"
|
|
480
|
+
)
|
|
481
|
+
return [Message.from_dict(m) for m in (out.get("messages") or [])]
|
|
482
|
+
|
|
483
|
+
async def bulk_delete(self, channel_id: Any, message_ids: Any) -> Any:
|
|
484
|
+
return await self.api("POST", f"/channels/{channel_id}/messages/bulk-delete", {"ids": list(message_ids)})
|
|
485
|
+
|
|
486
|
+
async def pin(self, channel_id: Any, message_id: Any) -> Any:
|
|
487
|
+
return await self.api("PUT", f"/channels/{channel_id}/messages/{message_id}/pin")
|
|
488
|
+
|
|
489
|
+
async def unpin(self, channel_id: Any, message_id: Any) -> Any:
|
|
490
|
+
return await self.api("DELETE", f"/channels/{channel_id}/messages/{message_id}/pin")
|
|
491
|
+
|
|
492
|
+
async def react(self, channel_id: Any, message_id: Any, emoji: str) -> Any:
|
|
493
|
+
from urllib.parse import quote
|
|
494
|
+
return await self.api("PUT", f"/channels/{channel_id}/messages/{message_id}/reactions/{quote(emoji, safe='')}")
|
|
495
|
+
|
|
496
|
+
async def unreact(self, channel_id: Any, message_id: Any, emoji: str) -> Any:
|
|
497
|
+
from urllib.parse import quote
|
|
498
|
+
return await self.api("DELETE", f"/channels/{channel_id}/messages/{message_id}/reactions/{quote(emoji, safe='')}")
|
|
499
|
+
|
|
500
|
+
async def reactions(self, channel_id: Any, message_id: Any) -> Any:
|
|
501
|
+
out = await self.api("GET", f"/channels/{channel_id}/messages/{message_id}/reactions")
|
|
502
|
+
return out.get("reactions") if isinstance(out, dict) else out
|
|
503
|
+
|
|
504
|
+
# ------------------------------------------------------------------ #
|
|
505
|
+
# Moderation
|
|
506
|
+
# ------------------------------------------------------------------ #
|
|
507
|
+
async def server(self, server_id: Any) -> Any:
|
|
508
|
+
out = await self.api("GET", f"/servers/{server_id}")
|
|
509
|
+
return out.get("server") if isinstance(out, dict) else out
|
|
510
|
+
|
|
511
|
+
async def roles(self, server_id: Any) -> list[Role]:
|
|
512
|
+
out = await self.api("GET", f"/servers/{server_id}/roles")
|
|
513
|
+
return [Role.from_dict(r) for r in (out.get("roles") or [])]
|
|
514
|
+
|
|
515
|
+
async def member(self, server_id: Any, user_id: Any) -> Member:
|
|
516
|
+
out = await self.api("GET", f"/servers/{server_id}/members/{user_id}")
|
|
517
|
+
m = out.get("member") if isinstance(out, dict) else out
|
|
518
|
+
return Member.from_dict(m)
|
|
519
|
+
|
|
520
|
+
async def add_role(self, server_id: Any, user_id: Any, role_id: Any) -> Any:
|
|
521
|
+
return await self.api("PUT", f"/servers/{server_id}/members/{user_id}/roles/{role_id}")
|
|
522
|
+
|
|
523
|
+
async def remove_role(self, server_id: Any, user_id: Any, role_id: Any) -> Any:
|
|
524
|
+
return await self.api("DELETE", f"/servers/{server_id}/members/{user_id}/roles/{role_id}")
|
|
525
|
+
|
|
526
|
+
async def set_nickname(self, server_id: Any, user_id: Any, nickname: str) -> Any:
|
|
527
|
+
return await self.api("PATCH", f"/servers/{server_id}/members/{user_id}", {"nickname": nickname})
|
|
528
|
+
|
|
529
|
+
async def kick(self, server_id: Any, user_id: Any) -> Any:
|
|
530
|
+
return await self.api("POST", f"/servers/{server_id}/kick", {"user_id": user_id})
|
|
531
|
+
|
|
532
|
+
async def ban(self, server_id: Any, user_id: Any) -> Any:
|
|
533
|
+
return await self.api("POST", f"/servers/{server_id}/bans", {"user_id": user_id})
|
|
534
|
+
|
|
535
|
+
async def unban(self, server_id: Any, user_id: Any) -> Any:
|
|
536
|
+
return await self.api("DELETE", f"/servers/{server_id}/bans/{user_id}")
|
|
537
|
+
|
|
538
|
+
async def bans(self, server_id: Any) -> Any:
|
|
539
|
+
out = await self.api("GET", f"/servers/{server_id}/bans")
|
|
540
|
+
return out.get("bans") if isinstance(out, dict) else out
|
|
541
|
+
|
|
542
|
+
async def timeout(self, server_id: Any, user_id: Any, minutes: int, reason: str = "") -> Any:
|
|
543
|
+
return await self.api("POST", f"/servers/{server_id}/timeouts", {
|
|
544
|
+
"user_id": user_id, "minutes": minutes, "reason": reason,
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
# ------------------------------------------------------------------ #
|
|
548
|
+
# Channels management
|
|
549
|
+
# ------------------------------------------------------------------ #
|
|
550
|
+
async def create_channel(self, server_id: Any, name: str, *, kind: str = "text", category_id: Any = None) -> Any:
|
|
551
|
+
out = await self.api("POST", f"/servers/{server_id}/channels", {
|
|
552
|
+
"name": name, "kind": kind, "category_id": category_id,
|
|
553
|
+
})
|
|
554
|
+
return out.get("channel") if isinstance(out, dict) else out
|
|
555
|
+
|
|
556
|
+
async def edit_channel(self, channel_id: Any, **changes: Any) -> Any:
|
|
557
|
+
body: dict[str, Any] = {}
|
|
558
|
+
mapping = {
|
|
559
|
+
"name": "name",
|
|
560
|
+
"category_id": "category_id",
|
|
561
|
+
"categoryId": "category_id",
|
|
562
|
+
"private": "private",
|
|
563
|
+
"role_ids": "role_ids",
|
|
564
|
+
"roleIds": "role_ids",
|
|
565
|
+
"read_only": "read_only",
|
|
566
|
+
"readOnly": "read_only",
|
|
567
|
+
"post_role_ids": "post_role_ids",
|
|
568
|
+
"postRoleIds": "post_role_ids",
|
|
569
|
+
"slowmode": "slowmode",
|
|
570
|
+
}
|
|
571
|
+
for k, v in changes.items():
|
|
572
|
+
key = mapping.get(k, k)
|
|
573
|
+
body[key] = v
|
|
574
|
+
out = await self.api("PATCH", f"/channels/{channel_id}", body)
|
|
575
|
+
return out.get("channel") if isinstance(out, dict) else out
|
|
576
|
+
|
|
577
|
+
async def delete_channel(self, channel_id: Any) -> Any:
|
|
578
|
+
return await self.api("DELETE", f"/channels/{channel_id}")
|
|
579
|
+
|
|
580
|
+
async def create_category(self, server_id: Any, name: str) -> Any:
|
|
581
|
+
out = await self.api("POST", f"/servers/{server_id}/categories", {"name": name})
|
|
582
|
+
return out.get("category") if isinstance(out, dict) else out
|
|
583
|
+
|
|
584
|
+
async def delete_category(self, server_id: Any, category_id: Any) -> Any:
|
|
585
|
+
return await self.api("DELETE", f"/servers/{server_id}/categories/{category_id}")
|
|
586
|
+
|
|
587
|
+
# ------------------------------------------------------------------ #
|
|
588
|
+
# DMs
|
|
589
|
+
# ------------------------------------------------------------------ #
|
|
590
|
+
async def dm(self, user_id: Any) -> Any:
|
|
591
|
+
out = await self.api("POST", f"/users/{user_id}/dm")
|
|
592
|
+
return out.get("channel_id") if isinstance(out, dict) else out
|
|
593
|
+
|
|
594
|
+
async def dm_send(self, user_id: Any, content: Any = "", **opts: Any) -> Any:
|
|
595
|
+
cid = await self.dm(user_id)
|
|
596
|
+
return await self.send(cid, content, **opts)
|
|
597
|
+
|
|
598
|
+
# ------------------------------------------------------------------ #
|
|
599
|
+
# Lifecycle
|
|
600
|
+
# ------------------------------------------------------------------ #
|
|
601
|
+
async def _start(self) -> None:
|
|
602
|
+
if self.running:
|
|
603
|
+
return
|
|
604
|
+
if not self.token.startswith("yb_"):
|
|
605
|
+
raise ConfigurationError("Pass your bot token (starts with yb_). Get one in Settings -> Developer.")
|
|
606
|
+
# Catch obvious placeholders early with a helpful message instead of a 401.
|
|
607
|
+
if ("..." in self.token) or len(self.token) < 20 or self.token.lower() in {"yb_", "yb_your_token", "yb_your_token_here"}:
|
|
608
|
+
raise ConfigurationError(
|
|
609
|
+
"That looks like a placeholder token, not a real one. "
|
|
610
|
+
"Create a bot in Yapper -> Settings -> Developer, copy the token "
|
|
611
|
+
"(shown once), and paste it in place of \"yb_...\"."
|
|
612
|
+
)
|
|
613
|
+
self.running = True
|
|
614
|
+
if self.log:
|
|
615
|
+
print("[pyyapper] connecting…")
|
|
616
|
+
|
|
617
|
+
# startup with retries
|
|
618
|
+
cmd_list = [{"name": n, "description": c.get("description", "")} for n, c in self.commands.items()]
|
|
619
|
+
for attempt in range(1, 100):
|
|
620
|
+
try:
|
|
621
|
+
self.me = User.from_dict(await self.api("GET", "/me"))
|
|
622
|
+
await self.api("PUT", "/commands", {"commands": cmd_list})
|
|
623
|
+
first = await self.api("GET", "/events?after=0&wait=0")
|
|
624
|
+
break
|
|
625
|
+
except Exception as e:
|
|
626
|
+
status = getattr(e, "status", None)
|
|
627
|
+
if status in (401, 400, 403):
|
|
628
|
+
self.running = False
|
|
629
|
+
raise
|
|
630
|
+
await self._fail(e)
|
|
631
|
+
await asyncio.sleep(_backoff(attempt))
|
|
632
|
+
if not self.running:
|
|
633
|
+
return
|
|
634
|
+
else:
|
|
635
|
+
self.running = False
|
|
636
|
+
return
|
|
637
|
+
|
|
638
|
+
self.last = first.get("last", 0) if isinstance(first, dict) else 0
|
|
639
|
+
if self.log:
|
|
640
|
+
print(f"[pyyapper] {self.me.username} is online"
|
|
641
|
+
+ (f" with {len(cmd_list)} command{'s' if len(cmd_list) != 1 else ''}" if cmd_list else ""))
|
|
642
|
+
await self._emit("ready", self.me)
|
|
643
|
+
|
|
644
|
+
failures = 0
|
|
645
|
+
while self.running:
|
|
646
|
+
try:
|
|
647
|
+
out = await self.api("GET", f"/events?after={self.last}&wait=25", timeout=45.0)
|
|
648
|
+
failures = 0
|
|
649
|
+
except Exception as e:
|
|
650
|
+
failures += 1
|
|
651
|
+
status = getattr(e, "status", None)
|
|
652
|
+
if status == 401:
|
|
653
|
+
await self._fail(Exception("Token rejected: make a new one in Settings -> Developer"))
|
|
654
|
+
self.running = False
|
|
655
|
+
break
|
|
656
|
+
await self._fail(e)
|
|
657
|
+
wait = getattr(e, "retry_after", None)
|
|
658
|
+
await asyncio.sleep(wait if wait else _backoff(failures))
|
|
659
|
+
continue
|
|
660
|
+
events = out.get("events", []) if isinstance(out, dict) else []
|
|
661
|
+
for ev in events:
|
|
662
|
+
eid = ev.get("id", 0)
|
|
663
|
+
if not (eid > self.last):
|
|
664
|
+
continue
|
|
665
|
+
self.last = eid
|
|
666
|
+
await self._handle(ev)
|
|
667
|
+
last = out.get("last") if isinstance(out, dict) else None
|
|
668
|
+
if isinstance(last, int) and last > self.last:
|
|
669
|
+
self.last = last
|
|
670
|
+
|
|
671
|
+
await self.http.close()
|
|
672
|
+
|
|
673
|
+
async def start(self) -> None:
|
|
674
|
+
try:
|
|
675
|
+
await self._start()
|
|
676
|
+
finally:
|
|
677
|
+
await self.http.close()
|
|
678
|
+
self.running = False
|
|
679
|
+
|
|
680
|
+
def run(self) -> None:
|
|
681
|
+
"""Synchronous entry point: ``bot.run()``."""
|
|
682
|
+
try:
|
|
683
|
+
asyncio.run(self.start())
|
|
684
|
+
except KeyboardInterrupt:
|
|
685
|
+
self.running = False
|
|
686
|
+
|
|
687
|
+
def stop(self) -> None:
|
|
688
|
+
self.running = False
|
|
689
|
+
|
|
690
|
+
# ------------------------------------------------------------------ #
|
|
691
|
+
# Internals
|
|
692
|
+
# ------------------------------------------------------------------ #
|
|
693
|
+
async def _emit(self, event: str, payload: Any) -> None:
|
|
694
|
+
for fn in list(self.handlers.get(event, [])):
|
|
695
|
+
try:
|
|
696
|
+
if asyncio.iscoroutinefunction(fn):
|
|
697
|
+
await fn(payload)
|
|
698
|
+
else:
|
|
699
|
+
fn(payload)
|
|
700
|
+
except Exception as e: # noqa: BLE001
|
|
701
|
+
await self._fail(e)
|
|
702
|
+
|
|
703
|
+
async def _fail(self, e: Exception) -> None:
|
|
704
|
+
if self.handlers.get("error"):
|
|
705
|
+
await self._emit("error", e)
|
|
706
|
+
elif self.log:
|
|
707
|
+
print("[pyyapper]", getattr(e, "message", None) or str(e))
|
|
708
|
+
|
|
709
|
+
async def _handle(self, ev: dict[str, Any]) -> None:
|
|
710
|
+
etype = ev.get("type")
|
|
711
|
+
data = ev.get("data") or {}
|
|
712
|
+
if etype == "message":
|
|
713
|
+
ctx = Context(self, data)
|
|
714
|
+
# resolve command
|
|
715
|
+
name = data.get("command")
|
|
716
|
+
args = data.get("args")
|
|
717
|
+
if not name:
|
|
718
|
+
content = data.get("content", "") or ""
|
|
719
|
+
m = re.match(r"^(\S)([a-z0-9_-]{1,32})(?:\s+([\s\S]*))?$", content, re.IGNORECASE)
|
|
720
|
+
if m and m.group(1) in self.prefixes and m.group(2).lower() in self.commands:
|
|
721
|
+
name = m.group(2).lower()
|
|
722
|
+
args = (m.group(3) or "").strip()
|
|
723
|
+
if name and name in self.commands:
|
|
724
|
+
ctx.command = name
|
|
725
|
+
ctx.args = args or ""
|
|
726
|
+
ctx.argv = ctx.args.split() if ctx.args else []
|
|
727
|
+
cmd = self.commands[name]
|
|
728
|
+
try:
|
|
729
|
+
res = cmd["fn"](ctx)
|
|
730
|
+
if asyncio.iscoroutine(res):
|
|
731
|
+
await res
|
|
732
|
+
except Exception as e: # noqa: BLE001
|
|
733
|
+
await self._fail(e)
|
|
734
|
+
await self._emit("command", ctx)
|
|
735
|
+
await self._emit("message", ctx)
|
|
736
|
+
elif etype == "interaction":
|
|
737
|
+
ic = InteractionContext(self, data)
|
|
738
|
+
for b in self.buttons:
|
|
739
|
+
match = b["match"]
|
|
740
|
+
hit = False
|
|
741
|
+
if isinstance(match, str):
|
|
742
|
+
hit = match == data.get("custom_id")
|
|
743
|
+
elif hasattr(match, "pattern"):
|
|
744
|
+
hit = bool(match.search(data.get("custom_id", "")))
|
|
745
|
+
elif hasattr(match, "test"):
|
|
746
|
+
# compiled regex in JS style; python re has search/match
|
|
747
|
+
try:
|
|
748
|
+
hit = bool(match.search(data.get("custom_id", "")))
|
|
749
|
+
except Exception:
|
|
750
|
+
hit = match == data.get("custom_id")
|
|
751
|
+
else:
|
|
752
|
+
hit = match == data.get("custom_id")
|
|
753
|
+
if hit:
|
|
754
|
+
try:
|
|
755
|
+
res = b["fn"](ic)
|
|
756
|
+
if asyncio.iscoroutine(res):
|
|
757
|
+
await res
|
|
758
|
+
except Exception as e: # noqa: BLE001
|
|
759
|
+
await self._fail(e)
|
|
760
|
+
await self._emit("interaction", ic)
|
|
761
|
+
else:
|
|
762
|
+
payload = dict(data)
|
|
763
|
+
payload.setdefault("type", etype)
|
|
764
|
+
await self._emit(etype, payload)
|
|
765
|
+
|
|
766
|
+
# allow attribute-style access for embed/button classes
|
|
767
|
+
Embed = Embed
|
|
768
|
+
Button = Button
|
|
769
|
+
Row = Row
|