yapper.py 1.0.0__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yapper.py
3
- Version: 1.0.0
4
- Summary: An unofficial Python Library for Yapper
5
- Author-email: LeAndre C <leandre@lecanact.me>
3
+ Version: 1.2.0
4
+ Summary: A Python Library for Yapin'
5
+ Author-email: "LeAndre C." <leandre@lecanact.me>
6
6
  License-Expression: GPL-3.0-only
7
7
  Project-URL: Repository, https://github.com/our-websites/yapper.py
8
8
  Project-URL: Issues, https://github.com/our-websites/yapper.py/issues
9
- Project-URL: Documentation, https://yapper.website/developers
9
+ Project-URL: Documentation, https://yapin.gg/developers
10
+ Keywords: yapper.py,yapin.py,discord.py,yapper,yapin
10
11
  Classifier: Programming Language :: Python :: 3
11
12
  Classifier: Operating System :: OS Independent
12
13
  Requires-Python: >=3.10
@@ -4,16 +4,23 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "yapper.py"
7
- version = "1.0.0"
7
+ version = "1.2.0"
8
8
  authors = [
9
- { name="LeAndre C", email="leandre@lecanact.me" },
9
+ { name="LeAndre C.", email="leandre@lecanact.me" },
10
10
  ]
11
- description = "An unofficial Python Library for Yapper"
11
+ description = "A Python Library for Yapin'"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.10"
14
14
  dependencies = [
15
15
  "python-dotenv>=1.2.4",
16
16
  ]
17
+ keywords = [
18
+ "yapper.py",
19
+ "yapin.py",
20
+ "discord.py",
21
+ "yapper",
22
+ "yapin"
23
+ ]
17
24
  classifiers = [
18
25
  "Programming Language :: Python :: 3",
19
26
  "Operating System :: OS Independent",
@@ -24,7 +31,7 @@ license-files = ["LICEN[CS]E*"]
24
31
  [project.urls]
25
32
  Repository = "https://github.com/our-websites/yapper.py"
26
33
  Issues = "https://github.com/our-websites/yapper.py/issues"
27
- Documentation = "https://yapper.website/developers"
34
+ Documentation = "https://yapin.gg/developers"
28
35
 
29
36
  [tool.setuptools.packages.find]
30
37
  where = ["src"]
@@ -0,0 +1,16 @@
1
+ from .main import (
2
+ Bot,
3
+ VoiceCall,
4
+ Cog,
5
+ command,
6
+ listener,
7
+ button,
8
+ YapperError,
9
+ row,
10
+ btn,
11
+ link_button
12
+ )
13
+
14
+ __all__ = ["Bot", "VoiceCall", "Cog", "command", "listener", "button", "YapperError", "row", "btn", "link_button"]
15
+
16
+ __version__ = "1.2"
@@ -1,4 +1,4 @@
1
- """Yapper bot library for Python. Python 3.10+, with python-dotenv as its only dependency.
1
+ """Yapin’ bot library for Python. One file, standard library only, Python 3.9+.
2
2
 
3
3
  import yapper
4
4
 
@@ -19,10 +19,12 @@
19
19
  bot.load_extensions("cogs") # optional: every cogs/*.py (see Cog below)
20
20
  bot.run()
21
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
22
+ Save this file next to your bot and run it with `python bot.py`. Nothing to install, nothing to host:
23
+ the bot keeps one WebSocket open to Yapin’ (the gateway) and gets its events pushed; when that is off or down
24
+ it fetches them itself (long polling). Docs: https://www.yapin.gg/developers
25
25
  """
26
+ import base64
27
+ import hashlib
26
28
  import importlib
27
29
  import importlib.util
28
30
  import inspect
@@ -30,6 +32,10 @@ import json
30
32
  import os
31
33
  import random
32
34
  import re
35
+ import select
36
+ import socket
37
+ import ssl
38
+ import struct
33
39
  import sys
34
40
  import time
35
41
  import traceback
@@ -40,7 +46,13 @@ from dotenv import load_dotenv
40
46
 
41
47
  load_dotenv()
42
48
 
43
- BASE = "https://www.yapper.website/api/bot/v1"
49
+ __all__ = ["Bot", "VoiceCall", "Cog", "command", "listener", "button", "YapperError", "row", "btn", "link_button"]
50
+ __version__ = "1.2"
51
+
52
+ BASE = "https://bots.yapper.website/api/bot/v1"
53
+ # The website's address for the same API: used for 10 minutes when BASE can't be reached.
54
+ FALLBACK_BASE = "https://www.yapin.gg/api/bot/v1"
55
+ GATEWAY = "wss://rt.yapper.website/bot"
44
56
  COMMAND_LIMIT = 100
45
57
  EVENTS = ("message", "command", "member_join", "member_leave", "reaction_add", "reaction_remove",
46
58
  "voice_join", "voice_leave", "interaction", "test", "ready", "error")
@@ -94,20 +106,20 @@ class Interaction(Data):
94
106
  """A button click: i.custom_id, i.user_id, i.username, and i.reply / i.ephemeral / i.update / i.ack.
95
107
  Answer within 3 seconds or the person sees "This interaction failed"."""
96
108
 
97
- def _answer(self, kind, content=None, embeds=None, components=None):
98
- return self._bot.respond(self, kind, content=content, embeds=embeds, components=components)
109
+ def _answer(self, kind, content=None, embeds=None, components=None, layout=None):
110
+ return self._bot.respond(self, kind, content=content, embeds=embeds, components=components, layout=layout)
99
111
 
100
- def reply(self, content="", embeds=None, components=None):
101
- """A new message in the channel."""
102
- return self._answer("reply", content, embeds, components)
112
+ def reply(self, content="", embeds=None, components=None, layout=None):
113
+ """A new message in the channel. layout=[...] sends a Components V2 message instead."""
114
+ return self._answer("reply", None if layout is not None else content, embeds, components, layout)
103
115
 
104
116
  def ephemeral(self, content="", embeds=None):
105
117
  """A message only the person who clicked sees."""
106
118
  return self._answer("ephemeral", content, embeds)
107
119
 
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)
120
+ def update(self, content=None, embeds=None, components=None, layout=None):
121
+ """Change the message the button is on (left out = kept). On a Components V2 message: update(layout=[...])."""
122
+ return self._answer("update", content, embeds, components, layout)
111
123
 
112
124
  def ack(self):
113
125
  """Just say "got it", so no error shows."""
@@ -136,6 +148,62 @@ def link_button(label, url):
136
148
  return {"type": "button", "style": "link", "label": label, "url": url}
137
149
 
138
150
 
151
+ # ---- Components V2 (bot.send_layout): layout components, standard JSON shape and type numbers ----
152
+
153
+ IS_COMPONENTS_V2 = 1 << 15
154
+ _COLORS = {"red": 0xED4245, "green": 0x57F287, "blue": 0x5865F2, "yellow": 0xFEE75C, "purple": 0x9B59B6,
155
+ "orange": 0xE67E22, "grey": 0x95A5A6, "gray": 0x95A5A6}
156
+
157
+
158
+ def _clean(d):
159
+ return {k: v for k, v in d.items() if v is not None and v is not False}
160
+
161
+
162
+ def text(content):
163
+ """Markdown text (type 10). Mentions, links and emoji work like in messages; no HTML."""
164
+ return {"type": 10, "content": str(content)}
165
+
166
+
167
+ def thumbnail(url, description=None, spoiler=False):
168
+ """A small picture (type 11), only as a section's accessory."""
169
+ return _clean({"type": 11, "media": {"url": url}, "description": description, "spoiler": spoiler})
170
+
171
+
172
+ def section(*texts, accessory):
173
+ """1 to 3 lines of text with a button or a thumbnail beside them (type 9): section("Ready?", accessory=btn("Go", "go"))."""
174
+ return {"type": 9, "components": [t if isinstance(t, dict) else text(t) for t in texts], "accessory": accessory}
175
+
176
+
177
+ def media(url, description=None, spoiler=False):
178
+ """One picture or video for gallery()."""
179
+ return _clean({"media": {"url": url}, "description": description, "spoiler": spoiler})
180
+
181
+
182
+ def gallery(*items):
183
+ """1 to 10 pictures or videos in a grid (type 12): gallery("https://…", media("https://…", spoiler=True))."""
184
+ return {"type": 12, "items": [i if isinstance(i, dict) else media(i) for i in items]}
185
+
186
+
187
+ def separator(divider=True, spacing="small"):
188
+ """A line (or just space with divider=False), spacing "small" or "large" (type 14)."""
189
+ return {"type": 14, "divider": bool(divider), "spacing": 2 if spacing in ("large", 2) else 1}
190
+
191
+
192
+ def file_display(url, spoiler=False):
193
+ """A file to download (type 13): an https link to a Yapin’ upload or an allowed host."""
194
+ return _clean({"type": 13, "file": {"url": url}, "spoiler": spoiler})
195
+
196
+
197
+ def container(*components, accent=None, spoiler=False):
198
+ """A card holding other components (type 17). accent: 0x5865F2, "#5865f2" or a name like "blue"."""
199
+ c = {"type": 17, "components": [x if isinstance(x, dict) else text(x) for x in components]}
200
+ if accent is not None:
201
+ c["accent_color"] = accent if isinstance(accent, int) else _COLORS.get(str(accent).lower()) or int(str(accent).lstrip("#"), 16)
202
+ if spoiler:
203
+ c["spoiler"] = True
204
+ return c
205
+
206
+
139
207
  # ---- decorators for cogs (on a Cog's methods) ----
140
208
 
141
209
  def command(name=None, description=""):
@@ -210,18 +278,172 @@ class _Owner:
210
278
  self.cogs = [] # for a file: the cogs its setup added
211
279
 
212
280
 
281
+ def _backoff(n):
282
+ """Seconds to wait before try number n (1, 2, ...): 1, 2, 4 ... up to 30, with some jitter so many bots don't sync up."""
283
+ return min(30, 2 ** (n - 1)) * (0.75 + random.random() / 2)
284
+
285
+
286
+ class _GatewayClosed(Exception):
287
+ """The gateway closed the socket. `.code` is the close code (0 when the connection just dropped)."""
288
+
289
+ def __init__(self, code=0, reason=""):
290
+ super().__init__(reason or f"gateway closed ({code})")
291
+ self.code = code
292
+ self.reason = reason
293
+
294
+
295
+ def _mask(data, key):
296
+ n = len(data)
297
+ return (int.from_bytes(data, "big") ^ int.from_bytes((key * (n // 4 + 1))[:n], "big")).to_bytes(n, "big")
298
+
299
+
300
+ class _WebSocket:
301
+ """Just enough of a WebSocket client (RFC 6455) for the gateway: text messages, ping/pong, close codes, TLS.
302
+ Standard library only, so the SDK still needs nothing installed."""
303
+
304
+ def __init__(self, url, timeout=15):
305
+ u = urllib.parse.urlsplit(url)
306
+ if u.scheme not in ("ws", "wss") or not u.hostname:
307
+ raise ValueError(f"Not a WebSocket URL: {url}")
308
+ tls = u.scheme == "wss"
309
+ port = u.port or (443 if tls else 80)
310
+ path = (u.path or "/") + (f"?{u.query}" if u.query else "")
311
+ self._buf = b""
312
+ self._frag = None # [opcode, bytearray] of a message still arriving in pieces
313
+ self.sock = socket.create_connection((u.hostname, port), timeout=timeout)
314
+ try:
315
+ self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
316
+ if tls:
317
+ self.sock = ssl.create_default_context().wrap_socket(self.sock, server_hostname=u.hostname)
318
+ key = base64.b64encode(os.urandom(16)).decode()
319
+ host = u.hostname if port == (443 if tls else 80) else f"{u.hostname}:{port}"
320
+ self.sock.sendall((f"GET {path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
321
+ f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n"
322
+ f"User-Agent: yapper-py/{__version__}\r\n\r\n").encode())
323
+ head = self._read_until(b"\r\n\r\n")
324
+ status, _, rest = head.partition(b"\r\n")
325
+ if b" 101 " not in status:
326
+ raise OSError(f"gateway handshake failed: {status.decode(errors='replace')[:80]}")
327
+ headers = {}
328
+ for line in rest.split(b"\r\n"):
329
+ k, _, v = line.decode(errors="replace").partition(":")
330
+ headers[k.strip().lower()] = v.strip()
331
+ want = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
332
+ if headers.get("sec-websocket-accept") != want:
333
+ raise OSError("gateway handshake failed: bad Sec-WebSocket-Accept")
334
+ except BaseException:
335
+ self.sock.close()
336
+ raise
337
+
338
+ def _read_until(self, mark):
339
+ while mark not in self._buf:
340
+ chunk = self.sock.recv(4096)
341
+ if not chunk:
342
+ raise OSError("gateway closed during the handshake")
343
+ self._buf += chunk
344
+ if len(self._buf) > 65536:
345
+ raise OSError("gateway handshake answer too long")
346
+ head, self._buf = self._buf.split(mark, 1)
347
+ return head
348
+
349
+ def _read(self, n):
350
+ while len(self._buf) < n:
351
+ chunk = self.sock.recv(max(4096, n - len(self._buf)))
352
+ if not chunk:
353
+ raise _GatewayClosed(0, "connection lost")
354
+ self._buf += chunk
355
+ out, self._buf = self._buf[:n], self._buf[n:]
356
+ return out
357
+
358
+ def _frame(self, opcode, payload=b""):
359
+ n = len(payload)
360
+ if n < 126:
361
+ head = bytes([0x80 | opcode, 0x80 | n])
362
+ elif n < 65536:
363
+ head = bytes([0x80 | opcode, 0x80 | 126]) + struct.pack("!H", n)
364
+ else:
365
+ head = bytes([0x80 | opcode, 0x80 | 127]) + struct.pack("!Q", n)
366
+ key = os.urandom(4)
367
+ self.sock.sendall(head + key + _mask(payload, key))
368
+
369
+ def send(self, obj):
370
+ self._frame(1, json.dumps(obj).encode())
371
+
372
+ def readable(self, wait):
373
+ """True when something can be read within `wait` seconds (bytes TLS already took off the wire count too)."""
374
+ if self._buf or getattr(self.sock, "pending", lambda: 0)():
375
+ return True
376
+ r, _, _ = select.select([self.sock], [], [], max(0.0, wait))
377
+ return bool(r)
378
+
379
+ def recv(self):
380
+ """The next text message as a string (None for a binary one). Answers pings itself; raises _GatewayClosed
381
+ when the other side closes."""
382
+ while True:
383
+ b1, b2 = self._read(2)
384
+ fin, opcode, n = b1 & 0x80, b1 & 0x0F, b2 & 0x7F
385
+ if n == 126:
386
+ n = struct.unpack("!H", self._read(2))[0]
387
+ elif n == 127:
388
+ n = struct.unpack("!Q", self._read(8))[0]
389
+ if n > 16 * 1024 * 1024:
390
+ raise OSError("gateway frame too big")
391
+ key = self._read(4) if b2 & 0x80 else None
392
+ data = self._read(n)
393
+ if key:
394
+ data = _mask(data, key)
395
+ if opcode == 8:
396
+ code = struct.unpack("!H", data[:2])[0] if len(data) >= 2 else 0
397
+ try:
398
+ self._frame(8, data[:2])
399
+ except OSError:
400
+ pass
401
+ raise _GatewayClosed(code, data[2:].decode("utf-8", "replace"))
402
+ if opcode == 9:
403
+ self._frame(10, data)
404
+ elif opcode in (1, 2):
405
+ if fin:
406
+ return data.decode("utf-8", "replace") if opcode == 1 else None
407
+ self._frag = [opcode, bytearray(data)]
408
+ elif opcode == 0 and self._frag:
409
+ self._frag[1] += data
410
+ if fin:
411
+ op, whole = self._frag
412
+ self._frag = None
413
+ return bytes(whole).decode("utf-8", "replace") if op == 1 else None
414
+
415
+ def close(self, code=1000, reason=""):
416
+ try:
417
+ self._frame(8, struct.pack("!H", code) + reason.encode()[:120])
418
+ except OSError:
419
+ pass
420
+ try:
421
+ self.sock.close()
422
+ except OSError:
423
+ pass
424
+
425
+
213
426
  class Bot:
214
- def __init__(self, token=None, base=BASE, prefixes=("/", "!"), log=True, ignore_bots=True):
427
+ def __init__(self, token=None, base=None, prefixes=("/", "!"), log=True, ignore_bots=True, gateway=GATEWAY):
215
428
  """`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."""
429
+ `ignore_bots` (default True): skip messages from other bots and webhooks, so bots can't reply to each other forever.
430
+ `gateway` (default on): where events come from over a WebSocket; used when it's on for your bot, otherwise
431
+ the bot long-polls. Pass None (or False) to always poll."""
217
432
  token = (token or os.environ.get("YAPPER_BOT_TOKEN") or "").strip()
218
433
  if not token.startswith("yb_"):
219
434
  raise SystemExit("Pass your bot token (starts with yb_) or set YAPPER_BOT_TOKEN. Get one in Settings -> Developer.")
220
435
  self.token = token
221
- self.base = base.rstrip("/")
436
+ # YAPPER_API_URL: set by bot hosting (usable anywhere) to point at another API host.
437
+ chosen = base or os.environ.get("YAPPER_API_URL")
438
+ self.base = (chosen or BASE).rstrip("/")
439
+ # Hosted bots get our host through YAPPER_API_URL: they keep the fallback too; any other chosen base doesn't.
440
+ self._fallback = None if chosen and self.base != BASE else FALLBACK_BASE
441
+ self._fallback_until = 0
222
442
  self.prefixes = tuple(prefixes)
223
443
  self.log = log
224
444
  self.ignore_bots = ignore_bots
445
+ self.gateway = gateway or None
446
+ self._ws = None
225
447
  self.me = None
226
448
  self.last = 0
227
449
  self.running = False
@@ -472,16 +694,25 @@ class Bot:
472
694
  # ---- HTTP ----
473
695
 
474
696
  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",
697
+ """Raw API call: bot.api("GET", "/servers"). A 429 with Retry-After, or Yapin’s own 503 "busy",
476
698
  is waited out and retried (nothing was done, so that's safe). Other errors raise YapperError."""
477
699
  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"})
700
+ on_fallback = bool(self._fallback) and time.time() < self._fallback_until
701
+ base = self._fallback if on_fallback else self.base
702
+ req = urllib.request.Request(base + path, method=method, data=data, headers={
703
+ "Authorization": f"Bot {self.token}", "Content-Type": "application/json", "User-Agent": f"yapper-py/{__version__}"})
480
704
  try:
481
705
  with urllib.request.urlopen(req, timeout=timeout) as r:
482
706
  raw = r.read()
483
- return json.loads(raw) if raw else {}
707
+ try:
708
+ return json.loads(raw) if raw else {}
709
+ except ValueError:
710
+ raise YapperError(f"{method} {path}: the server sent something that isn't JSON (is the API address right?)", r.status) from None
484
711
  except urllib.error.HTTPError as e:
712
+ if e.code in (502, 504) and self._fallback and not on_fallback:
713
+ self._use_fallback(f"answered {e.code}")
714
+ if method == "GET" and retries > 0:
715
+ return self.api(method, path, body, timeout, retries - 1)
485
716
  try:
486
717
  err = json.loads(e.read() or b"{}")
487
718
  except Exception:
@@ -497,10 +728,24 @@ class Bot:
497
728
  wait = 0
498
729
  if 0 < wait <= 60 and retries > 0:
499
730
  if self.log:
500
- print(f"[yapper] {'Yapper busy' if busy else 'rate limited'}, retrying {method} {path} in {wait:g}s")
731
+ print(f"[yapper] {'busy' if busy else 'rate limited'}, retrying {method} {path} in {wait:g}s")
501
732
  time.sleep(wait * (1 + random.random() * 0.5))
502
733
  return self.api(method, path, body, timeout, retries - 1)
503
734
  raise YapperError(err.get("error") or f"{method} {path} failed ({e.code})", e.code, wait or None) from None
735
+ except urllib.error.URLError:
736
+ # Our API host unreachable: the website's address for 10 minutes. A GET is asked again there at once;
737
+ # anything else isn't (it may have gone through), the next call goes there.
738
+ if not self._fallback or on_fallback:
739
+ raise
740
+ self._use_fallback("unreachable")
741
+ if method == "GET" and retries > 0:
742
+ return self.api(method, path, body, timeout, retries - 1)
743
+ raise
744
+
745
+ def _use_fallback(self, why):
746
+ self._fallback_until = time.time() + 600
747
+ if self.log:
748
+ print(f"[yapper] {self.base} {why}, using {self._fallback} for 10 minutes")
504
749
 
505
750
  # ---- messages and more ----
506
751
 
@@ -515,6 +760,19 @@ class Bot:
515
760
  body["components"] = components if isinstance(components, list) else [components]
516
761
  return Data(self.api("POST", f"/channels/{channel_id}/messages", body).get("message") or {})
517
762
 
763
+ def send_layout(self, channel_id, components, reply_to=None):
764
+ """Post a Components V2 message: the layout is the whole message (no content or embeds).
765
+ bot.send_layout(ch, [container(text("# Hi"), section("Ready?", accessory=btn("Go", "go")), accent="blue")])"""
766
+ body = {"flags": IS_COMPONENTS_V2, "components": components if isinstance(components, list) else [components]}
767
+ if reply_to:
768
+ body["reply_to"] = reply_to
769
+ return Data(self.api("POST", f"/channels/{channel_id}/messages", body).get("message") or {})
770
+
771
+ def edit_layout(self, channel_id, message_id, components):
772
+ """Swap the layout of one of the bot's Components V2 messages."""
773
+ body = {"flags": IS_COMPONENTS_V2, "components": components if isinstance(components, list) else [components]}
774
+ return Data(self.api("PATCH", f"/channels/{channel_id}/messages/{message_id}", body).get("message") or {})
775
+
518
776
  def edit(self, channel_id, message_id, content=None, embeds=None, components=None):
519
777
  body = {}
520
778
  if content is not None:
@@ -531,8 +789,8 @@ class Bot:
531
789
  def react(self, channel_id, message_id, emoji):
532
790
  return self.api("PUT", f"/channels/{channel_id}/messages/{message_id}/reactions/{urllib.parse.quote(emoji)}")
533
791
 
534
- def respond(self, interaction, kind, content=None, embeds=None, components=None):
535
- """Answer a button click: kind is reply, ephemeral, update or ack."""
792
+ def respond(self, interaction, kind, content=None, embeds=None, components=None, layout=None):
793
+ """Answer a button click: kind is reply, ephemeral, update or ack. layout=[...]: a Components V2 layout."""
536
794
  body = {"type": kind}
537
795
  if content is not None:
538
796
  body["content"] = str(content)
@@ -540,6 +798,9 @@ class Bot:
540
798
  body["embeds"] = embeds
541
799
  if components is not None:
542
800
  body["components"] = components
801
+ if layout is not None:
802
+ body["flags"] = IS_COMPONENTS_V2
803
+ body["components"] = layout if isinstance(layout, list) else [layout]
543
804
  return self.api("POST", f"/interactions/{interaction['id']}/{interaction['token']}/callback", body)
544
805
 
545
806
  def servers(self):
@@ -596,6 +857,53 @@ class Bot:
596
857
  """Time someone out for `minutes` (0 clears it)."""
597
858
  return self.api("POST", f"/servers/{server_id}/timeouts", {"user_id": user_id, "minutes": minutes, "reason": reason})
598
859
 
860
+ # ---- voice (Move Members for moves; 30 moves and 60 lookups a minute per bot) ----
861
+ def voice(self, server_id):
862
+ """Who is in voice: [{channel_id, user_ids}] for every voice channel the bot can see (empty ones too)."""
863
+ return self.api("GET", f"/servers/{server_id}/voice")["voice"]
864
+
865
+ def move_member(self, server_id, user_id, channel_id):
866
+ return self.api("POST", f"/servers/{server_id}/voice/move", {"user_id": user_id, "channel_id": channel_id})
867
+
868
+ def disconnect_member(self, server_id, user_id):
869
+ return self.api("POST", f"/servers/{server_id}/voice/disconnect", {"user_id": user_id})
870
+
871
+ # ---- the bot in voice: music bots (10 joins and leaves a minute; one voice channel per server) ----
872
+ def join_voice(self, server_id, channel_id):
873
+ """A ticket for a voice channel: {url, token, e2ee, voice_muted, channel_id, server_id}. Connect with any
874
+ LiveKit SDK within a minute. The bot sends one audio track (as a mic) and hears nobody. Joining another
875
+ channel of the same server drops it from the first. Most bots want connect_voice, which does all of it."""
876
+ return self.api("POST", f"/servers/{server_id}/voice/join", {"channel_id": channel_id})
877
+
878
+ def leave_voice(self, server_id):
879
+ """Take the bot out of voice in that server."""
880
+ return self.api("POST", f"/servers/{server_id}/voice/leave")
881
+
882
+ async def connect_voice(self, server_id, channel_id, sample_rate=48000, channels=2):
883
+ """Join a voice channel and get an audio source to play into. Needs `pip install livekit cryptography`
884
+ (only for this; the rest of this file stays standard library). Returns a VoiceCall: .room, .source (an
885
+ rtc.AudioSource: await source.capture_frame(rtc.AudioFrame(...))) and `await call.leave()`.
886
+ Calls on Yapin’ are end-to-end encrypted: this hands the bot's media key to everyone in the call the way the
887
+ app does (sealed to each person, the server never sees it) and encrypts the audio with it."""
888
+ import asyncio
889
+ from livekit import rtc # noqa: deferred so the library works without it
890
+ t = await asyncio.to_thread(self.join_voice, server_id, channel_id)
891
+ media_key = os.urandom(32) if t.get("e2ee") else None
892
+ room = rtc.Room()
893
+ opts = rtc.RoomOptions(auto_subscribe=False)
894
+ if media_key:
895
+ # Shared key mode encrypts our frames with media_key; HKDF (1) is how the app's browsers turn it into the frame key.
896
+ # `e2ee`, not `encryption` (which livekit marks as the new name): `encryption` also encrypts data messages,
897
+ # so the key handoff went out sealed under the key it carries and the app never got it (silent bot).
898
+ opts.e2ee = rtc.E2EEOptions(key_provider_options=rtc.KeyProviderOptions(
899
+ shared_key=media_key, ratchet_window_size=0, key_ring_size=16, key_derivation_function=1))
900
+ await room.connect(t["url"], t["token"], opts)
901
+ keys = await _hand_out_voice_key(room, media_key) if media_key else None
902
+ source = rtc.AudioSource(sample_rate, channels)
903
+ track = rtc.LocalAudioTrack.create_audio_track("audio", source)
904
+ await room.local_participant.publish_track(track, rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE, dtx=False))
905
+ return VoiceCall(room, source, track, bool(media_key), keys)
906
+
599
907
  def dm(self, user_id):
600
908
  """Open a DM with someone. Returns the channel id."""
601
909
  return self.api("POST", f"/users/{user_id}/dm")["channel_id"]
@@ -647,10 +955,20 @@ class Bot:
647
955
  return
648
956
  msg = Message.of(data, self)
649
957
  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()
958
+ # "/fish @OtherBot": a command picked for another bot (command_for) isn't ours to answer, even if we have /fish.
959
+ cf = data.get("command_for")
960
+ for_other = isinstance(cf, list) and (self.me or {}).get("id") not in cf
961
+ if not name and not for_other:
962
+ # A prefix can be longer than one character ("yap!"); the longest one that fits goes first,
963
+ # so "!!" wins over "!".
964
+ content = data.get("content") or ""
965
+ for p in sorted(self.prefixes, key=len, reverse=True):
966
+ if not p or not content.startswith(p):
967
+ continue
968
+ m = re.match(r"^([a-z0-9_-]{1,32})(?:\s+([\s\S]*))?$", content[len(p):], re.I)
969
+ if m and m.group(1).lower() in self.commands:
970
+ name, args = m.group(1).lower(), (m.group(2) or "").strip()
971
+ break
654
972
  c = self.commands.get(name) if name else None
655
973
  if c:
656
974
  msg.command, msg.args = name, args or ""
@@ -679,10 +997,13 @@ class Bot:
679
997
  self._fail(e, b["owner"], "button " + b["prefix"])
680
998
  self._emit("interaction", i)
681
999
  else:
1000
+ if kind == "test" and not self._listeners.get("test") and self.log:
1001
+ # "Send test event" in the developer settings: with no listener it used to vanish silently.
1002
+ print(f"[yapper] test event received{' (' + data['message'] + ')' if isinstance(data.get('message'), str) else ''}: the bot is connected and getting events")
682
1003
  self._emit(kind, Data(data, type=kind))
683
1004
 
684
1005
  def sync_commands(self):
685
- """Send the command list to Yapper (run() does it, and again after cogs change)."""
1006
+ """Send the command list to Yapin’ (run() does it, and again after cogs change)."""
686
1007
  self._dirty = False
687
1008
  cmds = [{"name": n, "description": c["description"]} for n, c in self.commands.items()]
688
1009
  self.api("PUT", "/commands", {"commands": cmds})
@@ -694,6 +1015,11 @@ class Bot:
694
1015
  self._run()
695
1016
  except KeyboardInterrupt:
696
1017
  print("[yapper] stopped")
1018
+ except YapperError as e:
1019
+ hint = {400: "the request was rejected", 401: "the token was rejected: make a new one in Settings -> Developer",
1020
+ 403: "this bot isn't allowed to do that"}.get(e.status, "")
1021
+ print(f"[yapper] could not start ({e.status}): {e}" + (f"\n[yapper] {hint}" if hint else ""))
1022
+ raise SystemExit(1) from None
697
1023
  finally:
698
1024
  self.running = False
699
1025
  self._registered = False
@@ -725,14 +1051,33 @@ class Bot:
725
1051
  print(f"[yapper] {self.me.username} is online" + (f" with {len(cmds)} command{'' if len(cmds) == 1 else 's'}" if cmds else ""))
726
1052
  self._emit("ready", self.me)
727
1053
  fails = 0
1054
+ gw_fails = 0
1055
+ gw_retry_at = 0.0
728
1056
  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)
1057
+ self._sync_if_dirty()
1058
+ # The gateway first; when it's off for this bot or keeps failing, the long poll below for a while.
1059
+ if self.gateway and time.time() >= gw_retry_at:
1060
+ r = self._gateway_session()
1061
+ if not self.running:
1062
+ break
1063
+ if r == "bad_token":
1064
+ raise SystemExit("Token rejected: make a new one in Settings -> Developer")
1065
+ if r == "disabled":
1066
+ gw_retry_at = time.time() + 10 * 60
1067
+ elif r == "ok":
1068
+ gw_fails = 0
1069
+ time.sleep(_backoff(1))
1070
+ continue
1071
+ else:
1072
+ gw_fails += 1
1073
+ if gw_fails >= 3:
1074
+ if self.log:
1075
+ print("[yapper] gateway unavailable, long polling for 5 minutes")
1076
+ gw_fails = 0
1077
+ gw_retry_at = time.time() + 5 * 60
1078
+ else:
1079
+ time.sleep(_backoff(gw_fails))
1080
+ continue
736
1081
  try:
737
1082
  out = self.api("GET", f"/events?after={self.last}&wait=25", retries=0)
738
1083
  fails = 0
@@ -752,9 +1097,94 @@ class Bot:
752
1097
 
753
1098
  start = run
754
1099
 
1100
+ def _sync_if_dirty(self):
1101
+ if not self._dirty: # a cog was loaded or unloaded: send the new list once
1102
+ return
1103
+ try:
1104
+ n = len(self.sync_commands())
1105
+ if self.log:
1106
+ print(f"[yapper] commands updated ({n})")
1107
+ except Exception as e:
1108
+ self._fail(e)
1109
+
1110
+ def _gateway_session(self):
1111
+ """One gateway connection (docs: /developers#gateway). Picks up after self.last, so nothing is missed between
1112
+ a long poll and the socket or across reconnects. Returns when the socket closes: "ok" (was up a while, connect
1113
+ again), "disabled" (not on for this bot: long poll), "bad_token" or "failed"."""
1114
+ try:
1115
+ ws = _WebSocket(self.gateway, timeout=15)
1116
+ except (OSError, ValueError, _GatewayClosed):
1117
+ return "failed"
1118
+ self._ws = ws
1119
+ ready_at = 0.0
1120
+ interval = 30.0
1121
+ next_beat = 0.0
1122
+ acked = True
1123
+ result = "failed"
1124
+ try:
1125
+ while self.running:
1126
+ if ready_at and time.time() >= next_beat:
1127
+ if not acked: # no answer to the last heartbeat: the connection is dead, start over
1128
+ result = "ok"
1129
+ break
1130
+ acked = False
1131
+ ws.send({"op": "heartbeat"})
1132
+ next_beat = time.time() + interval
1133
+ continue
1134
+ # A socket that never gets to ready doesn't hold the bot up: 15 s, then the next try (or the long poll).
1135
+ if not ws.readable(next_beat - time.time() if ready_at else 15.0):
1136
+ if not ready_at:
1137
+ break
1138
+ continue
1139
+ raw = ws.recv()
1140
+ if raw is None:
1141
+ continue
1142
+ try:
1143
+ msg = json.loads(raw)
1144
+ except ValueError:
1145
+ continue
1146
+ if not isinstance(msg, dict):
1147
+ continue
1148
+ op = msg.get("op")
1149
+ if op == "hello":
1150
+ ws.send({"op": "resume", "token": self.token, "seq": self.last})
1151
+ elif op == "ready":
1152
+ ready_at = time.time()
1153
+ interval = max(5.0, (msg.get("heartbeat_interval") or 30000) / 1000)
1154
+ next_beat = ready_at + interval
1155
+ if self.log:
1156
+ print("[yapper] connected to the gateway")
1157
+ elif op == "heartbeat_ack":
1158
+ acked = True
1159
+ elif op == "event" and isinstance(msg.get("d"), dict):
1160
+ ev = msg["d"]
1161
+ # Each event once, in order: anything at or below the cursor was already handled.
1162
+ if isinstance(ev.get("id"), int) and ev["id"] > self.last:
1163
+ self.last = ev["id"]
1164
+ try:
1165
+ self.handle(ev)
1166
+ except Exception as e:
1167
+ self._fail(e)
1168
+ self._sync_if_dirty()
1169
+ except _GatewayClosed as c:
1170
+ up = ready_at and time.time() - ready_at > 10
1171
+ result = "bad_token" if c.code == 4004 else "disabled" if c.code == 4003 else "ok" if up else "failed"
1172
+ except OSError:
1173
+ result = "ok" if ready_at and time.time() - ready_at > 10 else "failed"
1174
+ finally:
1175
+ ws.close(1000 if not self.running else 4000)
1176
+ self._ws = None
1177
+ return result
1178
+
755
1179
  def stop(self):
756
- """Stop after the current poll."""
1180
+ """Stop after the current poll (or close the gateway socket)."""
757
1181
  self.running = False
1182
+ ws = self._ws
1183
+ if ws is not None:
1184
+ try:
1185
+ ws.close()
1186
+ except Exception:
1187
+ pass
758
1188
 
759
1189
 
760
1190
  def _module_name(name):
@@ -772,3 +1202,79 @@ def _module_name(name):
772
1202
  mod = os.path.splitext(os.path.basename(path))[0]
773
1203
  return (f"{parent}.{mod}" if parent.isidentifier() else mod), path
774
1204
  return s, None
1205
+
1206
+
1207
+ class VoiceCall:
1208
+ """A bot in a voice channel (Bot.connect_voice): .room, .source, .track, .e2ee, and `await call.leave()`."""
1209
+
1210
+ def __init__(self, room, source, track, e2ee, keys):
1211
+ self.room, self.source, self.track, self.e2ee, self._keys = room, source, track, e2ee, keys
1212
+
1213
+ async def leave(self):
1214
+ if self._keys:
1215
+ self._keys()
1216
+ await self.room.disconnect()
1217
+
1218
+
1219
+ async def _hand_out_voice_key(room, media_key):
1220
+ """Encrypted calls: the bot's half of the app's key handoff. Our P-256 public key goes in the participant
1221
+ attribute `e2ee`; everyone who has one gets our media key over the data channel (topic yapper-e2ee), sealed with
1222
+ AES-GCM under the ECDH secret the two of us share and bound to "<from>|<to>|<index>". The bot hears nobody, so it
1223
+ never needs anyone's key back, only to answer when someone sends theirs. Returns a function that stops it."""
1224
+ import asyncio
1225
+ import base64
1226
+ from cryptography.hazmat.primitives.asymmetric import ec
1227
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
1228
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
1229
+
1230
+ me = room.local_participant.identity
1231
+ priv = ec.generate_private_key(ec.SECP256R1())
1232
+ my_pub = base64.b64encode(priv.public_key().public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)).decode()
1233
+ sent, shared, state = {}, {}, {"stopped": False}
1234
+
1235
+ async def send_to(p, pub=None):
1236
+ their = (p.attributes or {}).get("e2ee") or pub
1237
+ if state["stopped"] or not their or p.identity == me or sent.get(p.identity) == their:
1238
+ return
1239
+ sent[p.identity] = their
1240
+ try:
1241
+ if their not in shared:
1242
+ peer = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), base64.b64decode(their))
1243
+ shared[their] = AESGCM(priv.exchange(ec.ECDH(), peer))
1244
+ iv = os.urandom(12)
1245
+ ct = shared[their].encrypt(iv, media_key, f"{me}|{p.identity}|0".encode())
1246
+ msg = json.dumps({"i": 0, "iv": base64.b64encode(iv).decode(), "ct": base64.b64encode(ct).decode(), "k": my_pub})
1247
+ await room.local_participant.publish_data(msg.encode(), reliable=True, topic="yapper-e2ee", destination_identities=[p.identity])
1248
+ except Exception:
1249
+ sent.pop(p.identity, None) # muted by a moderator (no data permission) or a bad key: try again on the next change
1250
+
1251
+ def on_peer(p):
1252
+ asyncio.ensure_future(send_to(p))
1253
+
1254
+ def on_attrs(_changed, p):
1255
+ asyncio.ensure_future(send_to(p))
1256
+
1257
+ def on_data(packet):
1258
+ if packet.topic != "yapper-e2ee" or packet.participant is None:
1259
+ return
1260
+ try:
1261
+ k = json.loads(packet.data.decode()).get("k")
1262
+ except Exception:
1263
+ return
1264
+ if isinstance(k, str):
1265
+ sent.pop(packet.participant.identity, None)
1266
+ asyncio.ensure_future(send_to(packet.participant, k))
1267
+
1268
+ room.on("participant_connected", on_peer)
1269
+ room.on("participant_attributes_changed", on_attrs)
1270
+ room.on("data_received", on_data)
1271
+ await room.local_participant.set_attributes({"e2ee": my_pub})
1272
+ for p in list(room.remote_participants.values()):
1273
+ await send_to(p)
1274
+
1275
+ def stop():
1276
+ state["stopped"] = True
1277
+ room.off("participant_connected", on_peer)
1278
+ room.off("participant_attributes_changed", on_attrs)
1279
+ room.off("data_received", on_data)
1280
+ return stop
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yapper.py
3
- Version: 1.0.0
4
- Summary: An unofficial Python Library for Yapper
5
- Author-email: LeAndre C <leandre@lecanact.me>
3
+ Version: 1.2.0
4
+ Summary: A Python Library for Yapin'
5
+ Author-email: "LeAndre C." <leandre@lecanact.me>
6
6
  License-Expression: GPL-3.0-only
7
7
  Project-URL: Repository, https://github.com/our-websites/yapper.py
8
8
  Project-URL: Issues, https://github.com/our-websites/yapper.py/issues
9
- Project-URL: Documentation, https://yapper.website/developers
9
+ Project-URL: Documentation, https://yapin.gg/developers
10
+ Keywords: yapper.py,yapin.py,discord.py,yapper,yapin
10
11
  Classifier: Programming Language :: Python :: 3
11
12
  Classifier: Operating System :: OS Independent
12
13
  Requires-Python: >=3.10
@@ -9,4 +9,5 @@ src/yapper.py.egg-info/dependency_links.txt
9
9
  src/yapper.py.egg-info/requires.txt
10
10
  src/yapper.py.egg-info/top_level.txt
11
11
  tests/test_api_surface.py
12
+ tests/test_errors_and_test_event.py
12
13
  tests/test_metadata.py
@@ -1,6 +1,4 @@
1
- import importlib.util
2
- import pathlib
3
- import unittest
1
+ import importlib.util, pathlib, unittest
4
2
 
5
3
 
6
4
  ROOT = pathlib.Path(__file__).resolve().parents[1]
@@ -0,0 +1,46 @@
1
+ import contextlib, importlib.util, io, pathlib, unittest
2
+
3
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
4
+ spec = importlib.util.spec_from_file_location("yapper_main", ROOT / "src" / "yapper" / "main.py")
5
+ y = importlib.util.module_from_spec(spec)
6
+ spec.loader.exec_module(y)
7
+
8
+
9
+ def make_bot():
10
+ return y.Bot("yb_test")
11
+
12
+
13
+ class TestEventTests(unittest.TestCase):
14
+ def test_test_event_prints_without_listener(self):
15
+ bot = make_bot()
16
+ out = io.StringIO()
17
+ with contextlib.redirect_stdout(out):
18
+ bot.handle({"id": 1, "type": "test", "data": {}})
19
+ self.assertIn("test event received", out.getvalue())
20
+
21
+ def test_test_event_still_reaches_listener_and_stays_quiet(self):
22
+ bot = make_bot()
23
+ got = []
24
+ bot.on("test")(lambda e: got.append(e))
25
+ out = io.StringIO()
26
+ with contextlib.redirect_stdout(out):
27
+ bot.handle({"id": 1, "type": "test", "data": {}})
28
+ self.assertEqual(len(got), 1)
29
+ self.assertEqual(out.getvalue(), "")
30
+
31
+
32
+ class RunErrorTests(unittest.TestCase):
33
+ def test_bad_token_is_printed_not_raised_as_traceback(self):
34
+ bot = make_bot()
35
+ def boom(*a, **k):
36
+ raise y.YapperError("bad token", 401)
37
+ bot.api = boom
38
+ out = io.StringIO()
39
+ with contextlib.redirect_stdout(out), self.assertRaises(SystemExit):
40
+ bot.run()
41
+ self.assertIn("could not start (401)", out.getvalue())
42
+ self.assertIn("Settings -> Developer", out.getvalue())
43
+
44
+
45
+ if __name__ == "__main__":
46
+ unittest.main()
@@ -1,6 +1,4 @@
1
- import pathlib
2
- import tomllib
3
- import unittest
1
+ import pathlib, tomllib, unittest
4
2
 
5
3
 
6
4
  ROOT = pathlib.Path(__file__).resolve().parents[1]
@@ -1,13 +0,0 @@
1
- from .main import (
2
- Bot,
3
- Cog,
4
- YapperError,
5
- btn,
6
- button,
7
- command,
8
- link_button,
9
- listener,
10
- row,
11
- )
12
-
13
- __all__ = ["Bot", "Cog", "command", "listener", "button", "YapperError", "row", "btn", "link_button"]
File without changes
File without changes
File without changes