import collections import logging import json import requests from html import escape from datetime import timedelta import time from nio import RoomCreateError from modules.common.module import BotModule, ModuleCannotBeDisabled class LogDequeHandler(logging.Handler): def __init__(self, count): super().__init__(level = logging.NOTSET) self.logs = dict() self.level = logging.INFO def emit(self, record): try: self.logs[str(record.module)].append(record) except: self.logs[str(record.module)] = collections.deque([record], maxlen=15) class MatrixModule(BotModule): def __init__(self, name): super().__init__(name) self.starttime = None self.can_be_disabled = False def matrix_start(self, bot): super().matrix_start(bot) self.starttime = time.time() self.loghandler = LogDequeHandler(10) self.loghandler.setFormatter(logging.Formatter('%(levelname)s - %(name)s - %(message)s')) logging.root.addHandler(self.loghandler) async def matrix_message(self, bot, room, event): args = event.body.split(None, 2) if len(args) == 2: if args[1] == 'quit': await self.quit(bot, room, event) elif args[1] == 'version': await self.version(bot, room) elif args[1] == 'reload': await self.reload(bot, room, event) elif args[1] == 'status': await self.status(bot, room) elif args[1] == 'stats': await self.stats(bot, room) elif args[1] == 'leave': await self.leave(bot, room, event) elif args[1] == 'modules': await self.show_modules(bot, room) elif args[1] == 'export': await self.export_settings(bot, event) elif args[1] == 'ping': await self.get_ping(bot, room, event) elif args[1] == 'rooms': await self.rooms(bot, room, event) elif len(args) == 3: if args[1] == 'enable': await self.enable_module(bot, room, event, args[2]) elif args[1] == 'disable': await self.disable_module(bot, room, event, args[2]) elif args[1] == 'export': await self.export_settings(bot, event, module_name=args[2]) elif args[1] == 'import': await self.import_settings(bot, event) elif args[1] == 'logs': await self.last_logs(bot, room, event, args[2]) elif args[1] == 'uricache': await self.manage_uri_cache(bot, room, event, args[2]) else: pass # TODO: Make this configurable. By default don't say anything. # await bot.send_text(room, 'Unknown command, sorry.') async def get_ping(self, bot, room, event): self.logger.info(f'{event.sender} pinged the bot in {room.room_id}') # initial pong serv_before = event.server_timestamp local_before = time.time() pong = await bot.send_text(room, 'Pong!') local_delta = int((time.time() - local_before) * 1000) # ask the server what the timestamp was on our pong serv_delta = None event_url = f'{bot.client.homeserver}/_matrix/client/r0/rooms/{room.room_id}/event/{pong.event_id}?access_token={bot.client.access_token}' try: serv_delta = requests.get(event_url).json()['origin_server_ts'] - serv_before delta = f'server response in {local_delta}ms, event created in {serv_delta}ms' except Exception as e: self.logger.error(f"Failed getting server timestamp: {e}") delta = f'server response in {local_delta}ms' # update event content = { 'm.new_content': { 'msgtype': 'm.notice', 'body': f'Pong! ({delta})' }, 'm.relates_to': { 'rel_type': 'm.replace', 'event_id': pong.event_id }, 'msgtype': 'm.notice', 'body': delta } await bot.client.room_send(room.room_id, 'm.room.message', content) async def leave(self, bot, room, event): bot.must_be_admin(room, event) self.logger.info(f'{event.sender} asked bot to leave room {room.room_id}') await bot.send_text(room, f'By your command.') await bot.client.room_leave(room.room_id) async def stats(self, bot, room): roomcount = len(bot.client.rooms) homeservers = dict() for croomid in bot.client.rooms: try: users = bot.client.rooms[croomid].users except (KeyError, ValueError) as e: self.logger.warning(f"Couldn't get user list in room with id {croomid}, skipping: {repr(e)}") continue for user in users: user, hs = user.split(':', 1) if homeservers.get(hs): homeservers[hs].add(user) else: homeservers[hs] = {user} homeservers = {k: len(v) for k, v in homeservers.items()} usercount = sum(homeservers.values()) hscount = len(homeservers) homeservers = sorted(homeservers.items(), key=lambda kv: (kv[1], kv[0]), reverse=True) homeservers = ', '.join(['{} ({} users, {:.1f}%)'.format(hs[0], hs[1], 100.0 * hs[1] / usercount) for hs in homeservers[:10]]) await bot.send_text(room, f'I\'m seeing {usercount} users in {roomcount} rooms.' f' Top ten homeservers (out of {hscount}): {homeservers}') async def status(self, bot, room): systime = time.time() uptime = str(timedelta(seconds=(systime - self.starttime))).split('.', 1)[0] systime = time.ctime(systime) enabled = sum(1 for module in bot.modules.values() if module.enabled) return await bot.send_text(room, f'Uptime: {uptime} - System time: {systime} ' f'- {enabled} modules enabled out of {len(bot.modules)} loaded.') async def reload(self, bot, room, event): bot.must_be_owner(event) msg = await bot.send_text(room, f'Reloading modules...') bot.stop() bot.reload_modules() bot.start() # update event content = { 'm.new_content': { 'msgtype': 'm.notice', 'body': 'Modules reloaded!' }, 'm.relates_to': { 'rel_type': 'm.replace', 'event_id': msg.event_id }, 'msgtype': 'm.notice', 'body': 'Modules reloaded!' } await bot.client.room_send(room.room_id, 'm.room.message', content) async def version(self, bot, room): await bot.send_text(room, f'Hemppa version {bot.version} - https://github.com/vranki/hemppa') async def quit(self, bot, room, event): bot.must_be_owner(event) await bot.send_text(room, f'Quitting, as requested') self.logger.info(f'{event.sender} commanded bot to quit, so quitting..') bot.bot_task.cancel() async def enable_module(self, bot, room, event, module_name): bot.must_be_owner(event) self.logger.info(f"Asked to enable {module_name}") if bot.modules.get(module_name): module = bot.modules.get(module_name) module.enable() module.matrix_start(bot) bot.save_settings() return await bot.send_text(room, f"Module {module_name} enabled") return await bot.send_text(room, f"Module with name {module_name} not found. Execute !bot modules for a list of available modules") async def disable_module(self, bot, room, event, module_name): bot.must_be_owner(event) self.logger.info(f"asked to disable {module_name}") if bot.modules.get(module_name): module = bot.modules.get(module_name) try: module.disable() except ModuleCannotBeDisabled: return await bot.send_text(room, f"Module {module_name} cannot be disabled.") except Exception as e: return await bot.send_text(room, f"Module {module_name} was not disabled: {repr(e)}") module.matrix_stop(bot) bot.save_settings() return await bot.send_text(room, f"Module {module_name} disabled") return await bot.send_text(room, f"Module with name {module_name} not found. Execute !bot modules for a list of available modules") async def show_modules(self, bot, room): modules_message = "Modules:\n" for modulename, module in collections.OrderedDict(sorted(bot.modules.items())).items(): state = 'Enabled' if module.enabled else 'Disabled' modules_message += f"{state}: {modulename} - {module.help()}\n" await bot.send_text(room, modules_message) async def export_settings(self, bot, event, module_name=None): bot.must_be_owner(event) data = bot.get_account_data()['module_settings'] if module_name: data = data[module_name] self.logger.info(f"{event.sender} is exporting settings for module {module_name}") else: self.logger.info(f"{event.sender} is exporting all settings") await bot.send_msg(event.sender, f'Private message from {bot.matrix_user}', json.dumps(data)) async def import_settings(self, bot, event): bot.must_be_owner(event) self.logger.info(f"{event.sender} is importing settings") try: account_data = bot.get_account_data() child = account_data['module_settings'] except KeyError: # no data yet account_data['module_settings'] = dict() child = account_data['module_settings'] key = None data = event.body.split(None, 2)[2] while not data.startswith('{'): key, data = data.split(None, 1) if child.get(key): child = child[key] key = None else: break data = json.loads(data) if not key: child.update(data) else: child[key] = data bot.load_settings(account_data) bot.save_settings() await bot.send_msg(event.sender, f'Private message from {bot.matrix_user}', 'Updated bot settings') async def last_logs(self, bot, room, event, target): bot.must_be_owner(event) self.logger.info(f'{event.sender} asked for recent log messages.') msg_room = await bot.find_or_create_private_msg(event.sender, f'Private message from {bot.matrix_user}') if not msg_room or (type(msg_room) is RoomCreateError): # fallback to current room if we can't create one msg_room = room try: target, count = target.split() count = -abs(int(count)) except ValueError: count = 0 keys = list(self.loghandler.logs) for key in [target, f'module {target}']: try: logs = list(self.loghandler.logs[key]) break except (KeyError, TypeError): pass else: return await bot.send_text(msg_room, f'Unknown module {target}, or no logs yet') if count: logs = logs[count:] logs = '\n'.join([self.loghandler.format(record) for record in logs]) return await bot.send_html(msg_room, f'Logs for {key}:\n
{escape(logs)}
', f'Logs for {key}:\n' + logs) async def manage_uri_cache(self, bot, room, event, action): bot.must_be_owner(event) if action == 'view': self.logger.info(f"{event.sender} wants to see the uri cache") msg = [f'uri cache size: {len(bot.uri_cache)}'] for key, val in bot.uri_cache.items(): msg.append('- ' + key + ': ' + val[0]) return await bot.send_text(room, '\n'.join(msg)) if action in ['clean', 'clear']: self.logger.info(f"{event.sender} wants to clear the uri cache") bot.uri_cache = dict() bot.save_settings() async def rooms(self, bot, room, event): bot.must_be_owner(event) output = f'I\'m in following {len(bot.client.rooms)} rooms:\n' for croomid in bot.client.rooms: roomobj = bot.get_room_by_id(croomid) output = output + f' - {roomobj.display_name} ( {roomobj.machine_name} )\n' await bot.send_text(room, output) def disable(self): raise ModuleCannotBeDisabled def help(self): return 'Bot management commands. (quit, version, reload, status, stats, leave, modules, enable, disable, import, export, ping)' def long_help(self, bot=None, event=None, **kwargs): text = self.help() + ( '\n- "!bot version": get bot version' '\n- "!bot ping": get the ping time to the server' '\n- "!bot status": get bot uptime and status' '\n- "!bot stats": get current users, rooms, and homeservers') if bot and event and bot.is_owner(event): text += ('\n- "!bot quit": kill the bot :(' '\n- "!bot reload": reload the bot modules' '\n- "!bot uricache (view|clean)": view or clean the bot\'s URI cache' '\n- "!bot logs [module] ([count])": get [count] most recent logs from [module]' '\n- "!bot enable [module]": enable a module' '\n- "!bot disable [module]": disable a module' '\n- "!bot import ([module]) [json]": import settings into the bot' '\n- "!bot export ([module])": export settings from the bot' ) return text