diff --git a/Pipfile b/Pipfile index 6b7566a..f536374 100644 --- a/Pipfile +++ b/Pipfile @@ -6,6 +6,9 @@ name = "pypi" [packages] matrix-nio = "*" geopy = "*" +google-api-python-client = "*" +google-auth-httplib2 = "*" +google-auth-oauthlib = "*" [dev-packages] pylint = "*" diff --git a/README.md b/README.md index c52632e..0625140 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,59 @@ -# Hemppa - generic Matrix bot +# Hemppa - generic modular Matrix bot + +This bot is meant to be super easy platform to code Matrix bot functionality +with Python. It uses matrix-nio library https://github.com/poljar/matrix-nio/ . + +Type !help in room with this bot running to list active modules. ## Module list -Write !modulename in Matrix room where the bot is to use modules. +### Help + +Prints help on existing modules. ### Echo -Simple example module that just echoes what user said. Can be used as an -example for writing own modules. +Simple example module that just echoes what user said. -## First +### Metar + +Aviation weather metar service access. + +### TAF + +Aviation weather TAF service access. + +### Uptime + +Prints bot uptime. + +### Google Calendar (WIP) + +Displays changes and daily report of a google calendar to a room. This is a bit pain to set up, sorry. + +To set up, you'll need to generate credentials.json file - see https://console.developers.google.com/apis/credentials + +When credentials.json is present, you must authenticate the bot to access calendar. There will be a link in console like this: + +``` text +Please visit this URL to authorize this application: https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=907.... +``` + +Open the link and authenticate as needed. A new file token.pickle will be created in the directory and bot will read it in future. + +Now the bot should be usable. + +Use !googlecal add [calendar id] to add new calendar to a room. The bot lists availble calendar ID's on startup and you can find them +in google calendar. + +Commands: + +* !googlecal - Show next 10 events in calendar +* !googlecal today - Show today's events +* !googlecal add [calendar id] - Add new calendar to room +* !googlecal calendars - List calendars in this room + +## Bot setup * Create a Matrix user * Get user's access token - In Riot Web see Settings / Help & about @@ -51,4 +95,17 @@ join invites automatically. You can set MATRIX_PASSWORD if you want to get access token. Normally you can use Riot to get it. -## Testing +## Module API + +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. + +Functions: + +* matrix_start - Called once on startup +* async matrix_message - Called when a message is sent to room starting with !module_name +* matrix_stop - Called once before exit +* async matrix_poll - Called every 10 seconds +* help - Return one-liner help text + +You only need to implement the ones you need. See existing bots for examples diff --git a/bot.py b/bot.py index a1afb74..6a20a33 100755 --- a/bot.py +++ b/bot.py @@ -15,6 +15,8 @@ class Bot: client = None join_on_invite = False modules = dict() + pollcount = 0 + poll_task = None async def send_text(self, room, body): msg = { @@ -23,6 +25,14 @@ class Bot: } await self.client.room_send(self.get_room_id(room), 'm.room.message', msg) + async def send_html(self, room, html): + msg = { + "msgtype": "m.text", + "format": "org.matrix.custom.html", + "formatted_body": html + } + await self.client.room_send(self.get_room_id(room), 'm.room.message', msg) + def get_room_id(self, room): for roomid in self.client.rooms: if self.client.rooms[roomid].named_room_name() == room.named_room_name(): @@ -83,6 +93,17 @@ class Bot: if moduleobject: self.modules[modulename] = moduleobject + async def poll_timer(self): + 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: + traceback.print_exc(file=sys.stderr) + await asyncio.sleep(10) + def init(self): self.client = AsyncClient(os.environ['MATRIX_SERVER'], os.environ['MATRIX_USER']) self.client.access_token = os.getenv('MATRIX_ACCESS_TOKEN') @@ -116,6 +137,7 @@ class Bot: print("Logged in with password, access token:", self.client.access_token) await self.client.sync() + self.poll_task = asyncio.get_event_loop().create_task(self.poll_timer()) if self.client.logged_in: self.client.add_event_callback(self.message_cb, RoomMessageText) @@ -134,5 +156,7 @@ bot.init() try: asyncio.get_event_loop().run_until_complete(bot.run()) except KeyboardInterrupt: - pass + if bot.poll_task: + bot.poll_task.cancel() + bot.stop() diff --git a/modules/googlecal.py b/modules/googlecal.py new file mode 100644 index 0000000..3385430 --- /dev/null +++ b/modules/googlecal.py @@ -0,0 +1,173 @@ +from __future__ import print_function +from datetime import datetime +import datetime +import pickle +import os.path +import time +import os + +from googleapiclient.discovery import build +from google_auth_oauthlib.flow import InstalledAppFlow +from google.auth.transport.requests import Request + + + +# +# Google calendar notifications +# +# Note: Provide a token.pickle file for the service. +# It's created on first run (run from console!) and +# can be copied to another computer. +# +# ENV variables: +# +# Google calendar creds file: (defaults to this) +# GCAL_CREDENTIALS="credentials.json" +# + +class MatrixModule: + def matrix_start(self, bot): + self.bot = bot + self.SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'] + self.credentials_file = "credentials.json" + if os.getenv("GCAL_CREDENTIALS"): + self.credentials_file = os.getenv("GCAL_CREDENTIALS") + self.service = None + self.report_time = 8 + self.last_report_date = None + self.calendar_rooms = dict() # Contains rooms -> [calid, calid] .. + + creds = None + + if not os.path.exists(self.credentials_file): + return # No-op if not set up + + if os.path.exists('token.pickle'): + with open('token.pickle', 'rb') as token: + creds = pickle.load(token) + # If there are no (valid) credentials available, let the user log in. + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file( + self.credentials_file, self.SCOPES) + creds = flow.run_local_server(port=0) + # Save the credentials for the next run + with open('token.pickle', 'wb') as token: + pickle.dump(creds, token) + + self.service = build('calendar', 'v3', credentials=creds) + + calendar_list = self.service.calendarList().list().execute()['items'] + + print(f'Google calendar set up successfully with access to {len(calendar_list)} calendars:\n') + for calendar in calendar_list: + print(calendar['summary'] + ' - ' + calendar['id']) + + + async def matrix_message(self, bot, room, event): + if not self.service: + await bot.send_text(room, 'Google calendar not set up for this bot.') + return + args = event.body.split() + events = [] + calendars = self.calendar_rooms.get(room) or [] + + if len(args) == 2: + if args[1] == 'today': + for calid in calendars: + print('Listing events in cal', calid) + events = events + self.list_today(calid) + if args[1] == 'calendars': + await bot.send_text(room, 'Calendars in this room: ' + str(self.calendar_rooms.get(room))) + elif len(args) == 3: + if args[1] == 'add': + calid = args[2] + print(f'Adding calendar {calid} to room {room}') + + if self.calendar_rooms.get(room): + self.calendar_rooms[room].append(calid) + else: + self.calendar_rooms[room] = [calid] + + print(f'Calendars now for this room {self.calendar_rooms[room]}') + + await bot.send_text(room, 'Added new google calendar to this room') + else: + for calid in calendars: + print('Listing events in cal', calid) + events = events + self.list_upcoming(calid) + + if len(events) == 0: + await bot.send_text(room, 'No events found.') + else: + print(f'Found {len(events)} events') + await self.send_events(bot, events, room) + + async def send_events(self, bot, events, room): + for event in events: + start = event['start'].get('dateTime', event['start'].get('date')) + await bot.send_text(room, f"{self.parseDate(start)} {event['summary']}") + # await bot.send_text(room, f"{self.parseDate(start)} {event['summary']} {event['htmlLink']}") + # await bot.send_html(room, self.parseDate(start) + " " + event['summary'] + "") + + def list_upcoming(self, calid): + startTime = datetime.datetime.utcnow() + now = startTime.isoformat() + 'Z' + events_result = self.service.events().list(calendarId=calid, timeMin=now, + maxResults=10, singleEvents=True, + orderBy='startTime').execute() + events = events_result.get('items', []) + return events + + def list_today(self, calid): + startTime = datetime.datetime.utcnow() + startTime = startTime - datetime.timedelta(hours=startTime.hour, minutes=startTime.minute) + endTime = startTime + datetime.timedelta(hours=24) + now = startTime.isoformat() + 'Z' + end = endTime.isoformat() + 'Z' + events_result = self.service.events().list(calendarId=calid, timeMin=now, + timeMax=end, maxResults=10, singleEvents=True, + orderBy='startTime').execute() + events = events_result.get('items', []) + return events + + async def matrix_poll(self, bot, pollcount): + if not self.service: + return + + if pollcount % (6 * 5) == 0: # Poll every 5 min + pass # Not implemented yet + + needs_send = False + + today = datetime.datetime.now() + since_last = 999 + + # Bot's been started + if self.last_report_date: + since_last = (today - self.last_report_date).total_seconds() / 60 / 60 + + if since_last > 20 and today.hour >= self.report_time: + needs_send = True + + if needs_send: + self.last_report_date = today + + for room in self.calendar_rooms: + events = [] + for calid in self.calendar_rooms.get(room): + events = events + self.list_today(calid) + await self.send_events(bot, events, room) + + def help(self): + return('Google calendar. Lists 10 next events by default. today = list today\'s events.') + + def parseDate(self, start): + try: + dt = datetime.datetime.strptime(start, '%Y-%m-%dT%H:%M:%S%z') + return dt.strftime("%d.%m %H:%M") + except ValueError: + dt = datetime.datetime.strptime(start, '%Y-%m-%d') + return dt.strftime("%d.%m") diff --git a/modules/help.py b/modules/help.py new file mode 100644 index 0000000..4449a95 --- /dev/null +++ b/modules/help.py @@ -0,0 +1,16 @@ +class MatrixModule: + async def matrix_message(self, bot, room, event): + msg = 'This is Hemppa, 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' + msg = msg + "\nAdd your own commands at https://github.com/vranki/hemppa" + await bot.send_text(room, msg) + + def help(self): + return('Prints help on commands')