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.

@@ -1,12 +1,12 @@
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: 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://yapper.website/developers
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 = "1.0.0"
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 Yapper"
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://yapper.website/developers"
27
+ Documentation = "https://yapin.gg/developers"
28
28
 
29
29
  [tool.setuptools.packages.find]
30
30
  where = ["src"]
@@ -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,28 +19,17 @@
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 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
- BASE = "https://www.yapper.website/api/bot/v1"
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 Yapper's own 503 "busy",
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] {'Yapper busy' if busy else 'rate limited'}, retrying {method} {path} in {wait:g}s")
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 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()
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 Yapper (run() does it, and again after cogs change)."""
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: 1.0.0
4
- Summary: An unofficial Python Library for Yapper
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://yapper.website/developers
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
@@ -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]
@@ -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]
File without changes
File without changes
File without changes