diff --git a/.gitignore b/.gitignore index 6a18ad4..2d05c5d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Config +config.json +*.db + # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md index 4735f07..18b4cbd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ -# matrix-chatbot +This chatbot is based on [github.com/shawnanastasio/python-matrix-bot-api](https://github.com/shawnanastasio/python-matrix-bot-api/) which was published under GPL-3.0. It works with on the [Matrix network](https://matrix.org). -An extensible Python chatbot that is compatible with the Matrix protocol \ No newline at end of file + +For now, this README only contains notes. + +## Requirements: + +MySQLdb + +## Write about: +* File structure of plugins and structure of the class +* Create help function for bot and for all plugins diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..3c9526f --- /dev/null +++ b/config.json.example @@ -0,0 +1,17 @@ +{ + "bot_credentials": { + "username": "", + "password": "", + "server": "" + }, + + "character": { + "name": "Peter", + "avatar": "images/profilepic.jpg", + "avatar_mime": "image/jpeg" + }, + + "plugins": [ + "events" + ] +} diff --git a/images/profilepic.jpg b/images/profilepic.jpg new file mode 100644 index 0000000..3a93b70 Binary files /dev/null and b/images/profilepic.jpg differ diff --git a/matrix_bot_api/__init__.py b/matrix_bot_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/matrix_bot_api/help b/matrix_bot_api/help new file mode 100644 index 0000000..451d951 --- /dev/null +++ b/matrix_bot_api/help @@ -0,0 +1,13 @@ +

Peter's help

+Als je een zin start met Peter word ik geactiveerd! + +

+ +Ik heb verschillende submodules die je kunt gebruiken. Start een submodule door Peter <submodule naam> <commando>. Neem als voorbeeld de evenementen module. Om een lijst van de komende 10 evenementen te verkrijgen, type Peter evenementen lijst. + +

+ + + diff --git a/matrix_bot_api/matrix_bot_api.py b/matrix_bot_api/matrix_bot_api.py new file mode 100644 index 0000000..853ad8a --- /dev/null +++ b/matrix_bot_api/matrix_bot_api.py @@ -0,0 +1,197 @@ +import traceback +import re +import os +import sys +import importlib + +from matrix_client.client import MatrixClient +from matrix_client.api import MatrixRequestError, MatrixHttpApi +from matrix_client.user import User + +from matrix_bot_api.mregex_handler import MRegexHandler + +HELP_LOCATION = os.path.join(os.path.dirname(__file__), 'help') + +private_message = "Hey {}! Ik heb je even een privébericht gestuurd 🙂" + +def eprint(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + +class MatrixBotAPI: + # rooms - List of rooms ids to operate in, or None to accept all rooms + def __init__(self, config, rooms=None): + self.username = config['bot_credentials']['username'] + + # Authenticate with given credentials + self.client = MatrixClient(config['bot_credentials']['server']) + try: + self.token = self.client.login_with_password( + config['bot_credentials']['username'], + config['bot_credentials']['password']) + except MatrixRequestError as e: + print(e) + if e.code == 403: + print("Bad username/password") + except Exception as e: + print("Invalid server URL") + traceback.print_exc() + + self.api = MatrixHttpApi( + config['bot_credentials']['server'], + token=self.token) + + # Store allowed rooms + self.rooms = rooms + + # Store empty list of handlers + self.handlers = [] + + # If rooms is None, we should listen for invites and automatically + # accept them + if rooms is None: + self.client.add_invite_listener(self.handle_invite) + self.rooms = [] + + # Add all rooms we're currently in to self.rooms and add their + # callbacks + for room_id, room in self.client.get_rooms().items(): + room.add_listener(self.handle_message) + self.rooms.append(room_id) + else: + # Add the message callback for all specified rooms + for room in self.rooms: + room.add_listener(self.handle_message) + + # This flag can be set by the calling function to cancel all threads + # of all plugins + self.cancel = False + + # Store empty list of plugins + self.plugins = [] + + + # Try to import plugins. All plugins must be located in the + # ./plugins directory + modules = [] + + try: + for plugin in config['plugins']: + modules.append( + importlib.import_module( + "plugins.{0}.{0}".format(plugin), package = None)) + except: + eprint("Importing one or more of the plugins did not go well!") + sys.exit() + + # Loop through the available modules and add their handler variables + for i, module in enumerate(modules): + # Create new instance of plugin and append to plugins array + self.plugins.append(module.Plugin(self)) + + # Add handler of newly created instance to bot + self.add_handler(self.plugins[i].handler) + + # Run setup + self.setup(config) + + # Add callback for help function + self.help_handler = MRegexHandler("Peter help", self.help) + self.add_handler(self.help_handler) + + def setup(self, config): + """This function only runs once, ever. It initializes an SQLite + database, sets the bot's avatar, and name""" + + try: + # Try to make new directory + os.mkdir("data") + + # Get user instance + self.user = self.client.get_user(self.username) + + # Set username + self.user.set_display_name(config['character']['name']) + + # Open, upload, and set avatar + f = open(config['character']['avatar'], mode="rb") + + avatar = self.client.upload(f, config['charactar']['avatar_mime']) + self.user.set_avatar_url(avatar) + + except FileExistsError: + # Do nothing, data directory already exists + pass + + def send_message_private_public(self, room, event, message): + orig_room = room + found_room = False + + for room_id, room in self.client.get_rooms().items(): + joined_members = room.get_joined_members() + + # Check for rooms with only two members + if len(joined_members) == 2: + # Check if sender is in that room + for member in joined_members: + if event['sender'] == member.user_id: + found_room = True + + if found_room: + # If the flag is set, we do not need to check further rooms + break + + # Send help message to an existing room or to a new room + if found_room: + room.send_html(message) + else: + room = self.client.create_room(invitees=[event['sender']]); + room.send_html(message) + + if room != orig_room: + display_name_sender = self.api.get_display_name(event['sender']) + orig_room.send_text(private_message.format(display_name_sender)) + + def help(self, room, event): + """Prints a general help message and then grabs all help + messages from the different plugins + """ + + help_text = open(HELP_LOCATION, mode="r").read() + + for plugin in self.plugins: + help_text += plugin.help() + + self.send_message_private_public(room, event, help_text) + + def add_handler(self, handler): + self.handlers.append(handler) + + def handle_message(self, room, event): + # Make sure we didn't send this message + if re.match("@" + self.username, event['sender']): + return + + # Loop through all installed handlers and see if they need to be called + for handler in self.handlers: + if handler.test_callback(room, event): + # This handler needs to be called + try: + handler.handle_callback(room, event) + except: + traceback.print_exc() + + def handle_invite(self, room_id, state): + print("Got invite to room: " + str(room_id)) + print("Joining...") + room = self.client.join_room(room_id) + + # Add message callback for this room + room.add_listener(self.handle_message) + + # Add room to list + self.rooms.append(room) + + def start_polling(self): + # Starts polling for messages + self.client.start_listener_thread() + return self.client.sync_thread diff --git a/matrix_bot_api/mcommand_handler.py b/matrix_bot_api/mcommand_handler.py new file mode 100644 index 0000000..bc56789 --- /dev/null +++ b/matrix_bot_api/mcommand_handler.py @@ -0,0 +1,25 @@ +""" +Defines a matrix bot handler for commands +""" +import re + +from matrix_bot_api.mhandler import MHandler + + +class MCommandHandler(MHandler): + + # command - String of command to handle + # handle_callback - Function to call if message contains command + # cmd_char - Character that denotes a command. '!' by default + def __init__(self, command, handle_callback, cmd_char='!'): + MHandler.__init__(self, self.test_command, handle_callback) + self.command = command + self.cmd_char = cmd_char + + # Function called by Matrix bot api to determine whether or not to handle this message + def test_command(self, room, event): + # Test the message to see if it has our command + if event['type'] == "m.room.message": + if re.match(self.cmd_char + self.command, event['content']['body']): + return True + return False diff --git a/matrix_bot_api/mhandler.py b/matrix_bot_api/mhandler.py new file mode 100644 index 0000000..9701720 --- /dev/null +++ b/matrix_bot_api/mhandler.py @@ -0,0 +1,13 @@ +""" +Defines a Matrix bot message handler +""" + + +class MHandler(object): + # test_callback - function that takes a room and event and returns a boolean + # indicating whether we should pass the message on to handle_callback + # + # handle_callback - function that takes a room and event and handles them + def __init__(self, test_callback, handle_callback): + self.test_callback = test_callback + self.handle_callback = handle_callback diff --git a/matrix_bot_api/mregex_handler.py b/matrix_bot_api/mregex_handler.py new file mode 100644 index 0000000..32aa684 --- /dev/null +++ b/matrix_bot_api/mregex_handler.py @@ -0,0 +1,25 @@ +""" +Defines a matrix bot handler that uses regex to determine if message should be handled +""" +import re + +from matrix_bot_api.mhandler import MHandler + + +class MRegexHandler(MHandler): + + # regex_str - Regular expression to test message against + # + # handle_callback - Function to call if messages matches regex + def __init__(self, regex_str, handle_callback): + MHandler.__init__(self, self.test_regex, handle_callback) + self.regex_str = regex_str + + def test_regex(self, room, event): + # Test the message and see if it matches the regex + if event['type'] == "m.room.message": + if re.search(self.regex_str, event['content']['body']): + # The message matches the regex, return true + return True + + return False diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/events/README.md b/plugins/events/README.md new file mode 100644 index 0000000..abaf2fa --- /dev/null +++ b/plugins/events/README.md @@ -0,0 +1 @@ +Insert description of event plugin. diff --git a/plugins/events/__init__.py b/plugins/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/events/config.json.example b/plugins/events/config.json.example new file mode 100644 index 0000000..942ef89 --- /dev/null +++ b/plugins/events/config.json.example @@ -0,0 +1,13 @@ +{ + "database_credentials": { + "username": "", + "password": "", + "database": "" + }, + "cat_id_room_mapping": [ + [[], ""], + [[], ""] + ], + "update_time_span": 5, + "base_url_adm": "" +} diff --git a/plugins/events/events.py b/plugins/events/events.py new file mode 100644 index 0000000..22c0340 --- /dev/null +++ b/plugins/events/events.py @@ -0,0 +1,170 @@ +import os +import json +import time +import sqlite3 +import threading +import datetime as dt +import MySQLdb as mysql + +from matrix_bot_api.mregex_handler import MRegexHandler + +CONFIG_LOCATION = os.path.join(os.path.dirname(__file__), 'config.json') +HELP_LOCATION = os.path.join(os.path.dirname(__file__), 'help') +NEW_EVENT_LOCATION = os.path.join(os.path.dirname(__file__), 'new_event') + +class Plugin: + """ Description of event plugin """ + + def __init__(self, bot): + # Load the configuration + with open(CONFIG_LOCATION) as json_data: + self.config = json.load(json_data) + + # Define sensitivity + self.handler = MRegexHandler("Peter evenementen", self.callback) + + # Save parent bot + self.bot = bot + + # Start thread to check events + self.event_thread = threading.Thread(target=self.check_new_event_thread) + self.event_thread.start() + + self.setup() + + def setup(self): + """This function only runs once, ever. It initializes the necessary + SQLite tables.""" + + try: + # Define query to INSERT event table to SQLite DB + sql = """CREATE TABLE 'events' ( + 'dat_id' integer, + 'datetime_posted' datetime);""" + + # Open, execute, commit, and close SQLite database + sqlite_db = sqlite3.connect('data/bot.db') + sqlite_cursor = sqlite_db.cursor() + sqlite_cursor.execute(sql) + sqlite_db.commit() + sqlite_db.close() + + except sqlite3.OperationalError: + # Table already exists, do nothing + pass + + def check_new_event_thread(self): + while not self.bot.cancel: + self.check_new_event() + + def check_new_event(self): + """ Check every minutes for new events in the database. + + Since the check will only be performed every minutes, the + connection to the database will not be kept open. + + As soon as an event is posted to the defined room, its ID is + saved in an SQLite database. + """ + + # Connect to database + mysql_db = mysql.connect( + user = self.config['database_credentials']['username'], + password = self.config['database_credentials']['password'], + database = self.config['database_credentials']['database']) + + mysql_cursor = mysql_db.cursor() + + # Grab all events from database that start in the future + select_sql = """SELECT dat_id, dat_cat_id, + dat_headline, dat_begin, dat_end + FROM adm_dates + WHERE dat_begin >'{}'""".format( + dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + + mysql_cursor.execute(select_sql) + + # Fetch events + results = mysql_cursor.fetchall() + + mysql_db.close() + + # Open SQLite database + sqlite_db = sqlite3.connect('data/bot.db') + + sqlite_cursor = sqlite_db.cursor() + + # Define query to SELECT event from SQLite DB + select_sql = """SELECT dat_id + FROM events + WHERE dat_id = {}""" + + insert_sql = """INSERT INTO events(dat_id, datetime_posted) + VALUES(:dat_id, :datetime_posted)""" + + # Loop through event + for row in results: + # First, check if a message on this event was already sent + sqlite_cursor.execute(select_sql.format(row[0])) + + if sqlite_cursor.fetchall(): + # Do nothing. This event was already processed + pass + else: + # This appears to be a new event. Process it! + + # Generate links + base_date_url = "{}{}".format( + self.config['base_url_adm'], + "/adm_program/modules/dates/") + + view_link = "{}dates.php?id={}&view_mode=html&" + view_link += "view=detail&headline={}" + view_link = view_link.format(base_date_url, row[0], row[2]) + + function_link = "{}dates_function.php?mode={}&dat_id={}&" + + attend_link = function_link.format(base_date_url, '3', row[0]) + maybe_link = function_link.format(base_date_url, '7', row[0]) + cancel_link = function_link.format(base_date_url, '4', row[0]) + + new_event = open(NEW_EVENT_LOCATION, mode="r").read() + + # First, write to SQLite database that it is processed + sqlite_cursor.execute( + insert_sql, { + 'dat_id':row[0], + 'datetime_posted': + dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}) + + # Check role ID of event and check if config defines + # where it should be shared. + for mapping in self.config['cat_id_room_mapping']: + if row[1] in mapping[0]: + # We got a winner! + + for i, room in self.bot.client.get_rooms().items(): + if room.display_name in mapping[1]: + room.send_html( + new_event.format( + row[3].strftime("%d-%m-%Y"), + row[3].strftime("%H:%M"), + row[2], + view_link, + attend_link, + maybe_link, + cancel_link)) + + # Commit and close queries + sqlite_db.commit() + sqlite_db.close() + + # Sleep + #time.sleep(self.config['update_time_span'] * 60) + time.sleep(1) + + def callback(self, room, event): + room.send_text("Information ") + + def help(self): + return open(HELP_LOCATION, mode="r").read() diff --git a/plugins/events/help b/plugins/events/help new file mode 100644 index 0000000..4ab9dd6 --- /dev/null +++ b/plugins/events/help @@ -0,0 +1,7 @@ +
evenementen
+ diff --git a/plugins/events/new_event b/plugins/events/new_event new file mode 100644 index 0000000..10918a0 --- /dev/null +++ b/plugins/events/new_event @@ -0,0 +1,11 @@ +@room er is zojuist een nieuw evenement aangemaakt! Op {} om {} zal "{}" plaatsvinden. + +

+ +Ben je aangemeld op de leden database in je favoriete browser? Klik dan hier: + diff --git a/run.py b/run.py new file mode 100755 index 0000000..99cff11 --- /dev/null +++ b/run.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +""" +This is Peter. Peter is using the Python Matrix Bot API +""" + +import json + +# Bot API import +from matrix_bot_api.matrix_bot_api import MatrixBotAPI + +def main(): + # Load the configuration + with open('config.json') as json_data: + config = json.load(json_data) + + # Create an instance of the MatrixBotAPI + bot = MatrixBotAPI(config) + + # Start polling + bot.start_polling() + + # Infinitely read stdin to stall main thread while the bot runs in other + # threads. + try: + while True: + input() + except: + bot.cancel = True + +if __name__ == "__main__": + main()