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/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