extract BotModule class

This commit is contained in:
Frank Becker 2020-02-02 22:08:15 +01:00
parent 78901ea505
commit 16ade59c0c
20 changed files with 226 additions and 99 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
# editors
.vscode
.idea
# ignore Pipfile.lock
Pipfile.lock

9
bot.py
View File

@ -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()

View File

@ -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'

86
modules/common/module.py Normal file
View File

@ -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

View File

@ -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'

View File

@ -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)
self.daily_commands.pop(roomid, None)

View File

@ -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')

View File

@ -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'

View File

@ -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} 🚩 <a href={osm_link}>{location_text}</a>'
@ -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'

View File

@ -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)} <a href="{event["htmlLink"]}">{event["summary"]}</a>', f'{self.parse_date(start)} {event["summary"]}')
await bot.send_html(room, f'{self.parse_date(start)} <a href="{event["htmlLink"]}">{event["summary"]}</a>',
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}

View File

@ -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'<a href="{media.link}">Instagram {account}:</a> {media.caption}', f'{account}: {media.caption} {media.link}')
await bot.send_html(bot.get_room_by_id(roomid),
f'<a href="{media.link}">Instagram {account}:</a> {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:

View File

@ -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 <icao code>')
def help(self):
return('Metar data access (usage: !metar <icao code>)')
return ('Metar data access (usage: !metar <icao code>)')

View File

@ -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 <icao code>')
def help(self):
return('NOTAM data access (usage: !notam <icao code>) - Currently Finnish airports only')
return ('NOTAM data access (usage: !notam <icao code>) - 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':

View File

@ -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 <icao code>')
def help(self):
return('Taf data access (usage: !taf <icao code>)')
return ('Taf data access (usage: !taf <icao code>)')

View File

@ -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'):

View File

@ -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'<a href="https://twitter.com{tweet.tweet_url}">Twitter {account}</a>: {tweet.text}', f'Twitter {account}: {tweet.text} - https://twitter.com{tweet.tweet_url}')
await bot.send_html(bot.get_room_by_id(roomid),
f'<a href="https://twitter.com{tweet.tweet_url}">Twitter {account}</a>: {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:')

View File

@ -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)))

View File

@ -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'

View File

@ -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')

View File

@ -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?')