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/__init__.py +13 -0
- yapper/main.py +774 -0
- yapper_py-1.0.0.dist-info/METADATA +71 -0
- yapper_py-1.0.0.dist-info/RECORD +7 -0
- yapper_py-1.0.0.dist-info/WHEEL +5 -0
- yapper_py-1.0.0.dist-info/licenses/LICENSE +674 -0
- yapper_py-1.0.0.dist-info/top_level.txt +1 -0
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
|