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.
- {yapper_py-1.0.0/src/yapper.py.egg-info → yapper_py-1.2.0}/PKG-INFO +5 -4
- {yapper_py-1.0.0 → yapper_py-1.2.0}/pyproject.toml +11 -4
- yapper_py-1.2.0/src/yapper/__init__.py +16 -0
- {yapper_py-1.0.0 → yapper_py-1.2.0}/src/yapper/main.py +542 -36
- {yapper_py-1.0.0 → yapper_py-1.2.0/src/yapper.py.egg-info}/PKG-INFO +5 -4
- {yapper_py-1.0.0 → yapper_py-1.2.0}/src/yapper.py.egg-info/SOURCES.txt +1 -0
- {yapper_py-1.0.0 → yapper_py-1.2.0}/tests/test_api_surface.py +1 -3
- yapper_py-1.2.0/tests/test_errors_and_test_event.py +46 -0
- {yapper_py-1.0.0 → yapper_py-1.2.0}/tests/test_metadata.py +1 -3
- yapper_py-1.0.0/src/yapper/__init__.py +0 -13
- {yapper_py-1.0.0 → yapper_py-1.2.0}/LICENSE +0 -0
- {yapper_py-1.0.0 → yapper_py-1.2.0}/README.md +0 -0
- {yapper_py-1.0.0 → yapper_py-1.2.0}/setup.cfg +0 -0
- {yapper_py-1.0.0 → yapper_py-1.2.0}/src/yapper.py.egg-info/dependency_links.txt +0 -0
- {yapper_py-1.0.0 → yapper_py-1.2.0}/src/yapper.py.egg-info/requires.txt +0 -0
- {yapper_py-1.0.0 → yapper_py-1.2.0}/src/yapper.py.egg-info/top_level.txt +0 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: yapper.py
|
|
3
|
-
Version: 1.
|
|
4
|
-
Summary:
|
|
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://
|
|
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.
|
|
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 = "
|
|
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://
|
|
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
|
-
"""
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
(long polling). Docs: https://www.
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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] {'
|
|
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
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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.
|
|
4
|
-
Summary:
|
|
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://
|
|
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
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|