customrp-core 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- customrp_core/__init__.py +35 -0
- customrp_core/assets.py +64 -0
- customrp_core/errors.py +29 -0
- customrp_core/ipc.py +337 -0
- customrp_core/presence.py +423 -0
- customrp_core/preset.py +168 -0
- customrp_core/session.py +246 -0
- customrp_core/testing.py +259 -0
- customrp_core-0.1.2.dist-info/METADATA +141 -0
- customrp_core-0.1.2.dist-info/RECORD +13 -0
- customrp_core-0.1.2.dist-info/WHEEL +5 -0
- customrp_core-0.1.2.dist-info/licenses/LICENSE +22 -0
- customrp_core-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""customrp-core: the platform-independent parts of CustomRP, as a library.
|
|
2
|
+
|
|
3
|
+
Build a :class:`Presence`, then either turn it into a payload with :func:`build_activity` and send it
|
|
4
|
+
with :class:`DiscordIPC`, or let :class:`PresenceSession` handle connecting, retrying and resending.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.2"
|
|
8
|
+
|
|
9
|
+
from .errors import AssetsError, CustomRPError, PresenceError, PresetError # noqa: E402
|
|
10
|
+
from .ipc import DiscordIPC, display_name, ipc_paths, open_transport # noqa: E402
|
|
11
|
+
from .presence import ( # noqa: E402
|
|
12
|
+
DEFAULT_APPLICATION_ID,
|
|
13
|
+
MAX_LENGTH,
|
|
14
|
+
Activity,
|
|
15
|
+
ActivityType,
|
|
16
|
+
Button,
|
|
17
|
+
Issue,
|
|
18
|
+
Presence,
|
|
19
|
+
StatusDisplay,
|
|
20
|
+
TimestampMode,
|
|
21
|
+
build_activity,
|
|
22
|
+
check,
|
|
23
|
+
normalize,
|
|
24
|
+
validate,
|
|
25
|
+
)
|
|
26
|
+
from .preset import dump_preset, load_preset, parse_preset, save_preset # noqa: E402
|
|
27
|
+
from .session import PresenceSession, SessionState # noqa: E402
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Activity", "ActivityType", "AssetsError", "Button", "CustomRPError", "DEFAULT_APPLICATION_ID",
|
|
31
|
+
"DiscordIPC", "Issue", "MAX_LENGTH", "Presence", "PresenceError", "PresenceSession", "PresetError",
|
|
32
|
+
"SessionState", "StatusDisplay", "TimestampMode", "build_activity", "check", "display_name",
|
|
33
|
+
"dump_preset", "ipc_paths", "load_preset", "normalize", "open_transport", "parse_preset",
|
|
34
|
+
"save_preset", "validate",
|
|
35
|
+
]
|
customrp_core/assets.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Looking up the art assets uploaded to a Discord application.
|
|
2
|
+
|
|
3
|
+
CustomRP fills its image dropdowns from the same public endpoint; it needs no token.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .errors import AssetsError
|
|
15
|
+
|
|
16
|
+
ASSETS_URL = "https://discord.com/api/oauth2/applications/{}/assets"
|
|
17
|
+
ASSETS_PAGE_URL = "https://discord.com/developers/applications/{}/rich-presence/assets"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Asset:
|
|
22
|
+
id: str
|
|
23
|
+
name: str
|
|
24
|
+
#: Discord's asset type number, passed through as is (CustomRP doesn't use it)
|
|
25
|
+
type: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_id(application_id: str):
|
|
29
|
+
if not (application_id.isdigit() and application_id.isascii()):
|
|
30
|
+
raise AssetsError(f"{application_id!r} isn't a valid application ID")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def assets_page_url(application_id: str) -> str:
|
|
34
|
+
"""The Developer Portal page where the application's assets are managed."""
|
|
35
|
+
_check_id(application_id)
|
|
36
|
+
return ASSETS_PAGE_URL.format(application_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fetch_assets(application_id: str, *, timeout: float = 10.0) -> list[Asset]:
|
|
40
|
+
"""Fetches the list of assets uploaded to an application. Raises :class:`AssetsError` on failure."""
|
|
41
|
+
_check_id(application_id)
|
|
42
|
+
request = urllib.request.Request(ASSETS_URL.format(application_id),
|
|
43
|
+
headers={"User-Agent": f"customrp-core/{__version__}"})
|
|
44
|
+
try:
|
|
45
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
46
|
+
data = json.load(response)
|
|
47
|
+
except urllib.error.HTTPError as e:
|
|
48
|
+
if e.code == 404:
|
|
49
|
+
raise AssetsError(f"There's no application with the ID {application_id}") from e
|
|
50
|
+
raise AssetsError(f"Discord answered with HTTP {e.code} {e.reason}") from e
|
|
51
|
+
except (urllib.error.URLError, OSError) as e:
|
|
52
|
+
reason = getattr(e, "reason", e)
|
|
53
|
+
raise AssetsError(f"Couldn't reach Discord: {reason}") from e
|
|
54
|
+
except ValueError as e:
|
|
55
|
+
raise AssetsError("Discord sent something that isn't JSON") from e
|
|
56
|
+
|
|
57
|
+
return parse_assets(data)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_assets(data) -> list[Asset]:
|
|
61
|
+
if not isinstance(data, list):
|
|
62
|
+
raise AssetsError("Unexpected answer from Discord")
|
|
63
|
+
return [Asset(str(item.get("id", "")), str(item.get("name", "")), int(item.get("type", 0) or 0))
|
|
64
|
+
for item in data if isinstance(item, dict)]
|
customrp_core/errors.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Exceptions raised by customrp-core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CustomRPError(Exception):
|
|
7
|
+
"""Base class for every error this library raises on purpose."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PresenceError(CustomRPError, ValueError):
|
|
11
|
+
"""A presence can't be sent as it is.
|
|
12
|
+
|
|
13
|
+
``field`` names the :class:`~customrp_core.presence.Presence` attribute at fault
|
|
14
|
+
(for example ``"large_image"`` or ``"button1.url"``), or is ``None`` when the problem
|
|
15
|
+
isn't tied to one field.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, field: str | None = None):
|
|
19
|
+
super().__init__(f"{field}: {message}" if field else message)
|
|
20
|
+
self.message = message
|
|
21
|
+
self.field = field
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PresetError(CustomRPError):
|
|
25
|
+
"""A ``.crp`` preset file can't be read or written."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AssetsError(CustomRPError):
|
|
29
|
+
"""The list of an application's art assets couldn't be fetched."""
|
customrp_core/ipc.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""A minimal Discord RPC client speaking the local IPC protocol.
|
|
2
|
+
|
|
3
|
+
Discord listens on a Unix domain socket (Linux, macOS) or a named pipe (Windows) called
|
|
4
|
+
``discord-ipc-N`` with N from 0 to 9. Every message is a frame: a little-endian int32 opcode,
|
|
5
|
+
a little-endian int32 payload length, then a UTF-8 JSON payload.
|
|
6
|
+
|
|
7
|
+
All socket I/O happens on one worker thread. That matters on Windows, where a blocking read
|
|
8
|
+
on a synchronous pipe handle would also block writes to it from another thread.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import queue
|
|
17
|
+
import select
|
|
18
|
+
import socket
|
|
19
|
+
import struct
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
|
|
25
|
+
OP_HANDSHAKE = 0
|
|
26
|
+
OP_FRAME = 1
|
|
27
|
+
OP_CLOSE = 2
|
|
28
|
+
OP_PING = 3
|
|
29
|
+
OP_PONG = 4
|
|
30
|
+
|
|
31
|
+
HEADER = struct.Struct("<ii")
|
|
32
|
+
MAX_PAYLOAD = 64 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger("customrp_core.ipc")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- Transports ----------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
class SocketTransport:
|
|
40
|
+
"""A connected stream socket (a Unix socket in practice, any socket in tests)."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, sock: socket.socket):
|
|
43
|
+
self.sock = sock
|
|
44
|
+
|
|
45
|
+
def send(self, data: bytes):
|
|
46
|
+
self.sock.sendall(data)
|
|
47
|
+
|
|
48
|
+
def poll(self, timeout: float) -> bool:
|
|
49
|
+
readable, _, _ = select.select([self.sock], [], [], timeout)
|
|
50
|
+
return bool(readable)
|
|
51
|
+
|
|
52
|
+
def recv(self, size: int) -> bytes:
|
|
53
|
+
chunks = []
|
|
54
|
+
while size > 0:
|
|
55
|
+
chunk = self.sock.recv(size)
|
|
56
|
+
if not chunk:
|
|
57
|
+
raise ConnectionError("Discord closed the connection")
|
|
58
|
+
chunks.append(chunk)
|
|
59
|
+
size -= len(chunk)
|
|
60
|
+
return b"".join(chunks)
|
|
61
|
+
|
|
62
|
+
def close(self):
|
|
63
|
+
try:
|
|
64
|
+
self.sock.close()
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PipeTransport:
|
|
70
|
+
"""A Windows named pipe opened in synchronous mode; polling is done with PeekNamedPipe."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, path: str):
|
|
73
|
+
import ctypes
|
|
74
|
+
import msvcrt
|
|
75
|
+
from ctypes import wintypes
|
|
76
|
+
|
|
77
|
+
self.file = open(path, "r+b", buffering=0)
|
|
78
|
+
self.handle = msvcrt.get_osfhandle(self.file.fileno())
|
|
79
|
+
self._ctypes = ctypes
|
|
80
|
+
self._peek = ctypes.windll.kernel32.PeekNamedPipe
|
|
81
|
+
self._peek.argtypes = [wintypes.HANDLE, wintypes.LPVOID, wintypes.DWORD,
|
|
82
|
+
wintypes.LPVOID, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
|
|
83
|
+
self._peek.restype = wintypes.BOOL
|
|
84
|
+
self._available = wintypes.DWORD()
|
|
85
|
+
|
|
86
|
+
def send(self, data: bytes):
|
|
87
|
+
self.file.write(data)
|
|
88
|
+
|
|
89
|
+
def poll(self, timeout: float) -> bool:
|
|
90
|
+
deadline = time.monotonic() + timeout
|
|
91
|
+
while True:
|
|
92
|
+
if not self._peek(self.handle, None, 0, None, self._ctypes.byref(self._available), None):
|
|
93
|
+
raise ConnectionError("Discord closed the connection")
|
|
94
|
+
if self._available.value:
|
|
95
|
+
return True
|
|
96
|
+
if time.monotonic() >= deadline:
|
|
97
|
+
return False
|
|
98
|
+
time.sleep(0.02)
|
|
99
|
+
|
|
100
|
+
def recv(self, size: int) -> bytes:
|
|
101
|
+
chunks = []
|
|
102
|
+
while size > 0:
|
|
103
|
+
chunk = self.file.read(size)
|
|
104
|
+
if not chunk:
|
|
105
|
+
raise ConnectionError("Discord closed the connection")
|
|
106
|
+
chunks.append(chunk)
|
|
107
|
+
size -= len(chunk)
|
|
108
|
+
return b"".join(chunks)
|
|
109
|
+
|
|
110
|
+
def close(self):
|
|
111
|
+
try:
|
|
112
|
+
self.file.close()
|
|
113
|
+
except OSError:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _unix_candidate_dirs():
|
|
118
|
+
bases = []
|
|
119
|
+
for var in ("XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"):
|
|
120
|
+
value = os.environ.get(var)
|
|
121
|
+
if value and value not in bases:
|
|
122
|
+
bases.append(value)
|
|
123
|
+
if sys.platform == "darwin":
|
|
124
|
+
# Discord uses the per-user temporary folder, which is $TMPDIR in a login session.
|
|
125
|
+
# Programs started by launchd may not have TMPDIR set, so also ask the system.
|
|
126
|
+
try:
|
|
127
|
+
user_tmp = os.confstr("CS_DARWIN_USER_TEMP_DIR")
|
|
128
|
+
except (ValueError, OSError, AttributeError):
|
|
129
|
+
user_tmp = None
|
|
130
|
+
if user_tmp and user_tmp.rstrip("/") not in (b.rstrip("/") for b in bases):
|
|
131
|
+
bases.append(user_tmp)
|
|
132
|
+
if "/tmp" not in bases:
|
|
133
|
+
bases.append("/tmp")
|
|
134
|
+
|
|
135
|
+
# Sandboxed clients put the socket in a subdirectory
|
|
136
|
+
subdirs = ["", "app/com.discordapp.Discord", "app/com.discordapp.DiscordCanary",
|
|
137
|
+
"app/com.discordapp.DiscordPTB", "snap.discord", "snap.discord-canary",
|
|
138
|
+
".flatpak/dev.vencord.Vesktop/xdg-run", "app/dev.vencord.Vesktop"]
|
|
139
|
+
|
|
140
|
+
for base in bases:
|
|
141
|
+
for sub in subdirs:
|
|
142
|
+
yield os.path.join(base, sub) if sub else base
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def ipc_paths(pipe: int):
|
|
146
|
+
"""Possible locations of Discord's IPC endpoint. ``pipe`` of -1 means "try 0 to 9"."""
|
|
147
|
+
numbers = range(10) if pipe < 0 else [pipe]
|
|
148
|
+
|
|
149
|
+
if sys.platform == "win32":
|
|
150
|
+
for n in numbers:
|
|
151
|
+
yield rf"\\.\pipe\discord-ipc-{n}"
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
for n in numbers:
|
|
155
|
+
for directory in _unix_candidate_dirs():
|
|
156
|
+
yield os.path.join(directory, f"discord-ipc-{n}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def connect_path(path: str):
|
|
160
|
+
"""Opens one IPC endpoint: a named pipe on Windows, a Unix socket elsewhere."""
|
|
161
|
+
if sys.platform == "win32":
|
|
162
|
+
return PipeTransport(path)
|
|
163
|
+
|
|
164
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
165
|
+
try:
|
|
166
|
+
sock.connect(path)
|
|
167
|
+
except OSError:
|
|
168
|
+
sock.close()
|
|
169
|
+
raise
|
|
170
|
+
return SocketTransport(sock)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def open_transport(pipe: int = -1):
|
|
174
|
+
"""Connects to the first Discord IPC endpoint that accepts a connection.
|
|
175
|
+
|
|
176
|
+
Raises :class:`ConnectionError` when none does, which usually means Discord isn't running.
|
|
177
|
+
"""
|
|
178
|
+
errors = []
|
|
179
|
+
|
|
180
|
+
for path in ipc_paths(pipe):
|
|
181
|
+
if sys.platform != "win32" and not os.path.exists(path):
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
transport = connect_path(path)
|
|
185
|
+
except OSError as e:
|
|
186
|
+
errors.append(f"{path}: {e}")
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
log.info("Connected to %s", path)
|
|
190
|
+
return transport
|
|
191
|
+
|
|
192
|
+
raise ConnectionError("Couldn't find a running Discord client" + (" (" + "; ".join(errors[-3:]) + ")" if errors else ""))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# --- Client --------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
class DiscordIPC:
|
|
198
|
+
"""A Rich Presence connection to the Discord client.
|
|
199
|
+
|
|
200
|
+
The connection runs on a background thread, and all the ``on_*`` callbacks are invoked
|
|
201
|
+
from that thread:
|
|
202
|
+
|
|
203
|
+
* ``on_ready(user)`` - handshake succeeded; ``user`` is Discord's user object
|
|
204
|
+
* ``on_presence_update(activity)`` - Discord accepted a SET_ACTIVITY call
|
|
205
|
+
* ``on_error(code, message)`` - Discord rejected something or closed the connection on purpose
|
|
206
|
+
* ``on_connection_failed(message)`` - couldn't connect, or the connection dropped
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(self, client_id: str, pipe: int = -1, *, on_ready=None, on_presence_update=None,
|
|
210
|
+
on_error=None, on_connection_failed=None, transport_factory=open_transport):
|
|
211
|
+
self.client_id = client_id
|
|
212
|
+
self.pipe = pipe
|
|
213
|
+
self.user = None
|
|
214
|
+
|
|
215
|
+
self.on_ready = on_ready or (lambda user: None)
|
|
216
|
+
self.on_presence_update = on_presence_update or (lambda activity: None)
|
|
217
|
+
self.on_error = on_error or (lambda code, message: None)
|
|
218
|
+
self.on_connection_failed = on_connection_failed or (lambda message: None)
|
|
219
|
+
|
|
220
|
+
self._transport_factory = transport_factory
|
|
221
|
+
self._outgoing = queue.Queue()
|
|
222
|
+
self._stop = threading.Event()
|
|
223
|
+
self._thread = None
|
|
224
|
+
|
|
225
|
+
# Public API
|
|
226
|
+
|
|
227
|
+
def start(self):
|
|
228
|
+
self._thread = threading.Thread(target=self._run, name="discord-ipc", daemon=True)
|
|
229
|
+
self._thread.start()
|
|
230
|
+
|
|
231
|
+
def set_activity(self, activity):
|
|
232
|
+
"""Queues a presence update; it's sent as soon as the handshake is done."""
|
|
233
|
+
self._outgoing.put({
|
|
234
|
+
"cmd": "SET_ACTIVITY",
|
|
235
|
+
"args": {"pid": os.getpid(), "activity": activity},
|
|
236
|
+
"nonce": str(uuid.uuid4()),
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
def close(self):
|
|
240
|
+
"""Stops the connection. Callbacks won't fire after this returns."""
|
|
241
|
+
self._stop.set()
|
|
242
|
+
if self._thread and self._thread is not threading.current_thread():
|
|
243
|
+
self._thread.join(timeout=2)
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def closed(self) -> bool:
|
|
247
|
+
return self._stop.is_set()
|
|
248
|
+
|
|
249
|
+
# Worker thread
|
|
250
|
+
|
|
251
|
+
def _run(self):
|
|
252
|
+
transport = None
|
|
253
|
+
try:
|
|
254
|
+
transport = self._transport_factory(self.pipe)
|
|
255
|
+
self._send(transport, OP_HANDSHAKE, {"v": 1, "client_id": self.client_id})
|
|
256
|
+
|
|
257
|
+
while not self._stop.is_set():
|
|
258
|
+
if self.user is not None:
|
|
259
|
+
self._flush(transport)
|
|
260
|
+
|
|
261
|
+
if transport.poll(0.1):
|
|
262
|
+
if not self._handle(transport, *self._read(transport)):
|
|
263
|
+
break
|
|
264
|
+
except (OSError, ConnectionError, ValueError) as e:
|
|
265
|
+
if not self._stop.is_set():
|
|
266
|
+
log.debug("Connection failed: %s", e)
|
|
267
|
+
self.on_connection_failed(str(e))
|
|
268
|
+
finally:
|
|
269
|
+
if transport is not None:
|
|
270
|
+
transport.close()
|
|
271
|
+
log.debug("Connection closed")
|
|
272
|
+
|
|
273
|
+
def _flush(self, transport):
|
|
274
|
+
while True:
|
|
275
|
+
try:
|
|
276
|
+
payload = self._outgoing.get_nowait()
|
|
277
|
+
except queue.Empty:
|
|
278
|
+
return
|
|
279
|
+
self._send(transport, OP_FRAME, payload)
|
|
280
|
+
|
|
281
|
+
def _send(self, transport, op, payload):
|
|
282
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
283
|
+
log.debug("-> op %d %s", op, data.decode("utf-8"))
|
|
284
|
+
transport.send(HEADER.pack(op, len(data)) + data)
|
|
285
|
+
|
|
286
|
+
def _read(self, transport):
|
|
287
|
+
op, length = HEADER.unpack(transport.recv(HEADER.size))
|
|
288
|
+
if not 0 <= length <= MAX_PAYLOAD:
|
|
289
|
+
raise ValueError(f"Bad frame length {length}")
|
|
290
|
+
data = transport.recv(length) if length else b"{}"
|
|
291
|
+
log.debug("<- op %d %s", op, data.decode("utf-8", "replace"))
|
|
292
|
+
return op, json.loads(data)
|
|
293
|
+
|
|
294
|
+
def _handle(self, transport, op, payload) -> bool:
|
|
295
|
+
"""Processes a frame; returns False if the connection should end."""
|
|
296
|
+
if self._stop.is_set():
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
if op == OP_PING:
|
|
300
|
+
self._send(transport, OP_PONG, payload)
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
if op == OP_CLOSE:
|
|
304
|
+
code = payload.get("code", 0)
|
|
305
|
+
message = payload.get("message", "Connection closed by Discord")
|
|
306
|
+
log.debug("Closed by Discord: %s %s", code, message)
|
|
307
|
+
self.on_error(code, message)
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
if op != OP_FRAME:
|
|
311
|
+
return True
|
|
312
|
+
|
|
313
|
+
cmd = payload.get("cmd")
|
|
314
|
+
evt = payload.get("evt")
|
|
315
|
+
data = payload.get("data") or {}
|
|
316
|
+
|
|
317
|
+
if evt == "ERROR":
|
|
318
|
+
code = data.get("code", 0)
|
|
319
|
+
message = data.get("message", "Unknown error")
|
|
320
|
+
log.debug("Error %s: %s", code, message)
|
|
321
|
+
self.on_error(code, message)
|
|
322
|
+
elif cmd == "DISPATCH" and evt == "READY":
|
|
323
|
+
self.user = data.get("user") or {}
|
|
324
|
+
self.on_ready(self.user)
|
|
325
|
+
elif cmd == "SET_ACTIVITY":
|
|
326
|
+
self.on_presence_update(data)
|
|
327
|
+
|
|
328
|
+
return True
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def display_name(user: dict) -> str:
|
|
332
|
+
"""A user's name as Discord shows it: ``name``, or ``name#1234`` for legacy accounts."""
|
|
333
|
+
name = user.get("username", "")
|
|
334
|
+
discriminator = user.get("discriminator")
|
|
335
|
+
if discriminator and discriminator != "0":
|
|
336
|
+
return f"{name}#{discriminator}"
|
|
337
|
+
return name
|