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.
@@ -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
+ ]
@@ -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)]
@@ -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