Compare commits

..

No commits in common. "main" and "legacy" have entirely different histories.
main ... legacy

17 changed files with 1609 additions and 1933 deletions

1
.gitignore vendored
View file

@ -60,7 +60,6 @@ target/
# my other cruft
*.yaml
*.yml
*.db
*.sublime-project
*.sublime-workspace

56
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,56 @@
# This file is a template, and might need editing before it works on your project.
# To contribute improvements to CI/CD templates, please follow the Development guide at:
# https://docs.gitlab.com/ee/development/cicd/templates.html
# This specific template is located at:
# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Docker.gitlab-ci.yml
# Build a Docker image with CI/CD and push to the GitLab registry.
# Docker-in-Docker documentation: https://docs.gitlab.com/ee/ci/docker/using_docker_build.html
#
# This template uses one generic job with conditional builds
# for the default branch and all other (MR) branches.
stages:
- build
docker-build:
# Use the official docker image.
image: docker:latest
stage: build
services:
- docker:dind
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
# Default branch leaves tag empty (= latest tag)
# All other branches are tagged with the escaped branch name (commit ref slug)
script:
- |
if [[ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]]; then
tag=""
echo "Running on default branch '$CI_DEFAULT_BRANCH': tag = 'latest'"
else
tag=":$CI_COMMIT_REF_SLUG"
echo "Running on branch '$CI_COMMIT_BRANCH': tag = $tag"
fi
- docker build --pull -t "$CI_REGISTRY_IMAGE${tag}" .
- docker push "$CI_REGISTRY_IMAGE${tag}"
# Run this job in a branch where a Dockerfile exists
rules:
- if: $CI_COMMIT_BRANCH
exists:
- Dockerfile
# snap-build-arm64:
# stage: build
# tags:
# - snap-arm64
# script:
# - snapcraft
# - snapcraft upload --release=edge *.snap
#
# snap-build-amd64:
# stage: build
# tags:
# - snap-amd64
# script:
# - snapcraft
# - snapcraft upload --release=edge *.snap

View file

@ -1,28 +1,14 @@
FROM python:3.13-slim-bookworm AS builder
RUN apt-get update && apt-get install -y wget unzip
RUN pip install --no-cache-dir --upgrade pip setuptools wheel
FROM python:3.11-slim-bookworm
WORKDIR /usr/src/app
# Work around for the unreleased version of PNUTpy
RUN wget https://github.com/spacenerdmo/PNUTpy/archive/refs/heads/api_v1.zip
RUN unzip api_v1.zip
RUN cd PNUTpy-api_v1 && pip wheel . --wheel-dir /wheels --find-links /wheels
RUN apt-get update && apt-get install libmagic-dev curl -y
WORKDIR /usr/src/app/pnut-matrix
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip wheel . --wheel-dir /wheels --find-links /wheels
FROM python:3.13-slim-bookworm AS run
COPY --from=builder /wheels /wheels
RUN pip --no-cache-dir install --find-links /wheels --no-index pnut-matrix
ENV CONFIG_FILE=/data/config.yaml
VOLUME /data
WORKDIR /data
EXPOSE 5000
CMD [ "bash", "/usr/src/app/run.sh" ]
CMD [ "python", "/usr/src/app/pnut-matrix.py" ]

31
Jenkinsfile vendored
View file

@ -1,31 +0,0 @@
pipeline {
agent none
environment {
CHAT = "spacenerdmo@clacks.network"
}
stages {
stage('Build docker image') {
agent { label 'docker-build' }
environment {
TAG = "${env.BRANCH_NAME == "main" ? "latest" : env.BRANCH_NAME}"
}
steps {
jabberNotify buildToChatNotifier: [$class: 'ExtraMessageOnlyBuildToChatNotifier'], extraMessage: 'Build Started', notifySuspects: true, targets: "${CHAT}"
script {
docker.withRegistry('https://git.dreamfall.space/spacenerdmo', 'spacenerdmo-pkg-pub') {
def customImage = docker.build("git.dreamfall.space/spacenerdmo/pnut-matrix:${TAG}")
customImage.push()
}
}
}
post {
success {
jabberNotify buildToChatNotifier: [$class: 'ExtraMessageOnlyBuildToChatNotifier'], extraMessage: 'Build Succeeded', notifySuspects: true, targets: "${CHAT}"
}
failure {
jabberNotify buildToChatNotifier: [$class: 'ExtraMessageOnlyBuildToChatNotifier'], extraMessage: 'Build Failed', notifySuspects: true, targets: "${CHAT}"
}
}
}
}
}

View file

@ -7,19 +7,19 @@ This bridge will pass pnut.io channel messages through to Matrix, and Matrix mes
## Usage
The public bridge is not yet online pending uplift of this code base. Stay tuned!
The public bridge is once again online!
See [Using-the-public-bridge](https://gitlab.com/thrrgilag/pnut-matrix/-/wikis/Using-the-public-bridge) for details.
## Installation
**Warning! This code is extremely unstable and not yet ready for use on your matrix server.**
Currently pnut-matrix has been only tested with and confirmed to work with [synapse]. Please refer to the [synapse installation instructions] for details on how to setup your homeserver.
To install the latest version of pnut-matrix from source:
```sh
git clone https://git.dreamfall.space/spacenerdmo/pnut-matrix.git
git clone https://gitlab.com/thrrgilag/pnut-matrix.git
cd pnut-matrix
python3 -m venv env
source env/bin/activate
@ -43,11 +43,12 @@ curl --data '{"type": "m.login.application_service", "username": "your_sender_lo
## Contributing and support
Please submit bugs, feature requests, and patches to [morgan@mcmillian.dev].
You can open issues for bugs or feature requests and you can submit merge requests to this project on [GitLab]. You can also submit issues and patches directly to [morgan@mcmillian.dev].
Join my public chat room on pnut.io for development discussion.
Join my public chat room for development discussion.
- [pnut-matrix]
- [pnut-matrix on pnut.io]
- [#pnut_999:pnut-matrix.dreamfall.space]
## License
@ -57,7 +58,9 @@ GPLv3, see [LICENSE].
[synapse]: https://github.com/matrix-org/synapse
[synapse installation instructions]: https://matrix-org.github.io/synapse/latest/setup/installation.html
[syanpse configuration]: https://matrix-org.github.io/synapse/latest/application_services.html
[GitLab]: https://gitlab.com/thrrgilag/pnut-matrix/
[morgan@mcmillian.dev]: mailto:morgan@mcmillian.dev
[pnut-matrix]: https://patter.chat/999
[pnut-matrix on pnut.io]: https://patter.chat/999
[#pnut_999:pnut-matrix.dreamfall.space]: https://matrix.to/#/#pnut_999:pnut-matrix.dreamfall.space
[LICENSE]: LICENSE
[^1]: https://github.com/matrix-org/matrix-appservice-irc/issues/1270#issuecomment-849765090

917
appservice.py Normal file
View file

@ -0,0 +1,917 @@
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("/_matrix/app/v1/rooms/<alias>")
@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.public:
room['preset'] = 'public_chat'
room['visibility'] = 'public'
else:
abort(401)
url = app.config['MATRIX_HOST'] + '/_matrix/client/api/v1/createRoom'
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + app.config['MATRIX_AS_TOKEN']
}
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()
else:
logger.error("Unable to create room")
logger.error(r.status_code)
logger.error(r.text)
abort(400)
except pnutpy.errors.PnutPermissionDenied:
abort(401)
except Exception:
logger.exception("-couldn't get the pnut channel-")
abort(404)
return jsonify({})
@app.route("/_matrix/app/v1/transactions/<transaction>", methods=["PUT"])
@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.sticker':
new_sticker(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_sticker(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
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']
matrix_profile = get_profile(event['user_id'])
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)
# evtext, evraw = msg_from_event(event)
text = "sticker::" + event['content']['body'] + "\n"
value = {'type': "photo", 'version': "1.0"}
value['url'] = app.config['MATRIX_URL'] + '/_matrix/media/r0/download/' + event['content']['url'][6:]
value['title'] = event['content']['body']
if 'h' in event['content']['info'] and 'w' in event['content']['info']:
value['height'] = event['content']['info']['h']
value['width'] = event['content']['info']['h']
else:
return
raw = {'type': "io.pnut.core.oembed", 'value': value}
embed = [raw]
text = prefix + text
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-')
return
except Exception:
logger.exception('-something bad happened here-')
return
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']
matrix_profile = get_profile(event['user_id'])
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)]
evtext, evraw = msg_from_event(event)
text = prefix + evtext
if len(evraw) > 0:
embed.append(evraw)
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
raw = {}
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:]
raw = 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, raw
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, user):
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(user)
logger.debug(room)
room['invite'] = [user.matrix_id]
if 'name' in channel_settings:
room['name'] = channel_settings['name']
if 'description' in channel_settings:
room['topic'] = channel_settings['description']
if channel.acl.read.public:
room['preset'] = 'public_chat'
room['visibility'] = 'public'
elif channel.acl.read.any_user or channel.acl.read.you:
room['preset'] = 'private_chat'
room['visibility'] = 'private'
else:
abort(401)
url = app.config['MATRIX_HOST'] + '/_matrix/client/api/v1/createRoom'
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + app.config['MATRIX_AS_TOKEN']
}
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()
else:
logger.error("Unable to create room")
logger.error(r.status_code)
logger.error(r.text)
abort(400)
def new_matrix_user(username):
matrix_api = MatrixHttpApi(app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'])
data = {'type': 'm.login.application_service','username': app.config['MATRIX_PNUT_PREFIX'] + username}
try:
matrix_api.register(content=data)
except Exception:
errmsg = "- new_matrix_user user already exists -"
logger.warning(errmsg)
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(rid):
matrix_api = MatrixHttpApi(app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'])
pnutpy.api.add_authorization_token(app.config['MATRIX_PNUT_TOKEN'])
if rid.startswith('!'):
room = Rooms.query.filter(Rooms.room_id == rid).one_or_none()
else:
room = Rooms.query.filter(Rooms.pnut_chan == rid).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)
new_matrix_user(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']
matrix_profile = get_profile(event['user_id'])
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)]
evtext, evraw = msg_from_event(event)
text = prefix + evtext
if len(evraw) > 0:
embed.append(evraw)
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 <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"
reply += "!join <channel #>\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 <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
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 <channel #>"
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)
room = Rooms.query.filter(Rooms.pnut_chan == channel_id).one_or_none()
if room is None:
create_room(channel, user)
else:
matrix_api = MatrixHttpApi(app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'])
matrix_api.invite_user(room.room_id, sender)
reply = "ok"
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

24
database.py Normal file
View file

@ -0,0 +1,24 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.ext.declarative import declarative_base
import yaml
import os
configyaml = os.environ.get("CONFIG_FILE")
with open(configyaml, "rb") as config_file:
config = yaml.load(config_file, Loader=yaml.SafeLoader)
engine = create_engine(config['SERVICE_DB'])
db_session = scoped_session(sessionmaker(bind=engine))
Base = declarative_base()
Base.query = db_session.query_property()
def init_db():
# import all modules here that might define models so that
# they will be registered properly on the metadata. Otherwise
# you will have to import them first before calling init_db()
import models
Base.metadata.create_all(bind=engine)

44
models.py Normal file
View file

@ -0,0 +1,44 @@
from sqlalchemy import Column, ForeignKey, Integer, String, Boolean
from database import Base
class Avatars(Base):
__tablename__ = 'avatars'
id = Column(Integer, primary_key=True)
pnut_user = Column(String(250), unique=True)
avatar = Column(String(250))
class Rooms(Base):
__tablename__ = 'rooms'
id = Column(Integer, primary_key=True)
room_id = Column(String(250), unique=True)
pnut_chan = Column(Integer, unique=True)
portal = Column(Boolean)
class DirectRooms(Base):
__tablename__ = 'direct'
id = Column(Integer, primary_key=True)
room_id = Column(String(250), unique=True)
pnut_chan = Column(Integer, unique=True)
bridge_user = Column(String(250))
class ControlRooms(Base):
__tablename__ = 'control'
id = Column(Integer, primary_key=True)
room_id = Column(String(250), unique=True)
class Events(Base):
__tablename__ = 'events'
id = Column(Integer, primary_key=True)
event_id = Column(String(250))
room_id = Column(String(250))
pnut_msg_id = Column(Integer)
pnut_user_id = Column(Integer)
pnut_chan_id = Column(Integer)
deleted = Column(Boolean)
class Users(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
matrix_id = Column(String(250))
pnut_user_id = Column(Integer)
pnut_user_token = Column(String(250))

118
pnut-matrix-bot.py Normal file
View file

@ -0,0 +1,118 @@
import logging
import yaml
import time
import pnutpy
from matrix_bot_api.matrix_bot_api import MatrixBotAPI
from matrix_bot_api.mregex_handler import MRegexHandler
from matrix_bot_api.mcommand_handler import MCommandHandler
from models import Avatars, Rooms, Events, Users
from database import db_session
from sqlalchemy import and_
def help_cb(room, event):
reply = "This is an admin room for controlling your connection to pnut.io\n"
reply += "The following commands are available.\n\n"
reply += "!auth - Authorize your account on pnut.io\n"
reply += "!save <token> - Save your pnut.io auth token\n"
reply += "!drop - Drop your pnut.io auth token\n"
reply += "!status - Status of your pnut.io auth token\n"
room.send_notice(reply)
def auth_cb(room, event):
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"
room.send_notice(reply)
def save_cb(room, event):
args = event['content']['body'].split(' ', maxsplit=1)
if len(args) < 2:
reply = "You must provide a token with this command.\n"
reply += "!save <token>"
room.send_notice(reply)
return
pnutpy.api.add_authorization_token(args[1])
try:
response, meta = pnutpy.api.get_user('me')
user = Users(
matrix_id=event['sender'],
pnut_user_id=response.id,
pnut_user_token=args[1]
)
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."
room.send_notice(reply)
def drop_cb(room, event):
try:
user = Users.query.filter(Users.matrix_id == event['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."
room.send_notice(reply)
def status_cb(room, event):
try:
user = Users.query.filter(Users.matrix_id == event['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."
room.send_notice(reply)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
with open("config.yaml", "rb") as config_file:
config = yaml.load(config_file)
bot = MatrixBotAPI(config['TBOT_USER'], config['TBOT_PASS'], config['MATRIX_HOST'])
bot.add_handler(MCommandHandler("help", help_cb))
bot.add_handler(MCommandHandler("auth", auth_cb))
bot.add_handler(MCommandHandler("save", save_cb))
bot.add_handler(MCommandHandler("drop", drop_cb))
bot.add_handler(MCommandHandler("status", status_cb))
bot.start_polling()
while True:
time.sleep(1)

426
pnut-matrix.py Normal file
View file

@ -0,0 +1,426 @@
import websocket
import threading
import time
import logging
import logging.config
import yaml
import json
import pnutpy
import requests
import magic
import argparse
import os
import re
from matrix_client.api import MatrixHttpApi
from matrix_client.api import MatrixError, MatrixRequestError
from models import Avatars, Rooms, Events, DirectRooms, Users
from database import db_session, init_db
from sqlalchemy import and_
from appservice import app
logger = logging.getLogger()
_shutdown = threading.Event()
_reconnect = threading.Event()
class MLogFilter(logging.Filter):
ACCESS_TOKEN_RE = re.compile(r"(\?.*access(_|%5[Ff])token=)[^&]*(\s.*)$")
ACCESS_TOKEN_RE2 = re.compile(r"(\?.*access(_|%5[Ff])token=)[^&]*(.*)$")
def filter(self, record):
if record.name == "werkzeug" and len(record.args) > 0:
redacted_uri = MLogFilter.ACCESS_TOKEN_RE.sub(r"\1<redacted>\3", record.args[0])
record.args = (redacted_uri, ) + record.args[1:]
elif record.name == "urllib3.connectionpool" and len(record.args) > 3:
redacted_uri = MLogFilter.ACCESS_TOKEN_RE2.sub(r"\1<redacted>\3", record.args[4])
record.args = record.args[:4] + (redacted_uri,) + record.args[5:]
return True
def new_message(msg, meta):
logger.debug("channel: " + msg.channel_id)
logger.debug("username: " + msg.user.username)
if 'name' in msg.user:
logger.debug("name: " + msg.user.name)
logger.debug("text: " + msg.content.text)
# ignore messages posted by the bridge
if msg.user.username == config['MATRIX_PNUT_USER']:
return
if msg.source.id == config['PNUTCLIENT_ID']:
return
if meta['channel_type'] == 'io.pnut.core.chat':
room = Rooms.query.filter(Rooms.pnut_chan == msg.channel_id).one_or_none()
elif meta['channel_type'] == 'io.pnut.core.pm':
room = DirectRooms.query.filter(DirectRooms.pnut_chan == msg.channel_id).one_or_none()
if room is None:
# Do do an invite from the bridge user?
logger.debug('new invite?')
# create room and included matrix recpient
# subscribed_user_ids from meta
logger.debug(meta['subscribed_user_ids'])
pnut_user = matrix_id_from_pnut(msg.user.username)
profile = get_matrix_profile(pnut_user)
if not profile:
new_matrix_user(msg.user.username)
invitees=[]
for pm_user in meta['subscribed_user_ids']:
user = Users.query.filter(Users.pnut_user_id == pm_user).one_or_none()
if int(pm_user) == msg.user.id:
continue
if user is not None:
invitees.append(user.matrix_id)
if len(invitees) > 0:
room = new_room(pnut_user, invitees, msg.channel_id)
else:
room = None
logger.debug(room)
if room is None:
logger.debug('-not_mapped-')
return
matrix_id = matrix_id_from_pnut(msg.user.username)
matrix_api = MatrixHttpApi(config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'],
identity=matrix_id)
profile = get_matrix_profile(matrix_id)
if not profile:
new_matrix_user(msg.user.username)
logger.debug('-new_user-')
profile = {'displayname': None}
if profile['displayname'] != matrix_display_from_pnut(msg.user):
set_matrix_display(msg.user)
logger.debug('-set_display-')
avatar = Avatars.query.filter(Avatars.pnut_user == msg.user.username).one_or_none()
if avatar is None or avatar.avatar != msg.user.content.avatar_image.url:
set_matrix_avatar(msg.user)
logger.debug('-set_avatar-')
# members = matrix_api.get_room_members(room.room_id)
# logger.debug(members)
# join_room(room.room_id, config['MATRIX_AS_ID'])
# TODO: sort out room invite and join logic
join_room(room.room_id, matrix_id)
if 'content' in msg:
text = msg.content.text + "\n"
ts = int(time.time()) * 1000
lnktext = ""
for link in msg.content.entities.links:
if 'title' in link:
lnktext += link.title + "\n"
if 'url' in link:
lnktext += link.url + "\n"
if len(lnktext) > 0:
text += "\n" + lnktext
r = matrix_api.send_message(room.room_id, text, timestamp=ts)
event = Events(
event_id=r['event_id'],
room_id=room.room_id,
pnut_msg_id=msg.id,
pnut_user_id=msg.user.id,
pnut_chan_id=msg.channel_id,
deleted=False)
db_session.add(event)
db_session.commit()
if 'raw' in msg:
logger.debug('-handle media uploads-')
new_media(room.room_id, msg)
def new_media(room_id, msg):
matrix_id = matrix_id_from_pnut(msg.user.username)
matrix_api = MatrixHttpApi(config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'],
identity=matrix_id)
ts = int(time.time()) * 1000
if 'io.pnut.core.oembed' in msg.raw:
for oembed in msg.raw['io.pnut.core.oembed']:
info = {}
if oembed.type == 'photo':
msgtype = 'm.image'
dl_url = oembed.url
info['h'] = oembed.height
info['w'] = oembed.width
elif oembed.type == 'audio':
logger.debug("* recieved audio attachment")
continue
elif oembed.type == 'video':
logger.debug("* recieved video attachment")
continue
elif oembed.type == 'html5video':
logger.debug("* recieved html5 video attachment")
continue
elif oembed.type == 'rich':
logger.debug("* recieved video attachment")
continue
else:
logger.debug("* recieved unknown attachment")
continue
dl = requests.get(dl_url, stream=True)
dl.raise_for_status()
with magic.Magic(flags=magic.MAGIC_MIME_TYPE) as m:
info['mimetype'] = m.id_buffer(dl.content)
info['size'] = len(dl.content)
ul = matrix_api.media_upload(dl.content, info['mimetype'])
if 'title' in oembed:
title = oembed.title
else:
title = ""
r = matrix_api.send_content(room_id, ul['content_uri'], title, msgtype, extra_information=info, timestamp=ts)
event = Events(
event_id=r['event_id'],
room_id=room_id,
pnut_msg_id=msg.id,
pnut_user_id=msg.user.id,
pnut_chan_id=msg.channel_id,
deleted=False)
db_session.add(event)
db_session.commit()
def delete_message(msg):
matrix_id = matrix_id_from_pnut(msg.user.username)
matrix_api = MatrixHttpApi(config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'],
identity=matrix_id)
events = Events.query.filter(and_(Events.pnut_msg_id == msg.id, Events.deleted == False)).all()
for event in events:
matrix_api.redact_event(event.room_id, event.event_id)
event.deleted = True
db_session.commit()
def matrix_id_from_pnut(username):
return "@" + config['MATRIX_PNUT_PREFIX'] + username + ":" + config['MATRIX_DOMAIN']
def matrix_display_from_pnut(user):
if 'name' in user:
display = user.name + " (@" + user.username + ")"
else:
display = "@" + user.username
return display
# return user.username + " (pnut)"
def get_matrix_profile(matrix_id):
url = matrix_url + '/profile/' + matrix_id
r = requests.get(url)
if r.status_code == 200:
return r.json()
else:
return None
def set_matrix_display(user):
matrix_id = matrix_id_from_pnut(user.username)
matrix_api = MatrixHttpApi(config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'],
identity=matrix_id)
matrix_api.set_display_name(matrix_id, matrix_display_from_pnut(user))
def set_matrix_avatar(user):
matrix_id = matrix_id_from_pnut(user.username)
matrix_api = MatrixHttpApi(config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'],
identity=matrix_id)
dl = requests.get(user.content.avatar_image.url, stream=True)
dl.raise_for_status()
with magic.Magic(flags=magic.MAGIC_MIME_TYPE) as m:
mtype = m.id_buffer(dl.content)
ul = matrix_api.media_upload(dl.content, mtype)
try:
matrix_api.set_avatar_url(matrix_id, ul['content_uri'])
avatar = Avatars.query.filter(Avatars.pnut_user == user.username).one_or_none()
if avatar is None:
avatar = Avatars(pnut_user=user.username, avatar=user.content.avatar_image.url)
db_session.add(avatar)
else:
avatar.avatar = user.content.avatar_image.url
db_session.commit()
except MatrixRequestError:
logger.exception('failed to set user avatar')
def new_matrix_user(username):
matrix_api = MatrixHttpApi(config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'])
data = {
'type': 'm.login.application_service',
'username': config['MATRIX_PNUT_PREFIX'] + username
}
matrix_api.register(content=data)
def join_room(room_id, matrix_id):
matrix_api_as = MatrixHttpApi(config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'])
matrix_api = MatrixHttpApi(config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'],
identity=matrix_id)
try:
matrix_api.join_room(room_id)
except MatrixRequestError as e:
if e.code == 403:
matrix_api_as.invite_user(room_id, matrix_id)
matrix_api.join_room(room_id)
else:
logger.exception('failed to join room')
logger.debug('-room_join-')
def new_room(pnut_user, invitees, chan):
dr = None
matrix_api = MatrixHttpApi(config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'],
identity=pnut_user)
url = matrix_url + '/createRoom'
params = {"access_token": config['MATRIX_AS_TOKEN'], "user_id": pnut_user}
content = {"visibility": "private", "is_direct": True, "invite": invitees}
headers = {"Content-Type": "application/json"}
r = requests.post(url, headers=headers, params=params,
data=json.dumps(content))
response = r.json()
for bridge_user in invitees:
dr = DirectRooms(room_id=response['room_id'],
bridge_user=pnut_user, pnut_chan=chan)
logger.debug(dr)
db_session.add(dr)
db_session.commit()
return dr
def on_message(ws, message):
# logger.debug("on_message: " + message)
msg = json.loads(message)
logger.debug(msg['meta'])
if 'data' in msg:
if 'channel_type' in msg['meta']:
if msg['meta']['channel_type'] not in ['io.pnut.core.chat',
'io.pnut.core.pm']:
return
for d_item in msg['data']:
pmsg = pnutpy.models.Message.from_response_data(d_item)
if 'is_deleted' in msg['meta']:
if msg['meta']['is_deleted']:
logger.debug("message: delete")
delete_message(pmsg)
else:
new_message(pmsg, msg['meta'])
def on_error(ws, error):
logger.error("on_error: !!! ERROR !!!")
logger.error(error)
def on_close(ws):
logger.debug("on_close: ### CLOSED ###")
def on_open(ws):
def run(*args):
while not _shutdown.isSet() and not _reconnect.isSet():
time.sleep(3)
try:
ws.send(".")
except websocket._exceptions.WebSocketConnectionClosedException:
logger.debug('websocket closed exception caught...')
_reconnect.set()
time.sleep(1)
logger.debug("*** terminate thread ***")
t = threading.Thread(target=run)
t.start()
def wsthreader(threadfunc):
def wrapper():
while not _shutdown.isSet():
_reconnect.clear()
logger.debug('threadfunc start...')
running = threadfunc()
logger.debug('threadfunc end...')
if running:
time.sleep(5)
else:
_shutdown.set()
logger.debug('*** thread stopped ***')
return wrapper
if __name__ == '__main__':
a_parser = argparse.ArgumentParser()
a_parser.add_argument(
'-d', action='store_true', dest='debug',
help="debug logging"
)
# TODO: solve the database.py problem and enable this
# a_parser.add_argument(
# '-c', '--config', default="config.yaml",
# help="configuration file"
# )
args = a_parser.parse_args()
configyaml = os.environ.get("CONFIG_FILE")
with open(configyaml, "rb") as config_file:
config = yaml.load(config_file, Loader=yaml.SafeLoader)
# websocket.enableTrace(True)
logging.config.dictConfig(config['logging'])
redact_filter = MLogFilter()
logging.getLogger("werkzeug").addFilter(redact_filter)
logging.getLogger("urllib3.connectionpool").addFilter(redact_filter)
ws_url = 'wss://stream.pnut.io/v1/app?access_token='
ws_url += config['PNUT_APPTOKEN'] + '&key=' + config['PNUT_APPKEY']
ws_url += '&include_raw=1'
matrix_url = config['MATRIX_HOST'] + '/_matrix/client/r0'
# setup the database connection
init_db()
# setup the websocket connection
ws = websocket.WebSocketApp(ws_url, on_message=on_message,
on_error=on_error, on_close=on_close)
ws.on_open = on_open
wst = threading.Thread(target=wsthreader(ws.run_forever))
wst.start()
# setup the matrix app service
if config['MATRIX_ADMIN_ROOM']:
logger.debug("- sould join admin room -")
join_room(config['MATRIX_ADMIN_ROOM'], config['MATRIX_AS_ID'])
app.config.update(config)
app.run(host=config['LISTEN_HOST'], port=config['LISTEN_PORT'])
logger.info('!! shutdown initiated !!')
_shutdown.set()
ws.close()
time.sleep(2)

View file

@ -1,38 +0,0 @@
[project]
name = "pnut-matrix"
version = "1.3.99"
authors = [
{ name="Morgan McMillian", email="morgan@mcmillian.dev" },
]
description = "A matrix appservice bridge for pnut.io"
readme = "README.md"
requires-python = ">=3.10"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
]
dependencies = [
"pyyaml",
"requests",
"Flask[async]",
"pnutpy>=0.5.0",
"filemagic",
"mautrix>=0.20.6,<0.21",
"websockets",
"asyncclick",
"peewee",
]
[project.urls]
"Homepage" = "https://git.dreamfall.space/spacenerdmo/pnut-matrix"
"Bug Tracker" = "https://git.dreamfall.space/spacenerdmo/pnut-matrix/issues"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project.entry-points.console_scripts]
pnutservice = "pnut_matrix.pnutservice:main"
appservice = "pnut_matrix.appservice:main"
ascmd = "pnut_matrix.cmd:cmd"

8
requirements.txt Normal file
View file

@ -0,0 +1,8 @@
pyyaml
requests
matrix-client==0.3.2
Flask
pnutpy
sqlalchemy
websocket-client
filemagic

7
run.sh
View file

@ -1,7 +0,0 @@
!#/bin/bash
pnutservice -c /data/config.yml &
matrixappsvc -c /data/config.yml &
wait -n
exit $?

View file

@ -1,959 +0,0 @@
import json
import yaml
import requests
import logging
import logging.config
import re
import pnutpy
import textwrap
import time
import os
import argparse
from mautrix.client import ClientAPI
from mautrix.types import *
from pnut_matrix.models import *
from flask import Flask, jsonify, request, abort
app = Flask(__name__)
@app.errorhandler(404)
def not_found(error):
return jsonify({'errcode':'PNUT_NOT_FOUND'}), 404
@app.errorhandler(403)
def forbidden(error):
return jsonify({'errcode':'PNUT_FORBIDDEN'}), 403
@app.route("/_matrix/app/v1/rooms/<alias>")
@app.route("/rooms/<alias>")
async def query_alias(alias):
logging.debug("--- query alias ---")
alias_localpart = alias.split(":")[0][1:]
channel_id = int(alias_localpart.split('_')[1])
room = PnutChannels.select().where(PnutChannels.pnut_chan ==
channel_id).first()
if room is not None:
abort(404)
token = app.config['MATRIX_PNUT_TOKEN']
pnutpy.api.add_authorization_token(token)
try:
logging.debug("---- getting the channel ----")
channel, meta = pnutpy.api.get_channel(channel_id,
include_channel_raw=1)
if 'is_active' in channel and channel.is_active == False:
logging.debug("-channel isn't active-")
abort(404)
if 'io.pnut.core.chat-settings' in channel.raw:
for setting in channel.raw['io.pnut.core.chat-settings']:
if 'name' in setting:
name = f"🥜 {setting['name']}"
else:
name = f"🥜 channel {channel.id}"
if 'description' in setting:
topic = setting['description']['text']
else:
topic = None
matrix_api = ClientAPI(app.config['MATRIX_AS_ID'],
base_url=app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'])
if channel.acl.read.public:
visibility = RoomDirectoryVisibility.PUBLIC
preset = RoomCreatePreset.PUBLIC
else:
visibility = RoomDirectoryVisibility.PRIVATE
preset = RoomCreatePreset.PRIVATE
room_id = await matrix_api.create_room(alias_localpart,
visibility=visibility,
preset=preset,
name=name,
topic=topic)
if not channel.you_subscribed:
pnutpy.api.subscribe_channel(channel_id)
room = PnutChannels(room_id=room_id, pnut_chan=channel_id)
room.save()
logging.debug(f'-created new room for channel {room.pnut_chan}-')
logging.debug(room.room_id)
except pnutpy.errors.PnutPermissionDenied:
logging.debug("-permission denied-")
abort(401)
except Exception:
logging.exception("-couldn't get the pnut channel-")
abort(404)
return jsonify({})
@app.route("/_matrix/app/v1/transactions/<transaction>", methods=["PUT"])
@app.route("/transactions/<transaction>", methods=["PUT"])
async 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:
logging.debug('-----event-----')
logging.debug(event)
logging.debug('~~~~~~~~~~~~~~~')
if (app.config['MATRIX_ADMIN_ROOM'] and
app.config['MATRIX_ADMIN_ROOM'] == event['room_id']):
logging.debug('>----on_admin_event----<')
await on_admin_event(event)
return jsonify({})
user = PnutUsers.select().where(PnutUsers.matrix_id ==
event['sender']).first()
if event['type'] == 'm.room.message':
logging.debug('>----new_message----<')
await new_message(event, user)
elif event['type'] == 'm.room.redaction':
logging.debug('>----delete_message----<')
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']):
logging.debug('>----on_direct_invite----<')
await on_direct_invite(event)
return jsonify({})
if 'membership' in event['content']:
if event['content']['membership'] == "leave":
logging.debug('>----on_leave_event----<')
await on_leave_event(event)
return jsonify({})
return jsonify({})
async def new_message(event, user):
if event['sender'] == app.config['MATRIX_AS_ID']:
return
if app.config['MATRIX_PNUT_PREFIX'] in event['sender']:
return
if user.room_id == event['room_id']:
await on_control_message(event, user)
return
room = PnutChannels.select().where(PnutChannels.room_id ==
event['room_id']).first()
logging.debug(f'room: {room}')
if room is None:
if event['room_id'] == app.config['MATRIX_GLOBAL_ROOM']:
room = PnutChannels(pnut_chan=0,
room_id=app.config['MATRIX_GLOBAL_ROOM'])
else:
logging.debug('-room not mapped-')
return
if room.is_direct:
logging.debug('>----on_direct_message----<')
return on_direct_message(event, user, room)
if user is not None:
token = user.pnut_user_token
prefix = ""
else:
token = app.config['MATRIX_PNUT_TOKEN']
matrix_profile = get_profile(event['sender'])
if ('displayname' in matrix_profile):
prefix = (f"[{matrix_profile['displayname']}]"
f" ({event['sender']})\n")
else:
prefix = "(" + event['sender'] + ")\n"
pnutpy.api.add_authorization_token(token)
raw = {}
raw['io.pnut.core.crosspost'] = [crosspost_raw(event)]
text, oembed = await msg_from_event(event, user)
if text is None:
return
text = prefix + text
if oembed:
raw['io.pnut.core.oembed'] = [oembed]
logging.debug(oembed)
reply_to_id = None
ev_content = event['content']
if 'm.relates_to' in ev_content:
m_relates_to = ev_content['m.relates_to']
if 'm.in_reply_to' in m_relates_to:
reply_event_id = m_relates_to['m.in_reply_to']['event_id']
e = Events.select().where(Events.event_id ==
reply_event_id).first()
if e is not None:
reply_to_id = e.pnut_id
try:
payload = {'raw': raw}
if room.pnut_chan == 0:
if reply_to_id is not None:
orig, meta = pnutpy.api.get_post(reply_to_id)
if orig.user.id != user.pnut_user_id:
author = orig.user.username
text = f"@{author} {text}"
if 'content' in orig:
cc_list = []
for m in orig.content.entities.mentions:
if m.text == author:
continue
cc_list.append(f"@{m.text}")
if len(cc_list) > 0:
copy = " ".join(cc_list)
text = f"{text} /{copy}"
payload['reply_to'] = reply_to_id
payload['text'] = text
data, meta = pnutpy.api.create_post(data=payload)
else:
if reply_to_id is not None:
orig, meta = pnutpy.api.get_message(room.pnut_chan, reply_to_id)
if orig.user.id != user.pnut_user_id:
author = orig.user.username
text = f"@{author} {text}"
if 'content' in orig:
cc_list = []
for m in orig.content.entities.mentions:
if m.text == author:
continue
cc_list.append(f"@{m.text}")
if len(cc_list) > 0:
copy = " ".join(cc_list)
text = f"{text} /{copy}"
payload['reply_to'] = reply_to_id
payload['text'] = text
data, meta = pnutpy.api.create_message(room.pnut_chan,
data=payload)
bridge_event = Events(
event_id=event['event_id'],
room_id=event['room_id'],
pnut_id=data.id,
pnut_channel=room.pnut_chan
)
bridge_event.save()
except pnutpy.errors.PnutAuthAPIException:
if room.pnut_chan != 0:
logging.exception('-unable to post to pnut channel-')
else:
matrix_api = ClientAPI(app.config['MATRIX_AS_ID'],
base_url=app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'])
await matrix_api.redact(event['room_id'], event['event_id'],
reason='user not authenticated')
except Exception:
logging.exception('-something bad happened here-')
return
async def msg_from_event(event, user):
text = None
oembed = None
if (event['content']['msgtype'] == 'm.text' or
event['content']['msgtype'] == 'm.notice'):
text = event['content']['body']
fregex = re.compile(r'!file\s(\d+)')
m = fregex.search(text)
if m is not None:
file_id = m.group(1)
text = fregex.sub('', text)
pnut_file = PnutUploads.select().where(PnutUploads.pnut_file_id ==
file_id).first()
if pnut_file is not None:
oembed = {
'+io.pnut.core.file': {
'file_id': pnut_file.pnut_file_id,
'file_token': pnut_file.pnut_file_token,
'format': 'oembed'}}
elif event['content']['msgtype'] == 'm.emote':
text = "* " + event['content']['body']
elif ((event['content']['msgtype'] == 'm.image' or
event['content']['msgtype'] == 'm.video' or
event['content']['msgtype'] == 'm.audio') and
user is not None):
await media_from_event(event, user)
return None, None
elif (event['content']['msgtype'] == 'm.image' or
event['content']['msgtype'] == 'm.video' or
event['content']['msgtype'] == 'm.audio'):
oembed = oembed_from_event(event)
if ('title' in oembed and 'url' in oembed):
text = (f"[{oembed['title']}]"
f"({oembed['url']})")
elif event['content']['msgtype'] == 'm.file':
file_url = event['content']['url'][6:]
file_name = event['content']['body']
dl_url = (f"{app.config['MATRIX_URL']}"
f"/_matrix/client/v1/media/download/{file_url}"
f"/{file_name}")
text = (f"[{file_name}]"
f"({dl_url})")
else:
logging.debug('-unknown msg type- ' + event['content']['msgtype'])
return
return text, oembed
def crosspost_raw(event):
cross_profile = {'username': event['sender']}
matrix_profile = get_profile(event['sender'])
if "avatar_url" in matrix_profile:
cross_profile['avatar_image'] = (f"{app.config['MATRIX_URL']}"
f"/_matrix/media/r0/download/"
f"{matrix_profile['avatar_url'][6:]}")
crosspost = {}
crosspost['canonical_url'] = (f"https://matrix.to/#/{event['room_id']}"
f"/{event['event_id']}"
f":{app.config['MATRIX_DOMAIN']}")
crosspost['source'] = {'name': "matrix.", 'url': "https://matrix.org"}
crosspost['user'] = cross_profile
return crosspost
async def media_from_event(event, user):
mxc_url = event['content']['url']
if event['content']['msgtype'] == 'm.image':
kind = 'image'
elif event['content']['msgtype'] == 'm.video':
kind = 'video'
elif event['content']['msgtype'] == 'm.audio':
kind = 'audio'
else:
kind = 'other'
mime_type = event['content']['info']['mimetype']
file_name = event['content']['body']
file_data = {'type': f'dev.mcmillian.pnut-matrix.{kind}',
'name': file_name, 'kind': kind,
'mimetype': mime_type, 'is_public': True}
matrix_api = ClientAPI(app.config['MATRIX_AS_ID'],
base_url=app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'])
media_file = await matrix_api.download_media(mxc_url)
pnutpy.api.add_authorization_token(user.pnut_user_token)
try:
pnut_file, meta = pnutpy.api.create_file(files={'content': media_file},
data=file_data)
qf = PnutUploads(pnut_user_id=user.pnut_user_id,
pnut_file_id=pnut_file.id,
pnut_file_token=pnut_file.file_token)
qf.save()
reply = f"* Media upload ready, "
reply += f"attach to message with !file {pnut_file.id}"
except pnutpy.errors.PnutPermissionDenied:
reply = f"* Permission denied, you may need to authenticate again."
try:
message = TextMessageEventContent(msgtype='m.text', body=reply)
await matrix_api.send_message(event['room_id'], message)
except Exception:
errmsg = "- media_from_event -"
logging.exception(errmsg)
def oembed_from_event(event):
media_url = event['content']['url'][6:]
file_name = event['content']['body']
dl_url = (f"{app.config['MATRIX_URL']}"
f"/_matrix/client/v1/media/download/{media_url}"
f"/{file_name}")
oembed = {}
if event['content']['msgtype'] == 'm.image':
oembed['provider_name'] = "matrix"
oembed['provider_url'] = "https://matrix.org"
oembed['version'] = "1.0"
oembed['type'] = "photo"
oembed['title'] = file_name
oembed['url'] = dl_url
if 'info' in event['content']:
if 'h' in event['content']['info']:
oembed['height'] = event['content']['info']['h']
if 'w' in event['content']['info']:
oembed['width'] = event['content']['info']['w']
elif event['content']['msgtype'] == 'm.video':
oembed['provider_name'] = "matrix"
oembed['provider_url'] = "https://matrix.org"
oembed['version'] = "1.0"
oembed['type'] = "video"
oembed['title'] = file_name
oembed['url'] = dl_url
if 'info' in event['content']:
if 'h' in event['content']['info']:
oembed['height'] = event['content']['info']['h']
if 'w' in event['content']['info']:
oembed['width'] = event['content']['info']['w']
elif event['content']['msgtype'] == 'm.audio':
oembed['provider_name'] = "matrix"
oembed['provider_url'] = "https://matrix.org"
oembed['version'] = "1.0"
oembed['type'] = "audio"
oembed['title'] = file_name
oembed['url'] = dl_url
if 'info' in event['content']:
if 'duration' in event['content']['info']:
oembed['duration'] = event['content']['info']['duration']
return oembed
def delete_message(event, user):
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.select().where((Events.event_id == event['redacts']) &
(Events.deleted == False)).first()
if e is None:
logging.debug("- can't find the event to remove -")
return
try:
r, meta = pnutpy.api.delete_message(e.pnut_channel, e.pnut_id)
e.deleted = True
e.save()
except pnutpy.errors.PnutPermissionDenied:
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:
logging.exception('-unable to get channel settings-')
return channel_settings
async def create_pnut_matrix_room(channel, user):
name = None
topic = None
alias_localpart = f"{app.config['MATRIX_PNUT_PREFIX']}{channel.id}"
invitees = [user.matrix_id]
if channel.acl.read.public:
visibility = RoomDirectoryVisibility.PUBLIC
preset = RoomCreatePreset.PUBLIC
is_private = False
else:
visibility = RoomDirectoryVisibility.PRIVATE
preset = RoomCreatePreset.PRIVATE
is_private = True
if 'io.pnut.core.chat-settings' in channel.raw:
for setting in channel.raw['io.pnut.core.chat-settings']:
if 'name' in setting:
name = f"🥜 {setting['name']}"
else:
name = f"🥜 channel {channel.id}"
if 'description' in setting:
topic = setting['description']['text']
matrix_api = ClientAPI(app.config['MATRIX_AS_ID'],
base_url=app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'])
room_id = await matrix_api.create_room(alias_localpart,
invitees=invitees,
visibility=visibility,
preset=preset,
name=name,
topic=topic)
room = PnutChannels(room_id=room_id, pnut_chan=channel.id,
is_private=is_private)
room.save()
if is_private:
chan_member = PnutPrivateChanMembers(pnut_chan=channel.id,
room_id=room_id,
pnut_user_id=user.pnut_user_id,
matrix_id=user.matrix_id)
chan_member.save()
logging.debug('-create_pnut_matrix_room-')
logging.debug(f'-created new room for channel {room.pnut_chan}-')
logging.debug(room.room_id)
def new_matrix_user(username):
endpoint = "/_matrix/client/v3/register"
url = app.config['MATRIX_HOST'] + endpoint
params = {'kind': 'user'}
data = {
'type': 'm.login.application_service',
'username': app.config['MATRIX_PNUT_PREFIX'] + username
}
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + app.config['MATRIX_AS_TOKEN']
}
r = requests.post(url, headers=headers, json=data, params=params)
if r.status_code == 200:
return
else:
errmsg = f"- unable to register {username} -"
logging.warning(errmsg)
logging.debug(r.status_code)
logging.debug(r.text)
return
async def on_admin_event(event):
matrix_api = ClientAPI(app.config['MATRIX_AS_ID'],
base_url=app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'])
logging.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:
await matrix_api.send_message(event['room_id'],
cmd_admin_help(msg[1]))
else:
await matrix_api.send_message(event['room_id'],
cmd_admin_help())
elif msg[0] == 'list':
await matrix_api.send_message(event['room_id'], cmd_admin_list())
except Exception:
errmsg = "- on_admin_event -"
logging.exception(errmsg)
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."
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 TextMessageEventContent(msgtype='m.text', body=text)
def cmd_admin_list():
text = ""
rooms = PnutChannels.select()
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'
text += '\n'
return TextMessageEventContent(msgtype='m.text', body=text)
async def on_direct_invite(event):
# direct chat with the appservice user
if event['state_key'] == app.config['MATRIX_AS_ID']:
matrix_api = ClientAPI(app.config['MATRIX_AS_ID'],
base_url=app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'])
dm = PnutUsers(matrix_id=event['sender'], room_id=event['room_id'])
# direct chat with another pnut user
elif app.config['MATRIX_PNUT_PREFIX'] in event['state_key']:
matrix_api = ClientAPI(app.config['MATRIX_AS_ID'],
base_url=app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'],
as_user_id=event['state_key'])
bridge_user = event['state_key']
pnut_user = bridge_user.replace(app.config['MATRIX_PNUT_PREFIX'],
'').split(':')[0]
user = PnutUsers.select().where(PnutUsers.matrix_id ==
event['sender']).first()
if user is not None:
pnutpy.api.add_authorization_token(user.pnut_user_token)
try:
channel, meta = pnutpy.api.existing_pm(ids=pnut_user)
new_matrix_user(pnut_user)
dm = PnutChannels(pnut_chan=channel.id,
room_id=event['room_id'],
is_direct=True,
direct_pnut_user=bridge_user,
direct_mtrx_user=user.matrix_id)
except pnutpy.errors.PnutAuthAPIException:
abort(403)
else:
abort(403)
else:
return
try:
logging.debug('--> trying to join room <--')
await matrix_api.join_room_by_id(event['room_id'])
dm.save()
except Exception:
errmsg = "- on_direct_invite -"
logging.exception(errmsg)
async def on_leave_event(event):
direct_room = PnutChannels.select().where(
(PnutChannels.room_id == event['room_id']) &
(PnutChannels.direct_mtrx_user == event['sender']) &
(PnutChannels.is_direct == True)).first()
user = PnutUsers.select().where(PnutUsers.room_id ==
event['room_id']).first()
if direct_room is not None:
matrix_api = ClientAPI(app.config['MATRIX_AS_ID'],
base_url=app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'],
as_user_id=direct_room.direct_pnut_user.lower())
try:
await matrix_api.leave_room(event['room_id'])
direct_room.delete_instance()
except Exception:
errmsg = "- on_leave_event -"
logging.exception(errmsg)
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']
matrix_profile = get_profile(event['sender'])
if "displayname" in matrix_profile:
prefix = (f"[{matrix_profile['displayname']}]"
f" ({event['sender']})\n")
else:
prefix = "(" + event['sender'] + ")\n"
pnutpy.api.add_authorization_token(token)
raw = {}
raw['io.pnut.core.crosspost'] = [crosspost_raw(event)]
evtext, evraw = msg_from_event(event)
text = prefix + evtext
try:
msg, meta = pnutpy.api.create_message(room.pnut_chan,
data={'text': text, 'raw': raw})
bridge_event = Events(
event_id=event['event_id'],
room_id=event['room_id'],
pnut_id=msg.id,
pnut_channel=room.pnut_chan
)
bridge_event.save()
except pnutpy.errors.PnutAuthAPIException:
logging.exception('-unable to post to pnut channel-')
except Exception:
logging.exception('-something bad happened here-')
return jsonify({})
async def on_control_message(event, user):
matrix_api = ClientAPI(app.config['MATRIX_AS_ID'],
base_url=app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'])
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:
await matrix_api.send_message(event['room_id'],
cmd_user_help(msg[1]))
else:
await matrix_api.send_message(event['room_id'],
cmd_user_help())
elif msg[0] == '!auth':
await matrix_api.send_message(event['room_id'], cmd_user_auth())
elif msg[0] == '!save':
if len(msg) > 1:
await matrix_api.send_message(event['room_id'],
cmd_user_save(user, msg[1]))
else:
await matrix_api.send_message(event['room_id'],
cmd_user_save())
elif msg[0] == '!drop':
r = await cmd_user_drop(user)
await matrix_api.send_message(event['room_id'], r)
elif msg[0] == '!status':
await matrix_api.send_message(event['room_id'],
cmd_user_status(user))
elif msg[0] == '!join':
if len(msg) > 1:
r = await cmd_user_join(user, msg[1])
await matrix_api.send_message(event['room_id'], r)
else:
r = await cmd_user_join(user)
await matrix_api.send_message(event['room_id'], r)
except Exception:
errmsg = "- on_direct_message -"
logging.exception(errmsg)
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"
reply += "!join <channel #>\t- Join a private channel on pnut.io\n"
return TextMessageEventContent(msgtype='m.text', body=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=files,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 TextMessageEventContent(msgtype='m.text', body=reply)
def cmd_user_save(user, token=None):
if token is None:
reply = "You must provide a token with this command.\n"
reply += "!save <token>"
return TextMessageEventContent(msgtype='m.text', body=reply)
pnutpy.api.add_authorization_token(token)
try:
response, meta = pnutpy.api.get_user('me')
user.pnut_user_id = response.id
user.pnut_user_token = token
user.save()
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 TextMessageEventContent(msgtype='m.text', body=reply)
async def cmd_user_drop(user):
direct_rooms = PnutChannels.select().where(PnutChannels.direct_mtrx_user ==
user.matrix_id)
for dir_room in direct_rooms:
matrix_api = ClientAPI(app.config['MATRIX_AS_ID'],
base_url=app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'],
as_user_id=dir_room.direct_pnut_user.lower())
await matrix_api.leave_room(dir_room.room_id)
dir_room.delete_instance()
private_rooms = PnutPrivateChanMembers.select().where(
PnutPrivateChanMembers.pnut_user_id == user.pnut_user_id)
for priv_room in private_rooms:
matrix_api = ClientAPI(app.config['MATRIX_AS_ID'],
base_url=app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'])
await matrix_api.kick_user(priv_room.room_id, user.matrix_id,
reason='user left from bridge')
priv_room.delete_instance()
user.pnut_user_id = None
user.pnut_user_token = None
user.save()
reply = "Success! Your auth token has been removed."
return TextMessageEventContent(msgtype='m.text', body=reply)
def cmd_user_status(user):
try:
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 TextMessageEventContent(msgtype='m.text', body=reply)
async def cmd_user_join(user, channel_id=None):
if channel_id is None:
reply = "You must provide a channel id number with this command.\n"
reply += "!join <channel #>"
return TextMessageEventContent(msgtype='m.text', body=reply)
try:
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)
room = PnutChannels.select().where(PnutChannels.pnut_chan ==
channel_id).first()
if room is None:
await create_pnut_matrix_room(channel, user)
else:
matrix_api = ClientAPI(app.config['MATRIX_AS_ID'],
base_url=app.config['MATRIX_HOST'],
token=app.config['MATRIX_AS_TOKEN'])
await matrix_api.invite_user(room.room_id, user.matrix_id)
reply = "ok"
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 TextMessageEventContent(msgtype='m.text', body=reply)
class MLogFilter(logging.Filter):
ACCESS_TOKEN_RE = re.compile(r"(\?.*access(_|%5[Ff])token=)[^&]*(\s.*)$")
ACCESS_TOKEN_RE2 = re.compile(r"(\?.*access(_|%5[Ff])token=)[^&]*(.*)$")
def filter(self, record):
if record.name == "werkzeug" and len(record.args) > 0:
redacted_uri = MLogFilter.ACCESS_TOKEN_RE.sub(r"\1<redacted>\3",
record.args[0])
record.args = (redacted_uri, ) + record.args[1:]
elif record.name == "urllib3.connectionpool" and len(record.args) > 3:
redacted_uri = MLogFilter.ACCESS_TOKEN_RE2.sub(r"\1<redacted>\3",
record.args[4])
record.args = record.args[:4] + (redacted_uri,) + record.args[5:]
return True
def main():
a_parser = argparse.ArgumentParser()
a_parser.add_argument('-c', '--config', dest='configyaml',
default="config.yaml", help="configuration file")
args = a_parser.parse_args()
with open(args.configyaml, "rb") as config_file:
config = yaml.load(config_file, Loader=yaml.SafeLoader)
logging.config.dictConfig(config['logging'])
redact_filter = MLogFilter()
logging.getLogger("werkzeug").addFilter(redact_filter)
logging.getLogger("urllib3.connectionpool").addFilter(redact_filter)
app.config.update(config)
logging.basicConfig(level=logging.DEBUG)
db.init(config['SERVICE_DB'])
db_create_tables()
app.run(host=config['LISTEN_HOST'], port=config['LISTEN_PORT'])
if __name__ == '__main__':
main()

View file

@ -1,334 +0,0 @@
#!/usr/bin/env python3
import logging
import asyncio
import asyncclick as click
import requests
import pnutpy
import json
import yaml
from mautrix.client import ClientAPI
from mautrix.types import TextMessageEventContent, Format, MessageType, EventType
from mautrix.errors import MatrixConnectionError
from mautrix.errors.request import MNotFound, MForbidden
PNUT_API="https://api.pnut.io/v1"
@click.group()
@click.option('--debug', '-d', is_flag=True)
@click.option('--config', '-c', required=True)
@click.pass_context
async def cmd(ctx, debug, config):
if debug:
logging.basicConfig(level=logging.DEBUG)
ctx.ensure_object(dict)
with open(config, "rb") as config_file:
ctx.obj['config'] = yaml.load(config_file, Loader=yaml.SafeLoader)
@cmd.command()
@click.pass_context
async def get_streams(ctx):
config = ctx.obj['config']
logging.debug(config)
endpoint = "/streams"
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + config['PNUT_APPTOKEN']
}
url = f"{PNUT_API}{endpoint}"
r = requests.get(url, headers=headers)
if r.status_code == 200:
click.echo(json.dumps(r.json(), indent=4))
else:
click.echo(r.status_code)
click.echo(r.text)
@cmd.command()
@click.argument('key')
@click.pass_context
def rm_stream(ctx, key):
config = ctx.obj['config']
logging.debug(config)
endpoint = f"/streams/{key}"
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + config['PNUT_APPTOKEN']
}
url = f"{PNUT_API}{endpoint}"
r = requests.delete(url, headers=headers)
if r.status_code == 200:
click.echo(json.dumps(r.json(), indent=4))
else:
click.echo(r.status_code)
click.echo(r.text)
@cmd.command()
@click.pass_context
def rm_streams(ctx):
config = ctx.obj['config']
logging.debug(config)
endpoint = f"/streams"
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + config['PNUT_APPTOKEN']
}
url = f"{PNUT_API}{endpoint}"
r = requests.delete(url, headers=headers)
if r.status_code == 200:
click.echo(json.dumps(r.json(), indent=4))
else:
click.echo(r.status_code)
click.echo(r.text)
@cmd.command()
@click.argument('key')
@click.argument('object_types')
@click.pass_context
def new_stream(ctx, key, object_types):
config = ctx.obj['config']
endpoint = f"/streams"
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + config['PNUT_APPTOKEN']
}
otypes = [x.strip() for x in object_types.split(',')]
data = {
'type': "long_poll",
'object_types': otypes,
}
url = f"{PNUT_API}{endpoint}"
r = requests.post(url, headers=headers, json=data)
if r.status_code == 201:
click.echo(json.dumps(r.json(), indent=4))
else:
click.echo(r.status_code)
click.echo(r.text)
@cmd.command()
@click.argument('key')
@click.argument('object_types')
@click.pass_context
def update_stream(ctx, key, object_types):
config = ctx.obj['config']
endpoint = f"/streams/{key}"
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + config['PNUT_APPTOKEN']
}
otypes = [x.strip() for x in object_types.split(',')]
data = {
'object_types': otypes,
}
url = f"{PNUT_API}{endpoint}"
r = requests.put(url, headers=headers, json=data)
if r.status_code == 200:
click.echo(json.dumps(r.json(), indent=4))
else:
click.echo(r.status_code)
click.echo(r.text)
# TODO: this needs debugging
@cmd.command()
@click.pass_context
def pnut_app_token(ctx):
config = ctx.obj['config']
logging.debug(config)
# endpoint = f"/oauth/access_token"
# headers = {
# "Content-Type": "application/json",
# }
# data = {
# 'client_id': config['PNUTCLIENT_ID'],
# 'client_secret': config['PNUTCLIENT_SECRET'],
# 'grant_type': "client_credentials"
# }
#
# url = f"{PNUT_API}{endpoint}"
# r = requests.post(url, headers=headers, json=data)
#
# if r.status_code == 200:
# click.echo(json.dumps(r.json(), indent=4))
#
# else:
# click.echo(r.status_code)
# click.echo(r.text)
@cmd.command()
@click.argument('username')
@click.pass_context
def new_matrix_asuser(ctx, username):
config = ctx.obj['config']
endpoint = "/_matrix/client/v3/register"
url = config['MATRIX_HOST'] + endpoint
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + config['MATRIX_AS_TOKEN']
}
params = {'kind': 'user'}
data = {'type': 'm.login.application_service','username': username}
r = requests.post(url, headers=headers, json=data, params=params)
if r.status_code == 200:
click.echo("SUCCESS!")
else:
click.echo(r.status_code)
click.echo(r.text)
@cmd.command()
@click.argument('invitee')
@click.pass_context
def new_pnut_admin_room(ctx, invitee):
config = ctx.obj['config']
endpoint = "/_matrix/client/v3/createRoom"
url = config['MATRIX_HOST'] + endpoint
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + config['MATRIX_AS_TOKEN']
}
data = {
'visibility': "private",
'is_direct': False,
'name': "Pnut Bridge Admin Room",
'room_alias_name': f"{config['MATRIX_PNUT_PREFIX']}admin",
'invite': [invitee],
'power_level_content_override': {
'users': {
f"{config['MATRIX_AS_ID']}": 100,
f"{invitee}": 100
}
}
}
logging.debug(data)
r = requests.post(url, headers=headers, json=data)
if r.status_code == 200:
click.echo(json.dumps(r.json(), indent=4))
else:
click.echo(r.status_code)
click.echo(r.text)
@cmd.command()
@click.pass_context
def new_pnut_global_room(ctx):
config = ctx.obj['config']
endpoint = "/_matrix/client/v3/createRoom"
url = config['MATRIX_HOST'] + endpoint
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + config['MATRIX_AS_TOKEN']
}
# data = {
# 'visibility': "public",
# 'name': "Pnut Global Stream",
# 'room_alias_name': "pnut_global",
# 'power_level_content_override': {
# "events_default": 2,
# "invite": 2,
# }
# }
data = {
'visibility': "public",
'name': "Pnut Global Stream",
'room_alias_name': f"{config['MATRIX_PNUT_PREFIX']}global"
}
logging.debug(data)
r = requests.post(url, headers=headers, json=data)
if r.status_code == 200:
click.echo(json.dumps(r.json(), indent=4))
else:
click.echo(r.status_code)
click.echo(r.text)
@cmd.command()
@click.argument('room_id')
@click.argument('matrix_id')
@click.argument('power_level')
@click.pass_context
def elevate_matrix_user(ctx, room_id, matrix_id, power_level):
config = ctx.obj['config']
endpoint = f"/_matrix/client/v3/rooms/{room_id}/state/"
url = config['MATRIX_HOST'] + endpoint + "m.room.power_levels"
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + config['MATRIX_AS_TOKEN']
}
data = {
'users': {
f"{config['MATRIX_AS_ID']}": 100,
f"{matrix_id}": int(power_level)
}
}
logging.debug(data)
r = requests.put(url, headers=headers, json=data)
if r.status_code == 200:
click.echo(json.dumps(r.json(), indent=4))
else:
click.echo(r.status_code)
click.echo(r.text)
@cmd.command()
@click.pass_context
async def list_joined_rooms(ctx):
config = ctx.obj['config']
matrix_api = ClientAPI(config['MATRIX_AS_ID'],
base_url=config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'])
room_list = await matrix_api.get_joined_rooms()
for room_id in room_list:
click.echo(room_id)
try:
room_name = await matrix_api.get_state_event(room_id,
EventType.ROOM_NAME)
click.echo(f'name: {room_name.name}')
except MNotFound:
pass
try:
room_alias = await matrix_api.get_state_event(room_id,
EventType.ROOM_CANONICAL_ALIAS)
click.echo(f'alias: {room_alias.canonical_alias}')
except MNotFound:
pass
# members = await matrix_api.get_joined_members(room_id)
# click.echo(members)
click.echo('-----------')
@cmd.command()
@click.argument('room_id')
@click.pass_context
async def part_room(ctx, room_id):
config = ctx.obj['config']
matrix_api = ClientAPI(config['MATRIX_AS_ID'],
base_url=config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'])
# TODO: need to clear alias
await matrix_api.leave_room(room_id)
if __name__ == '__main__':
cmd(_anyio_backend="asyncio")

View file

@ -1,53 +0,0 @@
import logging
from peewee import *
from playhouse.migrate import *
db = SqliteDatabase(None)
migrator = SqliteMigrator(db)
class BaseModel(Model):
class Meta:
database = db
class PnutAvatars(BaseModel):
pnut_user = CharField(unique=True)
avatar_url = CharField()
class PnutChannels(BaseModel):
pnut_chan = IntegerField(unique=True)
room_id = CharField()
is_private = BooleanField(default=False)
is_direct = BooleanField(default=False)
direct_pnut_user = CharField(null=True)
direct_mtrx_user = CharField(null=True)
class PnutPrivateChanMembers(BaseModel):
pnut_chan = IntegerField()
room_id = CharField()
pnut_user_id = IntegerField()
matrix_id = CharField()
class Events(BaseModel):
event_id = CharField(unique=True)
room_id = CharField()
pnut_id = IntegerField()
pnut_channel = IntegerField()
revised = BooleanField(default=False)
deleted = BooleanField(default=False)
class PnutUsers(BaseModel):
matrix_id = CharField(unique=True)
room_id = CharField()
pnut_user_id = IntegerField(unique=True, null=True)
pnut_user_token = CharField(null=True)
class PnutUploads(BaseModel):
pnut_user_id = IntegerField()
pnut_file_id = IntegerField()
pnut_file_token = CharField()
def db_create_tables():
with db:
db.create_tables([PnutUsers, Events, PnutChannels, PnutAvatars,
PnutPrivateChanMembers, PnutUploads])

View file

@ -1,483 +0,0 @@
import time
import logging
import logging.config
import yaml
import json
import pnutpy
import requests
import magic
import argparse
import os
import re
import asyncio
from mautrix.client import ClientAPI
from mautrix.types import TextMessageEventContent, Format, MessageType
from mautrix.errors import MatrixConnectionError
from mautrix.errors.request import MNotFound, MForbidden
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed
from pnut_matrix.models import *
logger = logging.getLogger()
config = None
matrix_url = None
class MLogFilter(logging.Filter):
ACCESS_TOKEN_RE = re.compile(r"(\?.*access(_|%5[Ff])token=)[^&]*(\s.*)$")
ACCESS_TOKEN_RE2 = re.compile(r"(\?.*access(_|%5[Ff])token=)[^&]*(.*)$")
def filter(self, record):
if record.name == "werkzeug" and len(record.args) > 0:
redacted_uri = MLogFilter.ACCESS_TOKEN_RE.sub(r"\1<redacted>\3",
record.args[0])
record.args = (redacted_uri, ) + record.args[1:]
elif record.name == "urllib3.connectionpool" and len(record.args) > 3:
redacted_uri = MLogFilter.ACCESS_TOKEN_RE2.sub(r"\1<redacted>\3",
record.args[4])
record.args = record.args[:4] + (redacted_uri,) + record.args[5:]
return True
async def new_pnut_message(msg, meta):
logger.debug("channel: " + msg.channel_id)
logger.debug("username: " + msg.user.username)
if 'name' in msg.user:
logger.debug("name: " + msg.user.name)
logger.debug("text: " + msg.content.text)
# ignore messages posted by the bridge
if msg.user.username == config['MATRIX_PNUT_USER']:
return
if msg.source.id == config['PNUTCLIENT_ID']:
return
matrix_id = matrix_id_from_pnut(msg.user.username)
matrix_api = ClientAPI(config['MATRIX_AS_ID'],
base_url=config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'],
as_user_id=matrix_id.lower())
channel_id = int(msg.channel_id)
room = PnutChannels.select().where(PnutChannels.pnut_chan ==
channel_id).first()
if meta['channel_type'] == 'io.pnut.core.pm':
if room is None:
# Do do an invite from the bridge user?
logger.debug('new invite?')
# create room and included matrix recpient
# subscribed_user_ids from meta
logger.debug(meta['subscribed_user_ids'])
pnut_user = matrix_id_from_pnut(msg.user.username)
try:
profile = await matrix_api.get_profile(matrix_id.lower())
except MNotFound:
new_matrix_user(msg.user.username)
invitees=[]
for pm_user in meta['subscribed_user_ids']:
user = PnutUsers.select().where(PnutUsers.pnut_user_id ==
pm_user).first()
if int(pm_user) == msg.user.id:
continue
if user is not None:
invitees.append(user.matrix_id)
if len(invitees) > 0:
room = new_room(pnut_user, invitees, msg.channel_id)
logger.debug(room)
if room is None:
logger.debug('-not_mapped-')
return
try:
profile = await matrix_api.get_profile(matrix_id.lower())
logger.debug(profile)
except MNotFound:
new_matrix_user(msg.user.username)
logger.debug('-new_user-')
profile = {'displayname': None}
if profile['displayname'] != matrix_display_from_pnut(msg.user):
await set_matrix_display(msg.user)
logger.debug('-set_display-')
avatar = PnutAvatars.select().where(PnutAvatars.pnut_user ==
msg.user.username).first()
if avatar is None or avatar.avatar_url != msg.user.content.avatar_image.url:
await set_matrix_avatar(msg.user)
logger.debug('-set_avatar-')
if room.is_private:
matrix_api_as = ClientAPI(config['MATRIX_AS_ID'],
base_url=config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'])
await matrix_api_as.invite_user(room.room_id, matrix_id.lower())
await matrix_api.join_room(room.room_id)
if 'content' in msg:
eventtext = TextMessageEventContent(msgtype=MessageType.TEXT,
format=Format.HTML,
body=msg.content.text,
formatted_body=msg.content.html)
rid = await matrix_api.send_message(room.room_id, eventtext)
bridge_event = Events(
event_id=rid,
room_id=room.room_id,
pnut_id=msg.id,
pnut_channel=msg.channel_id
)
bridge_event.save()
if 'raw' in msg:
logger.debug('-handle media uploads-')
await new_media(room.room_id, msg)
async def new_pnut_post(post, meta):
if not config['PNUT_GLOBAL']:
return
if (config['PNUT_GLOBAL_HUMAN_ONLY'] and
post.user.type in ['feed', 'bot']):
logging.debug('-skipping non human post-')
return
if 'content' in post:
text = ""
if 'repost_of' in post:
text += f"<{post.user.username}> reposted >> "
post = post.repost_of
matrix_id = matrix_id_from_pnut(post.user.username)
matrix_api = ClientAPI(config['MATRIX_AS_ID'],
base_url=config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'],
as_user_id=matrix_id.lower())
try:
profile = await matrix_api.get_profile(matrix_id.lower())
except MNotFound:
new_matrix_user(post.user.username)
profile = {'displayname': None}
if profile['displayname'] != matrix_display_from_pnut(post.user):
await set_matrix_display(post.user)
logger.debug('-set_display-')
avatar = PnutAvatars.select().where(PnutAvatars.pnut_user ==
post.user.username).first()
if (avatar is None or
avatar.avatar_url != post.user.content.avatar_image.url):
await set_matrix_avatar(post.user)
logger.debug('-set_avatar-')
room_id = config['MATRIX_GLOBAL_ROOM']
await matrix_api.join_room(room_id)
postlink = f"https://posts.pnut.io/{post.id}"
plaintext = f"{post.content.text}\n{postlink}"
htmltext = (f"{post.content.html}"
f" &nbsp;<a href='{postlink}'>[🔗]</a>")
eventtext = TextMessageEventContent(msgtype=MessageType.TEXT,
format=Format.HTML,
body=plaintext,
formatted_body=htmltext)
rid = await matrix_api.send_message(room_id, eventtext)
bridge_event = Events(
event_id=rid,
room_id=room_id,
pnut_id=post.id,
pnut_channel=0
)
bridge_event.save()
if 'raw' in post:
logger.debug('-handle media uploads-')
await new_media(room_id, post)
async def new_media(room_id, msg):
matrix_id = matrix_id_from_pnut(msg.user.username)
matrix_api = ClientAPI(config['MATRIX_AS_ID'],
base_url=config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'],
as_user_id=matrix_id.lower())
if 'io.pnut.core.oembed' in msg.raw:
for oembed in msg.raw['io.pnut.core.oembed']:
info = {}
if oembed.type == 'photo':
msgtype = 'm.image'
dl_url = oembed.url
info['h'] = oembed.height
info['w'] = oembed.width
elif oembed.type == 'audio':
msgtype = 'm.audio'
dl_url = oembed.url
elif oembed.type == 'video':
msgtype = 'm.video'
dl_url = oembed.embeddable_url
info['h'] = oembed.height
info['w'] = oembed.width
elif oembed.type == 'html5video':
msgtype = 'm.video'
dl_url = oembed.url
info['h'] = oembed.height
info['w'] = oembed.width
elif oembed.type == 'rich':
logger.debug("* recieved rich attachment")
continue
else:
logger.debug("* recieved unknown attachment")
continue
dl = requests.get(dl_url, stream=True)
dl.raise_for_status()
with magic.Magic(flags=magic.MAGIC_MIME_TYPE) as m:
info['mimetype'] = m.id_buffer(dl.content)
info['size'] = len(dl.content)
ul = await matrix_api.upload_media(dl.content,
mime_type=info['mimetype'])
if 'title' in oembed:
title = oembed.title
else:
title = ""
rid = await matrix_api.send_file(room_id, ul,
file_name=title,
file_type=msgtype,
info=info)
if 'channel_id' in msg:
channel_id = msg.channel_id
else:
channel_id = 0
bridge_event = Events(
event_id=rid,
room_id=room_id,
pnut_id=msg.id,
pnut_channel=channel_id
)
bridge_event.save()
async def delete_message(msg):
matrix_id = matrix_id_from_pnut(msg.user.username)
matrix_api = ClientAPI(config['MATRIX_AS_ID'],
base_url=config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'],
as_user_id=matrix_id.lower())
events = Events.select().where((Events.pnut_id == msg.id) &
(Events.deleted == False))
for event in events:
await matrix_api.redact(event.room_id, event.event_id)
event.deleted = True
event.save()
def matrix_id_from_pnut(username):
matrix_id = (f"@{config['MATRIX_PNUT_PREFIX']}{username}"
f":{config['MATRIX_DOMAIN']}")
return matrix_id
def matrix_display_from_pnut(user):
if user.type == 'bot':
icon = ' 🤖'
elif user.type == 'feed':
icon = ' 📰'
else:
icon = ''
if 'name' in user:
display = user.name + " (@" + user.username + ")" + icon
else:
display = "@" + user.username + icon
return display
async def set_matrix_display(user):
matrix_id = matrix_id_from_pnut(user.username)
matrix_api = ClientAPI(config['MATRIX_AS_ID'],
base_url=config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'],
as_user_id=matrix_id.lower())
await matrix_api.set_displayname(matrix_display_from_pnut(user))
async def set_matrix_avatar(user):
matrix_id = matrix_id_from_pnut(user.username)
matrix_api = ClientAPI(config['MATRIX_AS_ID'],
base_url=config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'],
as_user_id=matrix_id.lower())
dl = requests.get(user.content.avatar_image.url, stream=True)
dl.raise_for_status()
with magic.Magic(flags=magic.MAGIC_MIME_TYPE) as m:
mtype = m.id_buffer(dl.content)
ul = await matrix_api.upload_media(dl.content, mtype)
logger.debug(ul)
try:
await matrix_api.set_avatar_url(ul)
avatar = PnutAvatars.select().where(PnutAvatars.pnut_user ==
user.username).first()
if avatar is None:
avatar = PnutAvatars(pnut_user=user.username,
avatar_url=user.content.avatar_image.url)
else:
avatar.avatar_url = user.content.avatar_image.url
avatar.save()
except Exception:
logger.exception('failed to set user avatar')
def new_matrix_user(username):
endpoint = "/_matrix/client/v3/register"
url = config['MATRIX_HOST'] + endpoint
params = {'kind': 'user'}
data = {
'type': 'm.login.application_service',
'username': config['MATRIX_PNUT_PREFIX'] + username
}
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer " + config['MATRIX_AS_TOKEN']
}
logger.debug(data)
r = requests.post(url, headers=headers, json=data, params=params)
if r.status_code == 200:
return
else:
errmsg = f"- unable to register {username} -"
logger.warning(errmsg)
logger.debug(r.status_code)
logger.debug(r.text)
return
def new_room(pnut_user, invitees, chan):
dr = None
url = matrix_url + '/createRoom'
params = {
"access_token": config['MATRIX_AS_TOKEN'],
"user_id": pnut_user.lower()
}
content = {
"visibility": "private",
"is_direct": True,
"invite": invitees
}
headers = {"Content-Type": "application/json"}
r = requests.post(url, headers=headers, params=params,
data=json.dumps(content))
response = r.json()
logger.debug(r.status_code)
logger.debug(r.text)
logger.debug(response)
for bridge_user in invitees:
direct_room = PnutChannels(pnut_chan=chan, room_id=response['room_id'],
is_direct=True, direct_pnut_user=pnut_user,
direct_mtrx_user=bridge_user)
direct_room.save()
return direct_room
async def on_message(message):
logger.debug("on_message: " + message)
msg = json.loads(message)
if 'meta' in msg:
meta = msg['meta']
else:
return
if 'data' in msg:
data = msg['data']
else:
return
if 'type' in meta:
if meta['type'] == "message":
channel_types = ['io.pnut.core.chat', 'io.pnut.core.pm']
if meta['channel_type'] not in channel_types:
return
for item in data:
pnut_msg = pnutpy.models.Message.from_response_data(item)
if 'is_deleted' in meta and meta['is_deleted']:
logger.debug("-message: delete-")
delete_message(pnut_msg)
else:
await new_pnut_message(pnut_msg, meta)
elif meta['type'] == "post":
for item in data:
pnut_post = pnutpy.models.Post.from_response_data(item)
await new_pnut_post(pnut_post, meta)
async def asmain():
if config['MATRIX_ADMIN_ROOM']:
logger.debug("- sould join admin room -")
matrix_api_as = ClientAPI(config['MATRIX_AS_ID'],
base_url=config['MATRIX_HOST'],
token=config['MATRIX_AS_TOKEN'])
await matrix_api_as.join_room(config['MATRIX_ADMIN_ROOM'])
ws_url = 'wss://stream.pnut.io/v1/app?access_token='
ws_url += config['PNUT_APPTOKEN'] + '&key=' + config['PNUT_APPKEY']
ws_url += '&include_raw=1'
async for websocket in connect(uri=ws_url):
try:
async for message in websocket:
await on_message(message)
await websocket.close()
except ConnectionClosed:
continue
def main():
global config
global matrix_url
a_parser = argparse.ArgumentParser()
a_parser.add_argument('-c', '--config', dest='configyaml',
default="config.yaml", help="configuration file")
args = a_parser.parse_args()
with open(args.configyaml, "rb") as config_file:
config = yaml.load(config_file, Loader=yaml.SafeLoader)
db.init(config['SERVICE_DB'])
db_create_tables()
logging.config.dictConfig(config['logging'])
redact_filter = MLogFilter()
logging.getLogger("werkzeug").addFilter(redact_filter)
logging.getLogger("urllib3.connectionpool").addFilter(redact_filter)
matrix_url = config['MATRIX_HOST'] + '/_matrix/client/v3'
asyncio.run(asmain())
logger.info('!! shutdown initiated !!')
if __name__ == '__main__':
main()