Commit d18f35b6 authored by Morgan McMillian's avatar Morgan McMillian

Merge branch '1.0.0' into 'master'

1.0.0

See merge request !1
parents fa376e4c 03587588
......@@ -5,9 +5,25 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
## [Unreleased]
## [1.0.0] - 2019-01-03
### Fixed
- database initialization
### Added
- Support for pnut app streams
- Sync avatars from pnut to matrix
- Administrator control room functions
- Example configuration file
### Changed
- Display names for matrix users not registered with pnut
- Simplified storage models
## 0.0.1 - 2018-08-23
### Added
- This CHANGELOG file because I can't believe for the last year I wasn't
keeping track of releases for this project. :p
[Unreleased]: https://gitlab.dreamfall.space/thrrgilag/pnut-matrix/compare/v0.0.1...HEAD
[Unreleased]: https://gitlab.dreamfall.space/thrrgilag/pnut-matrix/compare/1.0.0...HEAD
[1.0.0]: https://gitlab.dreamfall.space/thrrgilag/pnut-matrix/tags/1.0.0
[0.0.1]: https://gitlab.dreamfall.space/thrrgilag/pnut-matrix/tags/v0.0.1
This diff is collapsed.
import requests
import logging
import yaml
import sys
import time
import shlex
import json
import re
import pnutpy
from matrix_client.client import MatrixClient
from matrix_client.api import MatrixHttpApi
from matrix_client.api import MatrixError, MatrixRequestError
from models import *
class MonkeyBot:
txId = 0
def __init__(self):
with open("config.yaml", "rb") as config_file:
self.config = yaml.load(config_file)
self.api = MatrixHttpApi(self.config['MATRIX_HOST'], self.config['MATRIX_AS_TOKEN'])
self.pnut_token = self.config['MATRIX_PNUT_TOKEN']
pnutpy.api.add_authorization_token(self.pnut_token)
def on_invite(self, event):
logging.debug("<__on_invite__>")
logging.debug(event)
room = self.api.join_room(event['room_id'])
def on_message(self, event):
logging.debug("<__on_message__>")
logging.debug(event)
if event['type'] == 'm.room.message':
if event['content']['msgtype'] == 'm.text':
argv = shlex.split(event['content']['body'])
cmd = argv[0]
args = argv[1:]
self._parse_cmd(event, cmd, args)
def _parse_cmd(self, event, cmd, args):
logging.debug("<__parse_cmd__>")
logging.debug("<cmd> " + cmd)
logging.debug(args)
if cmd.lower() == 'help':
self.api.send_notice(event['room_id'], self._help())
elif cmd.lower() == 'set_access_token':
token = args[0]
pnutpy.api.add_authorization_token(token)
try:
response, meta = pnutpy.api.get_user('me')
user = MatrixUser(matrix_id=event['user_id'], room_id=event['room_id'],
pnut_id=response['username'], pnut_token=token)
db.session.add(user)
db.session.commit()
reply = "Token verified, you are now linked as " + response['username']
except pnut.api.PnutAuthAPIException as e:
reply = "Your account is not authorized."
except Exception as e:
reply = "Something went wrong...\n"
reply += str(e)
logging.exception('::set_access_token::')
self.api.send_notice(event['room_id'], reply)
pnutpy.api.add_authorization_token(self.pnut_token)
elif cmd.lower() == 'drop_access_token':
user = MatrixUser.query.filter_by(matrix_id=event['user_id']).first()
db.session.delete(user)
db.session.commit()
reply = "Your token has been removed."
self.api.send_notice(event['room_id'], reply)
else:
self.api.send_notice(event['room_id'], self._help())
def _help(self):
reply = "Visit the following URL to authorize pnut-matrix with your account on pnut.io.\n\n"
reply += "https://pnut.io/oauth/authenticate?client_id=6SeCRCpCZkmZOKFLFGWbcdAeq2fX1M5t&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=write_post,presence,messages&response_type=token\n\n"
reply += "The following commands are available.\n\n"
reply += "set_access_token <token>\n"
reply += " - Set your access token for matrix -> pnut.io account puppeting\n\n"
reply += "drop_access_token\n"
reply += " - Drop your access token to remove puppeting\n\n"
return reply
SERVICE_DB: 'sqlite:///store.db' # URL for the service database
LISTEN_PORT: 5000 # matrix app service port to listen on
MATRIX_HOST: 'https://localhost:8448' # URL of the matrix server
MATRIX_DOMAIN: '<DOMAIN_NAME>' # domain of the matrix server (right hand side of a matrix ID)
MATRIX_AS_ID: '<MATRIX_ID>' # matrix ID for the app service user
MATRIX_AS_TOKEN: '<AUTH_TOKEN>' # auth token for the app service user
MATRIX_HS_TOKEN: '<AUTH_TOKEN>' # auth token for the matrix server
MATRIX_PNUT_PREFIX: '<APP_SERVICE_PREFIX>' # prefix used for reserving matrix IDs and room aliases
MATRIX_ADMIN_ROOM: '<ROOM ID>' # Administrator control room ID
MATRIX_PNUT_USER: '<USERNAME>' # pnut.io username for the matrix bot
MATRIX_PNUT_TOKEN: '<AUTH_TOKEN>' # pnut.io auth token for the matrix bot
PNUTCLIENT_ID: '<CLIENT_ID>' # pnut.io app client ID
PNUT_APPTOKEN: '<APP TOKEN>' # pnut.io app token
PNUT_APPKEY: '<APPSTREAM KEY>' # pnut.io app stream key
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.ext.declarative import declarative_base
import yaml
with open("config.yaml", "rb") as config_file:
config = yaml.load(config_file)
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)
import yaml
from appservice import app
from models import *
from flask_script import Manager
from flask_migrate import Migrate, MigrateCommand
migrate = Migrate(app, db)
manager = Manager(app)
manager.add_command('db', MigrateCommand)
if __name__ == '__main__':
with open("config.yaml", "rb") as config_file:
config = yaml.load(config_file)
app.config['SQLALCHEMY_DATABASE_URI'] = config['SQLALCHEMY_DATABASE_URI']
manager.run()
Generic single-database configuration.
\ No newline at end of file
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
import logging
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option('sqlalchemy.url',
current_app.config.get('SQLALCHEMY_DATABASE_URI'))
target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.readthedocs.org/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
engine = engine_from_config(config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)
connection = engine.connect()
context.configure(connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args)
try:
with context.begin_transaction():
context.run_migrations()
finally:
connection.close()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}
"""empty message
Revision ID: 0ddf19141ead
Revises: d2033352cfdf
Create Date: 2017-03-04 13:42:49.178781
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0ddf19141ead'
down_revision = 'd2033352cfdf'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('matrix_msg_events',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('event_id', sa.Text(), nullable=True),
sa.Column('room_id', sa.Text(), nullable=True),
sa.Column('pnut_msgid', sa.Text(), nullable=True),
sa.Column('pnut_user', sa.Text(), nullable=True),
sa.Column('pnut_chan', sa.Text(), nullable=True),
sa.Column('deleted', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('matrix_msg_events')
# ### end Alembic commands ###
"""empty message
Revision ID: 744f11d26259
Revises: f878073e1b4a
Create Date: 2017-05-25 09:51:32.238059
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '744f11d26259'
down_revision = 'f878073e1b4a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('matrix_room2',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('room_id', sa.Text(), nullable=True),
sa.Column('pnut_chan', sa.Text(), nullable=True),
sa.Column('pnut_since', sa.Text(), nullable=True),
sa.Column('pnut_write', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('pnut_chan'),
sa.UniqueConstraint('room_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('matrix_room2')
# ### end Alembic commands ###
"""empty message
Revision ID: b6667aa4e705
Revises:
Create Date: 2017-03-04 11:32:13.728984
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b6667aa4e705'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('matrix_user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('matrix_id', sa.Text(), nullable=True),
sa.Column('room_id', sa.Text(), nullable=True),
sa.Column('pnut_id', sa.Text(), nullable=True),
sa.Column('pnut_token', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('matrix_id'),
sa.UniqueConstraint('pnut_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('matrix_user')
# ### end Alembic commands ###
"""empty message
Revision ID: d2033352cfdf
Revises: b6667aa4e705
Create Date: 2017-03-04 12:52:51.625451
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd2033352cfdf'
down_revision = 'b6667aa4e705'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('matrix_room',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('room_id', sa.Text(), nullable=True),
sa.Column('pnut_chan', sa.Text(), nullable=True),
sa.Column('pnut_since', sa.Text(), nullable=True),
sa.Column('pnut_write', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('room_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('matrix_room')
# ### end Alembic commands ###
"""empty message
Revision ID: f878073e1b4a
Revises: 0ddf19141ead
Create Date: 2017-05-03 23:11:07.925047
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f878073e1b4a'
down_revision = '0ddf19141ead'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('matrix_admin_rooms',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('matrix_id', sa.Text(), nullable=True),
sa.Column('room_id', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.drop_table('direct_msg_rooms')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('direct_msg_rooms',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('sender', sa.TEXT(), nullable=True),
sa.Column('room_id', sa.TEXT(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.drop_table('matrix_admin_rooms')
# ### end Alembic commands ###
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class MatrixUser(db.Model):
id = db.Column(db.Integer, primary_key=True)
matrix_id = db.Column(db.Text, unique=True)
room_id = db.Column(db.Text)
pnut_id = db.Column(db.Text, unique=True)
pnut_token = db.Column(db.Text)
def __init__(self, matrix_id, room_id, pnut_id, pnut_token):
self.matrix_id = matrix_id
self.room_id = room_id
self.pnut_id = pnut_id
self.pnut_token = pnut_token
def __repr__(self):
return '<MatrixUser %r>' % self.matrix_id
class MatrixRoom(db.Model):
id = db.Column(db.Integer, primary_key=True)
room_id = db.Column(db.Text, unique=True)
pnut_chan = db.Column(db.Text)
pnut_since = db.Column(db.Text)
pnut_write = db.Column(db.Boolean, default=True)
def __init__(self, room_id, pnut_chan, pnut_write=True):
self.room_id = room_id
self.pnut_chan = pnut_chan
self.pnut_write = pnut_write
def __repr__(self):
return '<MatrixRoom %r>' % self.room_id
class MatrixRoom2(db.Model):
id = db.Column(db.Integer, primary_key=True)
room_id = db.Column(db.Text, unique=True)
pnut_chan = db.Column(db.Text, unique=True)
pnut_since = db.Column(db.Text)
pnut_write = db.Column(db.Boolean, default=True)
def __init__(self, room_id, pnut_chan, pnut_write=True):
self.room_id = room_id
self.pnut_chan = pnut_chan
self.pnut_write = pnut_write
def __repr__(self):
return '<MatrixRoom %r>' % self.room_id
class MatrixMsgEvents(db.Model):
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Text)
room_id = db.Column(db.Text)
pnut_msgid = db.Column(db.Text)
pnut_user = db.Column(db.Text)
pnut_chan = db.Column(db.Text)
deleted = db.Column(db.Boolean, default=False)
def __init__(self, event_id, room_id, pnut_msgid, pnut_user, pnut_chan):
self.event_id = event_id
self.room_id = room_id
self.pnut_msgid = pnut_msgid
self.pnut_user = pnut_user
self.pnut_chan = pnut_chan
class MatrixAdminRooms(db.Model):
id = db.Column(db.Integer, primary_key=True)
matrix_id = db.Column(db.Text)
room_id = db.Column(db.Text)
def __init__(self, matrix_id, room_id):
self.matrix_id = matrix_id
self.room_id = room_id
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 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))
import yaml
import logging
import threading
import signal
import time
import datetime
import requests
import json
import pnutpy
from appservice import app
from models import *
_shutdown = threading.Event()
class ChannelMonitor(threading.Thread):
def __init__(self):
threading.Thread.__init__(self)
pnutpy.api.add_authorization_token(app.config['MATRIX_PNUT_TOKEN'])
self.matrix_api_url = app.config['MATRIX_HOST'] + '/_matrix/client/r0'
self.matrix_api_token = app.config['MATRIX_AS_TOKEN']
self.txId = 0
def generate_matrix_id(self, username):
m_username = app.config['MATRIX_PNUT_PREFIX'] + username
m_userid = "@" + app.config['MATRIX_PNUT_PREFIX'] + username + ":" + app.config['MATRIX_DOMAIN']
m_display = username + " (pnut)"
return {'username': m_username, 'user_id': m_userid, 'displayname': m_display}
def is_registered(self, user_id):
url = self.matrix_api_url + '/profile/' + user_id
r = requests.get(url)
if r.status_code == 200:
rdata = r.json()
if 'displayname' in rdata:
return rdata['displayname']
else:
return False
else:
return False
def create_matrix_user(self, username):
url = self.matrix_api_url + '/register'
params = {
'access_token': self.matrix_api_token
}
data = {
'type': 'm.login.application_service',
'user': username
}
r = requests.post(url, params=params, data=json.dumps(data))
if r.status_code == 200:
logging.info('REGISTERED USER: ' + username)
logging.info(r.text)
def set_displayname(self, muser):
url = self.matrix_api_url + '/profile/' + muser['user_id'] + '/displayname'
params = {
'access_token': self.matrix_api_token,
'user_id': muser['user_id']
}
data = {
'displayname': muser['displayname']
}
headers = {'Content-Type': 'application/json'}
r = requests.put(url, params=params, data=json.dumps(data), headers=headers)
def join_room(self, user_id, roomid):
url = self.matrix_api_url + '/join/' + roomid
params = {
'access_token': self.matrix_api_token,
'user_id': user_id
}
r = requests.post(url, params=params)
if r.status_code == 403:
self.invite_room(user_id, roomid)
requests.post(url, params=params)
def invite_room(self, user_id, roomid):
url = self.matrix_api_url + '/rooms/' + roomid + "/invite"
headers = {"Content-Type": "application/json"}
params = {
'access_token': self.matrix_api_token,
}
body = {
'user_id': user_id
}
r = requests.post(url, headers=headers, params=params, data=json.dumps(body))
def send_message(self, roomid, msg):