From 29ffe5de3c308013742b5bd97f7d75b09bd3b427 Mon Sep 17 00:00:00 2001 From: Jesusaves Date: Mon, 5 Feb 2024 11:17:23 -0300 Subject: Some button aligning, a CI template, and Discord RPC --- .gitignore | 1 + .gitlab-ci.yml | 67 +++ Config/config.xml | 263 +++++++++++ Config/serverlistplus.xml | 143 ++++++ Config/test.xml | 16 + __main__.py | 108 +++-- discord_rpc/__init__.py | 919 +++++++++++++++++++++++++++++++++++++ discord_rpc/codes/__init__.py | 0 discord_rpc/codes/errorcodes.py | 3 + discord_rpc/codes/opcodes.py | 5 + discord_rpc/codes/statecodes.py | 4 + discord_rpc/connection/__init__.py | 0 discord_rpc/connection/ipc.py | 387 ++++++++++++++++ discord_rpc/connection/rpc.py | 175 +++++++ discord_rpc/util/__init__.py | 0 discord_rpc/util/backoff.py | 35 ++ discord_rpc/util/limits.py | 32 ++ discord_rpc/util/types.py | 349 ++++++++++++++ discord_rpc/util/utils.py | 180 ++++++++ 19 files changed, 2638 insertions(+), 49 deletions(-) create mode 100644 .gitlab-ci.yml create mode 100644 Config/config.xml create mode 100644 Config/serverlistplus.xml create mode 100644 Config/test.xml create mode 100755 discord_rpc/__init__.py create mode 100644 discord_rpc/codes/__init__.py create mode 100644 discord_rpc/codes/errorcodes.py create mode 100644 discord_rpc/codes/opcodes.py create mode 100644 discord_rpc/codes/statecodes.py create mode 100644 discord_rpc/connection/__init__.py create mode 100644 discord_rpc/connection/ipc.py create mode 100644 discord_rpc/connection/rpc.py create mode 100644 discord_rpc/util/__init__.py create mode 100644 discord_rpc/util/backoff.py create mode 100644 discord_rpc/util/limits.py create mode 100644 discord_rpc/util/types.py create mode 100644 discord_rpc/util/utils.py diff --git a/.gitignore b/.gitignore index 3ba8733..79f7cb3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *.b64 +manaplus/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..50332e9 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,67 @@ +stages: + - test + - build + +pyflakes3: + stage: test + script: + - apt-get update + - apt-get -y -qq install python3 pyflakes3 + - pyflakes3 . + image: ubuntu:18.04 + allow_failure: true + +.compile: + stage: build + script: + - mkdir manaplus + - mv Config manaplus + - mkdir -p manaplus/Local/updates/updates.tmw2.org + - mkdir manaplus/Local/temp + - mkdir manaplus/Local/logs + - touch manaplus/Local/manaplus.log + - touch manaplus/Local/manaplustest.log + - echo "Fetching clients..." + - wget -t 0 -c "https://updates.tmw2.org/mana/linux/ManaPlus-x86_64.AppImage" -O manaplus/ManaVerse.AppImage + - wget -t 0 -c "https://updates.tmw2.org/mana/linux/Mana-x86_64.AppImage" -O manaplus/Mana.AppImage + - chmod +x manaplus/*.AppImage + - wget -t 0 -c "https://updates.tmw2.org/mana/windows/manaverse.zip" -O manaplus/manaverse.zip + # TODO: Unzip manaverse.zip + ##################################################### + - echo "Fetching updates for - Moubootaur Legends" + - mkdir -p manaplus/Local/updates/updates.tmw2.org/ml/local + - mkdir -p manaplus/Local/updates/updates.tmw2.org/ml/fix + - wget "https://updates.tmw2.org/ml/TMW2.zip" -O manaplus/Local/updates/updates.tmw2.org/ml/TMW2.zip + - wget "https://updates.tmw2.org/ml/TMW2-2b90b80.zip" -O manaplus/Local/updates/updates.tmw2.org/ml/TMW2-2b90b80.zip + - wget "https://updates.tmw2.org/ml/music.zip" -O manaplus/Local/updates/updates.tmw2.org/ml/music.zip + - wget "https://updates.tmw2.org/ml/musicv2.zip" -O manaplus/Local/updates/updates.tmw2.org/ml/musicv2.zip + - wget "https://updates.tmw2.org/ml/musicv3.zip" -O manaplus/Local/updates/updates.tmw2.org/ml/musicv3.zip + - wget "https://updates.tmw2.org/ml/musicv4.zip" -O manaplus/Local/updates/updates.tmw2.org/ml/musicv4.zip + - wget "https://updates.tmw2.org/ml/Bugfix-TMW2.zip" -O manaplus/Local/updates/updates.tmw2.org/ml/Bugfix-TMW2.zip + ##################################################### + - echo "Fetching updates for - TMW Classic" + - mkdir -p manaplus/Local/updates/updates.tmw2.org/legacy/local + - mkdir -p manaplus/Local/updates/updates.tmw2.org/legacy/fix + - wget "https://updates.tmw2.org/legacy/TMW.zip" -O manaplus/Local/updates/updates.tmw2.org/legacy/TMW.zip + - wget "https://updates.tmw2.org/legacy/TMW-music.zip" -O manaplus/Local/updates/updates.tmw2.org/legacy/TMW-music.zip + - wget "https://updates.tmw2.org/legacy/TMW-mods.zip" -O manaplus/Local/updates/updates.tmw2.org/legacy/TMW-mods.zip + ##################################################### + - echo "Fetching updates for - TMW Crossroads" + - mkdir -p manaplus/Local/updates/updates.tmw2.org/CR/local + - mkdir -p manaplus/Local/updates/updates.tmw2.org/CR/fix + - wget "https://updates.tmw2.org/CR/TMW2.zip" -O manaplus/Local/updates/updates.tmw2.org/CR/TMW2.zip + ##################################################### + # TODO: rEvolt updates, Mana updates + image: ubuntu:18.04 + artifacts: + paths: + - "." + expire_in: 2 weeks + only: + - master + +sast: + stage: test +include: +- template: Security/SAST.gitlab-ci.yml + diff --git a/Config/config.xml b/Config/config.xml new file mode 100644 index 0000000..5a47974 --- /dev/null +++ b/Config/config.xml @@ -0,0 +1,263 @@ + + + diff --git a/Config/serverlistplus.xml b/Config/serverlistplus.xml new file mode 100644 index 0000000..929e68b --- /dev/null +++ b/Config/serverlistplus.xml @@ -0,0 +1,143 @@ + + + + + PRODUCTION + https://themanaworld.org/ + + + https://github.com/themanaworld/tmwa-client-data + https://github.com/themanaworld/tmwa-server-data + https://github.com/themanaworld/tmw-music + https://github.com/themanaworld/tmwa + + + + https://www.themanaworld.org/index.php/Game_Rules + discontinued + + https://forums.themanaworld.org/viewtopic.php?f=1&t=17737 + https://www.themanaworld.org/registration.php + Join adventures with people from all over the world. + Rejoignez de nouvelles aventures avec des personnes du monde entier. + Beleef avonturen met mensen van over de hele wereld. + Faça parte de aventuras com pessoas de todo o mundo. + Junte-se em aventuras com pessoas de todo o mundo. + 和来自全球的玩家一同冒险。 + Trete Abenteuern mit Spielern aus aller Welt bei. + Inizia l'avventura insieme a giocatori da tutto il mondo. + 世界中のみんなと冒険にでかけませんか? + Przeżywaj przygody z ludźmi z całego świata! + Přidejte se k dobrodružství s lidmi z celého světa. + Присоединяйся к приключениям с людьми со всего мира. + Doe moa mee met mins'n van d'n 'ele wereld. + Deel avonturen met mensen van over de hele wereld. + Bergabung berpetualangan bersama seluruh pemain dari berbagai belahan dunia. + Únete a la aventura con gente de todo el mundo. + + + + + http://updates.moubootaurlegends.org + DEVELOPMENT + https://moubootaurlegends.org/ + + + https://gitlab.com/TMW2/clientdata + https://gitlab.com/TMW2/serverdata + https://gitlab.com/TMW2/evol-music + https://gitlab.com/evol/hercules + https://gitlab.com/evol/evol-hercules + + + + https://gitlab.com/TMW2/Docs + + Moubootaur Legends. + + + + + PRODUCTION + http://tmw-br.scall.org/ + http://tmw-br.scall.org/webchat + + + https://www.themanaworld.com.br/file/tmwa-server-data.txz + + + + https://www.themanaworld.com.br/rules + https://www.themanaworld.com.br/license + + Invite your friends and explore an original world in portuguese. + + + + + DEVELOPMENT + + + http://updates.tmw2.org/messworld + + + + http://server.themanaworld.org/test-updates/ + + http://themanaworld.org/ + https://forums.themanaworld.org/viewforum.php?f=2 + + + https://gitlab.com/evol/clientdata + https://gitlab.com/evol/serverdata + https://gitlab.com/evol/evol-music + https://gitlab.com/evol/hercules + https://gitlab.com/evol/evol-hercules + + + + https://www.themanaworld.org/index.php/Game_Rules + https://www.themanaworld.org/index.php/Dev:Main + + server.themanaworld.org/testing + http://updates.themanaworld.org/test-updates + New content can be tested here before release. + Les nouveautés peuvent être testées ici avant leur sortie officielle. + Nieuwe inhoud kan hier, alvorens te worden uitgebracht, worden getest. + Novo conteúdo pode ser testado aqui antes do lançamento. + Novos conteúdos são testados aqui antes de serem lançados. + 新内容发布前可以在这里测试。 + Neuer Inhalt kann hier vor Veröffentlichung getestet werden. + Server per testare i nuovi contenuti di gioco prima della release ufficiale. + リリース前の新しいコンテンツはこちらでテストできます + Nowa zawartość The Mana World jest testowana tutaj przed oficjalnym wydaniem. + Zde můžeš otestovat nový obsah před vydáním. + Здесь тестируется новый контент перед релизом. + Nieuw'n inhod kan ier getest weur'n voar release. + Nieuwe inhoud kan hier worden getest alvorens publiekelijk te gaan. + + Nuevos contenidos pueden ser probados aquí antes de ser liberados. + + + + + DEVELOPMENT + https://evol.tmw2.org/ + TMW rEvolt + + + + + DEVELOPMENT + https://manaplus.germantmw.de/ + ManaPlus Testserver based on evol2 (whitelist enabled) + + + + + DEVELOPMENT + https://manaplus.germantmw.de/ + ManaPlus Testserver based on tmwAthena (whitelist enabled) + + + diff --git a/Config/test.xml b/Config/test.xml new file mode 100644 index 0000000..170b073 --- /dev/null +++ b/Config/test.xml @@ -0,0 +1,16 @@ + + + diff --git a/__main__.py b/__main__.py index 26472e7..8ff730c 100755 --- a/__main__.py +++ b/__main__.py @@ -247,7 +247,7 @@ def launch_game(idx): if pref["local"]: CMD+=".AppImage" else: - ## Mana and M+ are not available on Windows (TODO) + ## Mana and M+ are not available on Windows yet (TODO) if pref["local"]: CMD+="Mana/manaplus.exe" # FIXME untested else: @@ -255,17 +255,23 @@ def launch_game(idx): ## Build the server options OPT="-s %s -y %s -p %s -S" % (HOST, serverlist[idx]["Type"], PORT) + ## Mana "fix" + if pref["mana"]: + OPT=OPT[:-2] print("%s %s" % (CMD, OPT)) + ## Local or System-Wide Config folders if pref["local"]: if not sys.platform.startswith('win'): OPT+=" -C %s/Config -L %s/Local" % (CWD, CWD) else: OPT+=" -C %s\\Config -L %s\\Local" % (CWD.replace('/','\\'), CWD.replace('/','\\')) - pass + ## Mana "fix" (TODO Untested) + if pref["mana"]: + OPT=OPT.replace(" -L ", " --localdata-dir ") ## Execute the app - ## TODO: Threading, MLP + ## TODO: Threading? if pref["shell"]: app=execute(san("%s %s%s" % (CMD, OPT, PWD)), shell=True) # nosec else: @@ -280,8 +286,7 @@ def launch_game_master(idx): stdout("[CLIENT] Mirror Lake triggered.") ## Set credentials auth = {"vaultId": vaultId, - "token": vaultToken, - "world": serverlist[idx]["UUID"]} + "token": vaultToken} r=vault.post(VAULT_HOST+"/getlake", json=auth, timeout=15.0) if r.status_code == 200: goto=r.json()["world"] @@ -315,7 +320,33 @@ def world_select(): canva.pack() label1 = tk.Label(root, text='World Selection', bg="#0c3251", fg="#fff") label1.config(font=('helvetica', 14)) - canva.create_window(200, 20, window=label1) + canva.create_window(200, 30, window=label1) + + ## Fetch soul data + auth = {"vaultId": vaultId, + "token": vaultToken} + + r=vault.post(VAULT_HOST+"/souldata", json=auth, timeout=15.0) + if r.status_code != 200: + raise Exception("Request error: %d" % r.status_code) + dat=r.json() + mySoul={} + mySoul["level"]=dat["soulv"] + mySoul["exp"]=dat["soulx"] + mySoul["next"]=dat["varlv"] + mySoul["up"]=False + ## Newer versions of API may level you up - catch it + try: + mySoul["up"]=dat["lvlup"] + mySoul["home"]=dat["homew"] + for s in serverlist: + if mySoul["home"] == s["UUID"]: + mySoul["home"] = s["Name"] + break + if mySoul["home"] == "VAULT": + mySoul["home"]="Not Set" + except: + pass ## Not really necessary? Or just TODO? ## Without these, max is 10 worlds @@ -325,19 +356,25 @@ def world_select(): ## Create a list of all worlds ypos = 60 for w in serverlist: - ## TODO: Do not block main thread, launch this in a threading + ## TODO: Do not block main thread, launch this in a threading? ## TODO: Make the button width fixed, so they align better ## TODO: Image button if an icon can be found - button = HoverButton(text=w["Name"], command=partial(launch_game_master, serverlist.index(w)), width=300, bg="#cc6600", fg="#fff", activebackground="#ee9933") - ## TODO: Handle MLP (on the threading?) - ## while app = 7 ... + button = HoverButton(text=w["Name"], command=partial(launch_game_master, serverlist.index(w)), width=40, anchor="w", bg="#cc6600", fg="#fff", activebackground="#ee9933") ## TODO: First login greeting? - canva.create_window(100, ypos, window=button) - ## TODO: World Info Button + canva.create_window(200, ypos, window=button) + ## TODO FIXME: World Info Button button = HoverButton(text="?", command=partial(info_game, serverlist.index(w)), bg="#cc6600", fg="#fff", activebackground="#ee9933") - canva.create_window(350, ypos, window=button) + canva.create_window(370, ypos, window=button) ypos += 40 + ## TODO: Footnote + labf = "Lv %d, %d/%d EXP" % (mySoul["level"], mySoul["exp"], mySoul["next"]) + labelf = tk.Label(root, text=labf, bg="#0c3251", fg="#fff") + labelf.config(font=('helvetica', 14)) + canva.create_window(200, 550, window=labelf) + labelf = tk.Label(root, text="Home: %s" % mySoul["home"], bg="#0c3251", fg="#fff") + labelf.config(font=('helvetica', 14)) + canva.create_window(200, 570, window=labelf) return ################################################################################# @@ -416,65 +453,38 @@ savePass.set(pref["pass"] != "") ## Email label1 = tk.Label(root, text='Email:', bg="#0c3251", fg="#fff") label1.config(font=('helvetica', 14)) -canva.create_window(200, 40, window=label1) +canva.create_window(180, 40, window=label1) entry1 = tk.Entry(root) entry1.insert(0, pref["user"]) -canva.create_window(200, 80, window=entry1) +canva.create_window(180, 80, window=entry1) c1 = tk.Checkbutton(root, text="Remember", variable=saveMail, bg="#0c3251", fg="#f70") -canva.create_window(350, 80, window=c1) +canva.create_window(320, 80, window=c1) label2 = tk.Label(root, text='Password:', bg="#0c3251", fg="#fff") label2.config(font=('helvetica', 14)) -canva.create_window(200, 120, window=label2) +canva.create_window(180, 120, window=label2) entry2 = tk.Entry(root, show="*") entry2.insert(0, pref["pass"]) -canva.create_window(200, 160, window=entry2) +canva.create_window(180, 160, window=entry2) c1 = tk.Checkbutton(root, text="Remember", variable=savePass, bg="#0c3251", fg="#f70") -canva.create_window(350, 160, window=c1) +canva.create_window(320, 160, window=c1) label3 = tk.Label(root, text='TOTP:', bg="#0c3251", fg="#fff") label3.config(font=('helvetica', 14)) -canva.create_window(200, 200, window=label3) +canva.create_window(180, 200, window=label3) entry3 = tk.Entry(root) #entry3.insert(0, pref["totp"]) -canva.create_window(200, 240, window=entry3) +canva.create_window(180, 240, window=entry3) button1 = HoverButton(text='Login', command=login, bg="#cc6600", fg="#fff", activebackground="#ee9933") canva.create_window(200, 300, window=button1) root.mainloop() -# Check if you're now logged in +# Check if you were logged in if vaultId < 1: exit(1) print("Thanks for playing!") - -exit(0) # <- FIXME: Not necessary? Just delete stuff below - -################################################################################# -""" - while True: - app=launch_game(idx) - - ## TODO: Handle MLP - while app == 7: - stdout("[CLIENT] Mirror Lake triggered.") - r=vault.post(VAULT_HOST+"/getlake", json=auth, timeout=15.0) - if r.status_code == 200: - goto=r.json()["world"] - stdout("MLP Target: %s" % str(goto)) - if goto == "" or goto.lower() == "vault": - stdout("Mirror Lake False Positive") - break - try: - idx=int(dl_search_idx(serverlist, "UUID", goto)) - app=launch_game(idx) - except: - traceback.print_exc() - break - else: - stdout("ERROR: Unknown command. Try \"help\".") -""" diff --git a/discord_rpc/__init__.py b/discord_rpc/__init__.py new file mode 100755 index 0000000..73f111c --- /dev/null +++ b/discord_rpc/__init__.py @@ -0,0 +1,919 @@ +from __future__ import absolute_import, print_function +""" +Python Rich Presence library for Discord +""" +from .util.backoff import Backoff +from copy import deepcopy +import logging +from threading import Lock, Thread +from .connection.rpc import RpcConnection +from .util.utils import get_process_id, is_callable, iter_items, iter_keys, is_python3, bytes, unicode, is_linux, \ + is_windows, get_executable_path +from .util.types import Int32, Int64 +import json +import time +try: + from Queue import Queue + from Queue import Empty as QueueEmpty +except ImportError: + try: + from queue import Queue + from queue import Empty as QueueEmpty + except ImportError: + # we somehow can't import either python Queue's + # create a fake Queue class that'll do nothing + # and without killing the program + from .util.utils import DummyQueue as Queue + from .util.utils import Empty as QueueEmpty +from os import path, makedirs +if not is_python3(): + import requests +else: + from urllib.request import urlopen, Request +from os import environ, system +from sys import stderr +if is_windows(): + if is_python3(): + import winreg + else: + import _winreg as winreg + + +VERSION = "1.3.0" +PROJECT_URL = "https://gitlab.com/somberdemise/discord-rpc.py" + +DISCORD_REPLY_NO = 0 +DISCORD_REPLY_YES = 1 +DISCORD_REPLY_IGNORE = 2 + + +_discord_rpc = None +_auto_update_connection = False +_update_thread = None +_connection_lock = Lock() +_http_rate_limit = None + + +class _DiscordRpc(object): + connection = None + _time_call = time.time + _just_connected = False + _just_disconnected = False + _got_error = False + _was_joining = False + _was_spectating = False + _spectate_secret = '' + _join_secret = '' + _last_err = [0, ''] + _last_disconnect = [0, ''] + __presence_lock = Lock() + __handler_lock = Lock() + __send_queue = Queue(8) + __join_ask_queue = Queue(8) + __connected_user = { + 'id': None, + 'username': None, + 'discriminator': None, + 'avatar': None, + } + __current_presence = None + __pid = 0 + __nonce = 1 + __reconnect_time = Backoff(500, 60000) + __next_connect = None + __callbacks = { + 'ready': None, + 'disconnected': None, + 'joinGame': None, + 'spectateGame': None, + 'joinRequest': None, + 'error': None, + } + __registered_handlers = { + 'joinGame': False, + 'spectateGame': False, + 'joinRequest': False, + } + __http_rate_limit = None + + def __init__(self, app_id, pid=None, pipe_no=0, log=True, logger=None, log_file=None, log_level=logging.INFO, + callbacks=None): + if pid is not None: + if not isinstance(pid, int): + raise TypeError('PID must be of int type!') + self.__pid = pid + else: + self.__pid = get_process_id() + if callbacks: + self.set_callbacks(**callbacks) + if self.connection is not None: + return + + self.connection = RpcConnection(app_id, pipe_no=pipe_no, log=log, logger=logger, log_file=log_file, + log_level=log_level) + self.connection.on_connect = self._on_connect + self.connection.on_disconnect = self._on_disconnect + + def shutdown(self): + if self.connection is None: + self._log('debug', 'Connection hasn\'t been established or recently shutdown; ignoring.') + return + self.connection.on_connect = None + self.connection.on_disconnect = None + self.__callbacks = { + 'ready': None, + 'disconnected': None, + 'joinGame': None, + 'spectateGame': None, + 'joinRequest': None, + 'error': None, + } + self.connection.destroy() + + def run_callbacks(self): + if self.connection is None: + return + + was_disconnected = self._just_disconnected + self._just_disconnected = False + is_connected = self.connection.is_open + + if is_connected: + with self.__handler_lock: + if was_disconnected: + self._run_callback('disconnected', self._last_disconnect[0], self._last_disconnect[1]) + + if self._just_connected: + with self.__handler_lock: + self._run_callback('ready', self.current_user) + self._just_connected = False + + if self._got_error: + with self.__handler_lock: + self._run_callback('error', self._last_err[0], self._last_err[1]) + self._got_error = False + + if self._was_joining: + with self.__handler_lock: + self._run_callback('joinGame', self._join_secret) + self._was_joining = False + + if self._was_spectating: + with self.__handler_lock: + self._run_callback('spectateGame', self._spectate_secret) + self._was_spectating = False + + users = list() + with self.__handler_lock: + if not self.__join_ask_queue.empty(): + # instead of trying to use a guesstimated value (qsize), + # grab data w/o blocking, and break when it raises the "empty" error + while True: + try: + user = self.__join_ask_queue.get(False) + users.append(deepcopy(user)) + # just in case + self.__join_ask_queue.task_done() + except QueueEmpty: + break + if len(users) > 0: + self._run_callback('joinRequest', users) + self._log('debug', "Users requesting to join: {}".format(users)) + else: + self._log('debug', "No users requesting to join game.") + + if not is_connected and was_disconnected: + with self.__handler_lock: + self._run_callback('disconnected', self._last_disconnect[0], self._last_disconnect[1]) + + def update_connection(self): + if self.connection is None: + return + + if not self.connection.is_open: + if self.__next_connect is None: + self.__next_connect = self.time_now() + if self.time_now() >= self.__next_connect: + self.update_reconnect_time() + self._log('debug', 'Next connection in {} seconds.'.format(self.__next_connect - self.time_now())) + self.connection.open() + else: + # reads + while True: + did_read, data = self.connection.read() + if not did_read: + break + evt = data.get('evt') + nonce = data.get('nonce') + if nonce is not None: + err_data = data.get('data', dict()) + if evt is not None and evt == 'ERROR': + self._last_err = [err_data.get('code', 0), err_data.get('message', '')] + self._got_error = True + else: + if evt is None: + self._log('debug', 'No event sent by Discord.') + continue + read_data = data.get('data', dict()) + if evt == 'ACTIVITY_JOIN': + self._join_secret = read_data.get('secret', '') + self._was_joining = True + elif evt == 'ACTIVITY_SPECTATE': + self._spectate_secret = read_data.get('secret', '') + self._was_spectating = True + elif evt == 'ACTIVITY_JOIN_REQUEST': + user = read_data.get('user', None) + uid = user.get('id', None) + uname = user.get('username', None) + discrim = user.get('discriminator', None) + if any(x is None for x in (uid, uname, discrim)): + # something is wrong with Discord (or we need to update this library) + self._log('warning', 'Discord failed to send required data for join request!') + continue + # the avatar hash to grab from https://cdn.discordapp.com/ + # can be None/empty string + avatar = user.get('avatar', None) + if not self.__join_ask_queue.full(): + user_data = {'id': uid, + 'username': uname, + 'discriminator': discrim, + 'avatar': avatar} + self.__join_ask_queue.put(user_data) + # writes + if self.__current_presence is not None and len(self.__current_presence) > 0: + with self.__presence_lock: + if self.connection.write(self.__current_presence): + # only erase if we successfully wrote + self.__current_presence = None + self._log('debug', 'Wrote presence data to IPC.') + if not self.__send_queue.empty(): + # instead of trying to use a guesstimated value (qsize), + # grab data w/o blocking, and break when it raises the "empty" error + while True: + try: + # attempt to grab data + sdata = self.__send_queue.get(False) + # apparently, we don't care for the write results, according to Discord's library + self.connection.write(sdata) + # just in case + self.__send_queue.task_done() + except QueueEmpty: + self._log('debug', 'Wrote queue of send data to IPC.') + break + + def __presence_to_json(self, **kwargs): + """Creates json rich presence info.""" + # See https://discordapp.com/developers/docs/rich-presence/how-to#updating-presence-update-presence-payload + # for max values for payload + if self.pid <= 0: + raise AttributeError('PID is required for payload!') + rp = dict() + rp['cmd'] = 'SET_ACTIVITY' + rp['args'] = dict() + # I don't really know why PID is required, but alright + rp['args']['pid'] = int(self.pid) + + rp['args']['activity'] = dict() + + state = kwargs.get('state', None) + if state is not None and len(state) > 0: + rp['args']['activity']['state'] = str(state[:128]) + + details = kwargs.get('details', None) + if details is not None and len(details) > 0: + rp['args']['activity']['details'] = str(details[:128]) + + # NOTE: Sending endTimestamp will always have the time displayed as "remaining" until the given time. + # Sending startTimestamp will show "elapsed" as long as there is no endTimestamp sent. + start_timestamp = kwargs.get('start_timestamp', None) + end_timestamp = kwargs.get('end_timestamp', None) + # we do int calculations beforehand, in case it turns out that the number ends up as 0 + if start_timestamp is not None: + start_timestamp = Int64(int(start_timestamp)).get_number() + if end_timestamp is not None: + end_timestamp = Int64(int(end_timestamp)).get_number() + if any(x for x in (start_timestamp, end_timestamp)): + rp['args']['activity']['timestamps'] = dict() + # start_timestamp != None && start_timestamp != 0 + if start_timestamp: + rp['args']['activity']['timestamps']['start'] = start_timestamp + # same for end + if end_timestamp: + rp['args']['activity']['timestamps']['end'] = end_timestamp + + large_image_key = kwargs.get('large_image_key', None) + large_image_text = kwargs.get('large_image_text', None) + small_image_key = kwargs.get('small_image_key', None) + small_image_text = kwargs.get('small_image_text', None) + if any(x is not None and len(x) > 0 for x in + (large_image_key, large_image_text, small_image_key, small_image_text)): + rp['args']['activity']['assets'] = dict() + if large_image_key is not None and len(large_image_key) > 0: + rp['args']['activity']['assets']['large_image'] = str(large_image_key[:128]).lower() + if large_image_text is not None and len(large_image_text) > 0: + rp['args']['activity']['assets']['large_text'] = str(large_image_text[:128]) + if small_image_key is not None and len(small_image_key) > 0: + rp['args']['activity']['assets']['small_image'] = str(small_image_key[:128]).lower() + if small_image_text is not None and len(small_image_text) > 0: + rp['args']['activity']['assets']['small_text'] = str(small_image_text[:128]) + + party_id = kwargs.get('party_id', None) + party_size = kwargs.get('party_size', None) + party_max = kwargs.get('party_max', None) + # we do int calculations beforehand, in case it turns out that the number ends up as 0 + if party_size is not None: + party_size = Int32(int(party_size)).get_number() + if party_max is not None: + party_max = Int32(int(party_max)).get_number() + # for some reason, Discord does partySize || partyMax, but then requires partySize & partyMax > 0... + # we shall correct that by throwing an all() + if (party_id is not None and len(party_id) > 0) or all(x for x in (party_size, party_max)): + rp['args']['activity']['party'] = dict() + if party_id is not None and len(party_id) > 0: + rp['args']['activity']['party']['id'] = str(party_id[:128]) + if party_size and party_max: + rp['args']['activity']['party']['size'] = [party_size, party_max] + + # match_secret = kwargs.get('match_secret', None) # deprecated + join_secret = kwargs.get('join_secret', None) + spectate_secret = kwargs.get('spectate_secret', None) + # if any(x is not None and len(x) > 0 for x in (match_secret, join_secret, spectate_secret)): + if any(x is not None and len(x) > 0 for x in (join_secret, spectate_secret)): + rp['args']['activity']['secrets'] = dict() + # if match_secret is not None and len(match_secret) > 0: + # rp['args']['secrets']['match'] = str(match_secret[:128]) + if join_secret is not None and len(join_secret) > 0: + rp['args']['activity']['secrets']['join'] = str(join_secret[:128]) + if spectate_secret is not None and len(spectate_secret) > 0: + rp['args']['activity']['secrets']['spectate'] = str(spectate_secret[:128]) + + # rp['args']['instance'] = bool(kwargs.get('instance', False)) # deprecated + rp['nonce'] = str(self.nonce) + self.__nonce += 1 + + self._log('debug', 'Presence data to be written: {}'.format(rp)) + return json.dumps(rp) + + def update_presence(self, **kwargs): + """ + :param kwargs: kwargs must consist of any of the following: + (optional) state (string) + (optional) details (string) + (optional) start_timestamp (int) + (optional) end_timestamp (int) + (optional) large_image_key (string, lowercase) + (optional) large_image_text (string) + (optional) small_image_key (string, lowercase) + (optional) small_image_text (string) + (optional) party_id (string) + (optional) party_size (int), party_max (int) (both are required if using either) + (optional) join_secret (string) + (optional) spectate_secret (string) + Note: see here https://discordapp.com/developers/docs/rich-presence/how-to#updating-presence + Note 2: We do not use deprecated parameters at this time + :return: N/A + """ + json_data = self.__presence_to_json(**kwargs) + with self.__presence_lock: + self.__current_presence = json_data + + def clear_presence(self): + self.update_presence() + + def respond(self, user_id, reply): + if self.connection is None or not self.connection.is_open: + self._log('warning', 'Cannot reply to discord user {}; connection not established!'.format(user_id)) + return + response = dict() + if reply == DISCORD_REPLY_YES: + cmd = 'SEND_ACTIVITY_JOIN_INVITE' + else: + cmd = 'CLOSE_ACTIVITY_JOIN_REQUEST' + response['cmd'] = cmd + response['args'] = dict() + response['args']['user_id'] = str(user_id) + response['nonce'] = str(self.nonce) + self.__nonce += 1 + if not self.__send_queue.full(): + self.__send_queue.put(json.dumps(response)) + self._log('debug', 'Queued reply: {}'.format(response)) + else: + self._log('warning', 'Cannot reply to discord user {}; send queue is full!') + + def last_error(self): + return self._last_err[0], self._last_err[1] + + def last_disconnect(self): + return self._last_disconnect[0], self._last_disconnect[1] + + def update_reconnect_time(self): + current_time = self.time_now() + delay = self.__reconnect_time.next_delay() / 1000 + self.__next_connect = current_time + delay + self._log('debug', 'Updating next connect to {}. Current time: {}, delay: {}'.format(self.__next_connect, + current_time, + delay)) + + def set_callbacks(self, **kwargs): + for name, callback_info in iter_items(kwargs): + self.set_callback(name, callback_info) + + def set_callback(self, callback_name, callback): + callback_name = callback_name.strip() + if callback_name in ('ready', 'disconnected', 'joinGame', 'spectateGame', 'joinRequest', 'error'): + if callback and not is_callable(callback): + raise TypeError('Callback must be callable! Callback name: {}, callback: {}'.format(callback_name, + callback)) + self.__callbacks[callback_name] = callback + + def update_handlers(self): + for handler in iter_keys(self.__registered_handlers): + if handler == 'joinGame': + event = 'ACTIVITY_JOIN' + elif handler == 'spectateGame': + event = 'ACTIVITY_SPECTATE' + elif handler == 'joinRequest': + event = 'ACTIVITY_JOIN_REQUEST' + else: + # unknown handler + self._log('warning', 'Unknown handler name "{}".'.format(handler)) + continue + if not self.__registered_handlers[handler] and self.__callbacks[handler] is not None: + if not self.__register_event(event): + self._log('warning', 'Unable to register event "{}"'.format(event)) + else: + self._log('info', "Registered handler {}".format(handler)) + elif self.__registered_handlers[handler] and self.__callbacks[handler] is None: + if not self.__unregister_event(event): + self._log('warning', 'Unable to unregister event "{}"'.format(event)) + else: + self._log('debug', 'Unregistered event {}'.format(event)) + + def _run_callback(self, callback_name, *args): + callback_name = callback_name.strip() + if callback_name in self.__callbacks: + if self.__callbacks[callback_name] is not None: + callback = self.__callbacks[callback_name] + if len(args) > 0: + callback(*args) + else: + callback() + else: + self._log('debug', 'No callback set for event "{}"'.format(callback_name)) + else: + self._log('debug', 'No such event name "{}"'.format(callback_name)) + + def _log(self, *args): + if self.connection is not None: + self.connection.log(*args) + + def _on_connect(self, data): + self.update_handlers() + self._log('debug', 'Data received: {}'.format(data)) + user_data = data.get('data', None) + if user_data is not None: + user = user_data.get('user', None) + uid = user.get('id', None) + uname = user.get('username', None) + if any(x is None for x in (uid, uname)): + self._log('warning', 'Discord failed to send current user data.') + else: + discrim = user.get('discriminator', None) # i'm not sure why this can be None for current user, but + # not others... + avatar = user.get('avatar', None) + self.__connected_user['id'] = uid + self.__connected_user['username'] = uname + self.__connected_user['discriminator'] = discrim + self.__connected_user['avatar'] = avatar + self._log('debug', 'Current discord user: {}'.format(self.__connected_user)) + else: + self._log('warning', 'Discord failed to send current user data.') + self._just_connected = True + self.__reconnect_time.reset() + + def _on_disconnect(self, err, msg): + self._just_disconnected = True + self._last_disconnect = [err, msg] + self.__registered_handlers['joinGame'] = False + self.__registered_handlers['joinRequest'] = False + self.__registered_handlers['spectateGame'] = False + self.update_reconnect_time() + + def __register_event(self, event): + data = dict() + data['nonce'] = str(self.nonce) + self.__nonce += 1 + data['cmd'] = 'SUBSCRIBE' + data['evt'] = event + if not self.__send_queue.full(): + self.__send_queue.put(json.dumps(data)) + return True + return False + + def __unregister_event(self, event): + data = dict() + data['nonce'] = str(self.nonce) + self.__nonce += 1 + data['cmd'] = 'UNSUBSCRIBE' + data['evt'] = event + if not self.__send_queue.full(): + self.__send_queue.put(json.dumps(data)) + return True + return False + + @property + def got_error(self): + return self._got_error + + @property + def was_joining(self): + return self._was_joining + + @property + def was_spectating(self): + return self._was_spectating + + @property + def current_user(self): + return deepcopy(self.__connected_user) + + @property + def spectate_secret(self): + return self._spectate_secret + + @property + def join_secret(self): + return self._join_secret + + @property + def pid(self): + return self.__pid + + @property + def nonce(self): + return self.__nonce + + @property + def time_now(self): + return self._time_call + + @time_now.setter + def time_now(self, callback): + if is_callable(callback): + self._time_call = callback + else: + self._log('warning', 'time_now must be callable!') + + @property + def app_id(self): + if self.connection is not None: + return self.connection.app_id + else: + return '0xDEADBEEF' + + +class _UpdateConnection(Thread): + def run(self): + global _discord_rpc + global _connection_lock + while True: + time.sleep(1) + with _connection_lock: + if _discord_rpc is None: + # we have shut down, break and return + break + _discord_rpc.update_connection() + _discord_rpc.run_callbacks() + + +def initialize(app_id, pid=None, callbacks=None, pipe_no=0, time_call=None, auto_update_connection=False, + log=True, logger=None, log_file=None, log_level=logging.INFO, + auto_register=False, steam_id=None, command=None): + """ + Initializes and connects to the Discord Rich Presence RPC + :param app_id: The Client ID from Discord (see https://github.com/discordapp/discord-rpc#basic-usage) + (NOTE: Must be a string) + :param pid: The main program ID (is automatically set if not passed) + :param callbacks: The callbacks and any extra args to run when events are fired ('ready', 'disconnected', + 'joinGame', 'spectateGame', + 'joinRequest', 'error') + :param time_call: The time function to call for epoch seconds (defaults to time.time()) + :param auto_update_connection: Do you want the library to automagically update the connection for you? + (defaults to False) + :param log: Do we want to use logging for the RPC connection (defaults to True) + :param logger: Your own logger to use (must be already set up) (defaults to automatically setting one up + internally) + :param log_file: The location of where the log file should reside (defaults to stdout only, ignored if + rpc_logger is used) + :param log_level: The log level to use (defaults to logging.INFO) + :param pipe_no: The pipe number to use in the RPC connection (must be 0-10, default 0) + :param auto_register: Do you want us to auto-register your program (defaults to False) (NOTE: currently does + nothing) + :param steam_id: The applications steam ID for auto-register (defaults to regular program registration, or + nothing if auto_register is False) (NOTE: Also does nothing currently) + :param command: The command to use for protocol registration (ex: /path/to/file --discord) + :return: N/A + """ + global _discord_rpc + global _auto_update_connection + global _update_thread + + if _discord_rpc is not None: + # don't initialize more than once + return + if auto_register: + register_game(app_id=app_id, steam_id=steam_id, command=command) + _discord_rpc = _DiscordRpc(app_id, pid=pid, pipe_no=pipe_no, log=log, logger=logger, log_file=log_file, + log_level=log_level, callbacks=callbacks) + if time_call is not None: + _discord_rpc.time_now = time_call + + if auto_update_connection: + _auto_update_connection = True + _update_thread = _UpdateConnection() + _update_thread.start() + + +def shutdown(): + """ + Shuts down the Discord Rich Presence connection + :return: N/A + """ + global _discord_rpc + global _auto_update_connection + global _connection_lock + if _discord_rpc is not None: + with _connection_lock: + _discord_rpc.shutdown() + # make sure user/programmer doesn't try to call stuff afterwards on 'discord_rpc' + _discord_rpc = None + if _auto_update_connection and _update_thread is not None: + _update_thread.join() + # always set to False + _auto_update_connection = False + + +def run_callbacks(): + """ + Runs the rich presence callbacks + :return: N/A + """ + global _discord_rpc + if _discord_rpc is not None: + _discord_rpc.run_callbacks() + + +def update_connection(): + """ + Updates the rich presence connection + :return: N/A + """ + global _discord_rpc + global _auto_update_connection + if _discord_rpc is not None and not _auto_update_connection: + _discord_rpc.update_connection() + + +def update_presence(**kwargs): + """ + :param kwargs: kwargs must consist of any of the following: + (optional) state (string) + (optional) details (string) + (optional) start_timestamp (int) + (optional) end_timestamp (int) + (optional) large_image_key (string, lowercase) + (optional) large_image_text (string) + (optional) small_image_key (string, lowercase) + (optional) small_image_text (string) + (optional) party_id (string) + (optional) party_size (int), party_max (int) (both are required if using either) + (optional) join_secret (string) + (optional) spectate_secret (string) + Note: see here https://discordapp.com/developers/docs/rich-presence/how-to#updating-presence + Note 2: We do not use deprecated parameters at this time + :return: N/A + """ + global _discord_rpc + if _discord_rpc is not None: + _discord_rpc.update_presence(**kwargs) + + +def clear_presence(): + """ + Clears the rich presence data last sent + :return: N/A + """ + global _discord_rpc + if _discord_rpc is not None: + _discord_rpc.clear_presence() + + +def respond(user_id, response): + """ + Respond to a discord user + :param user_id: The Discord user's snowflake ID (the '64 char' long one or so) + :param response: The response to send to the user (one of type DISCORD_REPLY_NO, DISCORD_REPLY_YES, + DISCORD_REPLY_IGNORE) + :return: N/A + """ + global _discord_rpc + if _discord_rpc is not None: + _discord_rpc.respond(user_id, response) + + +def download_profile_picture(user_id, discriminator, avatar_hash=None, cache_dir="cache", default_dir="default", + cert_file=None, game_name=None, game_version=None, game_url=None): + """ + Download a discord user's profile picture. + :param user_id: The discord user's ID + :param discriminator: The discord user's discriminator; required and used for when avatar_hash is None + :param avatar_hash: (optional) The discord user's avatar hash. NOTE: if None, defaults to a default avatar image + :param cache_dir: (optional) Path to store the profile picture + :param default_dir: (optional) The path within the cache_dir to use for default avatars + param cert_file: (optional) The path to the cacert file to use + :param game_name: (optional) The name of the game that is running + :param game_version: (optional) The game's version number + :param game_url: (optional) The game's website + :return: Path to profile picture, or None + """ + global _http_rate_limit + if avatar_hash is None: + url = "https://cdn.discordapp.com/embed/avatars/{}.png".format(int(discriminator) % 5) + # NOTE: we default to "./cache/default/" if no path specified + # NOTE 2: we use a "default" directory to save disk space and download calls in the long run + download_folder = path.join(cache_dir, default_dir) + else: + url = "https://cdn.discordapp.com/avatars/{}/{}.jpg?size=2048".format(user_id, avatar_hash) + # NOTE: we default to "./cache/user_id/" if no path specified + download_folder = path.join(cache_dir, user_id) + if not path.exists(download_folder): + makedirs(download_folder, 0o755) + if avatar_hash is not None: + avatar_file = path.join(download_folder, avatar_hash) + '.jpg' + else: + avatar_file = path.join(download_folder, str(int(discriminator) % 5)) + '.png' + if path.exists(avatar_file): + # technically, we downloaded it, so no need to worry about downloading + return avatar_file + # we check this after just in case we already have a cached image + if _http_rate_limit is not None: + if not _http_rate_limit > time.time(): + return None + # we're required to have a ua string + ua_str = "discord-rpc.py ({url}, {version})".format(url=PROJECT_URL, version=VERSION) + if game_name is not None and isinstance(game_name, (bytes, unicode)) and game_name.strip() != '': + # if we have a game name, append that + ua_str += ' {}'.format(game_name) + if all((x is not None and isinstance(x, (bytes, unicode)) and x.strip() != '') for x in (game_version, + game_url)): + # if we have both a url and version number, append those too + ua_str += " ({url}, {version}".format(url=game_url, version=game_version) + headers = {'User-Agent': ua_str} + if is_python3(): + if cert_file is not None: + r = Request( + url, + data=None, + headers=headers, + cafile=cert_file + ) + else: + r = Request( + url, + data=None, + headers=headers + ) + req = urlopen(r) + status_code = req.getcode() + else: + if cert_file is not None: + req = requests.get(url, headers=headers, verify=cert_file) + else: + req = requests.get(url, headers=headers) + status_code = req.status_code + if status_code != 200: + if status_code == 404: + # nonexistent avatar/hash; return None + return None + if 'X-RateLimit-Reset' in req.headers: + _http_rate_limit = int(req.headers['X-RateLimit-Reset']) + else: + try: + if is_python3(): + data = req.read() + json_data = json.loads(data.decode(req.info().get_content_charset('utf-8'))) + else: + json_data = req.json() + if 'retry_after' in json_data: + _http_rate_limit = time.time() + (int(json_data['retry_after']) / 1000.0) + except Exception: + pass + if _http_rate_limit is None: + # try again in 15 min (Discord shouldn't kill us for waiting 15 min anyways...) + _http_rate_limit = time.time() + (15 * 60) + return None + with open(avatar_file, 'wb') as f: + if is_python3(): + f.write(req.read()) + else: + f.write(req.content) + return avatar_file + + +def register_game(app_id, steam_id=None, command=None): + """ + Registers a protocol Discord can use to run your game. + :param app_id: The Client ID from Discord (see https://github.com/discordapp/discord-rpc#basic-usage) + (NOTE: Must be a string) + :param steam_id: The applications steam ID for auto-register (defaults to regular program registration) + :param command: The command to use for protocol registration (ex: /path/to/file --discord) + :return: + """ + if command is None and steam_id is None and (is_windows() or is_linux()): + command = get_executable_path() + if is_linux(): + # linux is the easiest + if steam_id: + command = "xdg-open steam://rungameid/{}".format(steam_id) + home = environ.get('HOME') + if home is None or home.strip() == '': + # no home? no registration! + return + file_contents = "[Desktop Entry]\nName=Game {app_id}\nExec={command} %u\nType=Application\n" + \ + "NoDisplay=true\nCategories=Discord;Games;\nMimeType=x-scheme-handler/discord-{app_id};\n" + file_contents = file_contents.format(app_id=app_id, command=command) + if home.endswith('/'): + home = home[:-1] + # we don't really need to here, but oh well + path_location = path.join(home, ".local", "share", "applications") + if not path.exists(path_location): + makedirs(path_location, 0o700) + with open(path.join(path_location, "discord-{}.desktop".format(app_id)), 'w') as f: + f.write(file_contents) + sys_call = "xdg-mime default discord-{0}.desktop x-scheme-handler/discord-{0}".format(app_id) + if system(sys_call) < 0: + print("Failed to register mime handler!", file=stderr) + elif is_windows(): + def read_key(reg_path, name): + try: + root_key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, reg_path, 0, winreg.KEY_READ) + value, reg_type = winreg.QueryValueEx(root_key, name) + winreg.CloseKey(root_key) + return value + except WindowsError: + return None + + def write_key(reg_path, name, value): + try: + # I know this can return a key if it exists, but oh well + winreg.CreateKey(winreg.HKEY_CURRENT_USER, reg_path) + root_key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, reg_path, 0, winreg.KEY_WRITE) + winreg.SetValueEx(root_key, name, 0, winreg.REG_SZ, value) + winreg.CloseKey(root_key) + return True + except WindowsError: + return False + + if steam_id: + tmp = read_key(r"Software\Valve\Steam", "SteamExe") + if tmp is not None and tmp.strip() != '': + command = "\"{}\" steam://rungameid/{}".format(tmp.replace("/", "\\"), steam_id) + + protocol_desc = "URL:Run game {} protocol".format(app_id) + protocol_path = r"Software\Classes\{}".format("discord-{}".format(app_id)) + if not write_key(protocol_path, None, protocol_desc): + # failed to write the key + print("Error writing description!", file=stderr) + if not write_key(protocol_path, "URL Protocol", None): + print("Error writing description!", file=stderr) + if not write_key(protocol_path + r"\DefaultIcon", None, get_executable_path()): + print("Error writing key!", file=stderr) + if not write_key(protocol_path + r"\shell\open\command", None, command): + print("Error writing command!", file=stderr) + else: + # assume Mac OSX here + def register_url(aid): + # TODO: figure out a feasable way to get this to work + print("Url registration under Mac OSX unimplemented. Cannot create for app ID {}".format(aid), file=stderr) + + def register_command(aid, cmd): + home = path.expanduser("~") + if home is None or home.strip() == '': + return + discord_path = path.join(home, "Library", "Application Support", "discord", "games") + if not path.exists(discord_path): + makedirs(discord_path) + with open(path.join(discord_path, "{}.json".format(aid)), 'w') as f: + f.write("{\"command\": \"{}\"}".format(cmd)) + + if steam_id: + command = "steam://rungameid/{}".format(steam_id) + if command: + register_command(app_id, command) + else: + register_url(app_id) + + +__all__ = ['DISCORD_REPLY_NO', 'DISCORD_REPLY_YES', 'DISCORD_REPLY_IGNORE', 'initialize', 'shutdown', 'run_callbacks', + 'update_connection', 'update_presence', 'clear_presence', 'respond', 'VERSION', 'PROJECT_URL', + 'download_profile_picture', 'register_game'] diff --git a/discord_rpc/codes/__init__.py b/discord_rpc/codes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/discord_rpc/codes/errorcodes.py b/discord_rpc/codes/errorcodes.py new file mode 100644 index 0000000..d501301 --- /dev/null +++ b/discord_rpc/codes/errorcodes.py @@ -0,0 +1,3 @@ +Success = 0 +PipeClosed = 1 +ReadCorrupt = 2 diff --git a/discord_rpc/codes/opcodes.py b/discord_rpc/codes/opcodes.py new file mode 100644 index 0000000..29c3baf --- /dev/null +++ b/discord_rpc/codes/opcodes.py @@ -0,0 +1,5 @@ +Handshake = 0 +Frame = 1 +Close = 2 +Ping = 3 +Pong = 4 diff --git a/discord_rpc/codes/statecodes.py b/discord_rpc/codes/statecodes.py new file mode 100644 index 0000000..51f1a46 --- /dev/null +++ b/discord_rpc/codes/statecodes.py @@ -0,0 +1,4 @@ +Disconnected = 0 +SentHandshake = 1 +AwaitingResponse = 2 +Connected = 3 diff --git a/discord_rpc/connection/__init__.py b/discord_rpc/connection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/discord_rpc/connection/ipc.py b/discord_rpc/connection/ipc.py new file mode 100644 index 0000000..bbb26a7 --- /dev/null +++ b/discord_rpc/connection/ipc.py @@ -0,0 +1,387 @@ +from __future__ import absolute_import +import errno +import logging +from ..codes import errorcodes +from ..util.utils import is_windows, range, get_temp_path, to_bytes, bytes, to_unicode, is_python3, is_callable +import struct +import sys +if is_windows(): + # we're going to have to do some ugly things, because Windows sucks + import ctypes + GENERIC_READ = 0x80000000 + GENERIC_WRITE = 0x40000000 + OPEN_EXISTING = 0x3 + INVALID_HANDLE_VALUE = -1 + PIPE_READMODE_MESSAGE = 0x2 + ERROR_FILE_NOT_FOUND = 0x2 + ERROR_PIPE_BUSY = 0xE7 + ERROR_MORE_DATA = 0xEA + BUFSIZE = 512 +else: + try: + from socket import MSG_NOSIGNAL + _msg_flags = MSG_NOSIGNAL + except ImportError: + _msg_flags = 0 + try: + from socket import SO_NOSIGPIPE + _do_sock_opt = True + except ImportError: + _do_sock_opt = False + import socket + import fcntl + from os import O_NONBLOCK + + +class BaseConnection(object): + """Generate IPC Connection handler.""" + # *nix specific + __sock = None + # Windows specific + __pipe = None + + __open = False + __logger = None + __is_logging = False + + def __init__(self, log=True, logger=None, log_file=None, log_level=logging.INFO): + if not isinstance(log, bool): + raise TypeError('log must be of bool type!') + if log: + if logger is not None: + # Panda3D notifies are similar, so we simply check if we can make the same calls as logger + if not hasattr(logger, 'debug'): + raise TypeError('logger must be of type logging!') + self.__logger = logger + else: + self.__logger = logging.getLogger(__name__) + log_fmt = logging.Formatter('[%(asctime)s][%(levelname)s] ' + '%(name)s - %(message)s') + if log_file is not None and hasattr(log_file, 'strip'): + fhandle = logging.FileHandler(log_file) + fhandle.setLevel(log_level) + fhandle.setFormatter(log_fmt) + self.__logger.addHandler(fhandle) + shandle = logging.StreamHandler(sys.stdout) + shandle.setLevel(log_level) + shandle.setFormatter(log_fmt) + self.__logger.addHandler(shandle) + self.__is_logging = True + + def log(self, callback_name, *args): + if self.__logger is not None: + if hasattr(self.__logger, callback_name) and is_callable(self.__logger.__getattribute__(callback_name)): + self.__logger.__getattribute__(callback_name)(*args) + + def __open_pipe(self, pipe_name, log_type='warning'): + """ + :param pipe_name: the named pipe string + :param log_type: the log type to use (default 'warning') + :return: opened(bool), try_again(bool) + """ + if not is_windows(): + self.log('error', 'Attempted to call a Windows call on a non-Windows OS.') + return + pipe = ctypes.windll.kernel32.CreateFileW(pipe_name, GENERIC_READ | GENERIC_WRITE, 0, None, OPEN_EXISTING, 0, + None) + if pipe != INVALID_HANDLE_VALUE: + self.__pipe = pipe + return True, False + err = ctypes.windll.kernel32.GetLastError() + if err == ERROR_FILE_NOT_FOUND: + self.log(log_type, 'File not found.') + self.log(log_type, 'Pipe name: {}'.format(pipe_name)) + return False, False + elif err == ERROR_PIPE_BUSY: + if ctypes.windll.kernel32.WaitNamedPipeW(pipe_name, 10000) == 0: + self.log(log_type, 'Pipe busy.') + return False, False + else: + # try again, should be free now + self.log('debug', 'Pipe was busy, but should be free now. Try again.') + return False, True + # some other error we don't care about + self.log('debug', 'Unknown error: {}'.format(err)) + return False, False + + def open(self, pipe_no=None): + if pipe_no is not None: + if not isinstance(pipe_no, int): + raise TypeError('pipe_no must be of type int!') + if pipe_no not in range(0, 10): + raise ValueError('pipe_no must be within range (0 <= pipe number < 10)!') + if is_windows(): + # NOTE: don't forget to use a number after ipc- + pipe_name = u'\\\\.\\pipe\\discord-ipc-{}' + if pipe_no is not None: + # we only care about the first value if pipe_no isn't None + opened, try_again = self.__open_pipe(pipe_name.format(pipe_no)) + if opened: + self.__open = True + self.log('info', 'Connected to pipe {}, as user requested.'.format(pipe_no)) + return + elif try_again: + self.open(pipe_no=pipe_no) + return + else: + num = 0 + while True: + if num >= 10: + break + opened, try_again = self.__open_pipe(pipe_name.format(num), log_type='debug') + if opened: + self.__open = True + self.log('debug', 'Automatically connected to pipe {}.'.format(num)) + return + if try_again: + continue + num += 1 + # we failed to get a pipe + self.__pipe = None + self.log('warning', 'Could not open a connection.') + else: + self.__sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + if self.__sock is None or self.__sock == -1: + self.log('warning', 'Could not open socket.') + self.close() + return + try: + fcntl.fcntl(self.__sock, fcntl.F_SETFL, O_NONBLOCK) + except Exception as e: + self.log('warning', e) + self.close() + return + if _do_sock_opt: + try: + socket.setsockopt(socket.SOL_SOCKET, SO_NOSIGPIPE) + except Exception as e: + self.log('warning', e) + self.log('debug', 'Attempting to use sock as is. Notify a developer if an error occurs.') + sock_addr = get_temp_path() + if sock_addr.endswith('/'): + sock_addr = sock_addr[:-1] + sock_addr += '/discord-ipc-{}' + if pipe_no is not None: + ret_val = self.__sock.connect_ex(sock_addr.format(pipe_no)) + if ret_val == 0: + self.__open = True + self.log('info', 'Connected to socket {}, as user requested.'.format(pipe_no)) + return + else: + self.log('warning', 'Could not open socket {}.'.format(pipe_no)) + self.close() + else: + for num in range(0, 10): + ret_val = self.__sock.connect_ex(sock_addr.format(num)) + if ret_val == 0: + self.__open = True + self.log('debug', 'Automatically connected to socket {}.'.format(num)) + return + self.log('warning', 'Could not open socket.') + self.close() + + def write(self, data, opcode): + if not self.connected(): + self.log('warning', 'Cannot write if we aren\'t connected yet!') + return False + if not isinstance(opcode, int): + raise TypeError('Opcode must be of int type!') + if data is None: + data = '' + try: + data = to_bytes(data) + except Exception as e: + self.log('warning', e) + return False + data_len = len(data) + # the following data must be little endian unsigned ints + # see: https://github.com/discordapp/discord-rpc/blob/master/documentation/hard-mode.md#notes + header = struct.pack(' self._max: + raise OverflowError() + elif num < self._min: + raise UnderflowError() + if self._min == 0: + if not number_only: + return self.__class__(num & self._max) + else: + return num & self._max + else: + if num & (1 << (self._bits-1)): + if not number_only: + return self.__class__(num | ~self._max) + else: + return num | ~self._max + else: + if not number_only: + return self.__class__(num & self._max) + else: + return num & self._max + else: + # there are no min/max to check with, so just return the number + if not number_only: + return self.__class__(num) + else: + return num + + def __lt__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._number < other + + def __le__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._number <= other + + def __eq__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._number == other + + def __ne__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._number != other + + def __gt__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._number > other + + def __ge__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._number >= other + + def __bool__(self): + return self._number + + def __repr__(self): + return "{class_name}({number})".format(class_name=self.__class__.__name__, number=self._number) + + def __str__(self): + return str(self._number) + + def __cmp__(self, other): + if isinstance(other, Number): + other = other.get_number() + if self._number == other: + return 0 + elif self._number < other: + return -1 + elif self._number > other: + return 1 + + def __nonzero__(self): + return self._number != 0 + + def __add__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(self._number + other) + + def __sub__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(self._number - other) + + def __mul__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(self._number * other) + + def __div__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(self._number / other) + + def __truediv__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self.__div__(other) + + def __floordiv__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(self._number // other) + + def __mod__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(self._number % other) + + def __divmod__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(divmod(self._number, other)) + + def __pow__(self, power, modulo=None): + if isinstance(power, Number): + power = power.get_number() + if modulo is not None and isinstance(modulo, Number): + modulo = modulo.get_number() + return self._check_number(pow(self._number, power, modulo)) + + def __lshift__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(self._number << other) + + def __rshift__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(self._number >> other) + + def __and__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(self._number & other) + + def __xor__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(self._number ^ other) + + def __or__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(self._number | other) + + def __radd__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(other + self._number) + + def __rsub__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(other - self._number) + + def __rmul__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(other * self._number) + + def __rdiv__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(other / self._number) + + def __rtruediv__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self.__rdiv__(other) + + def __rfloordiv__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(other // self._number) + + def __rmod__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(other % self._number) + + def __rdivmod__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(divmod(other, self._number)) + + def __rpow__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(pow(other, self._number)) + + def __rlshift__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(other << self._number) + + def __rrshift__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(other >> self._number) + + def __rand__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(other & self._number) + + def __rxor__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(other ^ self._number) + + def __ror__(self, other): + if isinstance(other, Number): + other = other.get_number() + return self._check_number(other | self._number) + + def __neg__(self): + return self._check_number(-self._number) + + def __pos__(self): + return self._check_number(+self._number) + + def __abs__(self): + return self._check_number(abs(self._number)) + + def __invert__(self): + return self._check_number(~self._number) + + def __complex__(self): + return complex(self._number) + + def __int__(self): + return int(self._number) + + def __long__(self): + return long(self._number) + + def __float__(self): + return float(self._number) + + def __oct__(self): + return oct(self._number) + + def __hex__(self): + return hex(self._number) + + def __round__(self, ndigits=None): + if ndigits is not None and isinstance(ndigits, Number): + ndigits = ndigits.get_number() + return round(self._number, ndigits) + + def __trunc__(self): + from math import trunc + return trunc(self._number) + + def __floor__(self): + from math import floor + return floor(self._number) + + def __ceil__(self): + from math import ceil + return ceil(self._number) + + def get_number(self): + return self._number + + def get_copy(self): + return self.__class__(self._number) + + +class _Int(Number): + def __init__(self, number=0, raise_exceptions=False): + if isinstance(number, Number): + number = number.get_number() + number = int(number) + Number.__init__(self, number, raise_exceptions) + + def _check_number(self, num, number_only=False): + if isinstance(num, Number): + num = num.get_number() + num = int(num) + return Number._check_number(self, num, number_only) + + +class Int32(_Int): + _min = INT32_MIN + _max = INT32_MAX + _bits = 32 + + +class Int64(_Int): + _min = INT64_MIN + _max = INT64_MAX + _bits = 64 + + +class UInt32(_Int): + _min = 0 + _max = UINT32_MAX + + +class UInt64(_Int): + _min = 0 + _max = UINT64_MAX diff --git a/discord_rpc/util/utils.py b/discord_rpc/util/utils.py new file mode 100644 index 0000000..8142596 --- /dev/null +++ b/discord_rpc/util/utils.py @@ -0,0 +1,180 @@ +from copy import deepcopy +import json +from os import getenv, getpid, path +import platform +from sys import version_info, argv + + +class Empty(Exception): + pass + + +class DummyQueue(object): + """ + Dummy queue thread that does nothing. Should only be used if imports fail. + """ + + def __init__(self, maxsize=0): + pass + + def qsize(self): + return 0 + + def empty(self): + return True + + def full(self): + return False + + def put(self, obj, *args, **kwargs): + pass + + def put_nowait(self, obj): + pass + + def get(self, *args, **kwargs): + raise Empty + + def get_nowait(self): + raise Empty + + def task_done(self): + pass + + def join(self): + pass + + +def is_python3(): + return version_info[0] == 3 + + +def is_windows(): + return platform.system() == 'Windows' + + +def is_linux(): + return platform.system() == 'Linux' + + +def is_mac_osx(): + # this may not be accurate, just going off of what I find off the internet + return platform.system() == 'Darwin' + + +def get_temp_path(): + if is_windows(): + return None + for val in ('XDG_RUNTIME_DIR', 'TMPDIR', 'TMP', 'TEMP'): + tmp = getenv(val) + if tmp is not None: + return tmp + return '/tmp' + + +def get_process_id(): + return getpid() + + +def is_callable(obj): + try: + # for Python 2.x or Python 3.2+ + return callable(obj) + except Exception: + # for Python version: 3 - 3.2 + return hasattr(obj, '__call__') + + +# python 2 + 3 compatibility +if is_python3(): + unicode = str + bytes = bytes +else: + bytes = str + unicode = unicode + + +def to_bytes(obj): + if isinstance(obj, type(b'')): + return obj + if hasattr(obj, 'encode') and is_callable(obj.encode): + return obj.encode('ascii', 'replace') + raise TypeError('Could not convert object type "{}" to bytes!'.format(type(obj))) + + +def to_unicode(obj): + if isinstance(obj, type(u'')): + return obj + if hasattr(obj, 'decode') and is_callable(obj.decode): + return obj.decode(encoding='utf-8') + raise TypeError('Could not convert object type "{}" to unicode!'.format(type(obj))) + + +def iter_keys(obj): + if not isinstance(obj, dict): + raise TypeError('Object must be of type dict!') + if is_python3(): + return obj.keys() + return obj.iterkeys() + + +def iter_items(obj): + if not isinstance(obj, dict): + raise TypeError('Object must be of type dict!') + if is_python3(): + return obj.items() + return obj.iteritems() + + +def iter_values(obj): + if not isinstance(obj, dict): + raise TypeError('Object must be of type dict!') + if is_python3(): + return obj.values() + return obj.itervalues() + + +def _py_dict(obj): + if not isinstance(obj, dict): + raise TypeError('Object must be of type dict!') + new_dict = dict() + for name, val in iter_items(obj): + if isinstance(name, type(b'')) and is_python3(): + name = to_unicode(name) + elif isinstance(name, type(u'')) and not is_python3(): + name = to_bytes(name) + if isinstance(val, dict): + val = _py_dict(val) + elif isinstance(val, type(b'')) and is_python3(): + val = to_unicode(val) + elif isinstance(val, type(u'')) and not is_python3(): + val = to_bytes(val) + new_dict[name] = val + return deepcopy(new_dict) + + +def json2dict(obj): + if isinstance(obj, dict): + return deepcopy(_py_dict(obj)) + if obj is None: + return dict() + if hasattr(obj, 'strip'): + if obj.strip() == '': + return dict() + else: + return deepcopy(_py_dict(deepcopy(json.loads(obj)))) + raise TypeError('Object must be of string type!') + + +if not is_python3(): + range = xrange +else: + range = range + + +def get_executable_directory(): + return path.abspath(path.dirname(argv[0])) + + +def get_executable_path(): + return path.join(get_executable_directory(), argv[0]) -- cgit v1.2.3-70-g09d2