import json import requests import logging import re import pnutpy import textwrap import time from matrix_client.api import MatrixHttpApi from matrix_client.api import MatrixError, MatrixRequestError from models import Avatars, Rooms, Events, Users, DirectRooms, ControlRooms from database import db_session from sqlalchemy import and_ from flask import Flask, jsonify, request, abort logger = logging.getLogger(__name__) app = Flask(__name__) @app.errorhandler(404) def not_found(error): return jsonify({'errcode':'COM.MONKEYSTEW.PNUT_NOT_FOUND'}), 404 @app.errorhandler(403) def forbidden(error): return jsonify({'errcode':'COM.MONKEYSTEW.PNUT_FORBIDDEN'}), 403 @app.teardown_appcontext def shutdown_session(exception=None): db_session.remove() @app.route("/rooms/") def query_alias(alias): alias_localpart = alias.split(":")[0][1:] channel_id = int(alias_localpart.split('_')[1]) room = Rooms.query.filter(Rooms.pnut_chan == channel_id).one_or_none() if room is not None: abort(404) token = app.config['MATRIX_PNUT_TOKEN'] pnutpy.api.add_authorization_token(token) try: channel, meta = pnutpy.api.get_channel(channel_id, include_raw=1) if 'is_active' in channel and channel.is_active == False: logger.debug("-channel isn't active-") abort(404) channel_settings = {} for item in channel.raw: if item.type == 'io.pnut.core.chat-settings': channel_settings = item.value # Matrix sdk doesn't include all details in a single call room = {'room_alias_name': alias_localpart} if 'name' in channel_settings: room['name'] = channel_settings['name'] if 'description' in channel_settings: room['topic'] = channel_settings['description'] if channel.acl.read.any_user: room['preset'] = 'public_chat' room['visibility'] = 'public' url = app.config['MATRIX_HOST'] + '/_matrix/client/api/v1/createRoom?access_token=' url += app.config['MATRIX_AS_TOKEN'] headers = {"Content-Type":"application/json"} r = requests.post(url, headers=headers, data=json.dumps(room)) if r.status_code == 200: pnutpy.api.subscribe_channel(channel_id) rdata = r.json() rr = Rooms( room_id=rdata['room_id'], pnut_chan=channel_id, portal=True ) db_session.add(rr) db_session.commit() except pnutpy.errors.PnutPermissionDenied: abort(401) except Exception: logger.exception("-couldn't get the pnut channel-") abort(404) return jsonify({}) @app.route("/transactions/", methods=["PUT"]) def on_receive_events(transaction): access_token = request.args.get('access_token', '') if access_token != app.config['MATRIX_HS_TOKEN']: abort(403) events = request.get_json()["events"] for event in events: logger.debug(event) if app.config['MATRIX_ADMIN_ROOM'] and app.config['MATRIX_ADMIN_ROOM'] == event['room_id']: return on_admin_event(event) user = Users.query.filter(Users.matrix_id == event['user_id']).one_or_none() if event['type'] == 'm.room.message': new_message(event, user) elif event['type'] == 'm.room.redaction': delete_message(event, user) elif event['type'] == 'm.room.member': if 'is_direct' in event['content'] and 'membership' in event['content']: if event['content']['membership'] == "invite" and event['content']['is_direct']: return on_direct_invite(event) if 'membership' in event['content']: if event['content']['membership'] == "leave": return on_leave_event(event) logger.debug("----room member event----") logger.debug(user) logger.debug(event) return jsonify({}) def new_message(event, user): if app.config['MATRIX_PNUT_PREFIX'] in event['user_id'] or 'pnut-bridge' in event['user_id']: logger.debug('-skipping dup event-') return if 'msgtype' not in event['content']: logger.debug('-unknown message type-') return control = ControlRooms.query.filter(ControlRooms.room_id == event['room_id']).one_or_none() if control is not None: return on_control_message(event) direct = DirectRooms.query.filter(DirectRooms.room_id == event['room_id']).one_or_none() if direct is not None: return on_direct_message(event, user, direct) room = Rooms.query.filter(Rooms.room_id == event['room_id']).one_or_none() if room is None: logger.debug('-room not mapped-') return if user is not None: token = user.pnut_user_token prefix = "" else: token = app.config['MATRIX_PNUT_TOKEN'] if "displayname" in matrix_profile: prefix = "[" + matrix_profile['displayname'] + "] (" + event['user_id'] + ")\n" else: prefix = "(" + event['user_id'] + ")\n" pnutpy.api.add_authorization_token(token) embed = [crosspost_raw(event)] text = prefix + msg_from_event(event) try: msg, meta = pnutpy.api.create_message(room.pnut_chan, data={'text': text, 'raw': embed}) revent = Events( event_id=event['event_id'], room_id=event['room_id'], pnut_msg_id=msg.id, pnut_user_id=msg.user.id, pnut_chan_id=room.pnut_chan, deleted=False ) db_session.add(revent) db_session.commit() if user is not None: cctag = re.search('##$', text) if cctag: raw = [] cname = get_channel_settings(room.pnut_chan)['name'] text = text[:-2] ftext = '\n\n[' + cname + "](https://patter.chat/room.html?channel=" + str(room.pnut_chan) + ")" mtext = textwrap.wrap(text + ftext, 254) if len(mtext) > 1: longpost = { 'title': "", 'body': text, 'tstamp': time.time() * 1000 } pretext = textwrap.wrap(text, 100) text = pretext[0] text += "... - https://longpo.st/p/{object_id} - #longpost" raw.append({'type':"nl.chimpnut.blog.post", 'value': longpost}) text += ftext r, meta = pnutpy.api.create_post(data={'text': text, 'raw': raw}) except pnutpy.errors.PnutAuthAPIException: logger.exception('-unable to post to pnut channel-') return except Exception: logger.exception('-something bad happened here-') return def msg_from_event(event): text = None if event['content']['msgtype'] == 'm.text' or event['content']['msgtype'] == 'm.notice': text = event['content']['body'] elif event['content']['msgtype'] == 'm.emote': text = "* " + event['content']['body'] elif event['content']['msgtype'] == 'm.image': text = event['content']['body'] + "\n" text += app.config['MATRIX_URL'] + '/_matrix/media/r0/download/' + event['content']['url'][6:] embed.append(raw_from_event(event)) elif event['content']['msgtype'] == 'm.video': text = event['content']['body'] + "\n" text += app.config['MATRIX_URL'] + '/_matrix/media/r0/download/' + event['content']['url'][6:] elif event['content']['msgtype'] == 'm.audio': text = event['content']['body'] + "\n" text += app.config['MATRIX_URL'] + '/_matrix/media/r0/download/' + event['content']['url'][6:] elif event['content']['msgtype'] == 'm.file': text = event['content']['body'] + "\n" text += app.config['MATRIX_URL'] + '/_matrix/media/r0/download/' + event['content']['url'][6:] else: logger.debug('-unknown msg type- ' + event['content']['msgtype']) return return text def crosspost_raw(event): cross_profile = {'username': event['user_id']} matrix_profile = get_profile(event['user_id']) if "avatar_url" in matrix_profile: cross_profile['avatar_image'] = app.config['MATRIX_URL'] + '/_matrix/media/r0/download/' + matrix_profile['avatar_url'][6:] embed = { 'type': "io.pnut.core.crosspost", 'value': { 'canonical_url': "https://matrix.to/#/" + event['room_id'] + "/" + event['event_id'] + ":" + app.config['MATRIX_DOMAIN'], 'source': { 'name': "matrix", 'url': "https://matrix.org" }, 'user': cross_profile } } return embed def raw_from_event(event): url = app.config['MATRIX_URL'] + '/_matrix/media/r0/download/' + event['content']['url'][6:] if event['content']['msgtype'] == 'm.image': value = {'type': "photo", 'version': "1.0"} value['url'] = url value['title'] = event['content']['body'] if 'info' in event['content']: if 'w' in event['content']['info']: value['width'] = event['content']['info']['w'] else: value['width'] = 200 if 'h' in event['content']['info']: value['height'] = event['content']['info']['h'] else: value['height'] = 200 if 'thumbnail_info' in event['content']['info']: if 'thumbnail_url' in event['content']['info']: value['thumbnail_url'] = app.config['MATRIX_URL'] + '/_matrix/media/r0/download/' + event['content']['info']['thumbnail_url'][6:] if 'w' in event['content']['info']['thumbnail_info']: value['thumbnail_width'] = event['content']['info']['thumbnail_info']['w'] else: value['thumbnail_width'] = 200 if 'h' in event['content']['info']['thumbnail_info']: value['thumbnail_height'] = event['content']['info']['thumbnail_info']['h'] else: value['thumbnail_height'] = 200 elif event['content']['msgtype'] == 'm.video': # TODO: Need to sort out the oembed for this media type value = {'type': "html5video", 'version': "1.0"} source = {'url': url} value['title'] = event['content']['body'] if 'info' in event['content']: value['width'] = event['content']['info']['w'] value['height'] = event['content']['info']['h'] source['type'] = event['content']['info']['mimetype'] else: return None value['sources'] = [source] elif event['content']['msgtype'] == 'm.audio': # TODO: Need to sort out the oembed for this media type value = {'type': "audio", 'version': "1.0"} return None else: return None return {'type': "io.pnut.core.oembed", 'value': value} def delete_message(event, user): # TODO: should there be moderator handled redactions? if user is not None: token = user.pnut_user_token else: token = app.config['MATRIX_PNUT_TOKEN'] pnutpy.api.add_authorization_token(token) e = Events.query.filter(and_(Events.event_id == event['redacts'], Events.deleted == False)).one_or_none() if e is None: logger.debug("- can't find the event to remove -") return try: r, meta = pnutpy.api.delete_message(e.pnut_chan_id, e.pnut_msg_id) e.deleted = True db_session.commit() except pnutpy.errors.PnutPermissionDenied as e: pass def get_profile(userid): url = app.config['MATRIX_HOST'] + "/_matrix/client/r0/profile/" + userid r = requests.get(url) if r.status_code == 200: return json.loads(r.text) return userid def get_channel_settings(channel_id): channel_settings = {} try: channel, meta = pnutpy.api.get_channel(channel_id, include_raw=1) for item in channel.raw: if item.type == 'io.pnut.core.chat-settings': channel_settings = item.value except Exception: logger.exception('-unable to get channel settings-') return channel_settings def create_room(channel, invite): channel_settings = {} for item in channel.raw: if item.type == 'io.pnut.core.chat-settings': channel_settings = item.value # Matrix sdk doesn't include all details in a single call room = {'room_alias_name': app.config['MATRIX_PNUT_PREFIX'] + channel.id} logger.debug(invite) logger.debug(room) room['invite'] = [invite] if 'name' in channel_settings: room['name'] = channel_settings['name'] if 'description' in channel_settings: room['topic'] = channel_settings['description'] if channel.acl.read.any_user: room['preset'] = 'public_chat' room['visibility'] = 'public' else: room['preset'] = 'private_chat' room['visibility'] = 'private' url = app.config['MATRIX_HOST'] + '/_matrix/client/api/v1/createRoom?access_token=' url += app.config['MATRIX_AS_TOKEN'] headers = {"Content-Type":"application/json"} r = requests.post(url, headers=headers, data=json.dumps(room)) if r.status_code == 200: #pnutpy.api.subscribe_channel(channel.id) rdata = r.json() rr = Rooms( room_id=rdata['room_id'], pnut_chan=channel.id, portal=True ) db_session.add(rr) db_session.commit() logger.debug(r.status_code) logger.debug(r) def on_admin_event(event): matrix_api = MatrixHttpApi(app.config['MATRIX_HOST'], token=app.config['MATRIX_AS_TOKEN']) logger.debug("- admin room event recieved -") if event['type'] != 'm.room.message': return jsonify({}) msg = event['content']['body'].split(' ') try: if msg[0] == 'help': if len(msg) > 1: matrix_api.send_message(event['room_id'], cmd_admin_help(msg[1])) else: matrix_api.send_message(event['room_id'], cmd_admin_help()) elif msg[0] == 'list': matrix_api.send_message(event['room_id'], cmd_admin_list()) elif msg[0] == 'unlink': if len(msg) > 1: matrix_api.send_message(event['room_id'], cmd_admin_unlink(msg[1])) else: matrix_api.send_message(event['room_id'], cmd_admin_help('unlink')) elif msg[0] == 'link': if len(msg) > 2: matrix_api.send_message(event['room_id'], cmd_admin_link(msg[1], msg[2])) else: matrix_api.send_message(event['room_id'], cmd_admin_help('link')) except Exception: errmsg = "- on_admin_event -" logger.exception(errmsg) return jsonify({}) def cmd_admin_help(cmd=None): help_usage = "help [command]" help_desc = "Show information about available commands." list_usage = "list" list_desc = "List the rooms currently linked with pnut.io." unlink_usage = "unlink | " unlink_desc = "Unlink a room between Matrix and pnut.io." link_usage = "link " link_desc = "Link a room between Matrix and pnut.io." if cmd == 'help': text = "usage: " + help_usage + "\n\n" text += help_desc if cmd == 'list': text = "usage: " + list_usage + "\n\n" text += list_desc elif cmd == 'unlink': text = "usage: " + unlink_usage + "\n\n" text += unlink_desc elif cmd == 'link': text = "usage: " + link_usage + "\n\n" text += link_desc else: text = "The following commands are available:\n\n" text += help_usage + "\n" text += list_usage + "\n" text += unlink_usage + "\n" text += link_usage + "\n" return text def cmd_admin_list(): text = "" rooms = Rooms.query.all() if len(rooms) > 0: text = "ID\tMATRIX ID\tPNUT CHANNEL\n" else: text = " - no rooms are currently linked - \n" for room in rooms: text += str(room.id) + '\t' text += room.room_id + '\t\t\t\t\t' text += str(room.pnut_chan) + '\t' if room.portal: text += "(portal)" text += '\n' return text def cmd_admin_link(room_id, pnut_chan_id): matrix_api = MatrixHttpApi(app.config['MATRIX_HOST'], token=app.config['MATRIX_AS_TOKEN']) pnutpy.api.add_authorization_token(app.config['MATRIX_PNUT_TOKEN']) mrcheck = Rooms.query.filter(Rooms.room_id == room_id).one_or_none() pncheck = Rooms.query.filter(Rooms.pnut_chan == pnut_chan_id).one_or_none() if mrcheck is not None or pncheck is not None: return "- room may already be linked -" try: channel, meta = pnutpy.api.subscribe_channel(pnut_chan_id) r = matrix_api.join_room(room_id) rec = Rooms( room_id=room_id, pnut_chan=channel.id, portal=False ) db_session.add(rec) db_session.commit() except pnutpy.errors.PnutAuthAPIException: errmsg = "- unable to subscribe to channel -" logger.exception(errmsg) return errmsg except Exception: errmsg = "- unable to link room for some reason -" logger.exception(errmsg) return errmsg def cmd_admin_unlink(id): matrix_api = MatrixHttpApi(app.config['MATRIX_HOST'], token=app.config['MATRIX_AS_TOKEN']) pnutpy.api.add_authorization_token(app.config['MATRIX_PNUT_TOKEN']) if id.startswith('!'): room = Rooms.query.filter(Rooms.room_id == id).one_or_none() else: room = Rooms.query.filter(Rooms.pnut_chan == id).one_or_none() if hasattr(room, 'portal'): if room.portal: alias = "#" + app.config['MATRIX_PNUT_PREFIX'] alias += str(room.pnut_chan) + ":" alias += app.config['MATRIX_DOMAIN'] matrix_api.remove_room_alias(alias) # Kicking users needs at least moderator privs members = matrix_api.get_room_members(room.room_id) reason = "Portal room has been unlinked by administrator" for m in members['chunk']: if m['content']['membership'] == 'join' and m['sender'] != app.config['MATRIX_AS_ID']: if room.portal: matrix_api.kick_user(room.room_id, m['sender'], reason=reason) else: if m['sender'].startswith("@" + app.config['MATRIX_PNUT_PREFIX']): matrix_api.kick_user(room.room_id, m['sender'], reason=reason) try: channel, meta = pnutpy.api.unsubscribe_channel(room.pnut_chan) matrix_api.leave_room(room.room_id) if room is not None: db_session.delete(room) db_session.commit() return "- room has been unlinked -" else: return "- unable to locate room to unlink -" except Exception: errmsg = "- error while unlinking room -" logger.exception(errmsg) return errmsg def on_direct_invite(event): if event['state_key'] == app.config['MATRIX_AS_ID']: matrix_api = MatrixHttpApi(app.config['MATRIX_HOST'], token=app.config['MATRIX_AS_TOKEN']) dm = ControlRooms(room_id=event['room_id']) else: matrix_api = MatrixHttpApi(app.config['MATRIX_HOST'], identity=event['state_key'], token=app.config['MATRIX_AS_TOKEN']) bridge_user = event['state_key'] pnut_user = bridge_user.replace(app.config['MATRIX_PNUT_PREFIX'],'').split(':')[0] user = Users.query.filter(Users.matrix_id == event['sender']).one_or_none() if user is not None: # TODO: need to handle if the user isn't registered pnutpy.api.add_authorization_token(user.pnut_user_token) channel, meta = pnutpy.api.existing_pm(ids=pnut_user) dm = DirectRooms(room_id=event['room_id'], bridge_user=bridge_user, pnut_chan=channel.id) try: matrix_api.join_room(event['room_id']) db_session.add(dm) db_session.commit() except Exception: errmsg = "- on_direct_invite -" logger.exception(errmsg) return jsonify({}) def on_leave_event(event): direct = DirectRooms.query.filter(DirectRooms.room_id == event['room_id']).one_or_none() if direct is not None: matrix_api = MatrixHttpApi(app.config['MATRIX_HOST'], identity=direct.bridge_user, token=app.config['MATRIX_AS_TOKEN']) try: matrix_api.leave_room(event['room_id']) db_session.delete(direct) db_session.commit() except Exception: errmsg = "- on_leave_event -" logger.exception(errmsg) control = ControlRooms.query.filter(ControlRooms.room_id == event['room_id']).one_or_none() if control is not None: matrix_api = MatrixHttpApi(app.config['MATRIX_HOST'], token=app.config['MATRIX_AS_TOKEN']) try: matrix_api.leave_room(event['room_id']) db_session.delete(control) db_session.commit() except Exception: errmsg = "- on_leave_event -" logger.exception(errmsg) return jsonify({}) def on_direct_message(event, user, room): if user is not None: token = user.pnut_user_token prefix = "" else: token = app.config['MATRIX_PNUT_TOKEN'] if "displayname" in matrix_profile: prefix = "[" + matrix_profile['displayname'] + "] (" + event['user_id'] + ")\n" else: prefix = "(" + event['user_id'] + ")\n" pnutpy.api.add_authorization_token(token) embed = [crosspost_raw(event)] text = prefix + msg_from_event(event) try: msg, meta = pnutpy.api.create_message(room.pnut_chan, data={'text': text, 'raw': embed}) revent = Events( event_id=event['event_id'], room_id=event['room_id'], pnut_msg_id=msg.id, pnut_user_id=msg.user.id, pnut_chan_id=room.pnut_chan, deleted=False ) db_session.add(revent) db_session.commit() except pnutpy.errors.PnutAuthAPIException: logger.exception('-unable to post to pnut channel-') except Exception: logger.exception('-something bad happened here-') return jsonify({}) def on_control_message(event): matrix_api = MatrixHttpApi(app.config['MATRIX_HOST'], token=app.config['MATRIX_AS_TOKEN']) logger.debug("- direct room event received -") if event['type'] != 'm.room.message': return jsonify({}) msg = event['content']['body'].split(' ') try: if msg[0] == '!help' or msg[0] == 'help': if len(msg) > 1: matrix_api.send_message(event['room_id'], cmd_user_help(msg[1])) else: matrix_api.send_message(event['room_id'], cmd_user_help()) elif msg[0] == '!auth': matrix_api.send_message(event['room_id'], cmd_user_auth()) elif msg[0] == '!save': if len(msg) > 1: matrix_api.send_message(event['room_id'], cmd_user_save(event['sender'], msg[1])) else: matrix_api.send_message(event['room_id'], cmd_user_save()) elif msg[0] == '!drop': matrix_api.send_message(event['room_id'], cmd_user_drop(event['sender'])) elif msg[0] == '!status': matrix_api.send_message(event['room_id'], cmd_user_status(event['sender'])) elif msg[0] == '!join': if len(msg) > 1: matrix_api.send_message(event['room_id'], cmd_user_join(event['sender'], msg[1])) else: matrix_api.send_message(event['room_id'], cmd_user_join(event['sender'])) except Exception: errmsg = "- on_direct_message -" logger.exception(errmsg) return jsonify({}) def cmd_user_help(cmd=None): reply = "This is an admin room for controlling your connection to pnut.io\n" reply += "The following commands are available.\n\n" reply += "!auth\t\t\t- Authorize your account on pnut.io\n" reply += "!save \t- Save your pnut.io auth token\n" reply += "!drop\t\t\t- Drop your pnut.io auth token\n" reply += "!status\t\t\t- Status of your pnut.io auth token\n" reply += "!join \t- Join a private channel on pnut.io\n" return reply def cmd_user_auth(): reply = "Visit the following URL to authorize your account on pnut.io.\n\n" reply += "https://pnut.io/oauth/authenticate" reply += "?client_id=6SeCRCpCZkmZOKFLFGWbcdAeq2fX1M5t" reply += "&redirect_uri=urn:ietf:wg:oauth:2.0:oob" reply += "&scope=write_post,presence,messages&response_type=token\n\n" reply += "You will be provided with a token that you store with the !save command.\n" return reply def cmd_user_save(sender=None, token=None): if token is None: reply = "You must provide a token with this command.\n" reply += "!save " return reply pnutpy.api.add_authorization_token(token) try: response, meta = pnutpy.api.get_user('me') user = Users( matrix_id=sender, pnut_user_id=response.id, pnut_user_token=token ) db_session.add(user) db_session.commit() reply = "Success! You are now authorized as " + response['username'] except pnutpy.errors.PnutAuthAPIException as e: reply = "Error! Unable to authorize your account." except Exception as e: logging.exception('!save') reply = "Error! Problem saving your token." return reply def cmd_user_drop(sender=None): try: user = Users.query.filter(Users.matrix_id == sender).one_or_none() if user is not None: db_session.delete(user) db_session.commit() reply = "Success! Your auth token has been removed." else: reply = "You do not appear to be registered." except Exception as e: logging.exception('!drop') reply = "Error! Problem removing your token." return reply def cmd_user_status(sender=None): try: user = Users.query.filter(Users.matrix_id == sender).one_or_none() if user is None: reply = "You are currently not authorized on pnut.io" else: pnutpy.api.add_authorization_token(user.pnut_user_token) response, meta = pnutpy.api.get_user('me') reply = "You are currently authorized as " + response.username except pnutpy.errors.PnutAuthAPIException as e: reply = "You are currently not authorized on pnut.io" except Exception as e: logging.exception('!status') reply = "Error! There was a problem checking your account." return reply def cmd_user_join(sender=None, channel_id=None): if channel_id is None: reply = "You must provide a channel id number with this command.\n" reply += "!join " return reply try: user = Users.query.filter(Users.matrix_id == sender).one_or_none() if user is None: reply = "You are currently not authorized on pnut.io" else: pnutpy.api.add_authorization_token(user.pnut_user_token) channel, meta = pnutpy.api.get_channel(channel_id, include_raw=1) create_room(channel, user.matrix_id) reply = "is working?" except pnutpy.errors.PnutAuthAPIException as e: reply = "You are currently not authorized on pnut.io" except pnutpy.errors.PnutPermissionDenied: reply = "You are not authorized for this channel" except Exception: logging.exception('!join') reply = "Error! There was a problem joining the channel." return reply