hemppa/modules/bot.py

334 lines
14 KiB
Python

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'<strong>Logs for {key}:</strong>\n<pre><code class="language-txt">{escape(logs)}</code></pre>', 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