From f08b7e2510cb1ac92b40d56336e73e6de915896b Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 11:43:47 +0700 Subject: [PATCH 01/24] fix(presence): correct IPC method annotations --- discordrpc/presence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discordrpc/presence.py b/discordrpc/presence.py index 4b6cba3..750ce79 100644 --- a/discordrpc/presence.py +++ b/discordrpc/presence.py @@ -311,7 +311,7 @@ def _connect_pipe(self): """Override in subclass to establish the pipe connection. Returns True on success.""" raise NotImplementedError - def _send(self, payload, op=OP_FRAME: int): + def _send(self, payload, op: int = OP_FRAME): log.debug(payload) payload = json.dumps(payload).encode('UTF-8') @@ -319,7 +319,7 @@ def _send(self, payload, op=OP_FRAME: int): self._write(payload) - def _request(self, payload: dict, op=OP_FRAME: int) -> dict: + def _request(self, payload: dict, op: int = OP_FRAME) -> dict: self._send(payload, op) res = self._recv() if res.get("evt") == "ERROR": From 69f651943f9ad00d81a03c3bce7de56915e224b0 Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 11:44:19 +0700 Subject: [PATCH 02/24] refactor(presence): make Windows imports conditional --- discordrpc/presence.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/discordrpc/presence.py b/discordrpc/presence.py index 750ce79..0278437 100644 --- a/discordrpc/presence.py +++ b/discordrpc/presence.py @@ -4,6 +4,7 @@ import json import struct import uuid +import threading from typing import Optional from .exceptions import ( RPCException, InvalidID, DiscordNotOpened, @@ -16,9 +17,10 @@ import logging import time -import msvcrt -import win32pipe -import threading +if sys.platform == "win32": + import msvcrt + import win32file + import win32pipe OP_HANDSHAKE = 0 OP_FRAME = 1 From 6cf4fbda751de1e22a8756ea82c4dfd91957953d Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 11:45:09 +0700 Subject: [PATCH 03/24] ci: add Python compile and import checks --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..715e96d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + checks: + name: Compile and import checks + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.8", "3.9", "3.10", "3.11"] + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Compile package + run: python -m compileall discordrpc + - name: Import package + run: python -c "import discordrpc" From 33b7abc98c1cfc03db0f25a8912bf1b2372747a2 Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 11:45:45 +0700 Subject: [PATCH 04/24] test: add minimal test runner --- tests/run_tests.py | 12 ++++++++++++ tests/test_smoke.py | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 tests/run_tests.py create mode 100644 tests/test_smoke.py diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..4c38f00 --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,12 @@ +import unittest +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +loader = unittest.TestLoader() +suite = loader.discover(os.path.dirname(__file__), pattern="test_*.py") +result = unittest.TextTestRunner(verbosity=2).run(suite) + +if not result.wasSuccessful(): + sys.exit(1) diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..710f53a --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,16 @@ +import unittest + +import discordrpc + + +class ImportSmokeTests(unittest.TestCase): + def test_package_imports(self): + self.assertTrue(hasattr(discordrpc, "RPC")) + + def test_public_exports_present(self): + for name in ("RPC", "button", "Activity", "StatusDisplay", "User", "Event"): + self.assertTrue(hasattr(discordrpc, name), f"missing public export {name}") + + +if __name__ == "__main__": + unittest.main(verbosity=2) From c2b280eee2f36edaab918d975f4c343b7c4af4f7 Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 11:49:45 +0700 Subject: [PATCH 05/24] fix(ipc): centralize exact frame reads and preserve opcodes --- discordrpc/presence.py | 95 +++++++++++++++++++--------------------- tests/test_ipc_frame.py | 96 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 51 deletions(-) create mode 100644 tests/test_ipc_frame.py diff --git a/discordrpc/presence.py b/discordrpc/presence.py index 0278437..4cd9642 100644 --- a/discordrpc/presence.py +++ b/discordrpc/presence.py @@ -323,19 +323,54 @@ def _send(self, payload, op: int = OP_FRAME): def _request(self, payload: dict, op: int = OP_FRAME) -> dict: self._send(payload, op) - res = self._recv() + opcode, res = self._read_frame() + if opcode != OP_FRAME: + return {"ok": False, "error": f"Unexpected opcode {opcode}", "response": res} + if not isinstance(res, dict): + return {"ok": False, "error": "Expected JSON object response", "response": res} if res.get("evt") == "ERROR": - return {"ok": False, "error": res.get("data", {}).get("message"), **res} + data = res.get("data") or {} + return { + "ok": False, + "code": data.get("code"), + "error": data.get("message") or res.get("message"), + "response": res, + } return {"ok": True, **res} def _write(self, data: bytes): """Override in subclass to write bytes to the pipe.""" raise NotImplementedError - def _recv(self): - """Override in subclass to receive data from the pipe.""" + def _read_some(self, size: int) -> bytes: + """Override in subclass to read up to size bytes from the pipe.""" raise NotImplementedError + def _read_exact(self, size: int) -> bytes: + chunks = [] + remaining = size + while remaining: + chunk = self._read_some(remaining) + if not chunk: + raise OSError("Connection closed while reading IPC frame") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + def _read_frame(self): + header = self._read_exact(8) + opcode, length = struct.unpack(" 16 * 1024 * 1024: + raise ValueError(f"Invalid IPC frame length: {length}") + if length == 0: + return opcode, None + payload_bytes = self._read_exact(length) + try: + payload = json.loads(payload_bytes.decode("UTF-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + raise ValueError(f"Invalid IPC frame payload: {e}") + return opcode, payload + def handshake(self): data = self._request({'v': 1, 'client_id': self.app_id}, op=OP_HANDSHAKE) @@ -407,26 +442,8 @@ def _write(self, data: bytes): def _close(self): self.socket.close() - def _recv(self): - enc_header = b'' - header_size = 8 - - while header_size: - enc_header += self.socket.read(header_size) - header_size -= len(enc_header) - - dec_header = struct.unpack(" bytes: + return self.socket.read(size) or b"" def read_with_timeout(self, timeout=1): handle = msvcrt.get_osfhandle(self.socket.fileno()) @@ -482,35 +499,11 @@ def _connect_pipe(self): return True def _write(self, data: bytes): - self.socket.send(data) + self.socket.sendall(data) def _close(self): self.socket.shutdown(socket.SHUT_RDWR) self.socket.close() - def _recv(self): - enc_header = b'' - header_size = 8 - - while header_size: - chunk = self.socket.recv(header_size) - if not chunk: - break - enc_header += chunk - header_size -= len(chunk) - - dec_header = struct.unpack(" bytes: + return self.socket.recv(size) diff --git a/tests/test_ipc_frame.py b/tests/test_ipc_frame.py new file mode 100644 index 0000000..0ad6c3a --- /dev/null +++ b/tests/test_ipc_frame.py @@ -0,0 +1,96 @@ +import json +import struct +import unittest + +from discordrpc.presence import _BasePipe + + +class FakePipe(_BasePipe): + def __init__(self, chunks): + self.chunks = list(chunks) + self._connected = True + + def _connect_pipe(self): + return True + + def _write(self, data): + pass + + def _close(self): + pass + + def _read_some(self, size): + if not self.chunks: + return b"" + chunk = self.chunks.pop(0) + if len(chunk) > size: + self.chunks.insert(0, chunk[size:]) + chunk = chunk[:size] + return chunk + + +def frame_bytes(opcode, payload): + data = json.dumps(payload).encode("UTF-8") + return struct.pack(" Date: Sun, 2 Aug 2026 11:53:40 +0700 Subject: [PATCH 06/24] fix(ipc): route responses by nonce through a single reader --- discordrpc/presence.py | 133 +++++++++++++++++++++++--------- tests/test_request_routing.py | 139 ++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 35 deletions(-) create mode 100644 tests/test_request_routing.py diff --git a/discordrpc/presence.py b/discordrpc/presence.py index 4cd9642..c3a2f2a 100644 --- a/discordrpc/presence.py +++ b/discordrpc/presence.py @@ -5,6 +5,7 @@ import struct import uuid import threading +import queue from typing import Optional from .exceptions import ( RPCException, InvalidID, DiscordNotOpened, @@ -56,7 +57,6 @@ def __init__(self, app_id:int, debug:bool=False, output:bool=True, exit_if_disco log.disabled = True self.is_running = False - self._reader_thread = None self._event_callbacks = {} self._setup() @@ -68,12 +68,7 @@ def _setup(self): if not self.ipc.connected: return self._user_data = self.ipc.handshake() - - def _start_event_listener(self): - if self._reader_thread and self._reader_thread.is_alive(): - return - self._reader_thread = threading.Thread(target=self._reader_loop, daemon=True) - self._reader_thread.start() + self.ipc._on_event = self._dispatch @property def connected(self): return self.ipc.connected @@ -225,7 +220,6 @@ def subscribe(self, event: str): self._event_callbacks.setdefault(event, []) log.info(f"Subscribed to {event}") - self._start_event_listener() return True def unsubscribe(self, event: str): @@ -261,21 +255,6 @@ def decorator(callback): return decorator - def _reader_loop(self): - log.debug("Starting events listener") - while self.ipc.connected: - try: - frame = self.ipc.read_with_timeout() - except Exception as e: - log.debug(f"IPC reader stopping: {e}") - break - - if not frame: - continue - - if frame.get('cmd') == 'DISPATCH': - self._dispatch(frame.get('evt'), frame.get('data', {}) or {}) - def _dispatch(self, evt: str, data: dict): for callback in self._event_callbacks.get(evt, []): try: @@ -308,6 +287,11 @@ def __init__(self, app_id, exit_if_discord_close, exit_on_disconnect): self.exit_if_discord_close = exit_if_discord_close self.exit_on_disconnect = exit_on_disconnect self.connected = self._connect_pipe() + self._write_lock = threading.Lock() + self._pending_requests = {} + self._pending_lock = threading.Lock() + self._reader_thread = None + self._reader_stop = threading.Event() def _connect_pipe(self): """Override in subclass to establish the pipe connection. Returns True on success.""" @@ -319,24 +303,94 @@ def _send(self, payload, op: int = OP_FRAME): payload = json.dumps(payload).encode('UTF-8') payload = struct.pack(' dict: - self._send(payload, op) - opcode, res = self._read_frame() + def _register_request(self, nonce): + q = queue.Queue(maxsize=1) + with self._pending_lock: + self._pending_requests[nonce] = q + return q + + def _unregister_request(self, nonce): + with self._pending_lock: + self._pending_requests.pop(nonce, None) + + def _request(self, payload: dict, op: int = OP_FRAME, timeout: float = 10.0) -> dict: + nonce = payload.get("nonce") + if not nonce: + raise ValueError("RPC request payload must include a nonce") + + self._start_reader() + wait_queue = self._register_request(nonce) + try: + self._send(payload, op) + try: + res = wait_queue.get(timeout=timeout) + except queue.Empty: + return {"ok": False, "error": "RPC request timed out", "response": None} + finally: + self._unregister_request(nonce) + + opcode, payload_res = res if opcode != OP_FRAME: - return {"ok": False, "error": f"Unexpected opcode {opcode}", "response": res} - if not isinstance(res, dict): - return {"ok": False, "error": "Expected JSON object response", "response": res} - if res.get("evt") == "ERROR": - data = res.get("data") or {} + return {"ok": False, "error": f"Unexpected opcode {opcode}", "response": payload_res} + if not isinstance(payload_res, dict): + return {"ok": False, "error": "Expected JSON object response", "response": payload_res} + if payload_res.get("evt") == "ERROR": + data = payload_res.get("data") or {} return { "ok": False, "code": data.get("code"), - "error": data.get("message") or res.get("message"), - "response": res, + "error": data.get("message") or payload_res.get("message"), + "response": payload_res, } - return {"ok": True, **res} + if payload_res.get("nonce") != nonce: + return {"ok": False, "error": "RPC response nonce mismatch", "response": payload_res} + return {"ok": True, **payload_res} + + def _start_reader(self): + if self._reader_thread and self._reader_thread.is_alive(): + return + self._reader_stop.clear() + self._reader_thread = threading.Thread(target=self._reader_loop, daemon=True) + self._reader_thread.start() + + def _reader_loop(self): + while self.connected and not self._reader_stop.is_set(): + try: + frame = self._read_frame() + except Exception as e: + log.debug(f"IPC reader stopping: {e}") + break + if frame is None: + continue + opcode, payload = frame + if payload is None: + continue + if not isinstance(payload, dict): + continue + if opcode == OP_PING: + self._send(payload, OP_PONG) + continue + if opcode == OP_FRAME: + nonce = payload.get("nonce") + if nonce: + with self._pending_lock: + pending = self._pending_requests.get(nonce) + if pending: + pending.put((opcode, payload)) + continue + if payload.get("cmd") == "DISPATCH": + self._on_event(payload.get("evt"), payload.get("data") or {}) + continue + if opcode == OP_CLOSE: + log.debug("Received OP_CLOSE from Discord") + self.connected = False + break + + def _on_event(self, evt, data): + """Override in subclass or by RPC to dispatch events.""" def _write(self, data: bytes): """Override in subclass to write bytes to the pipe.""" @@ -392,6 +446,8 @@ def disconnect(self): except Exception as e: log.debug("Socket closed before command was received") + self._reader_stop.set() + self._close_pending() self.socket = None self.connected = False @@ -399,6 +455,13 @@ def disconnect(self): if self.exit_on_disconnect: sys.exit() + def _close_pending(self): + with self._pending_lock: + pending = self._pending_requests + self._pending_requests = {} + for queue in pending.values(): + queue.put((OP_CLOSE, None)) + def _close(self): """Override in subclass to close the socket.""" raise NotImplementedError diff --git a/tests/test_request_routing.py b/tests/test_request_routing.py new file mode 100644 index 0000000..993c5f6 --- /dev/null +++ b/tests/test_request_routing.py @@ -0,0 +1,139 @@ +import queue +import threading +import time +import unittest + +from discordrpc.presence import _BasePipe, OP_FRAME, OP_CLOSE, OP_PING + + +class FakePipe(_BasePipe): + """Synchronous fake transport whose socket is driven by an in-process byte queue.""" + + def __init__(self): + super().__init__(None, False, False) + self._in = queue.Queue() + self._out = queue.Queue() + self.connected = True + self._close_called = False + + def _connect_pipe(self): + return True + + def _write(self, data): + self._out.put(data) + + def _read_some(self, size): + try: + data = self._in.get(timeout=0.1) + except queue.Empty: + return b"" + if len(data) > size: + self._in.put(data[size:]) + data = data[:size] + return data + + def _close(self): + self._close_called = True + + +def build_frame(opcode, payload): + import json + import struct + + data = json.dumps(payload).encode("UTF-8") + return struct.pack(" Date: Sun, 2 Aug 2026 11:54:23 +0700 Subject: [PATCH 07/24] fix(ipc): validate handshake and preserve error details --- discordrpc/presence.py | 15 ++++++--- tests/test_handshake.py | 73 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 tests/test_handshake.py diff --git a/discordrpc/presence.py b/discordrpc/presence.py index c3a2f2a..106b64c 100644 --- a/discordrpc/presence.py +++ b/discordrpc/presence.py @@ -425,19 +425,26 @@ def _read_frame(self): raise ValueError(f"Invalid IPC frame payload: {e}") return opcode, payload + def _handle_handshake_error(self, payload): + code = payload.get("code") + message = payload.get("error") or "Handshake failed" + if code == 4000 or "invalid" in str(message).lower(): + raise InvalidID() + raise RPCException(f"Handshake failed: {message}") + def handshake(self): data = self._request({'v': 1, 'client_id': self.app_id}, op=OP_HANDSHAKE) + if not data.get("ok"): + self._handle_handshake_error(data) + if data.get('cmd') == 'DISPATCH' and data.get('evt') == 'READY': user = data.get('data', {}).get('user') if user: log.info(f"Connected to {user.get('username')} ({user.get('id')})") return user - if data.get('code') == 4000: - raise InvalidID() - - raise RPCException() + raise RPCException("Handshake did not receive a READY event") def disconnect(self): try: diff --git a/tests/test_handshake.py b/tests/test_handshake.py new file mode 100644 index 0000000..10825a1 --- /dev/null +++ b/tests/test_handshake.py @@ -0,0 +1,73 @@ +import unittest + +from discordrpc.exceptions import InvalidID, RPCException +from discordrpc.presence import _BasePipe, OP_FRAME + + +class FakePipe(_BasePipe): + def __init__(self): + super().__init__(None, False, False) + self.responses = [] + self.sent = [] + self.connected = True + + def _connect_pipe(self): + return True + + def _write(self, data): + self.sent.append(data) + + def _read_some(self, size): + if not self.responses: + return b"" + data = self.responses.pop(0) + if len(data) > size: + self.responses.insert(0, data[size:]) + data = data[:size] + return data + + def _close(self): + pass + + def _request(self, payload, op=OP_FRAME, timeout=1.0): + if self.responses: + import json + + raw = self.responses.pop(0) + # FakePipe responses are raw payload dicts; the test builds the frame. + return {"ok": True, **raw} + return {"ok": False, "error": "RPC request timed out", "response": None} + + +class HandshakeTests(unittest.TestCase): + def test_ready_event_succeeds(self): + pipe = FakePipe() + pipe.responses = [ + {"cmd": "DISPATCH", "evt": "READY", "data": {"user": {"id": "1", "username": "seno"}}} + ] + user = pipe.handshake() + self.assertEqual(user.get("username"), "seno") + + def test_error_with_invalid_client_id_raises_invalid_id(self): + pipe = FakePipe() + pipe.responses = [ + {"cmd": "DISPATCH", "evt": "ERROR", "data": {"code": 4000, "message": "Invalid Client ID"}} + ] + # FakePipe ignores the evt==ERROR path in _request; emulate the base behavior by + # injecting the error dict directly. + with self.assertRaises(InvalidID): + data = {"cmd": "DISPATCH", "evt": "ERROR", "data": {"code": 4000, "message": "Invalid Client ID"}} + if data.get("evt") == "ERROR": + payload = {"ok": False, "code": 4000, "error": "Invalid Client ID", "response": data} + pipe._handle_handshake_error(payload) + + def test_unknown_error_raises_rpc_exception(self): + pipe = FakePipe() + data = {"cmd": "DISPATCH", "evt": "ERROR", "data": {"code": 1002, "message": "Something else"}} + payload = {"ok": False, "code": 1002, "error": "Something else", "response": data} + with self.assertRaises(RPCException): + pipe._handle_handshake_error(payload) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 0a1f3088a2a50fd91a1790c50cad03bce6b3a528 Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 11:57:17 +0700 Subject: [PATCH 08/24] fix(ipc): handle PING and PONG without corrupting requests --- discordrpc/presence.py | 2 +- tests/test_heartbeat.py | 84 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 tests/test_heartbeat.py diff --git a/discordrpc/presence.py b/discordrpc/presence.py index 106b64c..103aab9 100644 --- a/discordrpc/presence.py +++ b/discordrpc/presence.py @@ -321,8 +321,8 @@ def _request(self, payload: dict, op: int = OP_FRAME, timeout: float = 10.0) -> if not nonce: raise ValueError("RPC request payload must include a nonce") - self._start_reader() wait_queue = self._register_request(nonce) + self._start_reader() try: self._send(payload, op) try: diff --git a/tests/test_heartbeat.py b/tests/test_heartbeat.py new file mode 100644 index 0000000..253d5f6 --- /dev/null +++ b/tests/test_heartbeat.py @@ -0,0 +1,84 @@ +import json +import struct +import threading +import time +import unittest + +from discordrpc.presence import _BasePipe, OP_FRAME, OP_PING, OP_PONG + + +class FakePipe(_BasePipe): + def __init__(self): + super().__init__(None, False, False) + self._in = queue.Queue() + self._out = queue.Queue() + self._buffer = b"" + self.connected = True + + def _connect_pipe(self): + return True + + def _write(self, data): + self._out.put(data) + + def _read_some(self, size): + if self._buffer: + chunk = self._buffer[:size] + self._buffer = self._buffer[size:] + return chunk + while True: + try: + data = self._in.get(timeout=0.1) + except queue.Empty: + if self._reader_stop.is_set(): + return b"" + continue + if len(data) <= size: + return data + self._buffer = data[size:] + return data[:size] + + def _close(self): + pass + + +def build_frame(opcode, payload): + data = json.dumps(payload).encode("UTF-8") + return struct.pack(" Date: Sun, 2 Aug 2026 11:58:15 +0700 Subject: [PATCH 09/24] refactor(ipc): use blocking reader and drop Windows polling --- discordrpc/presence.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/discordrpc/presence.py b/discordrpc/presence.py index 103aab9..59ea5f4 100644 --- a/discordrpc/presence.py +++ b/discordrpc/presence.py @@ -18,11 +18,6 @@ import logging import time -if sys.platform == "win32": - import msvcrt - import win32file - import win32pipe - OP_HANDSHAKE = 0 OP_FRAME = 1 OP_CLOSE = 2 @@ -473,9 +468,6 @@ def _close(self): """Override in subclass to close the socket.""" raise NotImplementedError - def read_with_timeout(self, timeout=1): - raise NotImplementedError - class WindowsPipe(_BasePipe): def _connect_pipe(self): @@ -515,26 +507,6 @@ def _close(self): def _read_some(self, size: int) -> bytes: return self.socket.read(size) or b"" - def read_with_timeout(self, timeout=1): - handle = msvcrt.get_osfhandle(self.socket.fileno()) - header_size = 8 - - end = time.time() + timeout - - while time.time() < end: - _, available, _ = win32pipe.PeekNamedPipe(handle, 0) - - if available >= header_size: - frame = self.socket.read(header_size) - if frame: - dec_header = struct.unpack(" Date: Sun, 2 Aug 2026 12:01:18 +0700 Subject: [PATCH 10/24] fix(events): track subscriptions separately and stop idle readers --- discordrpc/presence.py | 105 ++++++++++++++++++++---------- tests/test_events.py | 143 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 34 deletions(-) create mode 100644 tests/test_events.py diff --git a/discordrpc/presence.py b/discordrpc/presence.py index 59ea5f4..ecfdce1 100644 --- a/discordrpc/presence.py +++ b/discordrpc/presence.py @@ -53,6 +53,8 @@ def __init__(self, app_id:int, debug:bool=False, output:bool=True, exit_if_disco self.is_running = False self._event_callbacks = {} + self._subscriptions = set() + self._state_lock = threading.Lock() self._setup() def _setup(self): @@ -194,64 +196,95 @@ def disconnect(self): self.ipc.disconnect() self.is_running = False - - def subscribe(self, event: str): - if event not in [event.value for event in Event]: + with self._state_lock: + self._subscriptions.clear() + self._event_callbacks.clear() + + def _normalize_event(self, event): + if isinstance(event, Event): + return event.value + if isinstance(event, str): + values = {e.value for e in Event} + if event in values: + return event raise InvalidEvent(event) + raise InvalidEventType(type(event).__name__) + + def subscribe(self, event) -> Optional[bool]: + event = self._normalize_event(event) if not self.ipc.connected: return - if event in self._event_callbacks.keys(): - log.debug(f"Event {event} already registered") - return - - payload = {"cmd": "SUBSCRIBE", "args": {}, "evt": event, "nonce": str(uuid.uuid4())} - res = self.ipc._request(payload) - if not res.get("ok"): - log.error(f'Failed to subscribe to {event}') - log.error(res.get("error")) - return False + with self._state_lock: + if event in self._subscriptions: + log.debug(f"Event {event} already subscribed") + return + + payload = {"cmd": "SUBSCRIBE", "args": {}, "evt": event, "nonce": str(uuid.uuid4())} + res = self.ipc._request(payload) + if not res.get("ok"): + log.error(f'Failed to subscribe to {event}') + log.error(res.get("error")) + return False - self._event_callbacks.setdefault(event, []) + self._subscriptions.add(event) + self._event_callbacks.setdefault(event, []) log.info(f"Subscribed to {event}") return True - def unsubscribe(self, event: str): - if event not in [event.value for event in Event]: - raise InvalidEvent(event) + def unsubscribe(self, event) -> Optional[bool]: + event = self._normalize_event(event) if not self.ipc.connected: return - if not event in self._event_callbacks.keys(): - log.error(f"Event {event} not registered") - return - - payload = {"cmd": "UNSUBSCRIBE", "args": {}, "evt": event, "nonce": str(uuid.uuid4())} - res = self.ipc._request(payload) - if not res.get("ok"): - log.error(f'Failed to unsubscribe from {event}') - log.error(res.get("error")) - return False + with self._state_lock: + if event not in self._subscriptions: + log.error(f"Event {event} not subscribed") + return - self._event_callbacks.pop(event, []) + payload = {"cmd": "UNSUBSCRIBE", "args": {}, "evt": event, "nonce": str(uuid.uuid4())} + res = self.ipc._request(payload) + if not res.get("ok"): + log.error(f'Failed to unsubscribe from {event}') + log.error(res.get("error")) + return False + + self._subscriptions.discard(event) + self._event_callbacks.pop(event, []) log.info(f"Unsubscribed from {event}") + self._stop_reader_if_idle() return True def on(self, event: Event): if type(event) != Event: - raise InvalidEventType(type(event)) - + raise InvalidEventType(type(event).__name__) + def decorator(callback): - self.subscribe(event.value) - self._event_callbacks.setdefault(event.value, []).append(callback) + result = self.subscribe(event) + if not result: + raise RPCException(f"Failed to subscribe to {event.value}") + with self._state_lock: + self._event_callbacks.setdefault(event.value, []).append(callback) return callback - + return decorator + def _stop_reader_if_idle(self): + with self._state_lock: + idle = not self._subscriptions + if idle and self.ipc._reader_thread and self.ipc._reader_thread.is_alive(): + self.ipc._reader_stop.set() + try: + self.ipc._close() + except Exception: + pass + def _dispatch(self, evt: str, data: dict): - for callback in self._event_callbacks.get(evt, []): + with self._state_lock: + callbacks = list(self._event_callbacks.get(evt, [])) + for callback in callbacks: try: callback(data) except Exception as e: @@ -450,6 +483,10 @@ def disconnect(self): self._reader_stop.set() self._close_pending() + reader = self._reader_thread + if reader and reader.is_alive() and reader is not threading.current_thread(): + reader.join(timeout=2) + self._reader_thread = None self.socket = None self.connected = False diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..fd5d2cb --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,143 @@ +import json +import queue +import struct +import threading +import time +import unittest + +from discordrpc.exceptions import InvalidEvent, InvalidEventType +from discordrpc.presence import _BasePipe, OP_FRAME, RPC +from discordrpc.types import Event + + +class FakePipe(_BasePipe): + def __init__(self): + super().__init__(None, False, False) + self._in = queue.Queue() + self._out = queue.Queue() + self._buffer = b"" + self.connected = True + + def _connect_pipe(self): + return True + + def _write(self, data): + self._out.put(data) + + def _read_some(self, size): + if self._buffer: + chunk = self._buffer[:size] + self._buffer = self._buffer[size:] + return chunk + while True: + try: + data = self._in.get(timeout=0.1) + except queue.Empty: + if self._reader_stop.is_set(): + return b"" + continue + if len(data) <= size: + return data + self._buffer = data[size:] + return data[:size] + + def _close(self): + pass + + +def build_frame(opcode, payload): + data = json.dumps(payload).encode("UTF-8") + return struct.pack(" Date: Sun, 2 Aug 2026 12:01:37 +0700 Subject: [PATCH 11/24] feat(events): export event exceptions from package root --- discordrpc/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discordrpc/__init__.py b/discordrpc/__init__.py index 9fa0d96..64df803 100644 --- a/discordrpc/__init__.py +++ b/discordrpc/__init__.py @@ -5,6 +5,7 @@ RPCException, Error, DiscordNotOpened, ActivityError, InvalidURL, InvalidID, ButtonError, ProgressbarError, InvalidActivityType, ActivityTypeDisabled, + InvalidEvent, InvalidEventType, ) from .types import Activity, StatusDisplay, User, Application, Event from .utils import remove_none, timestamp, date_to_timestamp, use_local_time, progress_bar, get_app_info From 9361d749eef7b013ee50dfe97a05a17545a4c901 Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 12:02:46 +0700 Subject: [PATCH 12/24] fix(url): validate URL structure and required fields --- discordrpc/button.py | 7 ++-- discordrpc/exceptions.py | 6 ++-- discordrpc/utils.py | 20 ++++++++++-- tests/test_url.py | 69 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 tests/test_url.py diff --git a/discordrpc/button.py b/discordrpc/button.py index 596380c..3c68cc6 100644 --- a/discordrpc/button.py +++ b/discordrpc/button.py @@ -1,6 +1,5 @@ -from .exceptions import InvalidURL -from .utils import valid_url +from .utils import required_url -def button(text:str, url:str): - return {"label": text, "url": valid_url(url)} +def button(text: str, url: str): + return {"label": text, "url": required_url(url)} diff --git a/discordrpc/exceptions.py b/discordrpc/exceptions.py index 232b5d1..cee7b4c 100644 --- a/discordrpc/exceptions.py +++ b/discordrpc/exceptions.py @@ -17,8 +17,10 @@ def __init__(self): super().__init__("An error has occurred in activity payload, do you have set your activity correctly?") class InvalidURL(RPCException): - def __init__(self): - super().__init__("URL must start with http:// or https://") + def __init__(self, message: str = None): + if message is None: + message = "URL must be a valid http:// or https:// URL" + super().__init__(message) class InvalidID(RPCException): def __init__(self): diff --git a/discordrpc/utils.py b/discordrpc/utils.py index e0abe95..d3f327b 100644 --- a/discordrpc/utils.py +++ b/discordrpc/utils.py @@ -1,6 +1,7 @@ import time import json import urllib.request +import urllib.parse from datetime import datetime import logging from .exceptions import ProgressbarError, InvalidURL @@ -30,11 +31,24 @@ def date_to_timestamp(date:str): datetime.strptime(date, "%d/%m/%Y-%H:%M:%S").timetuple() )) -def valid_url(url:str) -> str: - if url and not url.startswith(("http://", "https://")): - raise InvalidURL() +def _validate_url(url, required: bool): + if url is None: + if required: + raise InvalidURL("URL must be a valid http:// or https:// URL") + return url + if not isinstance(url, str) or not url: + raise InvalidURL("URL must be a valid http:// or https:// URL") + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise InvalidURL("URL must be a valid http:// or https:// URL") return url +def valid_url(url): + return _validate_url(url, required=False) + +def required_url(url): + return _validate_url(url, required=True) + def use_local_time(): now = datetime.now() seconds_since_midnight = now.hour * 3600 + now.minute * 60 + now.second diff --git a/tests/test_url.py b/tests/test_url.py new file mode 100644 index 0000000..2e8e69c --- /dev/null +++ b/tests/test_url.py @@ -0,0 +1,69 @@ +import unittest + +from discordrpc import button +from discordrpc.exceptions import InvalidURL +from discordrpc.utils import required_url, valid_url + + +class ValidUrlTests(unittest.TestCase): + def test_accepts_http_and_https(self): + self.assertEqual(valid_url("http://example.com"), "http://example.com") + self.assertEqual(valid_url("https://example.com/path"), "https://example.com/path") + + def test_accepts_query_and_fragment(self): + self.assertEqual(valid_url("https://example.com?a=1#frag"), "https://example.com?a=1#frag") + + def test_none_is_optional(self): + self.assertIsNone(valid_url(None)) + + def test_rejects_unsupported_schemes(self): + for url in ("ftp://example.com", "javascript:alert(1)", "file:///tmp/x"): + with self.assertRaises(InvalidURL): + valid_url(url) + + def test_rejects_bare_scheme_without_host(self): + for url in ("http://", "https://", "http://?", "https://#frag"): + with self.assertRaises(InvalidURL): + valid_url(url) + + def test_rejects_empty_string(self): + with self.assertRaises(InvalidURL): + valid_url("") + + def test_rejects_non_string(self): + with self.assertRaises(InvalidURL): + valid_url(12345) + + +class RequiredUrlTests(unittest.TestCase): + def test_none_rejected_when_required(self): + with self.assertRaises(InvalidURL): + required_url(None) + + def test_empty_rejected_when_required(self): + with self.assertRaises(InvalidURL): + required_url("") + + +class ButtonTests(unittest.TestCase): + def test_valid_button(self): + self.assertEqual( + button("Repository", "https://github.com/Senophyx/discord-rpc"), + {"label": "Repository", "url": "https://github.com/Senophyx/discord-rpc"}, + ) + + def test_none_url_rejected(self): + with self.assertRaises(InvalidURL): + button("Repository", None) + + def test_empty_url_rejected(self): + with self.assertRaises(InvalidURL): + button("Repository", "") + + def test_unsupported_scheme_rejected(self): + with self.assertRaises(InvalidURL): + button("Repository", "ftp://example.com") + + +if __name__ == "__main__": + unittest.main(verbosity=2) From d33ce1e9e67d358e8a2a328cbb9b10dfd4e5eac8 Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 12:03:54 +0700 Subject: [PATCH 13/24] refactor(presence): improve logging and disconnect cleanup --- discordrpc/presence.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/discordrpc/presence.py b/discordrpc/presence.py index ecfdce1..aa4277b 100644 --- a/discordrpc/presence.py +++ b/discordrpc/presence.py @@ -173,16 +173,14 @@ def set_activity( res = self.ipc._request(payload) if not res.get("ok"): self.is_running = False - log.error('Failed to set RPC') - log.error(res.get("error")) + log.error("Failed to set RPC: %s", res.get("error")) return False self.is_running = True - log.info('RPC set') + log.info("RPC set") return True - except Exception as e: - log.error('Failed to set RPC') - log.error(e) + except Exception: + log.exception("Failed to set RPC") self.disconnect() return False @@ -224,8 +222,7 @@ def subscribe(self, event) -> Optional[bool]: payload = {"cmd": "SUBSCRIBE", "args": {}, "evt": event, "nonce": str(uuid.uuid4())} res = self.ipc._request(payload) if not res.get("ok"): - log.error(f'Failed to subscribe to {event}') - log.error(res.get("error")) + log.error("Failed to subscribe to %s: %s", event, res.get("error")) return False self._subscriptions.add(event) @@ -247,8 +244,7 @@ def unsubscribe(self, event) -> Optional[bool]: payload = {"cmd": "UNSUBSCRIBE", "args": {}, "evt": event, "nonce": str(uuid.uuid4())} res = self.ipc._request(payload) if not res.get("ok"): - log.error(f'Failed to unsubscribe from {event}') - log.error(res.get("error")) + log.error("Failed to unsubscribe from %s: %s", event, res.get("error")) return False self._subscriptions.discard(event) @@ -287,8 +283,8 @@ def _dispatch(self, evt: str, data: dict): for callback in callbacks: try: callback(data) - except Exception as e: - log.error(f"Error in '{evt}' event callback: {e}") + except Exception: + log.exception("Error in '%s' event callback", evt) def run(self, update_every:int=1, ping_every:int=15): try: @@ -479,7 +475,7 @@ def disconnect(self): self._send({}, OP_CLOSE) self._close() except Exception as e: - log.debug("Socket closed before command was received") + log.debug("Socket closed before command was received: %s", e) self._reader_stop.set() self._close_pending() From 1ef1bcb226615af6c8db8f4a07ba3dc44d437885 Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 12:04:45 +0700 Subject: [PATCH 14/24] docs: document event subscription API and update changelog --- CHANGELOG.md | 12 +++++++++++ DOCS.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6456bc0..2f1ea2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,15 +14,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Asset` object support in `set_activity()` — pass `rpc.assets.get("key")` directly as `large_image` / `small_image` - `utils.get_assets(app_id)` function to fetch assets without initializing `RPC` - `RPC.connected` read-only property as alias for `self.ipc.connected` (PR [#63](https://github.com/Senophyx/Discord-RPC/pull/63) by @SuperZombi) +- Cross-platform event subscription with `Event` enum and `@rpc.on()` decorator (PR [#66](https://github.com/Senophyx/Discord-RPC/pull/66) by @SuperZombi) +- `InvalidEvent` and `InvalidEventType` exceptions exported from the package root +- `utils.required_url()` for validating required button URLs ### Changed - Bumped GitHub Actions versions: `checkout@v4→v6`, `setup-python@v4→v6` (PR [#62](https://github.com/Senophyx/Discord-RPC/pull/62) by @SuperZombi) - Link badges in `README.md` now point to local `CHANGELOG.md` and `DOCS.md` instead of `senophyx.id` - `DOCS.md` documentation URL in `pyproject.toml` updated to point to GitHub +- IPC reads now preserve the opcode and route responses by nonce through a single background reader +- Windows imports are now loaded only on Windows; the blocking reader no longer needs `msvcrt` or `win32pipe` +- URL validation now checks the scheme and host instead of only a prefix ### Fixed - Indentation in `examples/assets.py` corrected to 4 spaces - `User.__str__`, `Application.__str__`, `Asset.__str__`, and `Asset.__repr__` reformatted to multi-line for codebase consistency +- Invalid method annotations in `_send()` and `_request()` that prevented importing the package +- Unread `PONG` responses no longer corrupt subsequent RPC requests +- Incoming `PING` packets are answered with `PONG` +- Event subscription state no longer mixes callback storage with subscription state +- Failed subscriptions no longer register callbacks locally +- Reader thread stops after the last unsubscribe and is cleaned up during disconnect ## [6.0] - 2026-07-21 diff --git a/DOCS.md b/DOCS.md index cf1f7c7..1969677 100644 --- a/DOCS.md +++ b/DOCS.md @@ -295,6 +295,59 @@ from discordrpc import StatusDisplay --- +## Events + +You can subscribe to Rich Presence events and receive callbacks when they fire. + +```python +import discordrpc +from discordrpc import Event + +rpc = discordrpc.RPC(app_id=123456789) + +@rpc.on(Event.JOIN_REQUEST) +def on_join_request(data): + print("Ask to Join:", data) + +rpc.run() +``` + +Supported events (from `discordrpc.Event`): + +- `Event.JOIN` (`ACTIVITY_JOIN`) +- `Event.JOIN_REQUEST` (`ACTIVITY_JOIN_REQUEST`) +- `Event.SPECTATE` (`ACTIVITY_SPECTATE`) +- `Event.INVITE` (`ACTIVITY_INVITE`) + +Only these activity events are currently exposed. Other Discord RPC events are not supported yet. + +### Direct subscribe and unsubscribe + +```python +rpc.subscribe("ACTIVITY_JOIN") # string form accepted +rpc.subscribe(Event.JOIN) # enum form accepted +rpc.unsubscribe(Event.JOIN) +``` + +- `subscribe()` / `unsubscribe()` accept either an `Event` member or a valid event string. +- An unknown event name raises `InvalidEvent`. +- A non-string, non-enum value raises `InvalidEventType`. +- The `@rpc.on()` decorator raises `RPCException` if the subscription is rejected by Discord. + +### Callback behavior + +- Callbacks run on the internal IPC reader thread. Keep them short and non-blocking. +- A slow callback delays processing of other events and responses. +- An exception raised inside a callback is logged and does not stop the reader. +- Use locks or queues if your callback touches shared state. + +### Disconnect and reconnect + +- `disconnect()` clears all subscriptions and callbacks. +- After a reconnect, call `subscribe()` again if you want events. + +--- + ## Exceptions All exceptions extend `RPCException`. @@ -305,12 +358,14 @@ All exceptions extend `RPCException`. | `Error(message)` | Generic user error | | `DiscordNotOpened()` | Discord not found/running | | `ActivityError()` | Invalid activity payload | -| `InvalidURL()` | URL not starting with http/https | +| `InvalidURL(message)` | URL is not a valid http/https URL | | `InvalidID()` | Invalid Application ID | | `ButtonError(message)` | Button limit exceeded | | `ProgressbarError(message)` | Invalid progress values | | `InvalidActivityType(message)` | act_type not a valid Activity | | `ActivityTypeDisabled()` | Streaming/Custom blocked by Discord | +| `InvalidEvent(message)` | Event name is not subscribable | +| `InvalidEventType(message)` | Event input is not a string or Event | --- From d7abe1608fedb436d8447d58c455bc6b5c3e0454 Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 12:05:31 +0700 Subject: [PATCH 15/24] fix(events): release state lock during subscription requests --- discordrpc/presence.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/discordrpc/presence.py b/discordrpc/presence.py index aa4277b..977f7c6 100644 --- a/discordrpc/presence.py +++ b/discordrpc/presence.py @@ -219,12 +219,13 @@ def subscribe(self, event) -> Optional[bool]: log.debug(f"Event {event} already subscribed") return - payload = {"cmd": "SUBSCRIBE", "args": {}, "evt": event, "nonce": str(uuid.uuid4())} - res = self.ipc._request(payload) - if not res.get("ok"): - log.error("Failed to subscribe to %s: %s", event, res.get("error")) - return False + payload = {"cmd": "SUBSCRIBE", "args": {}, "evt": event, "nonce": str(uuid.uuid4())} + res = self.ipc._request(payload) + if not res.get("ok"): + log.error("Failed to subscribe to %s: %s", event, res.get("error")) + return False + with self._state_lock: self._subscriptions.add(event) self._event_callbacks.setdefault(event, []) log.info(f"Subscribed to {event}") @@ -241,12 +242,13 @@ def unsubscribe(self, event) -> Optional[bool]: log.error(f"Event {event} not subscribed") return - payload = {"cmd": "UNSUBSCRIBE", "args": {}, "evt": event, "nonce": str(uuid.uuid4())} - res = self.ipc._request(payload) - if not res.get("ok"): - log.error("Failed to unsubscribe from %s: %s", event, res.get("error")) - return False + payload = {"cmd": "UNSUBSCRIBE", "args": {}, "evt": event, "nonce": str(uuid.uuid4())} + res = self.ipc._request(payload) + if not res.get("ok"): + log.error("Failed to unsubscribe from %s: %s", event, res.get("error")) + return False + with self._state_lock: self._subscriptions.discard(event) self._event_callbacks.pop(event, []) log.info(f"Unsubscribed from {event}") From 8b9d6a84ed6076759a2498b969a7c01e5a24bdf2 Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 12:08:54 +0700 Subject: [PATCH 16/24] fix(packaging): use PEP 621 license table to unblock wheel builds --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f19c906..3c849f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ version = "6.5b1" description = "A Python wrapper for the Discord RPC API" readme = "README.md" requires-python = ">=3.7" -license = "MIT" +license = { file = "LICENSE" } authors = [{ name = "Senophyx", email = "contact@senophyx.id" }] keywords = ["Discord", "rpc", "discord rpc"] classifiers = [ From 3d58807f5a849ddcc93f6ef3120962f8055f8f5e Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 12:25:48 +0700 Subject: [PATCH 17/24] fix(ipc): read handshake frame directly without nonce routing --- discordrpc/presence.py | 28 +++++++++++++--- tests/test_handshake.py | 73 +++++++++++++++++++++++++---------------- 2 files changed, 68 insertions(+), 33 deletions(-) diff --git a/discordrpc/presence.py b/discordrpc/presence.py index 977f7c6..eb21768 100644 --- a/discordrpc/presence.py +++ b/discordrpc/presence.py @@ -459,13 +459,31 @@ def _handle_handshake_error(self, payload): raise RPCException(f"Handshake failed: {message}") def handshake(self): - data = self._request({'v': 1, 'client_id': self.app_id}, op=OP_HANDSHAKE) + self._send({'v': 1, 'client_id': self.app_id}, OP_HANDSHAKE) - if not data.get("ok"): - self._handle_handshake_error(data) + opcode, payload = self._read_frame() + if payload is None: + raise RPCException("Handshake did not receive a READY event") - if data.get('cmd') == 'DISPATCH' and data.get('evt') == 'READY': - user = data.get('data', {}).get('user') + if opcode == OP_CLOSE: + self.connected = False + raise RPCException("Handshake closed by Discord before READY") + + if not isinstance(payload, dict): + raise RPCException("Handshake did not receive a READY event") + + if payload.get("evt") == "ERROR": + data = payload.get("data") or {} + error_payload = { + "ok": False, + "code": data.get("code"), + "error": data.get("message") or payload.get("message"), + "response": payload, + } + self._handle_handshake_error(error_payload) + + if payload.get("cmd") == "DISPATCH" and payload.get("evt") == "READY": + user = payload.get("data", {}).get("user") if user: log.info(f"Connected to {user.get('username')} ({user.get('id')})") return user diff --git a/tests/test_handshake.py b/tests/test_handshake.py index 10825a1..81019d4 100644 --- a/tests/test_handshake.py +++ b/tests/test_handshake.py @@ -1,7 +1,14 @@ +import json +import struct import unittest from discordrpc.exceptions import InvalidID, RPCException -from discordrpc.presence import _BasePipe, OP_FRAME +from discordrpc.presence import _BasePipe, OP_CLOSE, OP_FRAME, OP_HANDSHAKE + + +def build_frame(opcode, payload): + data = json.dumps(payload).encode("UTF-8") + return struct.pack(" Date: Sun, 2 Aug 2026 13:01:36 +0700 Subject: [PATCH 18/24] docs: document party and secret requirements, add subscribe example --- DOCS.md | 17 +++++++++++++++++ examples/rpc-subscribe.py | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 examples/rpc-subscribe.py diff --git a/DOCS.md b/DOCS.md index 1969677..c85857c 100644 --- a/DOCS.md +++ b/DOCS.md @@ -321,6 +321,23 @@ Supported events (from `discordrpc.Event`): Only these activity events are currently exposed. Other Discord RPC events are not supported yet. +### Enabling JOIN and SPECTATE events + +- `party_id` is **required** for the "Ask to Join" button and the `ACTIVITY_JOIN_REQUEST` event to work. Without it, Discord cannot resolve the party, the event is never delivered, and the requester gets "Your message could not be delivered." +- `join_secret` and `spectate_secret` must have **different values**. Discord rejects the activity with `secrets must be unique` when they match. + +A minimal working setup: + +```python +rpc.set_activity( + name="VALORANT", + details="Valorant Ranked", + party_id=1234, + join_secret="anything", + spectate_secret="idk", +) +``` + ### Direct subscribe and unsubscribe ```python diff --git a/examples/rpc-subscribe.py b/examples/rpc-subscribe.py new file mode 100644 index 0000000..c948af3 --- /dev/null +++ b/examples/rpc-subscribe.py @@ -0,0 +1,24 @@ +import discordrpc +from discordrpc import Event + +rpc = discordrpc.RPC(app_id=123456789) + +@rpc.on(Event.JOIN) +@rpc.on(Event.JOIN_REQUEST) +@rpc.on(Event.SPECTATE) +@rpc.on(Event.INVITE) +def on_event(data): + print(data) + +rpc.set_activity( + name="VALORANT", + details="Valorant Ranked", + party_id=1234, + join_secret="anything", + spectate_secret="idk" +) + +try: + rpc.run() +except KeyboardInterrupt: + rpc.disconnect() From 2d11f215c89c00277a476db079f2ebfde9c16e19 Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 13:02:06 +0700 Subject: [PATCH 19/24] docs: rename subscribe example to rpc-events --- examples/{rpc-subscribe.py => rpc-events.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/{rpc-subscribe.py => rpc-events.py} (100%) diff --git a/examples/rpc-subscribe.py b/examples/rpc-events.py similarity index 100% rename from examples/rpc-subscribe.py rename to examples/rpc-events.py From f79f11ef6700eeadd60faa0732e5563a9a8aad39 Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 13:27:46 +0700 Subject: [PATCH 20/24] fix(events): allow stacked handlers and mark pipe disconnected when idle --- discordrpc/presence.py | 3 ++- tests/test_events.py | 59 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/discordrpc/presence.py b/discordrpc/presence.py index eb21768..faecd7b 100644 --- a/discordrpc/presence.py +++ b/discordrpc/presence.py @@ -217,7 +217,7 @@ def subscribe(self, event) -> Optional[bool]: with self._state_lock: if event in self._subscriptions: log.debug(f"Event {event} already subscribed") - return + return True payload = {"cmd": "SUBSCRIBE", "args": {}, "evt": event, "nonce": str(uuid.uuid4())} res = self.ipc._request(payload) @@ -278,6 +278,7 @@ def _stop_reader_if_idle(self): self.ipc._close() except Exception: pass + self.ipc.connected = False def _dispatch(self, evt: str, data: dict): with self._state_lock: diff --git a/tests/test_events.py b/tests/test_events.py index fd5d2cb..0d91a10 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -23,6 +23,20 @@ def _connect_pipe(self): def _write(self, data): self._out.put(data) + # Auto-respond to any request carrying a nonce so subscribe()/unsubscribe() + # can complete without a real Discord server. + opcode, length = struct.unpack(" Date: Sun, 2 Aug 2026 13:48:22 +0700 Subject: [PATCH 21/24] fix(activity): validate button URLs in set_activity --- discordrpc/presence.py | 13 +++++++- tests/test_url.py | 74 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/discordrpc/presence.py b/discordrpc/presence.py index faecd7b..1dd241f 100644 --- a/discordrpc/presence.py +++ b/discordrpc/presence.py @@ -13,7 +13,7 @@ InvalidEvent, InvalidEventType, ) from .types import Activity, StatusDisplay, User, Application, Asset, AssetManager, Event -from .utils import remove_none, get_app_info, get_assets, valid_url +from .utils import remove_none, get_app_info, get_assets, valid_url, required_url from functools import cached_property import logging import time @@ -113,6 +113,17 @@ def set_activity( if buttons and len(buttons) > 2: raise ButtonError("Max 2 buttons allowed") + # Validate button URLs so invalid URLs are caught client-side instead of + # being silently rejected by Discord (or missing from the presence). + if buttons: + buttons = [ + { + "label": item.get("label") if isinstance(item, dict) else None, + "url": required_url(item.get("url") if isinstance(item, dict) else item), + } + for item in buttons + ] + large_image = large_image.name if isinstance(large_image, Asset) else large_image small_image = small_image.name if isinstance(small_image, Asset) else small_image diff --git a/tests/test_url.py b/tests/test_url.py index 2e8e69c..445c4e0 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -65,5 +65,79 @@ def test_unsupported_scheme_rejected(self): button("Repository", "ftp://example.com") +class SetActivityButtonsTests(unittest.TestCase): + def setUp(self): + import queue + import threading + + from discordrpc.presence import _BasePipe, RPC + + class FakePipe(_BasePipe): + def __init__(self): + super().__init__(None, False, False) + self.connected = True + self.captured = None + + def _connect_pipe(self): + return True + + def _write(self, data): + pass + + def _read_some(self, size): + return b"" + + def _close(self): + pass + + def _request(self, payload, op=1, timeout=1.0): + self.captured = payload + return {"ok": True, "cmd": payload.get("cmd"), "nonce": payload.get("nonce"), "data": {}} + + self.pipe_cls = FakePipe + + def make_rpc(self): + import threading + + from discordrpc.presence import RPC + + rpc = RPC.__new__(RPC) + rpc.app_id = "1234" + rpc.exit_if_discord_close = False + rpc.exit_on_disconnect = False + rpc.try_reconnecting = True + rpc._user_data = {"id": "1", "username": "seno"} + rpc._app_info = None + rpc.is_running = False + rpc._event_callbacks = {} + rpc._subscriptions = set() + rpc._state_lock = threading.Lock() + rpc.ipc = self.pipe_cls() + rpc.ipc._on_event = rpc._dispatch + return rpc + + def test_buttons_with_valid_url_accepted(self): + rpc = self.make_rpc() + res = rpc.set_activity(buttons=[{"label": "A", "url": "https://valid.com"}]) + self.assertTrue(res) + buttons = rpc.ipc.captured["args"]["activity"]["buttons"] + self.assertEqual(buttons, [{"label": "A", "url": "https://valid.com"}]) + + def test_buttons_with_invalid_url_rejected(self): + rpc = self.make_rpc() + with self.assertRaises(InvalidURL): + rpc.set_activity(buttons=[{"label": "A", "url": "not-a-url"}]) + + def test_buttons_with_empty_url_rejected(self): + rpc = self.make_rpc() + with self.assertRaises(InvalidURL): + rpc.set_activity(buttons=[{"label": "A", "url": ""}]) + + def test_buttons_with_none_url_rejected(self): + rpc = self.make_rpc() + with self.assertRaises(InvalidURL): + rpc.set_activity(buttons=[{"label": "A", "url": None}]) + + if __name__ == "__main__": unittest.main(verbosity=2) From 705033bd2d00e0fceee8419492fd19497d4b7c4e Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 13:55:58 +0700 Subject: [PATCH 22/24] chore(release): bump version to 6.5b2 --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ discordrpc/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f1ea2e..8d4db08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [6.5b2] - Unreleased + +### Added +- Cross-platform event subscription with `Event` enum and `@rpc.on()` decorator (PR [#66](https://github.com/Senophyx/Discord-RPC/pull/66) by @SuperZombi) +- `InvalidEvent` and `InvalidEventType` exceptions exported from the package root +- `utils.required_url()` for validating required button URLs +- `examples/rpc-events.py` showing event subscription usage + +### Changed +- IPC reads now preserve the opcode and route responses by nonce through a single background reader +- Windows imports are now loaded only on Windows; the blocking reader no longer needs `msvcrt` or `win32pipe` +- URL validation now checks the scheme and host instead of only a prefix +- `set_activity()` now validates button URLs client-side + +### Fixed +- Invalid method annotations in `_send()` and `_request()` that prevented importing the package +- Unread `PONG` responses no longer corrupt subsequent RPC requests +- Incoming `PING` packets are answered with `PONG` +- Handshake no longer requires a nonce and reads the READY frame directly +- Event subscription state no longer mixes callback storage with subscription state +- Failed subscriptions no longer register callbacks locally +- Reader thread stops after the last unsubscribe and is cleaned up during disconnect +- `subscribe()` returns `True` for already-subscribed events so stacked handlers work +- Pipe is marked disconnected when the reader stops idle, allowing reconnect +- `pyproject.toml` license uses the PEP 621 table so wheel builds succeed + ## [Unreleased] ### Added diff --git a/discordrpc/__init__.py b/discordrpc/__init__.py index 64df803..f7ab8a6 100644 --- a/discordrpc/__init__.py +++ b/discordrpc/__init__.py @@ -14,7 +14,7 @@ try: __version__ = _pkg_ver('discord-rpc') except PackageNotFoundError: - __version__ = "6.5b1" + __version__ = "6.5b2" __authors__ = "Senophyx" __license__ = "MIT License" __copyright__ = "Copyright 2021-2025 Senophyx" diff --git a/pyproject.toml b/pyproject.toml index 3c849f1..9d5454d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "discord-rpc" ##### VERSION ##### -version = "6.5b1" +version = "6.5b2" ################### description = "A Python wrapper for the Discord RPC API" readme = "README.md" From 5aa4502d618a6ac8079bcf7db3e6f585f1ffca50 Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 13:57:11 +0700 Subject: [PATCH 23/24] docs(changelog): remove duplicate entries from unreleased --- CHANGELOG.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d4db08..15c7ab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,27 +40,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Asset` object support in `set_activity()` — pass `rpc.assets.get("key")` directly as `large_image` / `small_image` - `utils.get_assets(app_id)` function to fetch assets without initializing `RPC` - `RPC.connected` read-only property as alias for `self.ipc.connected` (PR [#63](https://github.com/Senophyx/Discord-RPC/pull/63) by @SuperZombi) -- Cross-platform event subscription with `Event` enum and `@rpc.on()` decorator (PR [#66](https://github.com/Senophyx/Discord-RPC/pull/66) by @SuperZombi) -- `InvalidEvent` and `InvalidEventType` exceptions exported from the package root -- `utils.required_url()` for validating required button URLs ### Changed - Bumped GitHub Actions versions: `checkout@v4→v6`, `setup-python@v4→v6` (PR [#62](https://github.com/Senophyx/Discord-RPC/pull/62) by @SuperZombi) - Link badges in `README.md` now point to local `CHANGELOG.md` and `DOCS.md` instead of `senophyx.id` - `DOCS.md` documentation URL in `pyproject.toml` updated to point to GitHub -- IPC reads now preserve the opcode and route responses by nonce through a single background reader -- Windows imports are now loaded only on Windows; the blocking reader no longer needs `msvcrt` or `win32pipe` -- URL validation now checks the scheme and host instead of only a prefix ### Fixed - Indentation in `examples/assets.py` corrected to 4 spaces - `User.__str__`, `Application.__str__`, `Asset.__str__`, and `Asset.__repr__` reformatted to multi-line for codebase consistency -- Invalid method annotations in `_send()` and `_request()` that prevented importing the package -- Unread `PONG` responses no longer corrupt subsequent RPC requests -- Incoming `PING` packets are answered with `PONG` -- Event subscription state no longer mixes callback storage with subscription state -- Failed subscriptions no longer register callbacks locally -- Reader thread stops after the last unsubscribe and is cleaned up during disconnect ## [6.0] - 2026-07-21 From ae17e26b9511842997b1ca87c8b12fde70d022f1 Mon Sep 17 00:00:00 2001 From: Senophyx Date: Sun, 2 Aug 2026 14:08:57 +0700 Subject: [PATCH 24/24] ci: run test suite on all platforms --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 715e96d..4092704 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: checks: - name: Compile and import checks + name: Compile, import, and tests runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -25,3 +25,5 @@ jobs: run: python -m compileall discordrpc - name: Import package run: python -c "import discordrpc" + - name: Run tests + run: python tests/run_tests.py