From 9f274e1cc7caf848450ab87db9caf63077b6e5c2 Mon Sep 17 00:00:00 2001 From: Frank Becker Date: Tue, 4 Feb 2020 23:18:58 +0100 Subject: [PATCH 01/11] reduce size of docker image --- Dockerfile | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1fa3707..0599c7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,15 @@ -FROM python:3 +FROM python:3.7-slim WORKDIR /bot -RUN pip install pipenv && rm -rf /root/.cache + COPY Pipfile . -RUN pipenv install --pre && rm -rf /root/.cache +RUN pip install pipenv && \ + pipenv install --pre && \ + pipenv install --deploy --system && \ + rm -r /root/.cache/* && \ + rm -r /root/.local/* COPY bot.py *.json *.pickle /bot/ COPY modules modules -CMD [ "pipenv", "run", "python", "-u", "./bot.py" ] +CMD [ "python", "-u", "./bot.py" ] From 93f8c65d0cb01871bf9b0c7f339720bc5ce8edc1 Mon Sep 17 00:00:00 2001 From: Frank Becker Date: Sun, 2 Feb 2020 22:08:15 +0100 Subject: [PATCH 02/11] extract BotModule class --- .gitignore | 1 + bot.py | 9 ++-- modules/bot.py | 30 ++++++----- modules/common/module.py | 86 ++++++++++++++++++++++++++++++++ modules/common/pollingservice.py | 16 +++--- modules/cron.py | 7 +-- modules/echo.py | 7 ++- modules/googlecal.py | 9 ++-- modules/help.py | 7 ++- modules/ig.py | 12 +++-- modules/loc.py | 7 +-- modules/metar.py | 8 +-- modules/notam.py | 12 +++-- modules/taf.py | 6 ++- modules/teamup.py | 14 +++--- modules/twitter.py | 8 ++- modules/url.py | 8 ++- 17 files changed, 187 insertions(+), 60 deletions(-) create mode 100644 modules/common/module.py 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/googlecal.py b/modules/googlecal.py index a089fdc..bfabb7f 100644 --- a/modules/googlecal.py +++ b/modules/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/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/ig.py b/modules/ig.py index 4609161..15a4226 100644 --- a/modules/ig.py +++ b/modules/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/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/metar.py b/modules/metar.py index ad94d15..44ffb18 100644 --- a/modules/metar.py +++ b/modules/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/notam.py b/modules/notam.py index 6e005d4..1c5012c 100644 --- a/modules/notam.py +++ b/modules/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/taf.py b/modules/taf.py index 5704f77..dfe018a 100644 --- a/modules/taf.py +++ b/modules/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/teamup.py b/modules/teamup.py index 5ae4de1..848adf0 100644 --- a/modules/teamup.py +++ b/modules/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/twitter.py b/modules/twitter.py index d302d6f..0542b38 100644 --- a/modules/twitter.py +++ b/modules/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/url.py b/modules/url.py index 9f37144..371daea 100644 --- a/modules/url.py +++ b/modules/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 From 299548b41fe362f5cf46f2de152904fa38e92f5e Mon Sep 17 00:00:00 2001 From: Frank Becker Date: Mon, 3 Feb 2020 21:45:14 +0000 Subject: [PATCH 03/11] minor cleanup and a fix for the type mapping of matrix_message parameters --- modules/bot.py | 90 ++++++++++++++++++-------------- modules/common/module.py | 6 +-- modules/common/pollingservice.py | 4 +- 3 files changed, 58 insertions(+), 42 deletions(-) diff --git a/modules/bot.py b/modules/bot.py index 7852ca9..8a9d09c 100644 --- a/modules/bot.py +++ b/modules/bot.py @@ -11,52 +11,66 @@ class MatrixModule(BotModule): args = event.body.split() if len(args) == 2: 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() + await self.quit(bot, room, event) elif args[1] == 'version': - await bot.send_text(room, f'Hemppa version {bot.version} - https://github.com/vranki/hemppa') + await self.version(bot, room) 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() + await self.reload(bot, room, event) 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.') + await self.status(bot, room) elif args[1] == 'stats': - roomcount = len(bot.client.rooms) - usercount = 0 - homeservers = dict() - - for croomid in bot.client.rooms: - roomobj = bot.client.rooms[croomid] - usercount = usercount + len(roomobj.users) - for user in roomobj.users: - hs = user.split(':')[1] - if homeservers.get(hs): - homeservers[hs] = homeservers[hs] + 1 - else: - homeservers[hs] = 1 - - homeservers = sorted(homeservers.items(), key=lambda kv: (kv[1], kv[0]), reverse=True) - - 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}') + await self.stats(bot, room) 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.') - await bot.client.room_leave(room.room_id) + await self.leave(bot, room, event) else: await bot.send_text(room, 'Unknown command, sorry.') + async def leave(self, bot, room, event): + 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.') + await bot.client.room_leave(room.room_id) + + async def stats(self, bot, room): + roomcount = len(bot.client.rooms) + usercount = 0 + homeservers = dict() + for croomid in bot.client.rooms: + roomobj = bot.client.rooms[croomid] + usercount = usercount + len(roomobj.users) + for user in roomobj.users: + hs = user.split(':')[1] + if homeservers.get(hs): + homeservers[hs] = homeservers[hs] + 1 + else: + homeservers[hs] = 1 + homeservers = sorted(homeservers.items(), key=lambda kv: (kv[1], kv[0]), reverse=True) + 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}') + + async def status(self, bot, room): + uptime = datetime.now() - self.starttime + await bot.send_text(room, + f'Uptime {uptime} - system time is {datetime.now()} - loaded {len(bot.modules)} modules.') + + async def reload(self, bot, room, event): + bot.must_be_admin(room, event) + await bot.send_text(room, f'Reloading modules..') + bot.stop() + bot.reload_modules() + bot.start() + + 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_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() + def help(self): return 'Bot management commands' diff --git a/modules/common/module.py b/modules/common/module.py index 56dce6c..88cc5e4 100644 --- a/modules/common/module.py +++ b/modules/common/module.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from nio import RoomMessageText, Event +from nio import RoomMessageText, MatrixRoom class BotModule(ABC): @@ -40,9 +40,9 @@ class BotModule(ABC): :param bot: a reference to the bot :type bot: Bot :param room: a matrix room message - :type room: RoomMessageText + :type room: MatrixRoom :param event: a handle to the event that triggered the callback - :type event: Event + :type event: RoomMessageText """ pass diff --git a/modules/common/pollingservice.py b/modules/common/pollingservice.py index b838cab..88623a0 100644 --- a/modules/common/pollingservice.py +++ b/modules/common/pollingservice.py @@ -1,8 +1,10 @@ from datetime import datetime, timedelta from random import randrange +from modules.common.module import BotModule -class PollingService: + +class PollingService(BotModule): def __init__(self): self.known_ids = set() self.account_rooms = dict() # Roomid -> [account, account..] From ca462c8ee3f3810f83a2c44f9497ff3208ebf1fb Mon Sep 17 00:00:00 2001 From: Frank Becker Date: Tue, 4 Feb 2020 19:22:11 +0100 Subject: [PATCH 04/11] implement proper shutdown. only show the last 16 digits of access token. handle login error --- bot.py | 47 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/bot.py b/bot.py index 7cc06a2..86851f8 100755 --- a/bot.py +++ b/bot.py @@ -12,7 +12,8 @@ import urllib.parse from importlib import reload import requests -from nio import AsyncClient, InviteEvent, JoinError, RoomMessageText +from nio import AsyncClient, InviteEvent, JoinError, RoomMessageText, MatrixRoom, LogoutResponse, LogoutError, \ + LoginError # Couple of custom exceptions @@ -131,6 +132,9 @@ class Bot: traceback.print_exc(file=sys.stderr) async def invite_cb(self, room, event): + room: MatrixRoom + event: InviteEvent + if self.join_on_invite or self.is_owner(event): for attempt in range(3): result = await self.client.join(room.room_id) @@ -139,10 +143,10 @@ class Bot: attempt, result.message, ) else: + print(f"joining room '{room.display_name}'({room.room_id}) invited by '{event.sender}'") break else: - print( - f'Received invite event, but not joining as sender is not owner or bot not configured to join on invite. {event}') + print(f'Received invite event, but not joining as sender is not owner or bot not configured to join on invite. {event}') def load_module(self, modulename): try: @@ -237,14 +241,19 @@ class Bot: async def run(self): if not self.client.access_token: - await self.client.login(os.environ['MATRIX_PASSWORD']) - print("Logged in with password, access token:", - self.client.access_token) + login_response = await self.client.login(os.environ['MATRIX_PASSWORD']) + + if isinstance(login_response, LoginError): + print(f"Failed to login: {login_response.message}") + return + + last_16 = self.client.access_token[-16:] + print(f"Logged in with password, access token: ...{last_16}") await self.client.sync() - for roomid in self.client.rooms: - print(f'Bot is on {roomid} with {len(self.client.rooms[roomid].users)} users') - if len(self.client.rooms[roomid].users) == 1: + for roomid, room in self.client.rooms.items(): + print(f"Bot is on '{room.display_name}'({roomid}) with {len(room.users)} users") + if len(room.users) == 1: print(f'Room {roomid} has no other users - leaving it.') print(await self.client.room_leave(roomid)) @@ -265,6 +274,25 @@ class Bot: else: print('Client was not able to log in, check env variables!') + async def shutdown(self): + + if self.client.logged_in: + logout = await self.client.logout() + + if isinstance(logout, LogoutResponse): + print("Logout successful") + try: + await self.client.close() + print("Connection closed") + except Exception as e: + print("error while closing client", e) + + else: + logout: LogoutError + print(f"Logout unsuccessful. msg: {logout.message}") + else: + await self.client.client_session.close() + bot = Bot() bot.init() @@ -276,3 +304,4 @@ except KeyboardInterrupt: bot.bot_task.cancel() bot.stop() +asyncio.get_event_loop().run_until_complete(bot.shutdown()) From 46fce1c3d640dd08e7ec7742baa814da69ec8bb5 Mon Sep 17 00:00:00 2001 From: Frank Becker Date: Wed, 5 Feb 2020 00:48:19 +0100 Subject: [PATCH 05/11] check environment variables. handle invalid access token --- bot.py | 47 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/bot.py b/bot.py index 86851f8..dbf9b7b 100755 --- a/bot.py +++ b/bot.py @@ -191,33 +191,62 @@ class Bot: await asyncio.sleep(10) def set_account_data(self, data): - userid = urllib.parse.quote(os.environ['MATRIX_USER']) + userid = urllib.parse.quote(self.matrix_user) ad_url = f"{self.client.homeserver}/_matrix/client/r0/user/{userid}/account_data/{self.appid}?access_token={self.client.access_token}" response = requests.put(ad_url, json.dumps(data)) + self.__handle_error_response(response) + if response.status_code != 200: print('Setting account data failed:', response, response.json()) def get_account_data(self): - userid = urllib.parse.quote(os.environ['MATRIX_USER']) + userid = urllib.parse.quote(self.matrix_user) ad_url = f"{self.client.homeserver}/_matrix/client/r0/user/{userid}/account_data/{self.appid}?access_token={self.client.access_token}" response = requests.get(ad_url) + self.__handle_error_response(response) + if response.status_code == 200: return response.json() print( f'Getting account data failed: {response} {response.json()} - this is normal if you have not saved any settings yet.') return None + def __handle_error_response(self, response): + if response.status_code == 401: + print("ERROR: access token is invalid or missing") + print("NOTE: check MATRIX_ACCESS_TOKEN or set MATRIX_PASSWORD") + sys.exit(2) + + def init(self): - self.client = AsyncClient( - os.environ['MATRIX_SERVER'], os.environ['MATRIX_USER']) - self.client.access_token = os.getenv('MATRIX_ACCESS_TOKEN') - self.join_on_invite = os.getenv("JOIN_ON_INVITE") is not None - self.owners = os.environ['BOT_OWNERS'].split(',') - self.get_modules() + + self.matrix_user = os.getenv('MATRIX_USER') + self.matrix_pass = os.getenv('MATRIX_PASSWORD') + matrix_server = os.getenv('MATRIX_SERVER') + bot_owners = os.getenv('BOT_OWNERS') + access_token = os.getenv('MATRIX_ACCESS_TOKEN') + join_on_invite = os.getenv('JOIN_ON_INVITE') + + if matrix_server and self.matrix_user and bot_owners: + self.client = AsyncClient(matrix_server, self.matrix_user) + self.client.access_token = access_token + + if self.client.access_token is None: + if self.matrix_pass is None: + print("Either MATRIX_ACCESS_TOKEN or MATRIX_PASSWORD need to be set") + sys.exit(1) + + self.join_on_invite = join_on_invite is not None + self.owners = bot_owners.split(',') + self.get_modules() + else: + print("The environment variables MATRIX_SERVER, MATRIX_USER and BOT_OWNERS are mandatory") + sys.exit(1) + def start(self): print(f'Starting {len(self.modules)} modules..') @@ -241,7 +270,7 @@ class Bot: async def run(self): if not self.client.access_token: - login_response = await self.client.login(os.environ['MATRIX_PASSWORD']) + login_response = await self.client.login(self.matrix_pass) if isinstance(login_response, LoginError): print(f"Failed to login: {login_response.message}") From b1f428517eb49ee1fea0dd0e62a361e1ce8a2795 Mon Sep 17 00:00:00 2001 From: Frank Becker Date: Thu, 6 Feb 2020 00:19:45 +0100 Subject: [PATCH 06/11] enable / disable bot module use !bot enable oder !bot disable to enabel or disable a module. to get a list of available modules and see if they are enabled run !bot modules --- bot.py | 32 ++++++++++++++++------------- modules/bot.py | 44 +++++++++++++++++++++++++++++++++++++++- modules/common/module.py | 14 +++++++++++-- modules/cron.py | 5 ++++- modules/googlecal.py | 5 ++++- modules/help.py | 18 ++++++++++------ modules/loc.py | 2 +- modules/teamup.py | 6 +++++- modules/twitter.py | 3 +++ modules/url.py | 5 ++++- 10 files changed, 106 insertions(+), 28 deletions(-) diff --git a/bot.py b/bot.py index dbf9b7b..6796ce1 100755 --- a/bot.py +++ b/bot.py @@ -119,7 +119,7 @@ class Bot: moduleobject = self.modules.get(command) - if "matrix_message" in dir(moduleobject): + if moduleobject.enabled and ("matrix_message" in dir(moduleobject)): try: await moduleobject.matrix_message(bot, room, event) except CommandRequiresAdmin: @@ -183,11 +183,12 @@ class Bot: while True: self.pollcount = self.pollcount + 1 for modulename, moduleobject in self.modules.items(): - if "matrix_poll" in dir(moduleobject): - try: - await moduleobject.matrix_poll(bot, self.pollcount) - except Exception: - traceback.print_exc(file=sys.stderr) + if moduleobject.enabled: + if "matrix_poll" in dir(moduleobject): + try: + await moduleobject.matrix_poll(bot, self.pollcount) + except Exception: + traceback.print_exc(file=sys.stderr) await asyncio.sleep(10) def set_account_data(self, data): @@ -243,20 +244,24 @@ class Bot: self.join_on_invite = join_on_invite is not None self.owners = bot_owners.split(',') self.get_modules() + else: print("The environment variables MATRIX_SERVER, MATRIX_USER and BOT_OWNERS are mandatory") sys.exit(1) def start(self): - print(f'Starting {len(self.modules)} modules..') + self.load_settings(self.get_account_data()) + enabled_modules = [module for module_name, module in self.modules.items() if module.enabled] + print(f'Starting {len(enabled_modules)} modules..') for modulename, moduleobject in self.modules.items(): - print('Starting', modulename, '..') - if "matrix_start" in dir(moduleobject): - try: - moduleobject.matrix_start(bot) - except Exception: - traceback.print_exc(file=sys.stderr) + if moduleobject.enabled: + print('Starting', modulename, '..') + if "matrix_start" in dir(moduleobject): + try: + moduleobject.matrix_start(bot) + except Exception: + traceback.print_exc(file=sys.stderr) def stop(self): print(f'Stopping {len(self.modules)} modules..') @@ -322,7 +327,6 @@ class Bot: else: await self.client.client_session.close() - bot = Bot() bot.init() try: diff --git a/modules/bot.py b/modules/bot.py index 8a9d09c..39fc2a5 100644 --- a/modules/bot.py +++ b/modules/bot.py @@ -4,6 +4,10 @@ from modules.common.module import BotModule class MatrixModule(BotModule): + def __init__(self): + super().__init__() + self.enable() + def matrix_start(self, bot): self.starttime = datetime.now() @@ -22,6 +26,14 @@ class MatrixModule(BotModule): 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 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]) else: await bot.send_text(room, 'Unknown command, sorry.') @@ -72,5 +84,35 @@ class MatrixModule(BotModule): print(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_admin(room, event) + print(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() + await bot.send_text(room, f"module {module_name} enabled") + else: + 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_admin(room, event) + print(f"asked to disable {module_name}") + if bot.modules.get(module_name): + module = bot.modules.get(module_name) + module.disable() + module.matrix_stop(bot) + bot.save_settings() + await bot.send_text(room, f"module {module_name} disabled") + else: + 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): + await bot.send_text(room, "Modules:\n") + for modulename, module in bot.modules.items(): + await bot.send_text(room, f"Name: {modulename:20s} Enabled: {module.enabled}") + def help(self): - return 'Bot management commands' + return 'Bot management commands. (quit, version, reload, status, stats, leave, modules, enable, disable)' diff --git a/modules/common/module.py b/modules/common/module.py index 88cc5e4..aaa62bf 100644 --- a/modules/common/module.py +++ b/modules/common/module.py @@ -25,6 +25,9 @@ class BotModule(ABC): """ + def __init__(self): + self.enabled = False + def matrix_start(self, bot): """Called once on startup @@ -75,7 +78,7 @@ class BotModule(ABC): :return: a dict object that can be converted to JSON :rtype: dict """ - pass + return {'enabled': self.enabled} def set_settings(self, data): """Load these settings. It should be the same JSON you returned in previous get_settings @@ -83,4 +86,11 @@ class BotModule(ABC): :param data: a dict object containing the settings read from the account :type data: dict """ - pass + if data.get('enabled'): + self.enabled = data['enabled'] + + def enable(self): + self.enabled = True + + def disable(self): + self.enabled = False \ No newline at end of file diff --git a/modules/cron.py b/modules/cron.py index a411da8..24e8c16 100644 --- a/modules/cron.py +++ b/modules/cron.py @@ -34,9 +34,12 @@ class MatrixModule(BotModule): return ('Runs scheduled commands') def get_settings(self): - return {'daily_commands': self.daily_commands} + data = super().get_settings() + data['daily_commands'] = self.daily_commands + return data def set_settings(self, data): + super().set_settings(data) if data.get('daily_commands'): self.daily_commands = data['daily_commands'] diff --git a/modules/googlecal.py b/modules/googlecal.py index bfabb7f..0a49cfd 100644 --- a/modules/googlecal.py +++ b/modules/googlecal.py @@ -166,9 +166,12 @@ class MatrixModule(BotModule): return ('Google calendar. Lists 10 next events by default. today = list today\'s events.') def get_settings(self): - return {'calendar_rooms': self.calendar_rooms} + data = super().get_settings() + data['calendar_rooms'] = self.calendar_rooms + return data def set_settings(self, data): + super().set_settings(data) if data.get('calendar_rooms'): self.calendar_rooms = data['calendar_rooms'] diff --git a/modules/help.py b/modules/help.py index 020870a..dc77952 100644 --- a/modules/help.py +++ b/modules/help.py @@ -2,16 +2,22 @@ from modules.common.module import BotModule class MatrixModule(BotModule): + + def __init__(self): + super().__init__() + self.enable() + async def matrix_message(self, bot, room, event): msg = f'This is Hemppa {bot.version}, a generic Matrix bot. Known commands:\n\n' for modulename, moduleobject in bot.modules.items(): - msg = msg + '!' + modulename - try: - msg = msg + ' - ' + moduleobject.help() + '\n' - except AttributeError: - pass - msg + msg + '\n' + if moduleobject.enabled: + msg = msg + '!' + modulename + try: + msg = msg + ' - ' + moduleobject.help() + '\n' + except AttributeError: + pass + msg + msg + '\n' msg = msg + "\nAdd your own commands at https://github.com/vranki/hemppa" await bot.send_text(room, msg) diff --git a/modules/loc.py b/modules/loc.py index dc341d5..7e1217f 100644 --- a/modules/loc.py +++ b/modules/loc.py @@ -1,5 +1,5 @@ from geopy.geocoders import Nominatim -from nio import RoomMessageUnknown +from nio import RoomMessageUnknown, AsyncClient from modules.common.module import BotModule diff --git a/modules/teamup.py b/modules/teamup.py index 848adf0..c2bc421 100644 --- a/modules/teamup.py +++ b/modules/teamup.py @@ -146,9 +146,13 @@ class MatrixModule(BotModule): self.calendars[calid].timestamp = int(time.time()) def get_settings(self): - return {'apikey': self.api_key or '', 'calendar_rooms': self.calendar_rooms} + data = super().get_settings() + data['apikey'] = self.api_key + data['calendar_rooms'] = self.calendar_rooms + return data def set_settings(self, data): + super().set_settings(data) if data.get('calendar_rooms'): self.calendar_rooms = data['calendar_rooms'] if data.get('apikey'): diff --git a/modules/twitter.py b/modules/twitter.py index 0542b38..4479368 100644 --- a/modules/twitter.py +++ b/modules/twitter.py @@ -1,3 +1,6 @@ +import sys +import traceback + from twitterscraper import query_tweets_from_user from modules.common.pollingservice import PollingService diff --git a/modules/url.py b/modules/url.py index 371daea..3f37e56 100644 --- a/modules/url.py +++ b/modules/url.py @@ -146,9 +146,12 @@ class MatrixModule(BotModule): return def get_settings(self): - return {"status": self.status} + data = super().get_settings() + data['status'] = self.status + return data def set_settings(self, data): + super().set_settings(data) if data.get("status"): self.status = data["status"] From 2f9d7a708e8a1014204551d5ca670d29859ce6ac Mon Sep 17 00:00:00 2001 From: Frank Becker Date: Thu, 6 Feb 2020 20:56:53 +0100 Subject: [PATCH 07/11] add stop and start message to BotModule class. add remove_callback --- bot.py | 72 ++++++++++++++++---------------- modules/bot.py | 5 ++- modules/common/module.py | 7 ++-- modules/common/pollingservice.py | 3 +- modules/googlecal.py | 13 +++--- modules/help.py | 6 +-- modules/ig.py | 4 +- modules/loc.py | 5 +++ modules/twitter.py | 4 +- modules/url.py | 26 ++++++++---- 10 files changed, 81 insertions(+), 64 deletions(-) diff --git a/bot.py b/bot.py index 6796ce1..639a777 100755 --- a/bot.py +++ b/bot.py @@ -53,6 +53,12 @@ class Bot: } await self.client.room_send(room.room_id, 'm.room.message', msg) + def remove_callback(self, callback): + for cb_object in bot.client.event_callbacks: + if cb_object.func == callback: + print("remove callback") + bot.client.event_callbacks.remove(cb_object) + def get_room_by_id(self, room_id): return self.client.rooms[room_id] @@ -82,11 +88,10 @@ class Bot: def save_settings(self): module_settings = dict() for modulename, moduleobject in self.modules.items(): - if "get_settings" in dir(moduleobject): - try: - module_settings[modulename] = moduleobject.get_settings() - except Exception: - traceback.print_exc(file=sys.stderr) + try: + module_settings[modulename] = moduleobject.get_settings() + except Exception: + traceback.print_exc(file=sys.stderr) data = {self.appid: self.version, 'module_settings': module_settings} self.set_account_data(data) @@ -97,12 +102,11 @@ class Bot: return for modulename, moduleobject in self.modules.items(): if data['module_settings'].get(modulename): - if "set_settings" in dir(moduleobject): - try: - moduleobject.set_settings( - data['module_settings'][modulename]) - except Exception: - traceback.print_exc(file=sys.stderr) + try: + moduleobject.set_settings( + data['module_settings'][modulename]) + except Exception: + traceback.print_exc(file=sys.stderr) async def message_cb(self, room, event): # Figure out the command @@ -119,7 +123,7 @@ class Bot: moduleobject = self.modules.get(command) - if moduleobject.enabled and ("matrix_message" in dir(moduleobject)): + if moduleobject.enabled: try: await moduleobject.matrix_message(bot, room, event) except CommandRequiresAdmin: @@ -154,7 +158,7 @@ class Bot: module = importlib.import_module('modules.' + modulename) module = reload(module) cls = getattr(module, 'MatrixModule') - return cls() + return cls(modulename) except ModuleNotFoundError: print('Module ', modulename, ' failed to load!') traceback.print_exc(file=sys.stderr) @@ -184,11 +188,10 @@ class Bot: self.pollcount = self.pollcount + 1 for modulename, moduleobject in self.modules.items(): if moduleobject.enabled: - if "matrix_poll" in dir(moduleobject): - try: - await moduleobject.matrix_poll(bot, self.pollcount) - except Exception: - traceback.print_exc(file=sys.stderr) + try: + await moduleobject.matrix_poll(bot, self.pollcount) + except Exception: + traceback.print_exc(file=sys.stderr) await asyncio.sleep(10) def set_account_data(self, data): @@ -222,15 +225,14 @@ class Bot: print("NOTE: check MATRIX_ACCESS_TOKEN or set MATRIX_PASSWORD") sys.exit(2) - def init(self): - self.matrix_user = os.getenv('MATRIX_USER') - self.matrix_pass = os.getenv('MATRIX_PASSWORD') - matrix_server = os.getenv('MATRIX_SERVER') - bot_owners = os.getenv('BOT_OWNERS') - access_token = os.getenv('MATRIX_ACCESS_TOKEN') - join_on_invite = os.getenv('JOIN_ON_INVITE') + self.matrix_user = os.getenv('MATRIX_USER') + self.matrix_pass = os.getenv('MATRIX_PASSWORD') + matrix_server = os.getenv('MATRIX_SERVER') + bot_owners = os.getenv('BOT_OWNERS') + access_token = os.getenv('MATRIX_ACCESS_TOKEN') + join_on_invite = os.getenv('JOIN_ON_INVITE') if matrix_server and self.matrix_user and bot_owners: self.client = AsyncClient(matrix_server, self.matrix_user) @@ -256,22 +258,18 @@ class Bot: print(f'Starting {len(enabled_modules)} modules..') for modulename, moduleobject in self.modules.items(): if moduleobject.enabled: - print('Starting', modulename, '..') - if "matrix_start" in dir(moduleobject): - try: - moduleobject.matrix_start(bot) - except Exception: - traceback.print_exc(file=sys.stderr) + try: + moduleobject.matrix_start(bot) + except Exception: + traceback.print_exc(file=sys.stderr) def stop(self): print(f'Stopping {len(self.modules)} modules..') for modulename, moduleobject in self.modules.items(): - print('Stopping', modulename, '..') - if "matrix_stop" in dir(moduleobject): - try: - moduleobject.matrix_stop(bot) - except Exception: - traceback.print_exc(file=sys.stderr) + try: + moduleobject.matrix_stop(bot) + except Exception: + traceback.print_exc(file=sys.stderr) async def run(self): if not self.client.access_token: diff --git a/modules/bot.py b/modules/bot.py index 39fc2a5..eaaf5bf 100644 --- a/modules/bot.py +++ b/modules/bot.py @@ -4,11 +4,12 @@ from modules.common.module import BotModule class MatrixModule(BotModule): - def __init__(self): - super().__init__() + def __init__(self, name): + super().__init__(name) self.enable() def matrix_start(self, bot): + super().matrix_start(bot) self.starttime = datetime.now() async def matrix_message(self, bot, room, event): diff --git a/modules/common/module.py b/modules/common/module.py index aaa62bf..db39f2e 100644 --- a/modules/common/module.py +++ b/modules/common/module.py @@ -25,8 +25,9 @@ class BotModule(ABC): """ - def __init__(self): + def __init__(self, name): self.enabled = False + self.name = name def matrix_start(self, bot): """Called once on startup @@ -34,7 +35,7 @@ class BotModule(ABC): :param bot: a reference to the bot :type bot: Bot """ - pass + print('Starting', self.name, '..') @abstractmethod async def matrix_message(self, bot, room, event): @@ -55,7 +56,7 @@ class BotModule(ABC): :param bot: a reference to the bot :type bot: Bot """ - pass + print('Stopping', self.name, '..') async def matrix_poll(self, bot, pollcount): """Called every 10 seconds diff --git a/modules/common/pollingservice.py b/modules/common/pollingservice.py index 88623a0..bedac51 100644 --- a/modules/common/pollingservice.py +++ b/modules/common/pollingservice.py @@ -5,7 +5,8 @@ from modules.common.module import BotModule class PollingService(BotModule): - def __init__(self): + def __init__(self, name): + super().__init__(name) self.known_ids = set() self.account_rooms = dict() # Roomid -> [account, account..] self.next_poll_time = dict() # Roomid -> datetime, None = not polled yet diff --git a/modules/googlecal.py b/modules/googlecal.py index 0a49cfd..ccfb6c1 100644 --- a/modules/googlecal.py +++ b/modules/googlecal.py @@ -21,13 +21,17 @@ from modules.common.module import BotModule class MatrixModule(BotModule): - def matrix_start(self, bot): - self.bot = bot - self.SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'] + def __init__(self, name): + super().__init__(name) self.credentials_file = "credentials.json" + self.SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'] + self.bot = None self.service = None self.calendar_rooms = dict() # Contains room_id -> [calid, calid] .. + def matrix_start(self, bot): + super().matrix_start(bot) + self.bot = bot creds = None if not os.path.exists(self.credentials_file) or os.path.getsize(self.credentials_file) == 0: @@ -43,8 +47,7 @@ class MatrixModule(BotModule): if creds and creds.expired and creds.refresh_token: creds.refresh(Request()) else: - flow = InstalledAppFlow.from_client_secrets_file( - self.credentials_file, self.SCOPES) + flow = InstalledAppFlow.from_client_secrets_file(self.credentials_file, self.SCOPES) # urn:ietf:wg:oauth:2.0:oob creds = flow.run_local_server(port=0) # Save the credentials for the next run diff --git a/modules/help.py b/modules/help.py index dc77952..2914165 100644 --- a/modules/help.py +++ b/modules/help.py @@ -3,8 +3,8 @@ from modules.common.module import BotModule class MatrixModule(BotModule): - def __init__(self): - super().__init__() + def __init__(self, name): + super().__init__(name) self.enable() async def matrix_message(self, bot, room, event): @@ -17,7 +17,7 @@ class MatrixModule(BotModule): msg = msg + ' - ' + moduleobject.help() + '\n' except AttributeError: pass - msg + msg + '\n' + msg = msg + '\n' msg = msg + "\nAdd your own commands at https://github.com/vranki/hemppa" await bot.send_text(room, msg) diff --git a/modules/ig.py b/modules/ig.py index 15a4226..7ec3e46 100644 --- a/modules/ig.py +++ b/modules/ig.py @@ -11,8 +11,8 @@ from modules.common.pollingservice import PollingService class MatrixModule(PollingService): - def __init__(self): - super().__init__() + def __init__(self, name): + super().__init__(name) self.instagram = Instagram() self.service_name = 'Instagram' diff --git a/modules/loc.py b/modules/loc.py index 7e1217f..8f0c475 100644 --- a/modules/loc.py +++ b/modules/loc.py @@ -7,9 +7,14 @@ class MatrixModule(BotModule): bot = None def matrix_start(self, bot): + super().matrix_start(bot) self.bot = bot bot.client.add_event_callback(self.unknown_cb, RoomMessageUnknown) + def matrix_stop(self, bot): + super().matrix_stop(bot) + bot.remove_callback(self.unknown_cb) + async def unknown_cb(self, room, event): if event.msgtype != 'm.location': return diff --git a/modules/twitter.py b/modules/twitter.py index 4479368..0582ea7 100644 --- a/modules/twitter.py +++ b/modules/twitter.py @@ -9,8 +9,8 @@ from modules.common.pollingservice import PollingService # https://github.com/taspinar/twitterscraper/tree/master/twitterscraper class MatrixModule(PollingService): - def __init__(self): - super().__init__() + def __init__(self, name): + super().__init__(name) self.service_name = 'Twitter' async def poll_implementation(self, bot, account, roomid, send_messages): diff --git a/modules/url.py b/modules/url.py index 3f37e56..3536d99 100644 --- a/modules/url.py +++ b/modules/url.py @@ -4,7 +4,7 @@ from functools import lru_cache import httpx from bs4 import BeautifulSoup -from nio import RoomMessageText +from nio import RoomMessageText, AsyncClient from modules.common.module import BotModule @@ -16,23 +16,31 @@ class MatrixModule(BotModule): Everytime a url is seen in a message we do http request to it and try to get a title tag contents to spit out to the room. """ - bot = None - status = dict() # room_id -> what to do with urls + def __init__(self, name): + super().__init__(name) - STATUSES = { - "OFF": "Not spamming this channel", - "TITLE": "Spamming this channel with titles", - "DESCRIPTION": "Spamming this channel with descriptions", - "BOTH": "Spamming this channel with both title and description", - } + self.bot = None + self.status = dict() # room_id -> what to do with urls + + self.STATUSES = { + "OFF": "Not spamming this channel", + "TITLE": "Spamming this channel with titles", + "DESCRIPTION": "Spamming this channel with descriptions", + "BOTH": "Spamming this channel with both title and description", + } def matrix_start(self, bot): """ Register callback for all RoomMessageText events on startup """ + super().matrix_start(bot) self.bot = bot bot.client.add_event_callback(self.text_cb, RoomMessageText) + def matrix_stop(self, bot): + super().matrix_stop(bot) + bot.remove_callback(self.text_cb) + async def text_cb(self, room, event): """ Handle client callbacks for all room text events From 8ff616246cd907015730b6f904bf707cc0d328b2 Mon Sep 17 00:00:00 2001 From: Frank Becker Date: Fri, 7 Feb 2020 17:54:36 +0100 Subject: [PATCH 08/11] handle unknown command --- bot.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/bot.py b/bot.py index 639a777..c396bf6 100755 --- a/bot.py +++ b/bot.py @@ -123,17 +123,22 @@ class Bot: moduleobject = self.modules.get(command) - if moduleobject.enabled: - try: - await moduleobject.matrix_message(bot, room, event) - except CommandRequiresAdmin: - await self.send_text(room, f'Sorry, you need admin power level in this room to run that command.') - 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') - traceback.print_exc(file=sys.stderr) + if moduleobject is not None: + if moduleobject.enabled: + try: + await moduleobject.matrix_message(bot, room, event) + except CommandRequiresAdmin: + await self.send_text(room, f'Sorry, you need admin power level in this room to run that command.') + 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') + traceback.print_exc(file=sys.stderr) + else: + print(f"Unknown command: {command}") + await self.send_text(room, f"Sorry. I don't know what to do. Execute !help to get a list of available commands.") + async def invite_cb(self, room, event): room: MatrixRoom From 267b4241f8b5947318c4792ce0579c4c586fb82e Mon Sep 17 00:00:00 2001 From: Frank Becker Date: Sat, 8 Feb 2020 11:02:58 +0100 Subject: [PATCH 09/11] fix: persistent settings for pollservices pollservices didn't return the settings dictionary and the call to super was missing. --- bot.py | 11 ++++++----- modules/common/module.py | 1 + modules/common/pollingservice.py | 11 ++++++----- modules/url.py | 4 ---- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/bot.py b/bot.py index c396bf6..6f08848 100755 --- a/bot.py +++ b/bot.py @@ -137,8 +137,8 @@ class Bot: traceback.print_exc(file=sys.stderr) else: print(f"Unknown command: {command}") - await self.send_text(room, f"Sorry. I don't know what to do. Execute !help to get a list of available commands.") - + await self.send_text(room, + f"Sorry. I don't know what to do. Execute !help to get a list of available commands.") async def invite_cb(self, room, event): room: MatrixRoom @@ -155,7 +155,8 @@ class Bot: print(f"joining room '{room.display_name}'({room.room_id}) invited by '{event.sender}'") break else: - print(f'Received invite event, but not joining as sender is not owner or bot not configured to join on invite. {event}') + print( + f'Received invite event, but not joining as sender is not owner or bot not configured to join on invite. {event}') def load_module(self, modulename): try: @@ -241,7 +242,7 @@ class Bot: if matrix_server and self.matrix_user and bot_owners: self.client = AsyncClient(matrix_server, self.matrix_user) - self.client.access_token = access_token + self.client.access_token = access_token if self.client.access_token is None: if self.matrix_pass is None: @@ -256,7 +257,6 @@ class Bot: print("The environment variables MATRIX_SERVER, MATRIX_USER and BOT_OWNERS are mandatory") sys.exit(1) - def start(self): self.load_settings(self.get_account_data()) enabled_modules = [module for module_name, module in self.modules.items() if module.enabled] @@ -330,6 +330,7 @@ class Bot: else: await self.client.client_session.close() + bot = Bot() bot.init() try: diff --git a/modules/common/module.py b/modules/common/module.py index db39f2e..29ab00f 100644 --- a/modules/common/module.py +++ b/modules/common/module.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod + from nio import RoomMessageText, MatrixRoom diff --git a/modules/common/pollingservice.py b/modules/common/pollingservice.py index bedac51..d51b5e3 100644 --- a/modules/common/pollingservice.py +++ b/modules/common/pollingservice.py @@ -94,22 +94,23 @@ class PollingService(BotModule): bot.must_be_admin(room, event) account = args[2] - print( - f'Removing {self.service_name} account {account} from room id {room.room_id}') + print(f'Removing {self.service_name} account {account} from room id {room.room_id}') if self.account_rooms.get(room.room_id): self.account_rooms[room.room_id].remove(account) - print( - f'{self.service_name} accounts now for this room {self.account_rooms.get(room.room_id)}') + print(f'{self.service_name} accounts now for this room {self.account_rooms.get(room.room_id)}') bot.save_settings() await bot.send_text(room, f'Removed {self.service_name} account from this room') def get_settings(self): - return {'account_rooms': self.account_rooms} + data = super().get_settings() + data['account_rooms'] = self.account_rooms + return data def set_settings(self, data): + super().set_settings(data) if data.get('account_rooms'): self.account_rooms = data['account_rooms'] diff --git a/modules/url.py b/modules/url.py index 3536d99..c68a4b4 100644 --- a/modules/url.py +++ b/modules/url.py @@ -165,7 +165,3 @@ class MatrixModule(BotModule): 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 From e7ddda220fb735857db245e54ad15643bccc3e25 Mon Sep 17 00:00:00 2001 From: plocki Date: Sat, 8 Feb 2020 11:46:40 +0100 Subject: [PATCH 10/11] Fix #35 --- modules/url.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/url.py b/modules/url.py index 9f37144..1d84efa 100644 --- a/modules/url.py +++ b/modules/url.py @@ -100,7 +100,7 @@ class MatrixModule: # try parse and get the title try: soup = BeautifulSoup(r.text, "html.parser") - title = soup.title.string + title = soup.title.string.strip() descr_tag = soup.find("meta", attrs={"name": "description"}) if descr_tag: description = descr_tag.get("content", None) From 0f1bd0070a4991179690ddea41c61f58e9e0ba3c Mon Sep 17 00:00:00 2001 From: plocki Date: Sat, 8 Feb 2020 14:58:51 +0100 Subject: [PATCH 11/11] Added modules commands --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 633ac07..270576f 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ Bot management commands. * !bot reload - reload all bot modules (Must be done as bot owner) * !bot stats - show statistics on matrix users seen by bot * !bot leave - ask bot to leave this room (Must be done as admin in room) +* !bot modules - list all modules including enabled status +* !bot enable [module] - enable module (Must be done as admin in room) +* !bot disable [module] - disable module (Must be done as admin in room) ### Help