diff --git a/README.md b/README.md index 5c065a0..92ac8d0 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,15 @@ To enable support for multiple users perform the steps below. zoffline's previou * To obtain the official map schedule and update files from Zwift server: create a ``cdn-proxy.txt`` file in the ``storage`` directory. This can only work if you are running zoffline on a different machine than the Zwift client. * To enable the password reset feature when multiplayer is enabled: create a ``gmail_credentials.txt`` file in the ``storage`` directory containing the login credentials of a Gmail account. You need to enable the "Less secure app access" in the account settings and you may need to access https://accounts.google.com/DisplayUnlockCaptcha to allow the login from the server. +* To enable the Discord bridge bot: ``pip3 install discord``` and create a ``discord.cfg`` file in the ``storage`` directory containing + ``` + [discord] + token = + webhook = + channel = + welcome_message = + help_message = + ``` * If the Zwift client is having issues connecting to the Linux server ("The request was aborted: Could not create SSL/TLS secure channel." or "The underlying connection was closed: An unexpected error occurred on a send. Received an unexpected EOF or 0 bytes from the transport stream."): change MinProtocol in /etc/ssl/openssl.cnf to TLSv1.0 ``` [system_default_sect] diff --git a/discord_bot.py b/discord_bot.py new file mode 100644 index 0000000..5037dd2 --- /dev/null +++ b/discord_bot.py @@ -0,0 +1,87 @@ +# Python 3 only (asyncio) + +import json +import os +import threading +import time + +from configparser import ConfigParser +from urllib import request + +import asyncio +import discord + +import zwift_offline + + +class DiscordBot(discord.Client): + # TODO: this should be part of __init__() + def set_vars(self, channel, welcome_msg, help_msg): + self.channel = channel + self.welcome_msg = welcome_msg + self.help_msg = help_msg + + async def on_ready(self): + self.channel = self.get_channel(self.channel) + + async def on_member_join(self, member): + if self.welcome_msg: + await self.channel.send('%s\n%s' % (member.mention, self.welcome_msg)) + + async def on_message(self, message): + if message.author.id == self.user.id: + return + if message.content == '!online': + await message.channel.send('%s riders online' % len(online)) + elif message.content == '!help' and self.help_msg: + await message.channel.send(self.help_msg) + elif message.content == '!ping': + await message.channel.send('pong') + elif message.channel == self.channel and not message.author.bot and not message.content.startswith('!'): + zwift_offline.send_message_to_all_online(message.content, message.author.name) + + +class DiscordThread(threading.Thread): + def __init__(self, config_file): + threading.Thread.__init__(self) + if not os.path.isfile(config_file): + raise Exception("DiscordThread invoked without a configuration file") + + self.CONFIG = ConfigParser() + SECTION = 'discord' + self.CONFIG.read(config_file) + self.token = self.CONFIG.get(SECTION, 'token') + self.webhook = self.CONFIG.get(SECTION, 'webhook') + self.channel = self.CONFIG.getint(SECTION, 'channel') + self.welcome_msg = self.CONFIG.get(SECTION, 'welcome_message') + self.help_msg = self.CONFIG.get(SECTION, 'help_message') + + self.intents = discord.Intents.default() + self.intents.members = True + self.loop = asyncio.get_event_loop() + self.start() + + async def starter(self): + self.discord_bot = DiscordBot(intents=self.intents) + self.discord_bot.set_vars(self.channel, self.welcome_msg, self.help_msg) + await self.discord_bot.start(self.token) + + def run(self): + try: + self.loop.run_until_complete(self.starter()) + except BaseException: + time.sleep(5) + + def send_message(self, message, sender_id=None): + if sender_id is not None: + profile = get_partial_profile(sender_id) + sender = profile.first_name + ' ' + profile.last_name + else: + sender = 'Server' + data = {} + data["content"] = message + data["username"] = sender + + req = request.Request(self.webhook, data=json.dumps(data).encode('UTF-8'), + headers={"Content-Type": "application/json"}) + request.urlopen(req).read() diff --git a/standalone.py b/standalone.py index ea092da..c828c11 100755 --- a/standalone.py +++ b/standalone.py @@ -1,7 +1,5 @@ #!/usr/bin/env python -from gevent import monkey -monkey.patch_all() import os import signal import struct @@ -17,10 +15,12 @@ if sys.version_info[0] > 2: import socketserver from http.server import SimpleHTTPRequestHandler from http.cookies import SimpleCookie + from configparser import ConfigParser else: import SocketServer as socketserver from SimpleHTTPServer import SimpleHTTPRequestHandler from Cookie import SimpleCookie + from ConfigParser import ConfigParser import zwift_offline import protobuf.udp_node_msgs_pb2 as udp_node_msgs_pb2 @@ -45,6 +45,7 @@ BOTS_DIR = '%s/bots' % SCRIPT_DIR PROXYPASS_FILE = "%s/cdn-proxy.txt" % STORAGE_DIR SERVER_IP_FILE = "%s/server-ip.txt" % STORAGE_DIR +DISCORD_CONFIG_FILE = "%s/discord.cfg" % STORAGE_DIR MAP_OVERRIDE = deque(maxlen=16) ghost_update_freq = 3 @@ -519,7 +520,11 @@ class UDPHandler(socketserver.BaseRequestHandler): for p_id in remove_players: online.pop(p_id) if state.roadTime: - online[player_id] = state + if player_id in online.keys(): + online[player_id] = state + else: + online[player_id] = state + zdiscord.send_message('%s riders online' % len(online)) #Remove ghosts entries for inactive players (disconnected?) keys = global_ghosts.keys() @@ -640,4 +645,13 @@ botthreadevent = threading.Event() bot = threading.Thread(target=play_bots) bot.start() -zwift_offline.run_standalone(online, global_pace_partners, global_bots, ghosts_enabled, save_ghost, player_update_queue) +if os.path.isfile(DISCORD_CONFIG_FILE): + from discord_bot import DiscordThread + discord = DiscordThread(DISCORD_CONFIG_FILE) +else: + class DummyDiscord(): + def send_message(msg, sender_id=None): + pass + discord = DummyDiscord() + +zwift_offline.run_standalone(online, global_pace_partners, global_bots, ghosts_enabled, save_ghost, player_update_queue, discord) diff --git a/zwift_offline.py b/zwift_offline.py index 94dbfea..783e543 100644 --- a/zwift_offline.py +++ b/zwift_offline.py @@ -462,7 +462,8 @@ def user_home(username): return render_template("user_home.html", username=current_user.username, enable_ghosts=bool(current_user.enable_ghosts), online=get_online(), is_admin=current_user.is_admin, restarting=restarting, restarting_in_minutes=restarting_in_minutes) -def send_message_to_all_online(message): + +def send_message_to_all_online(message, sender='Server'): player_update = udp_node_msgs_pb2.PlayerUpdate() player_update.f2 = 1 player_update.type = 5 #chat message type @@ -475,7 +476,7 @@ def send_message_to_all_online(message): chat_message.rider_id = 0 chat_message.to_rider_id = 0 chat_message.f3 = 1 - chat_message.firstName = 'Server' + chat_message.firstName = sender chat_message.lastName = '' chat_message.message = message chat_message.countryCode = 0 @@ -487,6 +488,7 @@ def send_message_to_all_online(message): player_update_queue[recieving_player_id] = list() player_update_queue[recieving_player_id].append(player_update.SerializeToString()) + def send_restarting_message(): global restarting global restarting_in_minutes @@ -495,10 +497,13 @@ def send_restarting_message(): time.sleep(60) restarting_in_minutes -= 1 if restarting and restarting_in_minutes == 0: - send_message_to_all_online('See you later! Look for the back online message.') + message = 'See you later! Look for the back online message.' + send_message_to_all_online(message) + discord.send_message(message) time.sleep(6) os.kill(os.getpid(), signal.SIGINT) + @app.route("/restart") @login_required def restart_server(): @@ -509,8 +514,10 @@ def restart_server(): restarting_in_minutes = 10 send_restarting_message_thread = threading.Thread(target=send_restarting_message) send_restarting_message_thread.start() + discord.send_message('Restarting / Shutting down in %s minutes. Save your progress or continue riding until server is back online' % restarting_in_minutes) return redirect('/user/%s/' % current_user.username) + @app.route("/cancelrestart") @login_required def cancel_restart_server(): @@ -519,9 +526,12 @@ def cancel_restart_server(): if bool(current_user.is_admin): restarting = False restarting_in_minutes = 0 - send_message_to_all_online('Restart of the server has been cancelled. Ride on!') + message = 'Restart of the server has been cancelled. Ride on!' + send_message_to_all_online(message) + discord.send_message(message) return redirect('/user/%s/' % current_user.username) + @app.route("/reloadbots") @login_required def reload_bots(): @@ -530,6 +540,7 @@ def reload_bots(): reload_pacer_bots = True return redirect('/user/%s/' % current_user.username) + @app.route("/upload//", methods=["GET", "POST"]) @login_required def upload(username): @@ -695,6 +706,7 @@ def logout(player_id): online.pop(player_id) if player_id in player_partial_profiles: player_partial_profiles.pop(player_id) + discord.send_message('%s riders online' % len(online)) @app.route('/api/users/logout', methods=['POST']) @@ -1053,6 +1065,10 @@ def api_profiles_activities_rideon(recieving_player_id): if not recieving_player_id in player_update_queue: player_update_queue[recieving_player_id] = list() player_update_queue[recieving_player_id].append(player_update.SerializeToString()) + + receiver = get_partial_profile(recieving_player_id) + message = 'Ride on ' + receiver.first_name + ' ' + receiver.last_name + '!' + discord.send_message(message, sending_player_id) return '{}', 200 @@ -1270,6 +1286,10 @@ def relay_worlds_generic(world_id=None): if not recieving_player_id in player_update_queue: player_update_queue[recieving_player_id] = list() player_update_queue[recieving_player_id].append(player_update.SerializeToString()) + if player_update.type == 5: + chat_message = udp_node_msgs_pb2.ChatMessage() + chat_message.ParseFromString(player_update.payload) + discord.send_message(chat_message.message, chat_message.rider_id) return '{}', 200 else: # protobuf request worlds = world_pb2.Worlds() @@ -1553,7 +1573,9 @@ def check_columns(): def send_server_back_online_message(): time.sleep(30) - send_message_to_all_online("We're back online. Ride on!") + message = "We're back online. Ride on!" + send_message_to_all_online(message) + discord.send_message(message) @app.before_first_request @@ -1564,8 +1586,6 @@ def before_first_request(): db.session.commit() # in case create_all created a table check_columns() db.session.close() - send_message_thread = threading.Thread(target=send_server_back_online_message) - send_message_thread.start() #################### @@ -1697,13 +1717,14 @@ def auth_realms_zwift_tokens_access_codes(): return FAKE_JWT, 200 -def run_standalone(passed_online, passed_global_pace_partners, passed_global_bots, passed_ghosts_enabled, passed_save_ghost, passed_player_update_queue): +def run_standalone(passed_online, passed_global_pace_partners, passed_global_bots, passed_ghosts_enabled, passed_save_ghost, passed_player_update_queue, passed_discord): global online global global_pace_partners global global_bots global ghosts_enabled global save_ghost global player_update_queue + global discord global login_manager online = passed_online global_pace_partners = passed_global_pace_partners @@ -1711,6 +1732,7 @@ def run_standalone(passed_online, passed_global_pace_partners, passed_global_bot ghosts_enabled = passed_ghosts_enabled save_ghost = passed_save_ghost player_update_queue = passed_player_update_queue + discord = passed_discord login_manager = LoginManager() login_manager.login_view = 'login' login_manager.session_protection = None @@ -1722,10 +1744,11 @@ def run_standalone(passed_online, passed_global_pace_partners, passed_global_bot def load_user(uid): return User.query.get(int(uid)) - server = WSGIServer(('0.0.0.0', 443), app, certfile='%s/cert-zwift-com.pem' % SSL_DIR, keyfile='%s/key-zwift-com.pem' % SSL_DIR, log=logger) - thread = threading.Thread(target=server.serve_forever) - thread.start() + send_message_thread = threading.Thread(target=send_server_back_online_message) + send_message_thread.start() logger.info("Server is running.") + server = WSGIServer(('0.0.0.0', 443), app, certfile='%s/cert-zwift-com.pem' % SSL_DIR, keyfile='%s/key-zwift-com.pem' % SSL_DIR, log=logger) + server.serve_forever() # app.run(ssl_context=('%s/cert-zwift-com.pem' % SSL_DIR, '%s/key-zwift-com.pem' % SSL_DIR), port=443, threaded=True, host='0.0.0.0') # debug=True, use_reload=False)