From 9082559dd41c8dab88ef5697cba7a1afa12cd385 Mon Sep 17 00:00:00 2001 From: Len Date: Sat, 22 Feb 2025 22:42:27 -0800 Subject: [PATCH] Initial commit --- .gitignore | 166 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 33 ++++++++++ bot.py | 87 +++++++++++++++++++++++++ parser.py | 22 +++++++ requirements.txt | 8 +++ templates.py | 72 ++++++++++++++++++++ 6 files changed, 388 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bot.py create mode 100644 parser.py create mode 100644 requirements.txt create mode 100644 templates.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..780cc2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,166 @@ +### dotenv template +.env + +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..da9f745 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Mumble-Notifier + +XMPP bot which notifies a MUC of newly joined Mumble users. + +## Setup + +Firstly, make sure the bot can access your Mumble server's log file. + +Either set the necessary environment variables at runtime, or put them in a `.env` file in this directory: + +``` +MUMBLE_BOT_JID=mumble-bot@linux.ucla.edu +MUMBLE_BOT_PASSWORD=hunter2 +MUMBLE_BOT_MUC=main@room.linux.ucla.edu +MUMBLE_BOT_MURMUR_LOG=/var/log/mumble-server/mumble-server.log +MUMBLE_BOT_MUMBLE_URL="mumble://linux.ucla.edu" +``` + +Install dependencies and run the project in a virtual environment like so: + +```sh +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +python3 bot.py +``` + +You will probably want to change `templates.py` to use a suitable notification message(s) for your own group. + +## Dependencies + +* slixmpp +* python-dotenv diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..f203704 --- /dev/null +++ b/bot.py @@ -0,0 +1,87 @@ +import asyncio +import logging +import os +from parser import parse_logs +from templates import decorate_message +from slixmpp import ClientXMPP +from dotenv import load_dotenv +from typing import Set + + +class MumbleNotifierBot(ClientXMPP): + def __init__(self, jid: str, password: str, room: str, nick: str = 'Mumble'): + ClientXMPP.__init__(self, jid, password) + + self.room = room + self.nick = nick + + self.add_event_handler('session_start', self.session_start) + + # If you wanted more functionality, here's how to register plugins: + # self.register_plugin('xep_0030') # Service Discovery + # self.register_plugin('xep_0199') # XMPP Ping + self.register_plugin('xep_0045') + + # Here's how to access plugins once you've registered them: + # self['xep_0030'].add_feature('echo_demo') + + async def session_start(self, event): + logging.info('Session started') + await self.get_roster() + self.send_presence() + await self.plugin['xep_0045'].join_muc_wait(self.room, self.nick) + logging.info('Joined MUC {} as {}'.format(self.room, self.nick)) + + # Most get_*/set_* methods from plugins use Iq stanzas, which + # are sent asynchronously. You can almost always provide a + # callback that will be executed when the reply is received. + + def notify_muc(self, new_mumble_users: Set[str]): + logging.info('Notifying MUC of newly joined Mumble users: {}'.format(new_mumble_users)) + self.send_message(mto=self.room, + mbody=decorate_message(new_mumble_users), + mtype='groupchat') + + +async def check_murmur_new_users(): + last_users = set() + first_check = True + + while True: + logging.debug('Checking Murmur logs for connected users') + with open(os.getenv('MUMBLE_BOT_MURMUR_LOG', 'r')) as logfile: + logs = logfile.read() + current_users = parse_logs(logs) + + if first_check: + last_users = current_users + first_check = False + else: + newly_joined = current_users - last_users + + if newly_joined: + unique_newly_joined = set(newly_joined) + xmpp.notify_muc(unique_newly_joined) + + last_users = current_users + + await asyncio.sleep(10) + + +if __name__ == '__main__': + # Ideally use optparse or argparse to get JID, + # password, and log level. + load_dotenv() + + logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s %(levelname)-8s %(message)s') + + xmpp = MumbleNotifierBot( + os.getenv('MUMBLE_BOT_JID'), + os.getenv('MUMBLE_BOT_PASSWORD'), + os.getenv('MUMBLE_BOT_MUC') + ) + xmpp.connect() + + xmpp.loop.create_task(check_murmur_new_users()) + xmpp.loop.run_forever() diff --git a/parser.py b/parser.py new file mode 100644 index 0000000..6e5c6f9 --- /dev/null +++ b/parser.py @@ -0,0 +1,22 @@ +import re +from typing import Set + +def parse_logs(logs: str) -> Set[str]: + current_users = set() + join_pattern = r'(?<=:)([^:()]*)(?=\()(?=.*\bAuthenticated\b)' + leave_pattern = r'(?<=:)([^:()]*)(?=\()(?=.*\bConnection closed\b)' + + for line in logs.splitlines(): + line = line.strip() + + join_match = re.search(join_pattern, line) + if join_match: + username = join_match.group(1) + current_users.add(username) + + leave_match = re.search(leave_pattern, line) + if leave_match: + username = leave_match.group(1) + current_users.discard(username) + + return current_users diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2b87af5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +aiodns==3.2.0 +cffi==1.17.1 +pyasn1==0.6.1 +pyasn1_modules==0.4.1 +pycares==4.5.0 +pycparser==2.22 +python-dotenv==1.0.1 +slixmpp==1.8.6 diff --git a/templates.py b/templates.py new file mode 100644 index 0000000..643a38b --- /dev/null +++ b/templates.py @@ -0,0 +1,72 @@ +import random +from typing import Set +import os +from dotenv import load_dotenv + +load_dotenv() +MUMBLE_URL = os.getenv('MUMBLE_BOT_MUMBLE_URL') + +def fmt_list(users: Set[str], bold=True): + if bold: + users = [f'*{user}*' for user in users] + if len(users) == 1: + return users[0] + return ', '.join(users[:-1]) + f' and {users[-1]}' + +def fmt1(users): + return "β†’ {users} just hopped in Mumble ←\nTalk tuah πŸ—£οΈ us at {url} πŸ’¦οΈ".format( + users=fmt_list(users), + url=MUMBLE_URL + ) + +def fmt2(users): + return """ +β†’ {users} just joined Mumble ← +Yap πŸ—£οΈ with us at {url} πŸ§οΈβ€ΌοΈ +""".format(users=fmt_list(users), url=MUMBLE_URL) + +def fmt3(users): + return """ +ZOMG!!!1 +{users} just showed up in teh Mumble servar xD +^_^ Talk with us on the 'net at: {url} βœ©β€§β‚ŠΛš +""".format(users=fmt_list(users), url=MUMBLE_URL) + +def fmt4(users): + return """ +`>be me` +`>want to vc with my linux friends` +`>notice that {users} just joined Mumble` +`>log on at {url}` +`>feelsgoodman.jpg` +""".format(users=fmt_list(users, bold=False), url=MUMBLE_URL) + +def fmt5(users): + return """ +πŸ‡ΊπŸ‡Έ IMPORTANT GOVERNMENT ALERT πŸ‡ΊπŸ‡ΈοΈ +The President urgently needs to speak with you on Mumble. Our freedom is at stake. +{users} just joined the server. Are YOU brave enough to go with them? 🫑️ +πŸ¦… ALL PATRIOTS GO: {url} πŸ¦… +""".format(users=fmt_list(users), url=MUMBLE_URL) + +def fmt6(users): + return """ +{users} just joined Mumble. +Last one to join is gay!!! πŸ³οΈβ€πŸŒˆοΈ +🐧️🌈️ {url}""".format(users=fmt_list(users), url=MUMBLE_URL) + +def fmt7(users): + return """ +{users} joined the Mumble server: {url}. +Let's get as many Bruins in Mumble as possible. Only U$C πŸŸ₯️🟨️ πŸ€‘οΈ uses proprietary software like Di$cord 🀒️. +U-C-L-A FIGHT FIGHT FIGHT! +🐧️ πŸ’™οΈπŸ»οΈπŸ’›οΈ πŸŽ“οΈ +{url} +""".format(users=fmt_list(users), url=MUMBLE_URL) + +NOTIFICATION_TEMPLATE_FUNCS = [fmt1, fmt2, fmt3, fmt4, fmt5, fmt6, fmt7] + + +def decorate_message(users: Set[str]) -> str: + func = random.choice(NOTIFICATION_TEMPLATE_FUNCS) + return func(users)