yapper.py 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.
yapper/main.py ADDED
@@ -0,0 +1,774 @@
1
+ """Yapper bot library for Python. Python 3.10+, with python-dotenv as its only dependency.
2
+
3
+ import yapper
4
+
5
+ bot = yapper.Bot() # token from YAPPER_BOT_TOKEN, or yapper.Bot("yb_...")
6
+
7
+ @bot.command("ping", "Is the bot alive?")
8
+ def ping(msg):
9
+ msg.reply("pong")
10
+
11
+ @bot.on("member_join")
12
+ def welcome(ev):
13
+ print(ev.username, "joined")
14
+
15
+ @bot.button("vote:") # clicks on buttons whose custom_id starts with "vote:"
16
+ def vote(i):
17
+ i.ephemeral("You picked " + i.custom_id[5:])
18
+
19
+ bot.load_extensions("cogs") # optional: every cogs/*.py (see Cog below)
20
+ bot.run()
21
+
22
+ Install it with `pip install yapper.py` (this also installs python-dotenv, which loads a .env file for you)
23
+ and run your bot with `python bot.py`. Nothing to host: the bot fetches its own events from Yapper
24
+ (long polling). Docs: https://www.yapper.website/developers
25
+ """
26
+ import importlib
27
+ import importlib.util
28
+ import inspect
29
+ import json
30
+ import os
31
+ import random
32
+ import re
33
+ import sys
34
+ import time
35
+ import traceback
36
+ import urllib.error
37
+ import urllib.parse
38
+ import urllib.request
39
+ from dotenv import load_dotenv
40
+
41
+ load_dotenv()
42
+
43
+ BASE = "https://www.yapper.website/api/bot/v1"
44
+ COMMAND_LIMIT = 100
45
+ EVENTS = ("message", "command", "member_join", "member_leave", "reaction_add", "reaction_remove",
46
+ "voice_join", "voice_leave", "interaction", "test", "ready", "error")
47
+
48
+
49
+ class YapperError(RuntimeError):
50
+ """An API call failed. `.status` is the HTTP status, `.retry_after` the seconds to wait on a 429 (or None)."""
51
+
52
+ def __init__(self, message, status=None, retry_after=None):
53
+ super().__init__(message)
54
+ self.status = status
55
+ self.retry_after = retry_after
56
+
57
+
58
+ class Data(dict):
59
+ """A dict you can also read with dots: ev.user_id is ev["user_id"]. Missing keys are None."""
60
+
61
+ def __getattr__(self, key):
62
+ if key.startswith("__"):
63
+ raise AttributeError(key)
64
+ return self.get(key)
65
+
66
+ def __setattr__(self, key, value):
67
+ self[key] = value
68
+
69
+ @classmethod
70
+ def of(cls, data, bot):
71
+ d = cls(data)
72
+ object.__setattr__(d, "_bot", bot) # kept off the dict, so json.dumps(msg) still works
73
+ return d
74
+
75
+
76
+ class Message(Data):
77
+ """A message event with helpers: msg.reply(text), msg.say(text), msg.author_mention.
78
+ Commands also have msg.command, msg.args (the rest of the line) and msg.argv (split on spaces)."""
79
+
80
+ def reply(self, content="", embeds=None, components=None):
81
+ """Reply to this message (quoted)."""
82
+ return self._bot.send(self.channel_id, content, embeds=embeds, components=components, reply_to=self.id)
83
+
84
+ def say(self, content="", embeds=None, components=None):
85
+ """Post in the same channel without quoting."""
86
+ return self._bot.send(self.channel_id, content, embeds=embeds, components=components)
87
+
88
+ @property
89
+ def author_mention(self):
90
+ return f"<@{self.author_id}>"
91
+
92
+
93
+ class Interaction(Data):
94
+ """A button click: i.custom_id, i.user_id, i.username, and i.reply / i.ephemeral / i.update / i.ack.
95
+ Answer within 3 seconds or the person sees "This interaction failed"."""
96
+
97
+ def _answer(self, kind, content=None, embeds=None, components=None):
98
+ return self._bot.respond(self, kind, content=content, embeds=embeds, components=components)
99
+
100
+ def reply(self, content="", embeds=None, components=None):
101
+ """A new message in the channel."""
102
+ return self._answer("reply", content, embeds, components)
103
+
104
+ def ephemeral(self, content="", embeds=None):
105
+ """A message only the person who clicked sees."""
106
+ return self._answer("ephemeral", content, embeds)
107
+
108
+ def update(self, content=None, embeds=None, components=None):
109
+ """Change the message the button is on (left out = kept)."""
110
+ return self._answer("update", content, embeds, components)
111
+
112
+ def ack(self):
113
+ """Just say "got it", so no error shows."""
114
+ return self._answer("ack")
115
+
116
+ @property
117
+ def user_mention(self):
118
+ return f"<@{self.user_id}>"
119
+
120
+
121
+ def row(*buttons):
122
+ """A row of up to 5 buttons: row(btn("Yes", "vote:yes", "success"), btn("No", "vote:no", "danger"))."""
123
+ return {"type": "row", "components": list(buttons)}
124
+
125
+
126
+ def btn(label, custom_id, style="secondary", emoji=None, disabled=False):
127
+ """A button. Styles: primary, secondary, success, danger."""
128
+ b = {"type": "button", "style": style, "label": label, "custom_id": custom_id, "disabled": disabled}
129
+ if emoji:
130
+ b["emoji"] = emoji
131
+ return b
132
+
133
+
134
+ def link_button(label, url):
135
+ """A button that opens a https link."""
136
+ return {"type": "button", "style": "link", "label": label, "url": url}
137
+
138
+
139
+ # ---- decorators for cogs (on a Cog's methods) ----
140
+
141
+ def command(name=None, description=""):
142
+ """Marks a Cog method as a command: @yapper.command("joke", "Tell a joke"). Leave out the name to use the method's."""
143
+ def deco(fn):
144
+ fn.__yapper_command__ = (name or fn.__name__, description)
145
+ return fn
146
+ return deco
147
+
148
+
149
+ def listener(event=None):
150
+ """Marks a Cog method as an event listener: @yapper.listener("member_join"), or @yapper.listener() on on_member_join."""
151
+ def deco(fn):
152
+ ev = event or fn.__name__
153
+ fn.__yapper_listener__ = ev[3:] if ev.startswith("on_") else ev
154
+ return fn
155
+ return deco
156
+
157
+
158
+ def button(prefix):
159
+ """Marks a Cog method as a button handler for custom_ids starting with `prefix`: @yapper.button("vote:")."""
160
+ def deco(fn):
161
+ fn.__yapper_button__ = prefix
162
+ return fn
163
+ return deco
164
+
165
+
166
+ class Cog:
167
+ """A group of commands, listeners and buttons, usually one file per topic (cogs/fun.py, cogs/mod.py).
168
+
169
+ class Fun(yapper.Cog):
170
+ @yapper.command("joke", "Tell a joke")
171
+ def joke(self, msg):
172
+ msg.reply("I know a UDP joke, but you might not get it")
173
+
174
+ @yapper.listener("member_join")
175
+ def hello(self, ev):
176
+ print(ev.username, "joined")
177
+
178
+ def check(self, msg): # optional: return False to skip this cog's commands
179
+ return True
180
+
181
+ bot.add_cog(Fun(bot))
182
+
183
+ Optional hooks: cog_load(self) and cog_unload(self). `self.bot` is the bot.
184
+ """
185
+
186
+ name = None # defaults to the class name
187
+
188
+ def __init__(self, bot):
189
+ self.bot = bot
190
+
191
+ def check(self, msg):
192
+ return True
193
+
194
+ def cog_load(self):
195
+ pass
196
+
197
+ def cog_unload(self):
198
+ pass
199
+
200
+
201
+ class _Owner:
202
+ """What one cog or extension added, so it can be taken out again."""
203
+
204
+ def __init__(self, name, label):
205
+ self.name = name
206
+ self.label = label
207
+ self.commands = set()
208
+ self.listeners = [] # (event, fn)
209
+ self.buttons = [] # entries in bot._buttons
210
+ self.cogs = [] # for a file: the cogs its setup added
211
+
212
+
213
+ class Bot:
214
+ def __init__(self, token=None, base=BASE, prefixes=("/", "!"), log=True, ignore_bots=True):
215
+ """`token` defaults to the YAPPER_BOT_TOKEN environment variable (bot hosting sets it for you).
216
+ `ignore_bots` (default True): skip messages from other bots and webhooks, so bots can't reply to each other forever."""
217
+ token = (token or os.environ.get("YAPPER_BOT_TOKEN") or "").strip()
218
+ if not token.startswith("yb_"):
219
+ raise SystemExit("Pass your bot token (starts with yb_) or set YAPPER_BOT_TOKEN. Get one in Settings -> Developer.")
220
+ self.token = token
221
+ self.base = base.rstrip("/")
222
+ self.prefixes = tuple(prefixes)
223
+ self.log = log
224
+ self.ignore_bots = ignore_bots
225
+ self.me = None
226
+ self.last = 0
227
+ self.running = False
228
+ self.commands = {} # name -> {"description", "fn", "owner", "check"}
229
+ self.cogs = {} # cog name -> Cog
230
+ self.extensions = {} # "cogs.fun" -> module
231
+ self._listeners = {} # event -> [fn]
232
+ self._buttons = [] # {"prefix", "fn", "owner"}
233
+ self._owners = {} # cog or extension name -> _Owner
234
+ self._loading = [] # the extension being set up right now (bot.command inside setup belongs to it)
235
+ self._registered = False
236
+ self._dirty = False
237
+
238
+ # ---- registering ----
239
+
240
+ def command(self, name, description=""):
241
+ """Decorator: @bot.command("ping", "Is the bot alive?"). People type /ping or !ping."""
242
+ def deco(fn):
243
+ self.add_command(name, description, fn)
244
+ return fn
245
+ return deco
246
+
247
+ def add_command(self, name, description, fn, owner=None, check=None):
248
+ owner = owner or (self._loading[-1] if self._loading else None)
249
+ n = str(name).lower().lstrip("/!")
250
+ if not re.fullmatch(r"[a-z0-9_-]{1,32}", n):
251
+ raise ValueError(f'Bad command name "{name}": letters, numbers, - and _ only')
252
+ old = self.commands.get(n)
253
+ if old and old["owner"] is not owner and (old["owner"] or owner):
254
+ where = lambda o: o.label if o else "your main file"
255
+ raise ValueError(f"Command /{n} is in both {where(old['owner'])} and {where(owner)}. Rename one of them.")
256
+ if not old and len(self.commands) >= COMMAND_LIMIT:
257
+ raise ValueError(f"Up to {COMMAND_LIMIT} commands per bot, /{n} is one too many")
258
+ self.commands[n] = {"description": str(description or "")[:100], "fn": fn, "owner": owner, "check": check}
259
+ if owner:
260
+ owner.commands.add(n)
261
+ self._dirty = True
262
+ return fn
263
+
264
+ def on(self, event, fn=None):
265
+ """Listen for an event: bot.on("member_join", fn), or as a decorator @bot.on("member_join").
266
+ Events: message, command, member_join, member_leave, reaction_add, reaction_remove, voice_join,
267
+ voice_leave, interaction, test, ready, error."""
268
+ def deco(f):
269
+ self._listeners.setdefault(event, []).append(f)
270
+ owner = self._loading[-1] if self._loading else None
271
+ if owner:
272
+ owner.listeners.append((event, f))
273
+ return f
274
+ return deco(fn) if fn else deco
275
+
276
+ def event(self, fn):
277
+ """Decorator: the function name says the event. @bot.event def on_member_join(ev): ..."""
278
+ name = fn.__name__
279
+ return self.on(name[3:] if name.startswith("on_") else name, fn)
280
+
281
+ def button(self, prefix):
282
+ """Decorator for button clicks whose custom_id starts with `prefix`: @bot.button("vote:")."""
283
+ def deco(fn):
284
+ owner = self._loading[-1] if self._loading else None
285
+ entry = {"prefix": prefix, "fn": fn, "owner": owner}
286
+ self._buttons.append(entry)
287
+ if owner:
288
+ owner.buttons.append(entry)
289
+ return fn
290
+ return deco
291
+
292
+ # ---- cogs and extensions ----
293
+
294
+ def add_cog(self, cog):
295
+ """Add a cog: bot.add_cog(Fun(bot)). Its commands, listeners and buttons start working right away."""
296
+ if inspect.isclass(cog):
297
+ cog = cog(self)
298
+ if not isinstance(cog, Cog):
299
+ raise TypeError("add_cog needs a Cog, like bot.add_cog(Fun(bot))")
300
+ name = cog.name or type(cog).__name__
301
+ if name in self.cogs:
302
+ raise ValueError(f'A cog called "{name}" is already loaded')
303
+ if getattr(cog, "bot", None) is None:
304
+ cog.bot = self
305
+ ext = self._loading[-1] if self._loading else None
306
+ owner = _Owner(name, ext.label if ext else f"cog {name}")
307
+ self._owners[name] = owner
308
+ self.cogs[name] = cog
309
+ if ext:
310
+ ext.cogs.append(name)
311
+ try:
312
+ seen = set()
313
+ for klass in type(cog).__mro__:
314
+ for attr, raw in vars(klass).items():
315
+ if attr in seen:
316
+ continue
317
+ seen.add(attr)
318
+ if not callable(raw):
319
+ continue
320
+ fn = getattr(cog, attr)
321
+ if hasattr(raw, "__yapper_command__"):
322
+ n, desc = raw.__yapper_command__
323
+ self.add_command(n, desc, fn, owner=owner, check=cog.check)
324
+ if hasattr(raw, "__yapper_listener__"):
325
+ ev = raw.__yapper_listener__
326
+ self._listeners.setdefault(ev, []).append(fn)
327
+ owner.listeners.append((ev, fn))
328
+ if hasattr(raw, "__yapper_button__"):
329
+ entry = {"prefix": raw.__yapper_button__, "fn": fn, "owner": owner}
330
+ self._buttons.append(entry)
331
+ owner.buttons.append(entry)
332
+ cog.cog_load()
333
+ except Exception:
334
+ self._drop(owner)
335
+ self.cogs.pop(name, None)
336
+ self._owners.pop(name, None)
337
+ raise
338
+ self._dirty = True
339
+ return cog
340
+
341
+ def remove_cog(self, name):
342
+ """Take a cog out again (runs its cog_unload). Returns the cog, or None if there was none."""
343
+ cog = self.cogs.pop(name, None)
344
+ owner = self._owners.pop(name, None)
345
+ if cog is None:
346
+ return None
347
+ try:
348
+ cog.cog_unload()
349
+ except Exception as e:
350
+ self._fail(e, owner, "cog_unload")
351
+ self._drop(owner)
352
+ self._dirty = True
353
+ return cog
354
+
355
+ def load_extension(self, name):
356
+ """Load a cog file: bot.load_extension("cogs.fun") (or "cogs/fun.py").
357
+ The file has `def setup(bot): bot.add_cog(Fun(bot))`. Without a setup, every Cog class in it is added."""
358
+ name, path = _module_name(name)
359
+ if name in self.extensions:
360
+ raise ValueError(f'"{name}" is already loaded. To load it again: bot.reload_extension("{name}")')
361
+ return self._load(name, path)
362
+
363
+ def _load(self, name, path):
364
+ if path:
365
+ spec = importlib.util.spec_from_file_location(name, path)
366
+ if spec is None:
367
+ raise ImportError(f"Can't load {path}")
368
+ mod = importlib.util.module_from_spec(spec)
369
+ sys.modules[name] = mod
370
+ try:
371
+ # Read the source every time (no cached .pyc), so a file edited a moment ago is what runs.
372
+ with open(path, encoding="utf-8") as f:
373
+ exec(compile(f.read(), path, "exec"), mod.__dict__)
374
+ except BaseException:
375
+ sys.modules.pop(name, None)
376
+ raise
377
+ else:
378
+ importlib.invalidate_caches() # files added while the bot runs
379
+ mod = importlib.import_module(name)
380
+ self._setup(name, mod)
381
+ return name
382
+
383
+ def load_extensions(self, folder="cogs"):
384
+ """Load every .py file in a folder, A to Z. Files starting with _ are skipped. Returns the names."""
385
+ base = folder
386
+ if not os.path.isabs(base):
387
+ here = os.path.dirname(os.path.abspath(sys.argv[0])) if sys.argv and sys.argv[0] else os.getcwd()
388
+ base = next((p for p in (os.path.join(here, folder), os.path.join(os.getcwd(), folder)) if os.path.isdir(p)), None)
389
+ if base is None:
390
+ raise FileNotFoundError(f"Can't find the folder {folder}")
391
+ names = []
392
+ for f in sorted(os.listdir(base)):
393
+ if f.endswith(".py") and not f.startswith(("_", ".")):
394
+ names.append(self.load_extension(os.path.join(base, f)))
395
+ return names
396
+
397
+ def unload_extension(self, name):
398
+ """Unload a cog file: its cogs, commands, listeners and buttons go away, and teardown(bot) runs if it has one."""
399
+ name, _ = _module_name(name)
400
+ mod = self.extensions.pop(name, None)
401
+ if mod is None:
402
+ raise ValueError(f'"{name}" is not loaded')
403
+ owner = self._owners.pop(name, None)
404
+ for cog_name in (owner.cogs if owner else []):
405
+ self.remove_cog(cog_name)
406
+ if hasattr(mod, "teardown"):
407
+ try:
408
+ mod.teardown(self)
409
+ except Exception as e:
410
+ self._fail(e, owner, "teardown")
411
+ self._drop(owner)
412
+ sys.modules.pop(name, None)
413
+ self._dirty = True
414
+
415
+ def reload_extension(self, name):
416
+ """Load a cog file again, fresh from disk, without restarting. If the new version breaks, the old one stays."""
417
+ name, _ = _module_name(name)
418
+ mod = self.extensions.get(name)
419
+ if mod is None:
420
+ raise ValueError(f'"{name}" is not loaded')
421
+ path = getattr(mod, "__file__", None)
422
+ self.unload_extension(name)
423
+ try:
424
+ self._load(name, path)
425
+ except Exception:
426
+ sys.modules[name] = mod
427
+ self._setup(name, mod)
428
+ raise
429
+ return name
430
+
431
+ def _setup(self, name, mod):
432
+ owner = _Owner(name, (getattr(mod, "__file__", None) and os.path.relpath(mod.__file__).replace(os.sep, "/")) or name)
433
+ self._owners[name] = owner
434
+ self.extensions[name] = mod
435
+ self._loading.append(owner)
436
+ try:
437
+ if hasattr(mod, "setup"):
438
+ mod.setup(self)
439
+ else:
440
+ classes = [c for c in vars(mod).values() if inspect.isclass(c) and issubclass(c, Cog) and c is not Cog and c.__module__ == mod.__name__]
441
+ if not classes:
442
+ raise ValueError(f"{owner.label} has no setup(bot) function and no Cog class. Add one of them.")
443
+ for c in classes:
444
+ self.add_cog(c(self))
445
+ except BaseException:
446
+ for cog_name in owner.cogs: # undo what setup managed to add before it broke
447
+ self.cogs.pop(cog_name, None)
448
+ self._drop(self._owners.pop(cog_name, None))
449
+ self._drop(owner)
450
+ self._owners.pop(name, None)
451
+ self.extensions.pop(name, None)
452
+ sys.modules.pop(name, None)
453
+ raise
454
+ finally:
455
+ self._loading.pop()
456
+ self._dirty = True
457
+
458
+ def _drop(self, owner):
459
+ if owner is None:
460
+ return
461
+ for n in owner.commands:
462
+ if n in self.commands and self.commands[n]["owner"] is owner:
463
+ del self.commands[n]
464
+ for ev, fn in owner.listeners:
465
+ if fn in self._listeners.get(ev, []):
466
+ self._listeners[ev].remove(fn)
467
+ self._buttons = [b for b in self._buttons if b["owner"] is not owner]
468
+ owner.commands.clear()
469
+ owner.listeners.clear()
470
+ owner.buttons.clear()
471
+
472
+ # ---- HTTP ----
473
+
474
+ def api(self, method, path, body=None, timeout=40, retries=3):
475
+ """Raw API call: bot.api("GET", "/servers"). A 429 with Retry-After, or Yapper's own 503 "busy",
476
+ is waited out and retried (nothing was done, so that's safe). Other errors raise YapperError."""
477
+ data = None if body is None else json.dumps(body).encode()
478
+ req = urllib.request.Request(self.base + path, method=method, data=data, headers={
479
+ "Authorization": f"Bot {self.token}", "Content-Type": "application/json", "User-Agent": "yapper-py/1"})
480
+ try:
481
+ with urllib.request.urlopen(req, timeout=timeout) as r:
482
+ raw = r.read()
483
+ return json.loads(raw) if raw else {}
484
+ except urllib.error.HTTPError as e:
485
+ try:
486
+ err = json.loads(e.read() or b"{}")
487
+ except Exception:
488
+ err = {}
489
+ if not isinstance(err, dict):
490
+ err = {}
491
+ busy = e.code == 503 and "retry_after" in err
492
+ wait = 0
493
+ if e.code == 429 or busy:
494
+ try:
495
+ wait = float(e.headers.get("Retry-After") or err.get("retry_after") or 0)
496
+ except (TypeError, ValueError):
497
+ wait = 0
498
+ if 0 < wait <= 60 and retries > 0:
499
+ if self.log:
500
+ print(f"[yapper] {'Yapper busy' if busy else 'rate limited'}, retrying {method} {path} in {wait:g}s")
501
+ time.sleep(wait * (1 + random.random() * 0.5))
502
+ return self.api(method, path, body, timeout, retries - 1)
503
+ raise YapperError(err.get("error") or f"{method} {path} failed ({e.code})", e.code, wait or None) from None
504
+
505
+ # ---- messages and more ----
506
+
507
+ def send(self, channel_id, content="", embeds=None, components=None, reply_to=None):
508
+ """Post a message. Embeds and buttons are plain dicts (see /developers). Returns the message."""
509
+ body = {"content": str(content or "")}
510
+ if reply_to:
511
+ body["reply_to"] = reply_to
512
+ if embeds:
513
+ body["embeds"] = embeds if isinstance(embeds, list) else [embeds]
514
+ if components:
515
+ body["components"] = components if isinstance(components, list) else [components]
516
+ return Data(self.api("POST", f"/channels/{channel_id}/messages", body).get("message") or {})
517
+
518
+ def edit(self, channel_id, message_id, content=None, embeds=None, components=None):
519
+ body = {}
520
+ if content is not None:
521
+ body["content"] = str(content)
522
+ if embeds is not None:
523
+ body["embeds"] = embeds
524
+ if components is not None:
525
+ body["components"] = components
526
+ return Data(self.api("PATCH", f"/channels/{channel_id}/messages/{message_id}", body).get("message") or {})
527
+
528
+ def delete(self, channel_id, message_id):
529
+ return self.api("DELETE", f"/channels/{channel_id}/messages/{message_id}")
530
+
531
+ def react(self, channel_id, message_id, emoji):
532
+ return self.api("PUT", f"/channels/{channel_id}/messages/{message_id}/reactions/{urllib.parse.quote(emoji)}")
533
+
534
+ def respond(self, interaction, kind, content=None, embeds=None, components=None):
535
+ """Answer a button click: kind is reply, ephemeral, update or ack."""
536
+ body = {"type": kind}
537
+ if content is not None:
538
+ body["content"] = str(content)
539
+ if embeds is not None:
540
+ body["embeds"] = embeds
541
+ if components is not None:
542
+ body["components"] = components
543
+ return self.api("POST", f"/interactions/{interaction['id']}/{interaction['token']}/callback", body)
544
+
545
+ def servers(self):
546
+ return [Data(s) for s in self.api("GET", "/servers")["servers"]]
547
+
548
+ def server(self, server_id):
549
+ return Data(self.api("GET", f"/servers/{server_id}")["server"])
550
+
551
+ def channels(self, server_id=None, type=None):
552
+ """Channels the bot can see, in sidebar order. channels(server_id, type="text") for text only."""
553
+ q = f"?type={type}" if type else ""
554
+ path = f"/servers/{server_id}/channels{q}" if server_id else f"/channels{q}"
555
+ return [Data(c) for c in self.api("GET", path)["channels"]]
556
+
557
+ def channel(self, channel_id):
558
+ try:
559
+ return Data(self.api("GET", f"/channels/{channel_id}")["channel"])
560
+ except YapperError:
561
+ return None
562
+
563
+ def members(self, server_id, after=None):
564
+ return [Data(m) for m in self.api("GET", f"/servers/{server_id}/members?limit=200" + (f"&after={after}" if after else ""))["members"]]
565
+
566
+ def member(self, server_id, user_id):
567
+ return Data(self.api("GET", f"/servers/{server_id}/members/{user_id}")["member"])
568
+
569
+ def roles(self, server_id):
570
+ return [Data(r) for r in self.api("GET", f"/servers/{server_id}/roles")["roles"]]
571
+
572
+ def user(self, user_id):
573
+ return Data(self.api("GET", f"/users/{user_id}")["user"])
574
+
575
+ def messages(self, channel_id, limit=50):
576
+ out = self.api("GET", f"/channels/{channel_id}/messages?limit={limit}")
577
+ by = {a["id"]: a for a in out.get("authors") or []}
578
+ return [Data(m, author=by.get(m.get("author_id"))) for m in out.get("messages") or []]
579
+
580
+ def add_role(self, server_id, user_id, role_id):
581
+ return self.api("PUT", f"/servers/{server_id}/members/{user_id}/roles/{role_id}")
582
+
583
+ def remove_role(self, server_id, user_id, role_id):
584
+ return self.api("DELETE", f"/servers/{server_id}/members/{user_id}/roles/{role_id}")
585
+
586
+ def kick(self, server_id, user_id):
587
+ return self.api("POST", f"/servers/{server_id}/kick", {"user_id": user_id})
588
+
589
+ def ban(self, server_id, user_id):
590
+ return self.api("POST", f"/servers/{server_id}/bans", {"user_id": user_id})
591
+
592
+ def unban(self, server_id, user_id):
593
+ return self.api("DELETE", f"/servers/{server_id}/bans/{user_id}")
594
+
595
+ def timeout(self, server_id, user_id, minutes, reason=""):
596
+ """Time someone out for `minutes` (0 clears it)."""
597
+ return self.api("POST", f"/servers/{server_id}/timeouts", {"user_id": user_id, "minutes": minutes, "reason": reason})
598
+
599
+ def dm(self, user_id):
600
+ """Open a DM with someone. Returns the channel id."""
601
+ return self.api("POST", f"/users/{user_id}/dm")["channel_id"]
602
+
603
+ def set_status(self, status="online", activity=None):
604
+ return self.api("PUT", "/me/status", {"status": status, "activity": activity})
605
+
606
+ def set_profile(self, display_name=None, bio=None):
607
+ return self.api("PATCH", "/me", {k: v for k, v in (("display_name", display_name), ("bio", bio)) if v is not None})
608
+
609
+ # ---- events ----
610
+
611
+ def _fail(self, e, owner=None, where=None):
612
+ if owner is not None and getattr(e, "cog", None) is None:
613
+ try:
614
+ e.cog, e.where = owner.name, where
615
+ except Exception:
616
+ pass
617
+ if self._listeners.get("error"):
618
+ for fn in list(self._listeners["error"]):
619
+ try:
620
+ fn(e)
621
+ except Exception as e2:
622
+ print("[yapper] error listener failed:", e2)
623
+ elif self.log:
624
+ tag = f"[yapper] [{e.cog}{' ' + e.where if getattr(e, 'where', None) else ''}]" if getattr(e, "cog", None) else "[yapper]"
625
+ print(tag, f"{type(e).__name__}: {e}")
626
+ traceback.print_exception(type(e), e, e.__traceback__, limit=-3)
627
+
628
+ def _emit(self, event, payload):
629
+ for fn in list(self._listeners.get(event, [])):
630
+ try:
631
+ fn(payload)
632
+ except Exception as e:
633
+ self._fail(e, self._owner_of(fn), event)
634
+
635
+ def _owner_of(self, fn):
636
+ for o in self._owners.values():
637
+ if any(f is fn for _, f in o.listeners) or any(b["fn"] is fn for b in o.buttons):
638
+ return o
639
+ return None
640
+
641
+ def handle(self, ev):
642
+ """Handle one event ({"id", "type", "data"}). run() calls this for you."""
643
+ data = ev.get("data") or {}
644
+ kind = ev.get("type")
645
+ if kind == "message":
646
+ if self.ignore_bots and data.get("author_bot"):
647
+ return
648
+ msg = Message.of(data, self)
649
+ name, args = data.get("command"), data.get("args")
650
+ if not name:
651
+ m = re.match(r"^(\S)([a-z0-9_-]{1,32})(?:\s+([\s\S]*))?$", data.get("content") or "", re.I)
652
+ if m and m.group(1) in self.prefixes and m.group(2).lower() in self.commands:
653
+ name, args = m.group(2).lower(), (m.group(3) or "").strip()
654
+ c = self.commands.get(name) if name else None
655
+ if c:
656
+ msg.command, msg.args = name, args or ""
657
+ msg.argv = msg.args.split() if msg.args else []
658
+ ok = True
659
+ if c["check"]:
660
+ try:
661
+ ok = c["check"](msg) is not False
662
+ except Exception as e:
663
+ ok = False
664
+ self._fail(e, c["owner"], "check")
665
+ if ok:
666
+ try:
667
+ c["fn"](msg)
668
+ except Exception as e:
669
+ self._fail(e, c["owner"], "/" + name)
670
+ self._emit("command", msg)
671
+ self._emit("message", msg)
672
+ elif kind == "interaction":
673
+ i = Interaction.of(data, self)
674
+ for b in list(self._buttons):
675
+ if str(data.get("custom_id") or "").startswith(b["prefix"]):
676
+ try:
677
+ b["fn"](i)
678
+ except Exception as e:
679
+ self._fail(e, b["owner"], "button " + b["prefix"])
680
+ self._emit("interaction", i)
681
+ else:
682
+ self._emit(kind, Data(data, type=kind))
683
+
684
+ def sync_commands(self):
685
+ """Send the command list to Yapper (run() does it, and again after cogs change)."""
686
+ self._dirty = False
687
+ cmds = [{"name": n, "description": c["description"]} for n, c in self.commands.items()]
688
+ self.api("PUT", "/commands", {"commands": cmds})
689
+ return cmds
690
+
691
+ def run(self):
692
+ """Register the commands and handle events until stopped (Ctrl+C)."""
693
+ try:
694
+ self._run()
695
+ except KeyboardInterrupt:
696
+ print("[yapper] stopped")
697
+ finally:
698
+ self.running = False
699
+ self._registered = False
700
+
701
+ def _run(self):
702
+ self.running = True
703
+ fails = 0
704
+ while self.running:
705
+ try:
706
+ self.me = Data(self.api("GET", "/me"))
707
+ cmds = self.sync_commands()
708
+ self.last = self.api("GET", "/events?after=0&wait=0").get("last") or 0 # skip old events
709
+ break
710
+ except YapperError as e:
711
+ if e.status in (400, 401, 403):
712
+ self.running = False
713
+ raise
714
+ fails += 1
715
+ self._fail(e)
716
+ time.sleep(min(30, 2 ** fails))
717
+ except OSError as e: # network down: keep trying
718
+ fails += 1
719
+ self._fail(e)
720
+ time.sleep(min(30, 2 ** fails))
721
+ if not self.running:
722
+ return
723
+ self._registered = True
724
+ if self.log:
725
+ print(f"[yapper] {self.me.username} is online" + (f" with {len(cmds)} command{'' if len(cmds) == 1 else 's'}" if cmds else ""))
726
+ self._emit("ready", self.me)
727
+ fails = 0
728
+ while self.running:
729
+ if self._dirty: # a cog was loaded or unloaded: send the new list once
730
+ try:
731
+ n = len(self.sync_commands())
732
+ if self.log:
733
+ print(f"[yapper] commands updated ({n})")
734
+ except Exception as e:
735
+ self._fail(e)
736
+ try:
737
+ out = self.api("GET", f"/events?after={self.last}&wait=25", retries=0)
738
+ fails = 0
739
+ except Exception as e:
740
+ fails += 1
741
+ if getattr(e, "status", None) == 401:
742
+ raise SystemExit("Token rejected: make a new one in Settings -> Developer")
743
+ self._fail(e)
744
+ time.sleep(max(getattr(e, "retry_after", None) or 0, min(30, 2 ** fails) * (0.75 + random.random() / 2)))
745
+ continue
746
+ for ev in out.get("events") or []:
747
+ if not ev["id"] > self.last: # already handled
748
+ continue
749
+ self.last = ev["id"]
750
+ self.handle(ev)
751
+ self.last = max(self.last, out.get("last") or 0)
752
+
753
+ start = run
754
+
755
+ def stop(self):
756
+ """Stop after the current poll."""
757
+ self.running = False
758
+
759
+
760
+ def _module_name(name):
761
+ """ "cogs.fun" -> ("cogs.fun", None); "cogs/fun.py" -> ("cogs.fun", "/abs/cogs/fun.py")."""
762
+ s = str(name)
763
+ if s.endswith(".py") or os.sep in s or "/" in s:
764
+ path = s if s.endswith(".py") else s + ".py"
765
+ if not os.path.isabs(path):
766
+ here = os.path.dirname(os.path.abspath(sys.argv[0])) if sys.argv and sys.argv[0] else os.getcwd()
767
+ path = next((p for p in (os.path.join(here, path), os.path.join(os.getcwd(), path)) if os.path.exists(p)), path)
768
+ path = os.path.abspath(path)
769
+ if not os.path.exists(path):
770
+ raise FileNotFoundError(f"Can't find {s}")
771
+ parent = os.path.basename(os.path.dirname(path))
772
+ mod = os.path.splitext(os.path.basename(path))[0]
773
+ return (f"{parent}.{mod}" if parent.isidentifier() else mod), path
774
+ return s, None