From cfd317785b5721c06247ad9d8eba92bafd918a94 Mon Sep 17 00:00:00 2001 From: Sanel K <20467576+sanelk2004@users.noreply.github.com> Date: Mon, 23 Aug 2021 03:25:01 -0400 Subject: [PATCH] Initial commit Let's goooooooooo --- .github/FUNDING.yml | 2 + .gitignore | 1 + LICENSE.txt | 7 + README.md | 86 ++++++++ example.config.json | 13 ++ oibot.py | 410 +++++++++++++++++++++++++++++++++++++++ requirements-windows.txt | 3 + requirements.txt | 2 + 8 files changed, 524 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 example.config.json create mode 100644 oibot.py create mode 100644 requirements-windows.txt create mode 100644 requirements.txt diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..5e3f700 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: sanelk2004 +custom: https://cash.app/$3reetop diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cffcb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.json \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..4b27794 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2021 Sanel Kukic + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e13fb88 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# oibot + +An XMPP chat bot that connects to [NWWS-OI](https://weather.gov/nwws) and can forward messages to a Discord webhook. + +I made this because I looked around the internet and couldn't find a single free-to-use or open-source XMPP client that was able to parse the contents of NWWS-OI messages and display them. There used to be clients that worked but they no longer work so I tasked myself with making my own client. + +Inspired by [jbuitt/nwws-python-client](https://github.com/jbuitt/nwws-python-client). + +## How does it work? +Using [slixmpp](https://slixmpp.readthedocs.io), it connects to the NWWS-OI XMPP server and listens for messages from WFOs. + +You can configure which WFOs you want to trigger the script in the configuration file, detailed below. Or, you can have it go off for every single message posted on NWWS-OI (not recommended in a production environment but useful for testing the script) + +Completely contained in one single Python file, too. + +## How do I use it? +For starters, you need to have credentials to access NWWS-OI. You can obtain these credentials on the [National Weather Service's webpage](https://weather.gov/nwws) + +Once you have obtained your credentials, you need to create a `.json` file that looks like the following. A blank, example configuration file is included with the Python script and in this repository. +```json +{ + "username": "", + "password": "", + "server": "nwws-oi.weather.gov", + "port": 5222, + "use_ssl": false, + "resource": "nwws", + "wfo_offices": [ + "" + ], + "discord_webhook": "", + "enable_win10_notifications": false +} +``` + +**If you wish to receive messages from all WFOs, put the word "all" in your `wfo_offices` configuration, such that it looks like this:** +```json +"wfo_offices": [ + "every" +] +``` + +Now, simply fill in the fields that have a placeholder in them, save the file, and run the following commands in your terminal: +```bash +$ pip install -r requirements.txt +$ python oibot.py +``` + +You can also run the script with the `-g` or `--gen-config` argument to generate a template configuration file akin to the one displayed above. + +Once you've done that, you should see the program start up, print some basic information, and if all goes well you should see the federal government warning message that everyone who logs in to NWWS-OI sees: +``` +**WARNING**WARNING**WARNING**WARNING**WARNING**WARNING**WARNING**WARNING** + +This is a United States Federal Government computer system, which may be +accessed and used only for official Government business by authorized +personnel. Unauthorized access or use of this computer system may +subject violators to criminal, civil, and/or administrative action. + +All information on this computer system may be intercepted, recorded, +read, copied, and disclosed by and to authorized personnel for official +purposes, including criminal investigations. Access or use of this +computer system by any person whether authorized or unauthorized, +CONSTITUTES CONSENT to these terms. + +**WARNING**WARNING**WARNING**WARNING**WARNING**WARNING**WARNING**WARNING** +``` + +Once you see this message, you know you're connected. All you have to do now is sit back and wait. Based on your configuration, the program will send messages to the Discord webhook you specified. + +### Enabling toast notifications on Windows 10 +If you are running this script on Windows 10, you can install an additional package and change a setting in your configuration file to enable toast notifications every time a new message is posted to NWWS-OI. To do this, follow these steps. + +1. Run `pip install -r requirements-windows.txt` in your terminal. +2. Open your configuration file and change the value for `enable_win10_notifications` from `false` to `true`, then save your configuration file. +3. Run the program and specify the path to your configuration file! + +It really is that easy :D + +## Is this free to use? Do I need to buy a license? +Yes, and no. This is completely free-to-use by anyone who is authorized to access NWWS-OI. You do not need to pay me a single penny to use this, however, I would greatly appreciate it if you [sponsor me on GitHub](https://github.com/sponsors/sanelk2004) or [bought me a coffee](https://cash.app/$3reetop). + +If you paid to obtain access to this software, you have been SCAMMED and you should contact your financial institution to report the fraud as soon as possible. You may also want to [report the fraud to the Federal Trade Commission](https://reportfraud.ftc.gov) and/or file a report with your local police department. If you believe your identity has been stolen, visit the [Federal Trade Commission's Identity Theft](https://identitytheft.gov) website to file a report. + +## License +This program is licensed under the terms and conditions of the MIT License. The full text of the license can be found in the [LICENSE.txt](./LICENSE.txt) file. diff --git a/example.config.json b/example.config.json new file mode 100644 index 0000000..6f3aaff --- /dev/null +++ b/example.config.json @@ -0,0 +1,13 @@ +{ + "username": "", + "password": "", + "server": "nwws-oi.weather.gov", + "port": 5222, + "use_ssl": false, + "resource": "nwws", + "wfo_offices": [ + "" + ], + "discord_webhook": "", + "enable_win10_notifications": false +} diff --git a/oibot.py b/oibot.py new file mode 100644 index 0000000..4fc4b12 --- /dev/null +++ b/oibot.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +# +# OIBot - A single-file, easy-to-use XMPP chatbot for NWWS-OI +# +# Copyright 2021 Sanel Kukic +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of this software +# and associated documentation files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, publish, distribute, +# sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished +# to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all copies or substantial portions +# of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO +# THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +# CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# +# +# Import required dependencies +# +# slixmpp is used for XMPP management +# json is used to load the configuration file +# xml is used to parse XMPP message stanzas +# ssl is used to specify the TLS version used to connect via XMPP +# signal is used to handle SIGINT and other signals +# sys and os are used for minor housekeeping things +# datetime is used for some minor housekeeping things +# argparse is used to implement commandline arguments +# traceback is used to allow tracebacks to be printed to stdout +# requests is used to make POST requests to the Discord webhook +import json +import slixmpp +from xml.dom import minidom +import ssl +import signal +import sys +import os +import datetime +from argparse import ArgumentParser +import traceback +import requests +import asyncio + +# Event handler for SIGINT (user interrupt) +def sigint(sig, frame): + print("\n\n[i] Disconnecting from NWWS-OI and exiting gracefully...") + # NWWS specifications say that it is important to disconnect every time you are finished + # using NWWS, as failing to do so could result in you not being able to log in again since you're already logged in + xmpp.disconnect() + sys.exit(0) + +# Event handler for SIGTERM (graceful termination) +def sigterm(sig, frame): + print("\n\n[i] Disconnecting from NWWS-OI and exiting gracefully...") + xmpp.disconnect() + sys.exit(0) + +# Helper function for async toast management +async def show_toast_msg(content) -> None: + toaster.show_toast("New message from NWWS-OI", content, duration=10) + +# I would add a handler for SIGKILL but the point of SIGKILL is that you should not handle it +# and it should immediately kill the process, so I won't. I doubt that a handler for SIGKILL +# would even work to be honest lmfao + +# Create new class that extends slixmpp.ClientXMPP +class OIBot(slixmpp.ClientXMPP): + + """ + A XMPP bot that reads and prints messages from NWWS-OI, and can send them to a Discord webhook. + + Developed by Sanel Kukic - https://sanelkukic.us.eu.org/projects/oibot + """ + + # Create constructor for this child class + def __init__(self, jid, password, room, nick): + # Call parent class's constructor + super().__init__(jid, password) + + # For convenience purposes, let's assign the values of room and nick + # to class-wide variables + self.room = room + self.nick = nick + + # Create some event handlers for XMPP events + self.add_event_handler('session_start', self.start) + self.add_event_handler('groupchat_message', self.message) + self.add_event_handler('message', self.message) + self.add_event_handler('connection_failed', self.connection_failed) + self.add_event_handler('disconnected', self.disconnected) + self.add_event_handler('connected', self.connected) + self.add_event_handler('failed_auth', self.failed_auth) + self.add_event_handler('session_end', self.session_end) + self.add_event_handler('session_resumed', self.session_resumed) + self.add_event_handler('socket_error', self.socket_error) + self.add_event_handler('stream_error', self.stream_error) + + def connection_failed(self, event): + print("[x] Connection failed") + sys.exit(1) + + def disconnected(self, event): + print("[x] Disconnected, reason: "+str(event)) + + def connected(self, event): + print("[i] Connected to XMPP server, starting session...") + + def failed_auth(self, event): + print("[x] Invalid login credentials. Please check your configuration file and try again") + sys.exit(1) + + def session_end(self, event): + print("[x] Session has ended") + + def session_resumed(self, event): + print("[i] Session has been resumed") + + def socket_error(self, event): + print("[x] Socket error, details: \n"+str(event)) + xmpp.disconnect() + sys.exit(1) + + def stream_error(self, event): + print("[x] Stream error, details: \n"+str(event)) + xmpp.disconnect() + sys.exit(1) + + # This asynchronous method will be called every time the session starts in XMPP + async def start(self, event): + print("[i] Session started. Sending presence, getting roster, and joining MUC...") + # We are required to send a presence event upon connecting + self.send_presence() + # And to retrieve the roster in an asynchronous manner + await self.get_roster() + + # Use XEP-0045 (Multi User Chat) to join a MUC chatroom + # The chatroom's ID is stored in self.room and the nickname we will use + # is stored in self.nick + self.plugin['xep_0045'].join_muc(self.room, self.nick) + + # This method will be called whenever a message is received in XMPP + async def message(self, message): + # If the message is from a MUC + if message['type'] == "groupchat": + # Parse the message stanza XML + xmldoc = minidom.parseString(str(message)) + # Find the "x" element in the message stanza + itemlist = xmldoc.getElementsByTagName('x') + + # This "x" element contains the information from the NWS office that issued the message + # So let's extract some basic attributes and store them in variables + ttaaii = itemlist[0].attributes['ttaaii'].value.lower() + cccc = itemlist[0].attributes['cccc'].value.lower() + awipsid = itemlist[0].attributes['awipsid'].value.lower() + id = itemlist[0].attributes['id'].value.lower() + content = itemlist[0].firstChild.nodeValue + + # If the ID of the WFO that issued the message is in our preconfigured list of WFOs we want + if cccc in config['wfo_offices'] or "every" in config['wfo_offices']: + # Print the message to the console output + print("\n\n==||== Incoming message from NWWS-OI ==||==") + print("\t:: TTAAII = " + ttaaii) + print("\t:: CCCC = " + cccc) + print("\t:: AWIPSID = " + awipsid) + print("\t:: ID = " + id) + print("\t:: Body = "+message['body']) + + # Send the message to the configured Discord webhook + print("\t\t[i] Sending message to Discord...") + + # Usually, we would have to save the message contents as a file and upload the file to Discord + # because most NWWS messages are over Discord's character limit of 2,000 characters + # + # But, recently, the fine employees at Discord decided to increase the character limit for embed descriptions + # meaning we should be able to fit most NWWS messages into the embed description with no need to upload a file + # + # There may still be that occasional message that will not fit, and in that case, it will not send via Discord + # I don't think I am able to fix that myself, so oh well. + # + # You'll also notice that I specify the date and time it was issued as a field instead of using the embed's + # timestamp property in the footer + # + # Reason I did that is because all embeds need to have at least 1 field in order to successfully send via Discord + # so I might as well add this timestamp as a field. + discord_post_body = { + 'content': '**New message from NWWS-OI**', + 'embeds': [ + { + 'title': message['body'], + 'description': '```\n'+content+'\n```', + 'color': 13035253, + 'timestamp': datetime.datetime.utcnow().isoformat(), + 'fields': [ + { + 'name': 'Issued on', + 'value': '`'+datetime.datetime.utcnow().strftime("%m-%d-%Y_%H:%M:%S")+'`', + 'inline': True + }, + { + 'name': 'TTAAII', + 'value': '`'+ttaaii+'`', + 'inline': True + }, + { + 'name': 'CCCC', + 'value': '`'+cccc+'`', + 'inline': True + }, + { + 'name': 'AWIPSID', + 'value': '`'+awipsid+'`', + 'inline': True + }, + { + 'name': 'ID', + 'value': '`'+id+'`', + 'inline': True + } + ], + 'footer': { + 'text': 'nwws@nwws-oi.weather.gov/nwws' + } + } + ] + } + try: + # Send a POST request with application/json content-type to Discord + # and a JSON body. + r = requests.post(config['discord_webhook'], data=json.dumps(discord_post_body), headers={'content-type': 'application/json'}) + # The r.ok property is true if the response HTTP status code is less than 400 + # Discord returns a 201 on successful webhook POSTs, so if the webhook POST was successful this property should be True + if r.ok: + print("\t\t[i] Successfully sent to Discord") + else: + print("\t\t[x] Error sending to Discord! Details: "+r.text) + except Exception as e: + # If we encounter an error, print the traceback to the console and just keep going. + print("\t\t[x] Error sending to Discord. Details:\n") + traceback.print_exc() + + # If we're on Windows and we have the toast notifications API enabled + if sys.platform == "win32" and config['enable_win10_notifications'] is True: + loop = asyncio.get_event_loop() + task = loop.create_task(show_toast_msg(message['body'])) + task.add_done_callback(_asyncio_task_handler) + print("\t\t[i] Successfully sent Windows 10 toast notification") + + print("==||== End Of Message ==||==") + + # If it is just a normal DM message, print the contents of it to the console. + # The reason I added this is because anyone who has used NWWS-OI knows that every single time + # you logon, you get a DM from a bot that warns you that you are accessing a U.S. federal government + # computer system and that everything may be logged and that if you aren't authorized to access the system + # you will be prosecuted. + # + # I wanted to display that message without having to hard-code it into my program, so I just decided to display + # the message as you would receive it if you connected normally via XMPP + # + # This comes with the added benefit that if they do change the verbage of that warning or something in the future, I don't have + # to do anything in my code. + if message['type'] in ('normal', 'chat'): + print("\n\n\n"+message['body']+"\n\n\n") + +# Helper function that lets the user generate a template configuration file right from this script. +def gen_config(): + CONFIG_TEMPLATE = """{ + "username": "", + "password": "", + "server": "nwws-oi.weather.gov", + "port": 5222, + "use_ssl": false, + "resource": "nwws", + "wfo_offices": [ + "" + ], + "discord_webhook": "", + "enable_win10_notifications": false +}""" + try: + f = open("./config.json", "w") + f.write(CONFIG_TEMPLATE) + f.close() + print("[i] Successfully written configuration template to ./config.json") + sys.exit(0) + except Exception as e: + print("[x] Failed to write configuration file template!") + sys.exit(1) + +def view_license(): + LICENSE_TXT = """ + Copyright 2021 Sanel Kukic + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + """ + print(LICENSE_TXT) + sys.exit(0) + +def _asyncio_task_handler(task: asyncio.Task) -> None: + try: + task.result() + except asyncio.CancelledError: + pass + except Exception as e: + print("[x] Exception encountered during asynchronous task = "+task+": \n") + traceback.print_exc() + +# Everything after this line will run when we run the script directly without passing parameters. +if __name__ == '__main__': + # Register SIGINT so that we can CTRL+C and exit gracefully + signal.signal(signal.SIGINT, sigint) + # Register SIGTERM + signal.signal(signal.SIGTERM, sigterm) + + # Set up commandline argument parser + parser = ArgumentParser(prog="oibot", description=OIBot.__doc__, epilog=""" + WARNING: This software is 100% open-source and available completely free for anyone to use at https://github.com/sanelk2004/oibot - If you paid for this software, you have been SCAMMED and you should report the fraud to your financial institution. + + Licensed under the terms of the MIT License.""") + parser.add_argument("config", nargs="?", help="Absolute path to the configuration JSON file to use.") + parser.add_argument("-g", "--gen-config", action="store_true", help="Generate a template configuration file in the current directory that you can fill in.") + parser.add_argument("-l", "--license", action="store_true", help="View the text of the program's license.") + args = parser.parse_args() + + # If the user did not specify the path to a configuration file + if args.gen_config: + gen_config() + elif args.license: + view_license() + elif args.config is None: + # Show an error message and exit + print("[x] You must specify the path to a JSON configuration file to use with OIBot!") + sys.exit(1) + else: + # Attempt to connect to the XMPP server + print("[i] Trying to connect with the following details...") + global config + config = None + try: + config = json.load(open(args.config)) + except Exception as e: + print("[x] Failed to load configuration file!") + sys.exit(1) + xmpp_jid = config['username'] + '@' + config['server'] + xmpp_room = 'nwws@conference.' + config['server'] + '/' + config['resource'] + # Print configuration details + print("\t:: Username = "+config['username']) + # Censor the password and print it + censored_pw = '*' * len(config['password']) + print("\t:: Password = "+censored_pw) + print("\t:: Server = "+config['server']) + print("\t:: Port = "+str(config['port'])) + print("\t:: Use SSL? "+str(config['use_ssl'])) + print("\t:: Resource = "+config['resource']) + print("\t:: Jabber ID = "+xmpp_jid) + print("\t:: Room ID = "+xmpp_room) + + # This is some hacky code to censor the token portion of the Discord webhook URL before I output + # it to the console, just for additional security purposes + discord_webhook_url = config['discord_webhook'] + discord_webhook_parts = discord_webhook_url.split('/') + # Split the token URL into an array of strings based on the / character + # The fifth element in the array is the token + webhook_token = discord_webhook_parts[6] + # Create a new string that is the same length as the real token, but this new string will consist of pure asterisk symbols + censored_webhook_token = '*' * len(webhook_token) + # Remove the token from the array + discord_webhook_parts.pop(6) + # Add the new "censored" token string to the array in the same place where the uncensored token was + discord_webhook_parts.append(censored_webhook_token) + # And combine the elements in the array to form a URL, then print that censored URL to the console + new_webhook_url = "https://" + discord_webhook_parts[2] + "/" + discord_webhook_parts[3] + "/" + discord_webhook_parts[4] + "/" + discord_webhook_parts[5] + "/" + discord_webhook_parts[6] + print("\t:: Discord webhook URL = "+new_webhook_url) + + # Check if we're on Windows 10 and if the user chose to enable the toast notifications feature + if sys.platform == "win32": + print("\t:: Enable Windows 10 Notifications API? "+str(config['enable_win10_notifications']) + "\n") + if config['enable_win10_notifications'] is True: + from win10toast import ToastNotifier + # Create a global variable for the toast notifications API and initialize an instance of ToastNotifier + global toaster + toaster = ToastNotifier() + + # Keep in mind that this process does not actually change the value of config['discord_webhook'], this is purely + # "cosmetic" because at no point did I perform an assignment operation on the value of config['discord_webhook'] here + + # The reason I made the xmpp variable global is so that I can use it in the SIGINT handler method above + # to gracefully disconnect from XMPP and quit the program. + global xmpp + xmpp = OIBot(xmpp_jid, config['password'], xmpp_room, config['username']) + # Register XEPs + xmpp.register_plugin('xep_0045') + xmpp.register_plugin('xep_0030') + xmpp.register_plugin('xep_0004') + xmpp.register_plugin('xep_0199') + xmpp.register_plugin('xep_0078') + xmpp.register_plugin('xep_0198') + # Set the SSL version to use, NWWS requires at least TLSv2.3 + xmpp.ssl_version = ssl.PROTOCOL_SSLv23 + # And finally, attempt to connect + xmpp.connect((config['server'], config['port']), config['use_ssl']) + # Make the process blocking and synchronous + xmpp.process(forever=True) diff --git a/requirements-windows.txt b/requirements-windows.txt new file mode 100644 index 0000000..f23465b --- /dev/null +++ b/requirements-windows.txt @@ -0,0 +1,3 @@ +win10toast +requests +slixmpp diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ffe1447 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +slixmpp +requests