yapper.py 1.0.0__tar.gz → 2.0.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.
Potentially problematic release.
This version of yapper.py might be problematic. Click here for more details.
- {yapper_py-1.0.0/src/yapper.py.egg-info → yapper_py-2.0.0}/PKG-INFO +4 -4
- {yapper_py-1.0.0 → yapper_py-2.0.0}/pyproject.toml +4 -4
- {yapper_py-1.0.0 → yapper_py-2.0.0}/src/yapper/main.py +227 -35
- {yapper_py-1.0.0 → yapper_py-2.0.0/src/yapper.py.egg-info}/PKG-INFO +4 -4
- {yapper_py-1.0.0 → yapper_py-2.0.0}/tests/test_api_surface.py +1 -3
- {yapper_py-1.0.0 → yapper_py-2.0.0}/tests/test_metadata.py +1 -3
- {yapper_py-1.0.0 → yapper_py-2.0.0}/LICENSE +0 -0
- {yapper_py-1.0.0 → yapper_py-2.0.0}/README.md +0 -0
- {yapper_py-1.0.0 → yapper_py-2.0.0}/setup.cfg +0 -0
- {yapper_py-1.0.0 → yapper_py-2.0.0}/src/yapper/__init__.py +0 -0
- {yapper_py-1.0.0 → yapper_py-2.0.0}/src/yapper.py.egg-info/SOURCES.txt +0 -0
- {yapper_py-1.0.0 → yapper_py-2.0.0}/src/yapper.py.egg-info/dependency_links.txt +0 -0
- {yapper_py-1.0.0 → yapper_py-2.0.0}/src/yapper.py.egg-info/requires.txt +0 -0
- {yapper_py-1.0.0 → yapper_py-2.0.0}/src/yapper.py.egg-info/top_level.txt +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: yapper.py
|
|
3
|
-
Version:
|
|
4
|
-
Summary: An unofficial Python Library for
|
|
5
|
-
Author-email: LeAndre C <leandre@lecanact.me>
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: An unofficial 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
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: Operating System :: OS Independent
|
|
12
12
|
Requires-Python: >=3.10
|
|
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "yapper.py"
|
|
7
|
-
version = "
|
|
7
|
+
version = "2.0.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
|
|
11
|
+
description = "An unofficial Python Library for Yapin'"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.10"
|
|
14
14
|
dependencies = [
|
|
@@ -24,7 +24,7 @@ license-files = ["LICEN[CS]E*"]
|
|
|
24
24
|
[project.urls]
|
|
25
25
|
Repository = "https://github.com/our-websites/yapper.py"
|
|
26
26
|
Issues = "https://github.com/our-websites/yapper.py/issues"
|
|
27
|
-
Documentation = "https://
|
|
27
|
+
Documentation = "https://yapin.gg/developers"
|
|
28
28
|
|
|
29
29
|
[tool.setuptools.packages.find]
|
|
30
30
|
where = ["src"]
|
|
@@ -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,28 +19,17 @@
|
|
|
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.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 fetches its own events from Yapin’ (long polling). Docs: https://www.yapin.gg/developers
|
|
25
24
|
"""
|
|
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
|
|
25
|
+
import importlib, importlib.util, inspect, json, os, random, re, sys, time, traceback, urllib.error, urllib.parse, urllib.request
|
|
39
26
|
from dotenv import load_dotenv
|
|
40
27
|
|
|
41
28
|
load_dotenv()
|
|
42
29
|
|
|
43
|
-
|
|
30
|
+
__all__ = ["Bot", "VoiceCall", "Cog", "command", "listener", "button", "YapperError", "row", "btn", "link_button"]
|
|
31
|
+
|
|
32
|
+
BASE = "https://www.yapin.gg/api/bot/v1"
|
|
44
33
|
COMMAND_LIMIT = 100
|
|
45
34
|
EVENTS = ("message", "command", "member_join", "member_leave", "reaction_add", "reaction_remove",
|
|
46
35
|
"voice_join", "voice_leave", "interaction", "test", "ready", "error")
|
|
@@ -94,20 +83,20 @@ class Interaction(Data):
|
|
|
94
83
|
"""A button click: i.custom_id, i.user_id, i.username, and i.reply / i.ephemeral / i.update / i.ack.
|
|
95
84
|
Answer within 3 seconds or the person sees "This interaction failed"."""
|
|
96
85
|
|
|
97
|
-
def _answer(self, kind, content=None, embeds=None, components=None):
|
|
98
|
-
return self._bot.respond(self, kind, content=content, embeds=embeds, components=components)
|
|
86
|
+
def _answer(self, kind, content=None, embeds=None, components=None, layout=None):
|
|
87
|
+
return self._bot.respond(self, kind, content=content, embeds=embeds, components=components, layout=layout)
|
|
99
88
|
|
|
100
|
-
def reply(self, content="", embeds=None, components=None):
|
|
101
|
-
"""A new message in the channel."""
|
|
102
|
-
return self._answer("reply", content, embeds, components)
|
|
89
|
+
def reply(self, content="", embeds=None, components=None, layout=None):
|
|
90
|
+
"""A new message in the channel. layout=[...] sends a Components V2 message instead."""
|
|
91
|
+
return self._answer("reply", None if layout is not None else content, embeds, components, layout)
|
|
103
92
|
|
|
104
93
|
def ephemeral(self, content="", embeds=None):
|
|
105
94
|
"""A message only the person who clicked sees."""
|
|
106
95
|
return self._answer("ephemeral", content, embeds)
|
|
107
96
|
|
|
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)
|
|
97
|
+
def update(self, content=None, embeds=None, components=None, layout=None):
|
|
98
|
+
"""Change the message the button is on (left out = kept). On a Components V2 message: update(layout=[...])."""
|
|
99
|
+
return self._answer("update", content, embeds, components, layout)
|
|
111
100
|
|
|
112
101
|
def ack(self):
|
|
113
102
|
"""Just say "got it", so no error shows."""
|
|
@@ -136,6 +125,62 @@ def link_button(label, url):
|
|
|
136
125
|
return {"type": "button", "style": "link", "label": label, "url": url}
|
|
137
126
|
|
|
138
127
|
|
|
128
|
+
# ---- Components V2 (bot.send_layout): layout components, standard JSON shape and type numbers ----
|
|
129
|
+
|
|
130
|
+
IS_COMPONENTS_V2 = 1 << 15
|
|
131
|
+
_COLORS = {"red": 0xED4245, "green": 0x57F287, "blue": 0x5865F2, "yellow": 0xFEE75C, "purple": 0x9B59B6,
|
|
132
|
+
"orange": 0xE67E22, "grey": 0x95A5A6, "gray": 0x95A5A6}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _clean(d):
|
|
136
|
+
return {k: v for k, v in d.items() if v is not None and v is not False}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def text(content):
|
|
140
|
+
"""Markdown text (type 10). Mentions, links and emoji work like in messages; no HTML."""
|
|
141
|
+
return {"type": 10, "content": str(content)}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def thumbnail(url, description=None, spoiler=False):
|
|
145
|
+
"""A small picture (type 11), only as a section's accessory."""
|
|
146
|
+
return _clean({"type": 11, "media": {"url": url}, "description": description, "spoiler": spoiler})
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def section(*texts, accessory):
|
|
150
|
+
"""1 to 3 lines of text with a button or a thumbnail beside them (type 9): section("Ready?", accessory=btn("Go", "go"))."""
|
|
151
|
+
return {"type": 9, "components": [t if isinstance(t, dict) else text(t) for t in texts], "accessory": accessory}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def media(url, description=None, spoiler=False):
|
|
155
|
+
"""One picture or video for gallery()."""
|
|
156
|
+
return _clean({"media": {"url": url}, "description": description, "spoiler": spoiler})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def gallery(*items):
|
|
160
|
+
"""1 to 10 pictures or videos in a grid (type 12): gallery("https://…", media("https://…", spoiler=True))."""
|
|
161
|
+
return {"type": 12, "items": [i if isinstance(i, dict) else media(i) for i in items]}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def separator(divider=True, spacing="small"):
|
|
165
|
+
"""A line (or just space with divider=False), spacing "small" or "large" (type 14)."""
|
|
166
|
+
return {"type": 14, "divider": bool(divider), "spacing": 2 if spacing in ("large", 2) else 1}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def file_display(url, spoiler=False):
|
|
170
|
+
"""A file to download (type 13): an https link to a Yapin’ upload or an allowed host."""
|
|
171
|
+
return _clean({"type": 13, "file": {"url": url}, "spoiler": spoiler})
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def container(*components, accent=None, spoiler=False):
|
|
175
|
+
"""A card holding other components (type 17). accent: 0x5865F2, "#5865f2" or a name like "blue"."""
|
|
176
|
+
c = {"type": 17, "components": [x if isinstance(x, dict) else text(x) for x in components]}
|
|
177
|
+
if accent is not None:
|
|
178
|
+
c["accent_color"] = accent if isinstance(accent, int) else _COLORS.get(str(accent).lower()) or int(str(accent).lstrip("#"), 16)
|
|
179
|
+
if spoiler:
|
|
180
|
+
c["spoiler"] = True
|
|
181
|
+
return c
|
|
182
|
+
|
|
183
|
+
|
|
139
184
|
# ---- decorators for cogs (on a Cog's methods) ----
|
|
140
185
|
|
|
141
186
|
def command(name=None, description=""):
|
|
@@ -472,7 +517,7 @@ class Bot:
|
|
|
472
517
|
# ---- HTTP ----
|
|
473
518
|
|
|
474
519
|
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
|
|
520
|
+
"""Raw API call: bot.api("GET", "/servers"). A 429 with Retry-After, or Yapin’s own 503 "busy",
|
|
476
521
|
is waited out and retried (nothing was done, so that's safe). Other errors raise YapperError."""
|
|
477
522
|
data = None if body is None else json.dumps(body).encode()
|
|
478
523
|
req = urllib.request.Request(self.base + path, method=method, data=data, headers={
|
|
@@ -497,7 +542,7 @@ class Bot:
|
|
|
497
542
|
wait = 0
|
|
498
543
|
if 0 < wait <= 60 and retries > 0:
|
|
499
544
|
if self.log:
|
|
500
|
-
print(f"[yapper] {'
|
|
545
|
+
print(f"[yapper] {'busy' if busy else 'rate limited'}, retrying {method} {path} in {wait:g}s")
|
|
501
546
|
time.sleep(wait * (1 + random.random() * 0.5))
|
|
502
547
|
return self.api(method, path, body, timeout, retries - 1)
|
|
503
548
|
raise YapperError(err.get("error") or f"{method} {path} failed ({e.code})", e.code, wait or None) from None
|
|
@@ -515,6 +560,19 @@ class Bot:
|
|
|
515
560
|
body["components"] = components if isinstance(components, list) else [components]
|
|
516
561
|
return Data(self.api("POST", f"/channels/{channel_id}/messages", body).get("message") or {})
|
|
517
562
|
|
|
563
|
+
def send_layout(self, channel_id, components, reply_to=None):
|
|
564
|
+
"""Post a Components V2 message: the layout is the whole message (no content or embeds).
|
|
565
|
+
bot.send_layout(ch, [container(text("# Hi"), section("Ready?", accessory=btn("Go", "go")), accent="blue")])"""
|
|
566
|
+
body = {"flags": IS_COMPONENTS_V2, "components": components if isinstance(components, list) else [components]}
|
|
567
|
+
if reply_to:
|
|
568
|
+
body["reply_to"] = reply_to
|
|
569
|
+
return Data(self.api("POST", f"/channels/{channel_id}/messages", body).get("message") or {})
|
|
570
|
+
|
|
571
|
+
def edit_layout(self, channel_id, message_id, components):
|
|
572
|
+
"""Swap the layout of one of the bot's Components V2 messages."""
|
|
573
|
+
body = {"flags": IS_COMPONENTS_V2, "components": components if isinstance(components, list) else [components]}
|
|
574
|
+
return Data(self.api("PATCH", f"/channels/{channel_id}/messages/{message_id}", body).get("message") or {})
|
|
575
|
+
|
|
518
576
|
def edit(self, channel_id, message_id, content=None, embeds=None, components=None):
|
|
519
577
|
body = {}
|
|
520
578
|
if content is not None:
|
|
@@ -531,8 +589,8 @@ class Bot:
|
|
|
531
589
|
def react(self, channel_id, message_id, emoji):
|
|
532
590
|
return self.api("PUT", f"/channels/{channel_id}/messages/{message_id}/reactions/{urllib.parse.quote(emoji)}")
|
|
533
591
|
|
|
534
|
-
def respond(self, interaction, kind, content=None, embeds=None, components=None):
|
|
535
|
-
"""Answer a button click: kind is reply, ephemeral, update or ack."""
|
|
592
|
+
def respond(self, interaction, kind, content=None, embeds=None, components=None, layout=None):
|
|
593
|
+
"""Answer a button click: kind is reply, ephemeral, update or ack. layout=[...]: a Components V2 layout."""
|
|
536
594
|
body = {"type": kind}
|
|
537
595
|
if content is not None:
|
|
538
596
|
body["content"] = str(content)
|
|
@@ -540,6 +598,9 @@ class Bot:
|
|
|
540
598
|
body["embeds"] = embeds
|
|
541
599
|
if components is not None:
|
|
542
600
|
body["components"] = components
|
|
601
|
+
if layout is not None:
|
|
602
|
+
body["flags"] = IS_COMPONENTS_V2
|
|
603
|
+
body["components"] = layout if isinstance(layout, list) else [layout]
|
|
543
604
|
return self.api("POST", f"/interactions/{interaction['id']}/{interaction['token']}/callback", body)
|
|
544
605
|
|
|
545
606
|
def servers(self):
|
|
@@ -596,6 +657,51 @@ class Bot:
|
|
|
596
657
|
"""Time someone out for `minutes` (0 clears it)."""
|
|
597
658
|
return self.api("POST", f"/servers/{server_id}/timeouts", {"user_id": user_id, "minutes": minutes, "reason": reason})
|
|
598
659
|
|
|
660
|
+
# ---- voice (Move Members for moves; 30 moves and 60 lookups a minute per bot) ----
|
|
661
|
+
def voice(self, server_id):
|
|
662
|
+
"""Who is in voice: [{channel_id, user_ids}] for every voice channel the bot can see (empty ones too)."""
|
|
663
|
+
return self.api("GET", f"/servers/{server_id}/voice")["voice"]
|
|
664
|
+
|
|
665
|
+
def move_member(self, server_id, user_id, channel_id):
|
|
666
|
+
return self.api("POST", f"/servers/{server_id}/voice/move", {"user_id": user_id, "channel_id": channel_id})
|
|
667
|
+
|
|
668
|
+
def disconnect_member(self, server_id, user_id):
|
|
669
|
+
return self.api("POST", f"/servers/{server_id}/voice/disconnect", {"user_id": user_id})
|
|
670
|
+
|
|
671
|
+
# ---- the bot in voice: music bots (10 joins and leaves a minute; one voice channel per server) ----
|
|
672
|
+
def join_voice(self, server_id, channel_id):
|
|
673
|
+
"""A ticket for a voice channel: {url, token, e2ee, voice_muted, channel_id, server_id}. Connect with any
|
|
674
|
+
LiveKit SDK within a minute. The bot sends one audio track (as a mic) and hears nobody. Joining another
|
|
675
|
+
channel of the same server drops it from the first. Most bots want connect_voice, which does all of it."""
|
|
676
|
+
return self.api("POST", f"/servers/{server_id}/voice/join", {"channel_id": channel_id})
|
|
677
|
+
|
|
678
|
+
def leave_voice(self, server_id):
|
|
679
|
+
"""Take the bot out of voice in that server."""
|
|
680
|
+
return self.api("POST", f"/servers/{server_id}/voice/leave")
|
|
681
|
+
|
|
682
|
+
async def connect_voice(self, server_id, channel_id, sample_rate=48000, channels=2):
|
|
683
|
+
"""Join a voice channel and get an audio source to play into. Needs `pip install livekit cryptography`
|
|
684
|
+
(only for this; the rest of this file stays standard library). Returns a VoiceCall: .room, .source (an
|
|
685
|
+
rtc.AudioSource: await source.capture_frame(rtc.AudioFrame(...))) and `await call.leave()`.
|
|
686
|
+
Calls on Yapin’ are end-to-end encrypted: this hands the bot's media key to everyone in the call the way the
|
|
687
|
+
app does (sealed to each person, the server never sees it) and encrypts the audio with it."""
|
|
688
|
+
import asyncio
|
|
689
|
+
from livekit import rtc # noqa: deferred so the library works without it
|
|
690
|
+
t = await asyncio.to_thread(self.join_voice, server_id, channel_id)
|
|
691
|
+
media_key = os.urandom(32) if t.get("e2ee") else None
|
|
692
|
+
room = rtc.Room()
|
|
693
|
+
opts = rtc.RoomOptions(auto_subscribe=False)
|
|
694
|
+
if media_key:
|
|
695
|
+
# Shared key mode encrypts our frames with media_key; HKDF (1) is how the app's browsers turn it into the frame key.
|
|
696
|
+
opts.encryption = rtc.E2EEOptions(key_provider_options=rtc.KeyProviderOptions(
|
|
697
|
+
shared_key=media_key, ratchet_window_size=0, key_ring_size=16, key_derivation_function=1))
|
|
698
|
+
await room.connect(t["url"], t["token"], opts)
|
|
699
|
+
keys = await _hand_out_voice_key(room, media_key) if media_key else None
|
|
700
|
+
source = rtc.AudioSource(sample_rate, channels)
|
|
701
|
+
track = rtc.LocalAudioTrack.create_audio_track("audio", source)
|
|
702
|
+
await room.local_participant.publish_track(track, rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE, dtx=False))
|
|
703
|
+
return VoiceCall(room, source, track, bool(media_key), keys)
|
|
704
|
+
|
|
599
705
|
def dm(self, user_id):
|
|
600
706
|
"""Open a DM with someone. Returns the channel id."""
|
|
601
707
|
return self.api("POST", f"/users/{user_id}/dm")["channel_id"]
|
|
@@ -647,10 +753,20 @@ class Bot:
|
|
|
647
753
|
return
|
|
648
754
|
msg = Message.of(data, self)
|
|
649
755
|
name, args = data.get("command"), data.get("args")
|
|
650
|
-
if
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
756
|
+
# "/fish @OtherBot": a command picked for another bot (command_for) isn't ours to answer, even if we have /fish.
|
|
757
|
+
cf = data.get("command_for")
|
|
758
|
+
for_other = isinstance(cf, list) and (self.me or {}).get("id") not in cf
|
|
759
|
+
if not name and not for_other:
|
|
760
|
+
# A prefix can be longer than one character ("yap!"); the longest one that fits goes first,
|
|
761
|
+
# so "!!" wins over "!".
|
|
762
|
+
content = data.get("content") or ""
|
|
763
|
+
for p in sorted(self.prefixes, key=len, reverse=True):
|
|
764
|
+
if not p or not content.startswith(p):
|
|
765
|
+
continue
|
|
766
|
+
m = re.match(r"^([a-z0-9_-]{1,32})(?:\s+([\s\S]*))?$", content[len(p):], re.I)
|
|
767
|
+
if m and m.group(1).lower() in self.commands:
|
|
768
|
+
name, args = m.group(1).lower(), (m.group(2) or "").strip()
|
|
769
|
+
break
|
|
654
770
|
c = self.commands.get(name) if name else None
|
|
655
771
|
if c:
|
|
656
772
|
msg.command, msg.args = name, args or ""
|
|
@@ -682,7 +798,7 @@ class Bot:
|
|
|
682
798
|
self._emit(kind, Data(data, type=kind))
|
|
683
799
|
|
|
684
800
|
def sync_commands(self):
|
|
685
|
-
"""Send the command list to
|
|
801
|
+
"""Send the command list to Yapin’ (run() does it, and again after cogs change)."""
|
|
686
802
|
self._dirty = False
|
|
687
803
|
cmds = [{"name": n, "description": c["description"]} for n, c in self.commands.items()]
|
|
688
804
|
self.api("PUT", "/commands", {"commands": cmds})
|
|
@@ -772,3 +888,79 @@ def _module_name(name):
|
|
|
772
888
|
mod = os.path.splitext(os.path.basename(path))[0]
|
|
773
889
|
return (f"{parent}.{mod}" if parent.isidentifier() else mod), path
|
|
774
890
|
return s, None
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
class VoiceCall:
|
|
894
|
+
"""A bot in a voice channel (Bot.connect_voice): .room, .source, .track, .e2ee, and `await call.leave()`."""
|
|
895
|
+
|
|
896
|
+
def __init__(self, room, source, track, e2ee, keys):
|
|
897
|
+
self.room, self.source, self.track, self.e2ee, self._keys = room, source, track, e2ee, keys
|
|
898
|
+
|
|
899
|
+
async def leave(self):
|
|
900
|
+
if self._keys:
|
|
901
|
+
self._keys()
|
|
902
|
+
await self.room.disconnect()
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
async def _hand_out_voice_key(room, media_key):
|
|
906
|
+
"""Encrypted calls: the bot's half of the app's key handoff. Our P-256 public key goes in the participant
|
|
907
|
+
attribute `e2ee`; everyone who has one gets our media key over the data channel (topic yapper-e2ee), sealed with
|
|
908
|
+
AES-GCM under the ECDH secret the two of us share and bound to "<from>|<to>|<index>". The bot hears nobody, so it
|
|
909
|
+
never needs anyone's key back, only to answer when someone sends theirs. Returns a function that stops it."""
|
|
910
|
+
import asyncio
|
|
911
|
+
import base64
|
|
912
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
913
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
914
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
915
|
+
|
|
916
|
+
me = room.local_participant.identity
|
|
917
|
+
priv = ec.generate_private_key(ec.SECP256R1())
|
|
918
|
+
my_pub = base64.b64encode(priv.public_key().public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)).decode()
|
|
919
|
+
sent, shared, state = {}, {}, {"stopped": False}
|
|
920
|
+
|
|
921
|
+
async def send_to(p, pub=None):
|
|
922
|
+
their = (p.attributes or {}).get("e2ee") or pub
|
|
923
|
+
if state["stopped"] or not their or p.identity == me or sent.get(p.identity) == their:
|
|
924
|
+
return
|
|
925
|
+
sent[p.identity] = their
|
|
926
|
+
try:
|
|
927
|
+
if their not in shared:
|
|
928
|
+
peer = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), base64.b64decode(their))
|
|
929
|
+
shared[their] = AESGCM(priv.exchange(ec.ECDH(), peer))
|
|
930
|
+
iv = os.urandom(12)
|
|
931
|
+
ct = shared[their].encrypt(iv, media_key, f"{me}|{p.identity}|0".encode())
|
|
932
|
+
msg = json.dumps({"i": 0, "iv": base64.b64encode(iv).decode(), "ct": base64.b64encode(ct).decode(), "k": my_pub})
|
|
933
|
+
await room.local_participant.publish_data(msg.encode(), reliable=True, topic="yapper-e2ee", destination_identities=[p.identity])
|
|
934
|
+
except Exception:
|
|
935
|
+
sent.pop(p.identity, None) # muted by a moderator (no data permission) or a bad key: try again on the next change
|
|
936
|
+
|
|
937
|
+
def on_peer(p):
|
|
938
|
+
asyncio.ensure_future(send_to(p))
|
|
939
|
+
|
|
940
|
+
def on_attrs(_changed, p):
|
|
941
|
+
asyncio.ensure_future(send_to(p))
|
|
942
|
+
|
|
943
|
+
def on_data(packet):
|
|
944
|
+
if packet.topic != "yapper-e2ee" or packet.participant is None:
|
|
945
|
+
return
|
|
946
|
+
try:
|
|
947
|
+
k = json.loads(packet.data.decode()).get("k")
|
|
948
|
+
except Exception:
|
|
949
|
+
return
|
|
950
|
+
if isinstance(k, str):
|
|
951
|
+
sent.pop(packet.participant.identity, None)
|
|
952
|
+
asyncio.ensure_future(send_to(packet.participant, k))
|
|
953
|
+
|
|
954
|
+
room.on("participant_connected", on_peer)
|
|
955
|
+
room.on("participant_attributes_changed", on_attrs)
|
|
956
|
+
room.on("data_received", on_data)
|
|
957
|
+
await room.local_participant.set_attributes({"e2ee": my_pub})
|
|
958
|
+
for p in list(room.remote_participants.values()):
|
|
959
|
+
await send_to(p)
|
|
960
|
+
|
|
961
|
+
def stop():
|
|
962
|
+
state["stopped"] = True
|
|
963
|
+
room.off("participant_connected", on_peer)
|
|
964
|
+
room.off("participant_attributes_changed", on_attrs)
|
|
965
|
+
room.off("data_received", on_data)
|
|
966
|
+
return stop
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: yapper.py
|
|
3
|
-
Version:
|
|
4
|
-
Summary: An unofficial Python Library for
|
|
5
|
-
Author-email: LeAndre C <leandre@lecanact.me>
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: An unofficial 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
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: Operating System :: OS Independent
|
|
12
12
|
Requires-Python: >=3.10
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|