summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authornico <nico@magicbroccoli.de>2020-10-26 19:23:22 +0100
committernico <nico@magicbroccoli.de>2020-10-26 19:23:22 +0100
commita109b226bf6503584b4dea5a4203141e44338b85 (patch)
tree3b531cc3a12a099e5df169e312ba0da4a93c5bf5
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.
-rw-r--r--.gitattributes1
-rw-r--r--.gitignore208
-rw-r--r--config.py74
-rw-r--r--config.yml.example28
-rw-r--r--main.py134
-rw-r--r--requirements.txt16
6 files changed, 461 insertions, 0 deletions
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