diff --git a/.gitignore b/.gitignore index ef64859..554baea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # editors .vscode +.idea # ignore Pipfile.lock Pipfile.lock diff --git a/bot.py b/bot.py index fe5204f..7cc06a2 100755 --- a/bot.py +++ b/bot.py @@ -9,10 +9,11 @@ import re import sys import traceback import urllib.parse +from importlib import reload import requests from nio import AsyncClient, InviteEvent, JoinError, RoomMessageText -from importlib import reload + # Couple of custom exceptions @@ -125,7 +126,8 @@ class Bot: except CommandRequiresOwner: await self.send_text(room, f'Sorry, only bot owner can run that command.') except Exception: - await self.send_text(room, f'Module {command} experienced difficulty: {sys.exc_info()[0]} - see log for details') + await self.send_text(room, + f'Module {command} experienced difficulty: {sys.exc_info()[0]} - see log for details') traceback.print_exc(file=sys.stderr) async def invite_cb(self, room, event): @@ -144,6 +146,7 @@ class Bot: def load_module(self, modulename): try: + print("load module: " + modulename) module = importlib.import_module('modules.' + modulename) module = reload(module) cls = getattr(module, 'MatrixModule') @@ -168,7 +171,7 @@ class Bot: moduleobject = self.load_module(modulename) if moduleobject: self.modules[modulename] = moduleobject - + def clear_modules(self): self.modules = dict() diff --git a/modules/bot.py b/modules/bot.py index f18855e..7852ca9 100644 --- a/modules/bot.py +++ b/modules/bot.py @@ -1,30 +1,33 @@ -import urllib.request -from datetime import datetime, timedelta +from datetime import datetime +from modules.common.module import BotModule + + +class MatrixModule(BotModule): -class MatrixModule: def matrix_start(self, bot): self.starttime = datetime.now() - + async def matrix_message(self, bot, room, event): args = event.body.split() if len(args) == 2: - if args[1]=='quit': + if args[1] == 'quit': bot.must_be_admin(room, event) await bot.send_text(room, f'Quitting, as requested') print(f'{event.sender} commanded bot to quit, so quitting..') bot.bot_task.cancel() - elif args[1]=='version': + elif args[1] == 'version': await bot.send_text(room, f'Hemppa version {bot.version} - https://github.com/vranki/hemppa') - elif args[1]=='reload': + elif args[1] == 'reload': bot.must_be_admin(room, event) await bot.send_text(room, f'Reloading modules..') bot.stop() bot.reload_modules() bot.start() - elif args[1]=='status': + elif args[1] == 'status': uptime = datetime.now() - self.starttime - await bot.send_text(room, f'Uptime {uptime} - system time is {datetime.now()} - loaded {len(bot.modules)} modules.') - elif args[1]=='stats': + await bot.send_text(room, + f'Uptime {uptime} - system time is {datetime.now()} - loaded {len(bot.modules)} modules.') + elif args[1] == 'stats': roomcount = len(bot.client.rooms) usercount = 0 homeservers = dict() @@ -44,8 +47,9 @@ class MatrixModule: if len(homeservers) > 10: homeservers = homeservers[0:10] - await bot.send_text(room, f'I\'m seeing {usercount} users in {roomcount} rooms. Top ten homeservers: {homeservers}') - elif args[1]=='leave': + await bot.send_text(room, + f'I\'m seeing {usercount} users in {roomcount} rooms. Top ten homeservers: {homeservers}') + elif args[1] == 'leave': bot.must_be_admin(room, event) print(f'{event.sender} asked bot to leave room {room.room_id}') await bot.send_text(room, f'By your command.') @@ -55,4 +59,4 @@ class MatrixModule: await bot.send_text(room, 'Unknown command, sorry.') def help(self): - return('Bot management commands') + return 'Bot management commands' diff --git a/modules/common/module.py b/modules/common/module.py new file mode 100644 index 0000000..56dce6c --- /dev/null +++ b/modules/common/module.py @@ -0,0 +1,86 @@ +from abc import ABC, abstractmethod +from nio import RoomMessageText, Event + + +class BotModule(ABC): + """Abtract bot module + + A module derives from this class to process and interact on room messages. The subcluss must be named `MatrixModule`. + Just write a python file with desired command name and place it in modules. See current modules for examples. + + No need to register it anywhere else. + + Example: + + class MatrixModule(BotModule): + async def matrix_message(self, bot, room, event): + args = event.body.split() + args.pop(0) + + # Echo what they said back + await bot.send_text(room, ' '.join(args)) + + def help(self): + return 'Echoes back what user has said' + + """ + + def matrix_start(self, bot): + """Called once on startup + + :param bot: a reference to the bot + :type bot: Bot + """ + pass + + @abstractmethod + async def matrix_message(self, bot, room, event): + """Called when a message is sent to room starting with !module_name + + :param bot: a reference to the bot + :type bot: Bot + :param room: a matrix room message + :type room: RoomMessageText + :param event: a handle to the event that triggered the callback + :type event: Event + """ + pass + + def matrix_stop(self, bot): + """Called once before exit + + :param bot: a reference to the bot + :type bot: Bot + """ + pass + + async def matrix_poll(self, bot, pollcount): + """Called every 10 seconds + + :param bot: a reference to the bot + :type bot: Bot + :param pollcount: the actual poll count + :type pollcount: int + """ + pass + + @abstractmethod + def help(self): + """Return one-liner help text""" + pass + + def get_settings(self): + """Must return a dict object that can be converted to JSON and sent to server + + :return: a dict object that can be converted to JSON + :rtype: dict + """ + pass + + def set_settings(self, data): + """Load these settings. It should be the same JSON you returned in previous get_settings + + :param data: a dict object containing the settings read from the account + :type data: dict + """ + pass diff --git a/modules/common/pollingservice.py b/modules/common/pollingservice.py index a1ff14f..b838cab 100644 --- a/modules/common/pollingservice.py +++ b/modules/common/pollingservice.py @@ -1,15 +1,14 @@ -import traceback -import sys from datetime import datetime, timedelta from random import randrange + class PollingService: def __init__(self): self.known_ids = set() self.account_rooms = dict() # Roomid -> [account, account..] self.next_poll_time = dict() # Roomid -> datetime, None = not polled yet self.service_name = "Service" - self.poll_interval_min = 30 # TODO: Configurable + self.poll_interval_min = 30 # TODO: Configurable self.poll_interval_random = 30 async def matrix_poll(self, bot, pollcount): @@ -47,16 +46,17 @@ class PollingService: await self.poll_implementation(bot, account, roomid, send_messages) - async def matrix_message(self, bot, room, event): args = event.body.split() if len(args) == 2: if args[1] == 'list': - await bot.send_text(room, f'{self.service_name} accounts in this room: {self.account_rooms.get(room.room_id) or []}') + await bot.send_text(room, + f'{self.service_name} accounts in this room: {self.account_rooms.get(room.room_id) or []}') elif args[1] == 'debug': - await bot.send_text(room, f"{self.service_name} accounts: {self.account_rooms.get(room.room_id) or []} - known ids: {self.known_ids}\n" \ - f"Next poll in this room at {self.next_poll_time.get(room.room_id)} - in {self.next_poll_time.get(room.room_id) - datetime.now()}") + await bot.send_text(room, + f"{self.service_name} accounts: {self.account_rooms.get(room.room_id) or []} - known ids: {self.known_ids}\n" \ + f"Next poll in this room at {self.next_poll_time.get(room.room_id)} - in {self.next_poll_time.get(room.room_id) - datetime.now()}") elif args[1] == 'poll': bot.must_be_owner(event) print(f'{self.service_name} force polling requested by {event.sender}') @@ -111,4 +111,4 @@ class PollingService: self.account_rooms = data['account_rooms'] def help(self): - return(f'{self.service_name} polling') + return f'{self.service_name} polling' diff --git a/modules/cron.py b/modules/cron.py index a823096..a411da8 100644 --- a/modules/cron.py +++ b/modules/cron.py @@ -1,8 +1,9 @@ import shlex from datetime import datetime +from .common.module import BotModule -class MatrixModule: +class MatrixModule(BotModule): daily_commands = dict() # room_id -> command json last_hour = datetime.now().hour @@ -30,7 +31,7 @@ class MatrixModule: await bot.send_text(room, 'Cleared commands on this room.') def help(self): - return('Runs scheduled commands') + return ('Runs scheduled commands') def get_settings(self): return {'daily_commands': self.daily_commands} @@ -54,4 +55,4 @@ class MatrixModule: delete_rooms.append(room_id) for roomid in delete_rooms: - self.daily_commands.pop(roomid, None) \ No newline at end of file + self.daily_commands.pop(roomid, None) diff --git a/modules/echo.py b/modules/echo.py index 6143341..0d73581 100644 --- a/modules/echo.py +++ b/modules/echo.py @@ -1,4 +1,7 @@ -class MatrixModule: +from modules.common.module import BotModule + + +class MatrixModule(BotModule): async def matrix_message(self, bot, room, event): args = event.body.split() args.pop(0) @@ -7,4 +10,4 @@ class MatrixModule: await bot.send_text(room, ' '.join(args)) def help(self): - return('Echoes back what user has said') + return ('Echoes back what user has said') diff --git a/modules/help.py b/modules/help.py index b55bd86..020870a 100644 --- a/modules/help.py +++ b/modules/help.py @@ -1,4 +1,7 @@ -class MatrixModule: +from modules.common.module import BotModule + + +class MatrixModule(BotModule): async def matrix_message(self, bot, room, event): msg = f'This is Hemppa {bot.version}, a generic Matrix bot. Known commands:\n\n' @@ -13,4 +16,4 @@ class MatrixModule: await bot.send_text(room, msg) def help(self): - return('Prints help on commands') + return 'Prints help on commands' diff --git a/modules/loc.py b/modules/loc.py index fdecfb6..dc341d5 100644 --- a/modules/loc.py +++ b/modules/loc.py @@ -1,8 +1,9 @@ from geopy.geocoders import Nominatim from nio import RoomMessageUnknown +from modules.common.module import BotModule -class MatrixModule: +class MatrixModule(BotModule): bot = None def matrix_start(self, bot): @@ -29,7 +30,7 @@ class MatrixModule: float(latlon[1]) osm_link = 'https://www.openstreetmap.org/?mlat=' + \ - latlon[0] + "&mlon=" + latlon[1] + latlon[0] + "&mlon=" + latlon[1] plain = sender + ' 🚩 ' + osm_link html = f'{sender} 🚩 {location_text}' @@ -58,4 +59,4 @@ class MatrixModule: await bot.send_text(room, "Can't find " + query + " on map!") def help(self): - return('Search for locations and display Matrix location events as OSM links') + return 'Search for locations and display Matrix location events as OSM links' diff --git a/modules/off/googlecal.py b/modules/off/googlecal.py index a089fdc..bfabb7f 100644 --- a/modules/off/googlecal.py +++ b/modules/off/googlecal.py @@ -9,6 +9,7 @@ from google.auth.transport.requests import Request from google_auth_oauthlib.flow import InstalledAppFlow from googleapiclient.discovery import build + # # Google calendar notifications # @@ -16,9 +17,10 @@ from googleapiclient.discovery import build # It's created on first run (run from console!) and # can be copied to another computer. # +from modules.common.module import BotModule -class MatrixModule: +class MatrixModule(BotModule): def matrix_start(self, bot): self.bot = bot self.SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'] @@ -135,7 +137,8 @@ class MatrixModule: async def send_events(self, bot, events, room): for event in events: start = event['start'].get('dateTime', event['start'].get('date')) - await bot.send_html(room, f'{self.parse_date(start)} {event["summary"]}', f'{self.parse_date(start)} {event["summary"]}') + await bot.send_html(room, f'{self.parse_date(start)} {event["summary"]}', + f'{self.parse_date(start)} {event["summary"]}') def list_upcoming(self, calid): startTime = datetime.utcnow() @@ -160,7 +163,7 @@ class MatrixModule: return events_result.get('items', []) def help(self): - return('Google calendar. Lists 10 next events by default. today = list today\'s events.') + return ('Google calendar. Lists 10 next events by default. today = list today\'s events.') def get_settings(self): return {'calendar_rooms': self.calendar_rooms} diff --git a/modules/off/ig.py b/modules/off/ig.py index 4609161..15a4226 100644 --- a/modules/off/ig.py +++ b/modules/off/ig.py @@ -1,13 +1,15 @@ -import traceback import sys +import traceback from datetime import datetime, timedelta from random import randrange -from modules.common.pollingservice import PollingService from igramscraper.exception.instagram_not_found_exception import \ InstagramNotFoundException from igramscraper.instagram import Instagram +from modules.common.pollingservice import PollingService + + class MatrixModule(PollingService): def __init__(self): super().__init__() @@ -21,12 +23,14 @@ class MatrixModule(PollingService): for media in medias: if send_messages: if media.identifier not in self.known_ids: - await bot.send_html(bot.get_room_by_id(roomid), f'Instagram {account}: {media.caption}', f'{account}: {media.caption} {media.link}') + await bot.send_html(bot.get_room_by_id(roomid), + f'Instagram {account}: {media.caption}', + f'{account}: {media.caption} {media.link}') self.known_ids.add(media.identifier) except InstagramNotFoundException: print('ig error: there is ', account, - ' account that does not exist - deleting from room') + ' account that does not exist - deleting from room') self.account_rooms[roomid].remove(account) bot.save_settings() except Exception: diff --git a/modules/off/metar.py b/modules/off/metar.py index ad94d15..44ffb18 100644 --- a/modules/off/metar.py +++ b/modules/off/metar.py @@ -1,13 +1,15 @@ import urllib.request +from modules.common.module import BotModule -class MatrixModule: + +class MatrixModule(BotModule): async def matrix_message(self, bot, room, event): args = event.body.split() if len(args) == 2: icao = args[1] metar_url = "https://tgftp.nws.noaa.gov/data/observations/metar/stations/" + \ - icao.upper() + ".TXT" + icao.upper() + ".TXT" response = urllib.request.urlopen(metar_url) lines = response.readlines() await bot.send_text(room, lines[1].decode("utf-8").strip()) @@ -15,4 +17,4 @@ class MatrixModule: await bot.send_text(room, 'Usage: !metar ') def help(self): - return('Metar data access (usage: !metar )') + return ('Metar data access (usage: !metar )') diff --git a/modules/off/notam.py b/modules/off/notam.py index 6e005d4..1c5012c 100644 --- a/modules/off/notam.py +++ b/modules/off/notam.py @@ -1,8 +1,10 @@ -import urllib.request import re +import urllib.request + +from modules.common.module import BotModule -class MatrixModule: +class MatrixModule(BotModule): async def matrix_message(self, bot, room, event): args = event.body.split() if len(args) == 2 and len(args[1]) == 4: @@ -13,12 +15,12 @@ class MatrixModule: await bot.send_text(room, 'Usage: !notam ') def help(self): - return('NOTAM data access (usage: !notam ) - Currently Finnish airports only') + return ('NOTAM data access (usage: !notam ) - Currently Finnish airports only') -# TODO: This handles only finnish airports. Implement support for other countries. + # TODO: This handles only finnish airports. Implement support for other countries. def get_notam(self, icao): if not icao.startswith('EF'): - return('Only Finnish airports supported currently, sorry.') + return ('Only Finnish airports supported currently, sorry.') icao_first_letter = icao[2] if icao_first_letter < 'M': diff --git a/modules/off/taf.py b/modules/off/taf.py index 5704f77..dfe018a 100644 --- a/modules/off/taf.py +++ b/modules/off/taf.py @@ -1,7 +1,9 @@ import urllib.request +from modules.common.module import BotModule -class MatrixModule: + +class MatrixModule(BotModule): async def matrix_message(self, bot, room, event): args = event.body.split() if len(args) == 2: @@ -18,4 +20,4 @@ class MatrixModule: await bot.send_text(room, 'Usage: !taf ') def help(self): - return('Taf data access (usage: !taf )') + return ('Taf data access (usage: !taf )') diff --git a/modules/off/teamup.py b/modules/off/teamup.py index 5ae4de1..848adf0 100644 --- a/modules/off/teamup.py +++ b/modules/off/teamup.py @@ -3,12 +3,14 @@ from datetime import datetime from pyteamup import Calendar + # # TeamUp calendar notifications # +from modules.common.module import BotModule -class MatrixModule: +class MatrixModule(BotModule): api_key = None calendar_rooms = dict() # Roomid -> [calid, calid..] calendars = dict() # calid -> Calendar @@ -87,7 +89,7 @@ class MatrixModule: await bot.send_text(room, 'Api key set') def help(self): - return('Polls teamup calendar.') + return ('Polls teamup calendar.') async def poll_all_calendars(self, bot): delete_rooms = [] @@ -102,7 +104,7 @@ class MatrixModule: await bot.send_text(bot.get_room_by_id(roomid), 'Calendar: ' + self.eventToString(event)) else: delete_rooms.append(roomid) - + for roomid in delete_rooms: self.calendar_rooms.pop(roomid, None) @@ -115,7 +117,7 @@ class MatrixModule: return datetime.strptime(dts, '%Y-%m-%dT%H:%M:%S') except ValueError: pos = len(dts) - 3 - dts = dts[:pos] + dts[pos+1:] + dts = dts[:pos] + dts[pos + 1:] return datetime.strptime(dts, '%Y-%m-%dT%H:%M:%S%z') def eventToString(self, event): @@ -123,7 +125,7 @@ class MatrixModule: if len(event['title']) == 0: event['title'] = '(empty name)' - if(event['delete_dt']): + if (event['delete_dt']): s = event['title'] + ' deleted.' else: s = event['title'] + " " + (event['notes'] or '') + \ @@ -144,7 +146,7 @@ class MatrixModule: self.calendars[calid].timestamp = int(time.time()) def get_settings(self): - return {'apikey': self.api_key or '', 'calendar_rooms': self.calendar_rooms} + return {'apikey': self.api_key or '', 'calendar_rooms': self.calendar_rooms} def set_settings(self, data): if data.get('calendar_rooms'): diff --git a/modules/off/twitter.py b/modules/off/twitter.py index d302d6f..0542b38 100644 --- a/modules/off/twitter.py +++ b/modules/off/twitter.py @@ -1,9 +1,11 @@ from twitterscraper import query_tweets_from_user + from modules.common.pollingservice import PollingService + # https://github.com/taspinar/twitterscraper/tree/master/twitterscraper -class MatrixModule(PollingService): +class MatrixModule(PollingService): def __init__(self): super().__init__() self.service_name = 'Twitter' @@ -15,7 +17,9 @@ class MatrixModule(PollingService): for tweet in tweets: if tweet.tweet_id not in self.known_ids: if send_messages: - await bot.send_html(bot.get_room_by_id(roomid), f'Twitter {account}: {tweet.text}', f'Twitter {account}: {tweet.text} - https://twitter.com{tweet.tweet_url}') + await bot.send_html(bot.get_room_by_id(roomid), + f'Twitter {account}: {tweet.text}', + f'Twitter {account}: {tweet.text} - https://twitter.com{tweet.tweet_url}') self.known_ids.add(tweet.tweet_id) except Exception: print('Polling twitter account failed:') diff --git a/modules/off/url.py b/modules/off/url.py index 9f37144..371daea 100644 --- a/modules/off/url.py +++ b/modules/off/url.py @@ -6,8 +6,10 @@ import httpx from bs4 import BeautifulSoup from nio import RoomMessageText +from modules.common.module import BotModule -class MatrixModule: + +class MatrixModule(BotModule): """ Simple url fetch and spit out title module. @@ -152,3 +154,7 @@ class MatrixModule: def help(self): return "If I see a url in a message I will try to get the title from the page and spit it out" + + def dump(self, obj): + for attr in dir(obj): + print("obj.%s = %r" % (attr, getattr(obj, attr))) \ No newline at end of file diff --git a/modules/ping.py b/modules/ping.py index 96956b9..a40f425 100755 --- a/modules/ping.py +++ b/modules/ping.py @@ -1,40 +1,37 @@ from timeit import default_timer as timer -import os -import sys -import urllib.request from urllib.request import urlopen +from modules.common.module import BotModule -class MatrixModule: + +class MatrixModule(BotModule): async def matrix_message(self, bot, room, event): args = event.body.split() args.pop(0) - url=args[0] + url = args[0] # check url - if (not (url.startswith('http://') or url.startswith('https://'))): + if not (url.startswith('http://') or url.startswith('https://')): # print ("adding trailing https") - url="https://"+url - + url = "https://" + url + print(url) start = timer() try: data = urlopen(url) - length = format(len(data.read())/1024,'.3g') #kB + length = format(len(data.read()) / 1024, '.3g') # kB retcode = data.getcode() except Exception as e: - await bot.send_text(room, "Ping failed: " +str(e)) - print ("Error: " + str(e)) - return False - + await bot.send_text(room, "Ping failed: " + str(e)) + print("Error: " + str(e)) + return False + end = timer() - - await bot.send_text(room, url + ": OK (" + str(retcode) + ") / " + "Size: "+ str(length) + - " kB / Time: " + str(format(end - start, '.3g')) +" sec") - + + await bot.send_text(room, url + ": OK (" + str(retcode) + ") / " + "Size: " + str(length) + + " kB / Time: " + str(format(end - start, '.3g')) + " sec") def help(self): - return('check if IP or URL is accessable') - + return 'check if IP or URL is accessible' diff --git a/modules/task.py b/modules/task.py index 06462a9..8428ed8 100644 --- a/modules/task.py +++ b/modules/task.py @@ -1,30 +1,32 @@ import subprocess -import os -class MatrixModule: +from modules.common.module import BotModule + + +class MatrixModule(BotModule): async def matrix_message(self, bot, room, event): args = event.body.split() args.pop(0) - encoding="utf-8" - - allowed_args = ['list', 'add', 'del','done', 'undo', 'calc'] + encoding = "utf-8" + + allowed_args = ['list', 'add', 'del', 'done', 'undo', 'calc'] # wrap task if not args: - args=['list'] + args = ['list'] if args[0] not in allowed_args: - await bot.send_text(room, "command not allowed") - return() + await bot.send_text(room, "command not allowed") + return () result = subprocess.check_output( - ["task", - "rc.confirmation:no", - "rc.verbose:list", - "rc.bulk:0", - "rc.recurrence.confirmation:yes"] - + args, stderr=subprocess.DEVNULL) + ["task", + "rc.confirmation:no", + "rc.verbose:list", + "rc.bulk:0", + "rc.recurrence.confirmation:yes"] + + args, stderr=subprocess.DEVNULL) await bot.send_text(room, result.decode(encoding)) def help(self): - return('taskwarrior') + return ('taskwarrior') diff --git a/modules/weather.py b/modules/weather.py index 89a1cef..d731536 100644 --- a/modules/weather.py +++ b/modules/weather.py @@ -1,16 +1,17 @@ import subprocess -import os +from modules.common.module import BotModule -class MatrixModule: + +class MatrixModule(BotModule): async def matrix_message(self, bot, room, event): args = event.body.split() args.pop(0) - encoding="utf-8" + encoding = "utf-8" # get weather from ansiweather - result = subprocess.check_output(["ansiweather","-a false","-l", ' '.join(args)]) + result = subprocess.check_output(["ansiweather", "-a false", "-l", ' '.join(args)]) await bot.send_text(room, result.decode(encoding)) def help(self): - return('How\'s the weather?') + return ('How\'s the weather?')