From fdc2fb9b30700e30b4883004512c6de8048bf4bd Mon Sep 17 00:00:00 2001 From: Dorian Zedler Date: Thu, 29 Sep 2022 20:19:35 +0200 Subject: [PATCH] Feat: first working version :) --- README.md | 7 +- run.sh.example | 4 + src/ldapHelper.py | 3 + src/main.py | 225 ++++++++++++++++++++++++++++++++++++++------ src/matrixHelper.py | 113 ++++++++++++++++------ 5 files changed, 294 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 9f79e88..5adf318 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ # matrix-bot -Unser Matrix-Bot, der Gruppen, Gruppenmitgliedschaften und Rechte verwaltet. \ No newline at end of file +Unser Matrix-Bot, der Gruppen, Gruppenmitgliedschaften und Rechte verwaltet. + +# How it works + +1. load all users from matrix +2. add all users to the default channels \ No newline at end of file diff --git a/run.sh.example b/run.sh.example index 3f9f710..992af23 100644 --- a/run.sh.example +++ b/run.sh.example @@ -10,4 +10,8 @@ export MATRIX_BOT_MATRIX_SPACE_ID="!bajjed:matrix.org" export MATRIX_BOT_MATRIX_USERNAME="synapse" export MATRIX_BOT_MATRIX_PASSWORD="SomeSuperSafePassword" +export MATRIX_BOT_DEFAULT_ROOMS="info,talk,show" +export MATRIX_BOT_ADMIN_GROUP="p_admin" +export MATRIX_BOT_MODERATOR_GROUP="p_moderator" + python3 src/main.py \ No newline at end of file diff --git a/src/ldapHelper.py b/src/ldapHelper.py index df4ae44..f8d3d01 100644 --- a/src/ldapHelper.py +++ b/src/ldapHelper.py @@ -9,6 +9,9 @@ class LdapHelper: self._bindPassword = ldapBindPassword self._baseDn = ldapBaseDn + def __del__(self): + self.unbind() + def bind(self): try: self._ldapConnection = ldap.initialize(f"{self._uri}") diff --git a/src/main.py b/src/main.py index 3fe7880..0b7022a 100644 --- a/src/main.py +++ b/src/main.py @@ -1,53 +1,211 @@ -import sys, coloredlogs, logging, os, time, asyncio +from email.policy import default +import sys +import coloredlogs +import logging +import os +import time +import asyncio +from tokenize import group from urllib import response from ldapHelper import LdapHelper from matrixHelper import MatrixHelper -coloredlogs.install(level='INFO', fmt='%(asctime)s - [%(levelname)s] %(message)s') +coloredlogs.install( + level='INFO', fmt='%(asctime)s - [%(levelname)s] %(message)s') + class MlmMatrixBot: def __init__(self) -> None: self._config = self._readConfig() + self._matrixUsername = f"@{self._config['MATRIX_USERNAME']}:{self._config['MATRIX_DOMAIN']}" - self._ldapHelper = LdapHelper(self._config['LDAP_URI'], self._config['LDAP_BIND_DN'], self._config['LDAP_BIND_DN_PASSWORD'], self._config['LDAP_BASE_DN']) - self._matrixHelper = MatrixHelper(self._config['MATRIX_SERVER'], f"@{self._config['MATRIX_USERNAME']}:{self._config['MATRIX_DOMAIN']}", self._config['MATRIX_SPACE_ID']) + self._ldapHelper = LdapHelper(self._config['LDAP_URI'], self._config['LDAP_BIND_DN'], + self._config['LDAP_BIND_DN_PASSWORD'], self._config['LDAP_BASE_DN']) + self._matrixHelper = MatrixHelper( + self._config['MATRIX_SERVER'], self._matrixUsername, self._config['MATRIX_SPACE_ID'], self._matrixUsername) - def __del__(self): - self._ldapHelper.unbind() - asyncio.run(self._matrixHelper.logout()) - - async def run(self): + async def init(self): if not self._ldapHelper.bind(): return False - - print(self._ldapHelper.search("sophomorixType=project")) - if not await self._matrixHelper.login(self._config['MATRIX_PASSWORD']): return False - - room = await self._matrixHelper.createRoom("test11", "test11") - response = await self._matrixHelper._client.joined_rooms() - print(response.rooms) + return True - print(await self._matrixHelper._client.room_invite(room.room_id, "@dozedler:makerchat.net")) - - await self._matrixHelper._client.sync_forever() + async def run(self): - pass + logging.info("==> Sync started <==") + + await self._create_default_rooms() + + current_users, all_projects = await self._load_users_and_groups() + + await self._create_project_rooms(all_projects) + + + logging.info("= (4/5) Loading current rooms =") + matrix_rooms = await self._get_managed_rooms(with_power_levels=True) + matrix_room_ids = list(map(lambda room: room['id'], matrix_rooms)) + room_name_id_map = {} + room_id_map = { + self._config['MATRIX_SPACE_ID']: { + 'name': "default space", + } + } + + for room in matrix_rooms: + room_name_id_map[room['name']] = room['id'] + room_id_map[room['id']] = room + + logging.info("= (5/5) Syncing user memberships =") + for user in current_users: + if not "mlm" in user['groups']: + continue + + # default groups and space + rooms_to_join = self._get_default_rooms() + [self._config["MATRIX_SPACE_ID"]] + # projects + rooms_to_join += list(map(lambda group: group.replace('p_', ''), filter(lambda project: project.startswith("p_"), user['groups']))) + # resolve names to ids + rooms_to_join = list(map( + lambda room: room if room.startswith("!") else room_name_id_map[room], + rooms_to_join)) + + logging.info(f" \t* Syncing user {user['username']}") + for room_id in rooms_to_join: + # set power level + if room_id in user['rooms']: + if ( user['matrix_username'] in room_id_map[room_id]['power_levels'] and room_id_map[room_id]['power_levels'][user['matrix_username']] != user['power_level']) or (user['matrix_username'] not in room_id_map[room_id]['power_levels'] and user['power_level'] != 0): + logging.info(f"\t\t* Setting power level in {room_id_map[room_id]['name']} to {user['power_level']}") + await self._matrixHelper.set_user_power_level_in_room(user['matrix_username'], room_id, user['power_level']) + continue + + logging.info(f"\t* joining {room_id_map[room_id]['name']}") + await self._matrixHelper.add_user_to_room(user['matrix_username'], room_id) + if user['power_level'] != 0: + logging.info(f"\t\t* Setting power level in {room_id_map[room_id]['name']} to {user['power_level']}") + await self._matrixHelper.set_user_power_level_in_room(user['matrix_username'], room_id, user['power_level']) + + + # remove from rooms that are not in the groups + rooms_to_be_left = list(filter(lambda room: room in matrix_room_ids and not room in rooms_to_join, user['rooms'])) + for room_id in rooms_to_be_left: + logging.info(f"\t* leaving {room_id_map[room_id]['name']}") + await self._matrixHelper.remove_user_from_room( + user['matrix_username'], room_id) + + + logging.info("==> Sync finished <==") + + async def _create_default_rooms(self): + # create default rooms + logging.info("= (1/5) Creating default rooms =") + await self._create_rooms(self._get_default_rooms()) + + async def _load_users_and_groups(self): + """ + loads users and their groups from ldap and matrix and merges them + returns: + ([ + { + 'username': 'dozedler', + 'groups': ['mlm', 'p_3d-printing'], + 'rooms': ['!room1:matrix.org', '!room2:matrix.org'], + 'matrix_username': '@dozedler:matrix.org', + 'is_admin': True + } + ], + [ + 'mlm', + 'p_3d-printing' + ]) + """ + logging.info("= (2/5) Loading users and their groups =") + matrix_users = await self._matrixHelper.get_users() + current_users = [] + all_projects = [] + + for user in matrix_users: + username = user['name'].split(':')[0].replace('@', '') + rc, ldap_user = self._ldapHelper.search( + f"(sAMAccountName={username})", ['distinguishedName']) + if not rc: + continue + + rc, raw_groups = self._ldapHelper.search(f"(&(member:1.2.840.113556.1.4.1941:={ldap_user[0]['distinguishedName']})(|(sophomorixType=adminclass)(sophomorixType=project)))", [ + 'sAMAccountName', 'sophomorixType', 'sophomorixMailList']) + + if not rc: + continue + + all_groups = list(map(lambda group: group['sAMAccountName'], raw_groups)) + power_level = 100 if self._config['ADMIN_GROUP'] in all_groups else 50 if self._config['MODERATOR_GROUP'] in all_groups else 0 + + groups = list(filter(lambda group: group['sophomorixMailList'] == 'TRUE' or group['sophomorixType'] == "adminclass", raw_groups)) + + all_projects.extend(list(map(lambda group: group['sAMAccountName'], filter( + lambda group: 'sophomorixType' in group and group['sophomorixType'] == 'project', groups)))) + + rooms = await self._matrixHelper.get_rooms_of_user(user['name']) + groups = list(map(lambda group: group['sAMAccountName'], groups)) + + current_users.append({ + 'username': username, + 'matrix_username': user['name'], + 'groups': groups, + 'rooms': rooms, + 'power_level': power_level + }) + + all_projects = list(set(all_projects)) + + return current_users, all_projects + + async def _create_project_rooms(self, projects): + logging.info("= (3/5) Creating project rooms =") + projects = list(map(lambda project: project.replace('p_', ''), projects)) + await self._create_rooms(projects) + + async def _create_rooms(self, rooms): + matrix_room_names = list(map(lambda room: room['name'], await self._get_managed_rooms())) + for room in rooms: + if room not in matrix_room_names: + logging.info(f"* Creating room {room}") + await self._matrixHelper.create_room(room, room) + + async def _get_managed_rooms(self, with_power_levels=False): + matrix_rooms = await self._matrixHelper.get_rooms() + matrix_rooms = filter( + lambda room: room['name'] != None and room['creator'] == self._matrixUsername, matrix_rooms) + matrix_rooms = map( + lambda room: {'id': room['room_id'], 'name': room['name']}, matrix_rooms) + matrix_rooms = list(matrix_rooms) + + if with_power_levels: + for i in range(len(matrix_rooms)): + logging.info(f"\t* Getting power levels of room {matrix_rooms[i]['name']}") + matrix_rooms[i]['power_levels'] = await self._matrixHelper.get_room_power_levels(matrix_rooms[i]['id']) + + return matrix_rooms + + def _get_default_rooms(self): + return self._config['DEFAULT_ROOMS'].split(',') def _readConfig(self): requiredConfigKeys = [ - 'MATRIX_BOT_LDAP_URI', + 'MATRIX_BOT_LDAP_URI', 'MATRIX_BOT_LDAP_BASE_DN', - 'MATRIX_BOT_LDAP_BIND_DN', + 'MATRIX_BOT_LDAP_BIND_DN', 'MATRIX_BOT_LDAP_BIND_DN_PASSWORD', 'MATRIX_BOT_MATRIX_DOMAIN', 'MATRIX_BOT_MATRIX_SPACE_ID', 'MATRIX_BOT_MATRIX_SERVER', 'MATRIX_BOT_MATRIX_USERNAME', 'MATRIX_BOT_MATRIX_PASSWORD', + 'MATRIX_BOT_DEFAULT_ROOMS', + 'MATRIX_BOT_ADMIN_GROUP', + 'MATRIX_BOT_MODERATOR_GROUP' ] allowedConfigKeys = [ @@ -58,12 +216,15 @@ class MlmMatrixBot: for configKey in requiredConfigKeys: if configKey not in os.environ: - sys.exit (f"Required environment value {configKey} is not set") - config[configKey.replace('MATRIX_BOT_', '')] = os.environ[configKey] + logging.error(f"Required environment value {configKey} is not set") + sys.exit() + config[configKey.replace('MATRIX_BOT_', '') + ] = os.environ[configKey] for configKey in allowedConfigKeys: if configKey in os.environ: - config[configKey.replace('MATRIX_BOT_', '')] = os.environ[configKey] + config[configKey.replace( + 'MATRIX_BOT_', '')] = os.environ[configKey] logging.info("CONFIG:") for key, value in config.items(): @@ -71,9 +232,17 @@ class MlmMatrixBot: return config -if __name__ == "__main__": + +async def main(): bot = MlmMatrixBot() + await bot.init() + while True: + await bot.run() + time.sleep(60) + +if __name__ == "__main__": try: - asyncio.run(bot.run()) + asyncio.run(main()) + except KeyboardInterrupt: - logging.info("Bye") \ No newline at end of file + logging.info("Bye") diff --git a/src/matrixHelper.py b/src/matrixHelper.py index 1da7e68..0bf6a13 100644 --- a/src/matrixHelper.py +++ b/src/matrixHelper.py @@ -1,40 +1,35 @@ +from asyncio import current_task, protocols from dataclasses import dataclass, field from typing import Dict, Any, Optional, Sequence import logging import nio +import synapse_admin +import asyncio -class Schemas: - room_state = { - "type": "object", - "properties": {"event_id": {"type": "string"}}, - "required": ["event_id"], - } +class MatrixAdminClient: + def __init__(self, server: str, access_token: str) -> None: + protocol = server.split("://")[0] + "://" + server = server.split("://")[1] - -@dataclass -class RoomStateResponse(nio.responses.Response): - event_id: str = field() - - @staticmethod - def create_error(parsed_dict): - return nio.responses.ErrorResponse.from_dict(parsed_dict) - - @classmethod - def from_dict(cls, parsed_dict: Dict[Any, Any]): - try: - nio.validate_json(parsed_dict, Schemas.room_state) - except (nio.SchemaError, nio.ValidationError): - return cls.create_error(parsed_dict) - - return cls(parsed_dict["event_id"]) + self.user = synapse_admin.User(server, 443, access_token, protocol) + self.room = synapse_admin.Room(server, 443, access_token, protocol) + self.media = synapse_admin.Media(server, 443, access_token, protocol) + self.management = synapse_admin.Management( + server, 443, access_token, protocol) class MatrixHelper: - def __init__(self, server, username, space_id: str) -> None: + def __init__(self, server: str, username, space_id: str, matrix_username: str) -> None: self._client = nio.AsyncClient(server, username) self._loggedIn = False self._space_id = space_id + self._server = server + self._username = username + self._matrix_username = matrix_username + + def __del__(self): + asyncio.run(self.logout()) async def login(self, password): try: @@ -46,15 +41,23 @@ class MatrixHelper: logging.error("The bot user is not in the space!") return False + # admin login + self._admin_client = MatrixAdminClient( + self._server, self._client.access_token) + user = self._admin_client.user.query(self._username) + + logging.info(f"Logged into Synapse-admin as {user['name']}") + return True except Exception as e: + print(e) logging.error(f"Error while logging into matrix server!") return False async def logout(self): await self._client.close() - async def createRoom(self, name, alias): + async def create_room(self, name, alias, joinable_for_space_members=False): # we not only need to create the room but also have to add it to the space initial_state = [ { @@ -79,12 +82,64 @@ class MatrixHelper: }, { "type": "m.room.history_visibility", - "content": {"history_visibility": "invited"} + "content": {"history_visibility": "shared"} + }, + { + "type": "m.room.power_levels", + "content": { + "users": { + self._matrix_username: 200, + } + } } ] + + if joinable_for_space_members: + initial_state.append({ + "type": "m.room.guest_access", + "state_key": "", + "content": {"guest_access": "can_join"} + }) + room = await self._client.room_create(visibility=nio.RoomVisibility.private, name=name, alias=alias, federate=False, initial_state=initial_state) - print(room) - logging.info( - f"Created room {room.room_id} with alias {alias} and added it to space {self._space_id}") + assert room.room_id is not None + return room + + async def get_rooms(self): + return self._admin_client.room.lists() + + async def get_users(self): + return self._admin_client.user.lists() + + async def get_rooms_of_user(self, username): + return self._admin_client.user.joined_room(username) + + async def add_user_to_room(self, user, room): + return self._admin_client.user.join_room(user, room) + + async def remove_user_from_room(self, user, room): + # TODO: Ban users? + return await self._client.room_kick(room, user, "You have been removed automatically since you are no longer a member of the group associated with this room. If you think, this is an error, please contact us.") + + async def get_room_power_levels(self, room): + current_state = await self._client.room_get_state_event(room, "m.room.power_levels") + return current_state.content['users'] + + async def set_user_power_level_in_room(self, user, room, power_level): + if user == self._matrix_username: + return False + if power_level != 100 and power_level != 50 and power_level != 0: + return False + + + users = await self.get_room_power_levels(room) + users[user] = power_level + if not self._matrix_username in users: + users[self._matrix_username] = 200 + + + return await self._client.room_put_state(room, "m.room.power_levels", { + "users": users + })