A Python script to show your currently playing Yandex music in your Discord status!
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

207 lines
5.5 KiB

  1. # References:
  2. # * https://github.com/devsnek/discord-rpc/tree/master/src/transports/IPC.js
  3. # * https://github.com/devsnek/discord-rpc/tree/master/example/main.js
  4. # * https://github.com/discordapp/discord-rpc/tree/master/documentation/hard-mode.md
  5. # * https://github.com/discordapp/discord-rpc/tree/master/src
  6. # * https://discordapp.com/developers/docs/rich-presence/how-to#updating-presence-update-presence-payload-fields
  7. from abc import ABCMeta, abstractmethod
  8. import json
  9. import logging
  10. import os
  11. import socket
  12. import sys
  13. import struct
  14. import uuid
  15. OP_HANDSHAKE = 0
  16. OP_FRAME = 1
  17. OP_CLOSE = 2
  18. OP_PING = 3
  19. OP_PONG = 4
  20. logger = logging.getLogger(__name__)
  21. class DiscordIpcError(Exception):
  22. pass
  23. class DiscordIpcClient(metaclass=ABCMeta):
  24. """Work with an open Discord instance via its JSON IPC for its rich presence API.
  25. In a blocking way.
  26. Classmethod `for_platform`
  27. will resolve to one of WinDiscordIpcClient or UnixDiscordIpcClient,
  28. depending on the current platform.
  29. Supports context handler protocol.
  30. """
  31. def __init__(self, client_id):
  32. self.client_id = client_id
  33. self._connect()
  34. self._do_handshake()
  35. logger.info("connected via ID %s", client_id)
  36. @classmethod
  37. def for_platform(cls, client_id, platform=sys.platform):
  38. if platform == 'win32':
  39. return WinDiscordIpcClient(client_id)
  40. else:
  41. return UnixDiscordIpcClient(client_id)
  42. @abstractmethod
  43. def _connect(self):
  44. pass
  45. def _do_handshake(self):
  46. ret_op, ret_data = self.send_recv({'v': 1, 'client_id': self.client_id}, op=OP_HANDSHAKE)
  47. # {'cmd': 'DISPATCH', 'data': {'v': 1, 'config': {...}}, 'evt': 'READY', 'nonce': None}
  48. if ret_op == OP_FRAME and ret_data['cmd'] == 'DISPATCH' and ret_data['evt'] == 'READY':
  49. return
  50. else:
  51. if ret_op == OP_CLOSE:
  52. self.close()
  53. raise RuntimeError(ret_data)
  54. @abstractmethod
  55. def _write(self, date: bytes):
  56. pass
  57. @abstractmethod
  58. def _recv(self, size: int) -> bytes:
  59. pass
  60. def _recv_header(self) -> (int, int):
  61. header = self._recv_exactly(8)
  62. return struct.unpack("<II", header)
  63. def _recv_exactly(self, size) -> bytes:
  64. buf = b""
  65. size_remaining = size
  66. while size_remaining:
  67. chunk = self._recv(size_remaining)
  68. buf += chunk
  69. size_remaining -= len(chunk)
  70. return buf
  71. def close(self):
  72. logger.warning("closing connection")
  73. try:
  74. self.send({}, op=OP_CLOSE)
  75. finally:
  76. self._close()
  77. @abstractmethod
  78. def _close(self):
  79. pass
  80. def __enter__(self):
  81. return self
  82. def __exit__(self, *_):
  83. self.close()
  84. def send_recv(self, data, op=OP_FRAME):
  85. self.send(data, op)
  86. return self.recv()
  87. def send(self, data, op=OP_FRAME):
  88. logger.debug("sending %s", data)
  89. data_str = json.dumps(data, separators=(',', ':'))
  90. data_bytes = data_str.encode('utf-8')
  91. header = struct.pack("<II", op, len(data_bytes))
  92. self._write(header)
  93. self._write(data_bytes)
  94. def recv(self) -> (int, "JSON"):
  95. """Receives a packet from discord.
  96. Returns op code and payload.
  97. """
  98. op, length = self._recv_header()
  99. payload = self._recv_exactly(length)
  100. data = json.loads(payload.decode('utf-8'))
  101. logger.debug("received %s", data)
  102. return op, data
  103. def set_activity(self, act):
  104. # act
  105. data = {
  106. 'cmd': 'SET_ACTIVITY',
  107. 'args': {'pid': os.getpid(),
  108. 'activity': act},
  109. 'nonce': str(uuid.uuid4())
  110. }
  111. self.send(data)
  112. class WinDiscordIpcClient(DiscordIpcClient):
  113. _pipe_pattern = R'\\?\pipe\discord-ipc-{}'
  114. def _connect(self):
  115. for i in range(10):
  116. path = self._pipe_pattern.format(i)
  117. try:
  118. self._f = open(path, "w+b")
  119. except OSError as e:
  120. logger.error("failed to open {!r}: {}".format(path, e))
  121. else:
  122. break
  123. else:
  124. return DiscordIpcError("Failed to connect to Discord pipe")
  125. self.path = path
  126. def _write(self, data: bytes):
  127. self._f.write(data)
  128. self._f.flush()
  129. def _recv(self, size: int) -> bytes:
  130. return self._f.read(size)
  131. def _close(self):
  132. self._f.close()
  133. class UnixDiscordIpcClient(DiscordIpcClient):
  134. def _connect(self):
  135. self._sock = socket.socket(socket.AF_UNIX)
  136. pipe_pattern = self._get_pipe_pattern()
  137. for i in range(10):
  138. path = pipe_pattern.format(i)
  139. if not os.path.exists(path):
  140. continue
  141. try:
  142. self._sock.connect(path)
  143. except OSError as e:
  144. logger.error("failed to open {!r}: {}".format(path, e))
  145. else:
  146. break
  147. else:
  148. return DiscordIpcError("Failed to connect to Discord pipe")
  149. @staticmethod
  150. def _get_pipe_pattern():
  151. env_keys = ('XDG_RUNTIME_DIR', 'TMPDIR', 'TMP', 'TEMP')
  152. for env_key in env_keys:
  153. dir_path = os.environ.get(env_key)
  154. if dir_path:
  155. break
  156. else:
  157. dir_path = '/tmp'
  158. return os.path.join(dir_path, 'discord-ipc-{}')
  159. def _write(self, data: bytes):
  160. self._sock.sendall(data)
  161. def _recv(self, size: int) -> bytes:
  162. return self._sock.recv(size)
  163. def _close(self):
  164. self._sock.close()