pnut-matrix/appservice.py
2021-03-06 11:06:38 -08:00

734 lines
25 KiB
Python

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/<alias>")
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/<transaction>", 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 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 <room_id> | <pnut_channel_id>"
unlink_desc = "Unlink a room between Matrix and pnut.io."
link_usage = "link <room_id> <pnut_channel_id>"
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']))
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 <token>\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"
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 <token>"
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