From a109b226bf6503584b4dea5a4203141e44338b85 Mon Sep 17 00:00:00 2001 From: nico Date: Mon, 26 Oct 2020 19:23:22 +0100 Subject: Initial working release XMPP retired MUC Bot This Bot connects to a chosen MUC to inform connecting users of the new/ different MUC they should connect to. --- .gitattributes | 1 + .gitignore | 208 +++++++++++++++++++++++++++++++++++++++++++++++++++++ config.py | 74 +++++++++++++++++++ config.yml.example | 28 ++++++++ main.py | 134 ++++++++++++++++++++++++++++++++++ requirements.txt | 16 +++++ 6 files changed, 461 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 config.py create mode 100644 config.yml.example create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..48f44e4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.py diff=python diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5218875 --- /dev/null +++ b/.gitignore @@ -0,0 +1,208 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,pycharm,venv,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm,venv,visualstudiocode + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# .idea +.idea/ + +# CMake +cmake-build-*/ + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +### Python ### +# 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/ +pip-wheel-metadata/ +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/ +pytestdebug.log + +# 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/ +doc/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.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 + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +pythonenv* + +# 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/ + +# profiling data +.prof + +### venv ### +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +pip-selfcheck.json + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,venv,visualstudiocode + +# config +config.yml diff --git a/config.py b/config.py new file mode 100644 index 0000000..9287aca --- /dev/null +++ b/config.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import sys +from pathlib import Path + +from ruamel.yaml import YAML +from ruamel.yaml.parser import ParserError +from ruamel.yaml.scanner import ScannerError + + +class Config: + def __init__(self): + # class variables + self.content = None + + # select config file + self.conf_file = Path("config.yml") + if not Path.exists(self.conf_file): + self.conf_file = Path("/etc/retiredmuc-bot.yml") + + # read config file + self._read() + + def _read(self): + """init the config object with this method""" + self._check() + + # open file as an iostream + with open(self.conf_file, "r", encoding="utf-8") as f: + try: + self.content = YAML(typ="safe").load(f) + + # catch decoding errors + except (ParserError, ScannerError) as err: + print(err, file=sys.stderr) + exit(1) + + def _check(self): + """internal method to check if the config file exists""" + try: + # if file is present continue + if self.conf_file.exists(): + return + + # if not create a blank file + else: + self.conf_file.touch(mode=0o640) + + # catch permission exceptions as this tries to write to /etc/ + except PermissionError as err: + print(err, file=sys.stderr) + sys.exit(err.errno) + + def get( + self, key: str = None, default: (str, int) = None + ) -> (dict, str, int, None): + """method to retrieve the whole config data, a single value or the optional default value""" + # if a special key is request, return only that value + if key is not None: + + # safety measure + if key in self.content: + return self.content[key] + + # if a default value is given return that + if default is not None: + return default + + # if the key isn't part if self.content return None + else: + return None + + # else return everything + return self.content diff --git a/config.yml.example b/config.yml.example new file mode 100644 index 0000000..8687db7 --- /dev/null +++ b/config.yml.example @@ -0,0 +1,28 @@ +# bot credentials +login: + jid: "james2@example.tld" + password: "5up3r 53cr37" + nick: "James2" + +rooms: +# "join this room": "redirect to this room" + "home@conference.example.tld": "nursing_home@conference.example.tld" + +messages: + # reply to direct messages + # {nick}: bot's nickname + direct_msg: "I am {nick} only tasked to redirect people. I am a Bot Ding Ding don't reply." + + # replay to group messages + # {user_nick}: joined user's nickname + # {new_room}: the configured room the bot points to + grp_msg: "{user_nick}, this room is retired please join xmpp:{new_room}?join." + +# features -- currently todo and not implemented +features: + # utilize xep 249 direct muc invite + direct_invite: false + + # what to do with the user in the retired room + #kick_user: false + ban_user: false diff --git a/main.py b/main.py new file mode 100644 index 0000000..c7eb398 --- /dev/null +++ b/main.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import logging + +from slixmpp import ClientXMPP + +from config import Config + + +class RetiredMucBot(ClientXMPP): + def __init__(self, jid, password, nick, config): + ClientXMPP.__init__(self, jid, password) + self.use_message_ids = True + self.use_ssl = True + + # passthrough the config obj + self.config = config + + self.rooms = None + self.nick = nick + self.messages = self.config.get("messages") + + # feature config + self.functions = self.config.get("features") + + # session start disconnect events + self.add_event_handler("session_start", self.start_session) + self.add_event_handler("disconnected", self.reconnect_session) + + # register receive handler for both groupchat and normal message events + self.add_event_handler("message", self.message) + + def reconnect_session(self, event): + """ + method to handle disconnects / session drops + """ + self.connect() + + def start_session(self, event): + """ + session start + """ + self.send_presence() + self.join_rooms() + + def join_rooms(self): + """ + method to join configured rooms and register their response handlers + """ + self.rooms = self.config.get("rooms") + + if self.rooms is not None: + for room in self.rooms: + self.add_event_handler(f"muc::{room}::got_online", self.notify_user) + self.plugin["xep_0045"].join_muc(room, self.nick, wait=True) + + def message(self, msg): + """ + method to handle incoming chat, normal messages + :param msg: incoming msg object + """ + + # do not process our own messages + ourself = self.plugin['xep_0045'].get_our_jid_in_room(msg.get_mucroom()) + if msg['from'] == ourself: + return + + # ever other messages will be answered statically + if msg['type'] in ('normal', 'chat'): + self.send_message( + mto=msg['from'], + mbody=self.messages['direct_msg'].format(nick=self.nick), + mtype=msg['type'], + ) + + def notify_user(self, presence): + """ + method to compose the redirect action + :param presence: incoming user presence object + """ + user_nick = presence["muc"]["nick"] + + # catch empty user_nicks slixmpp does that for some reason + if user_nick == "": + return + + # handle all incoming user presences, this may cause duplicates + if user_nick != self.nick: + new_room = self.rooms[presence.get_from().bare] + self.send_message( + mto=presence.get_from().bare, + mbody=self.messages['grp_msg'].format(user_nick=user_nick, new_room=new_room), + mtype="groupchat", + ) + + if self.functions['direct_invite']: + jid = presence["muc"].get_jid().bare + self.invite_user(jid, new_room) + + def invite_user(self, jid, room): + """ + method to invite the user to the new room + :param jid: jid to invite + :param room: room to which the jid should be invited + """ + self.plugin["xep_0045"].invite( + room=jid, + jid=room, + reason='redirection due to retirement', + ) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + + # init config obj + config = Config() + + # config + login = config.get("login", default=None) + + # init the bot and register used slixmpp plugins + xmpp = RetiredMucBot(login["jid"], login["password"], login["nick"], config) + #xmpp.register_plugin("xep_0004") # Data Forms + xmpp.register_plugin("xep_0030") # Service Discovery + xmpp.register_plugin("xep_0045") # Multi-User Chat + xmpp.register_plugin("xep_0085") # Chat State Notifications + xmpp.register_plugin('xep_0092') # Software Version + xmpp.register_plugin("xep_0199") # XMPP Ping + xmpp.register_plugin("xep_0249") # Direct MUC Invite + + # connect and start receiving stanzas + xmpp.connect() + xmpp.process() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4306fba --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +aiodns==2.0.0 +aiohttp==3.6.3 +async-timeout==3.0.1 +attrs==20.2.0 +cffi==1.14.3 +chardet==3.0.4 +idna==2.10 +multidict==4.7.6 +pyasn1==0.4.8 +pyasn1-modules==0.2.8 +pycares==3.1.1 +pycparser==2.20 +ruamel.yaml==0.16.12 +ruamel.yaml.clib==0.2.2 +slixmpp==1.5.2 +yarl==1.5.1 -- cgit v1.2.3-18-g5258