Merge pull request #11 from maubot/management

Add management API
This commit is contained in:
Tulir Asokan 2018-11-02 01:28:30 +02:00 committed by GitHub
commit a7a4c07411
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 12098 additions and 231 deletions

View File

@ -1,15 +1,16 @@
root = true root = true
[*] [*]
indent_style = tab indent_style = space
indent_size = 4 indent_size = 4
end_of_line = lf end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
[*.py]
max_line_length = 99 max_line_length = 99
[*.{yaml,yml,py}] [*.json]
indent_style = space indent_size = 2
[spec.yaml]
indent_size = 2

3
.gitignore vendored
View File

@ -8,8 +8,9 @@ pip-selfcheck.json
__pycache__ __pycache__
*.db *.db
*.yaml /*.yaml
!example-config.yaml !example-config.yaml
logs/ logs/
plugins/ plugins/
trash/

View File

@ -6,6 +6,8 @@ RUN apk add --no-cache \
py3-aiohttp \ py3-aiohttp \
py3-sqlalchemy \ py3-sqlalchemy \
py3-attrs \ py3-attrs \
py3-bcrypt \
py3-cffi \
ca-certificates \ ca-certificates \
&& pip3 install -r requirements.txt && pip3 install -r requirements.txt

View File

@ -1,6 +1,8 @@
# maubot # maubot
A plugin-based [Matrix](https://matrix.org) bot system written in Python. A plugin-based [Matrix](https://matrix.org) bot system written in Python.
Management API spec: [maubot.xyz/spec](https://maubot.xyz/spec)
## Discussion ## Discussion
Matrix room: [#maubot:maunium.net](https://matrix.to/#/#maubot:maunium.net) Matrix room: [#maubot:maunium.net](https://matrix.to/#/#maubot:maunium.net)
@ -13,12 +15,12 @@ Matrix room: [#maubot:maunium.net](https://matrix.to/#/#maubot:maunium.net)
* [dice](https://github.com/maubot/dice) - A combined dice rolling and calculator bot. * [dice](https://github.com/maubot/dice) - A combined dice rolling and calculator bot.
* [karma](https://github.com/maubot/karma) - A user karma tracker bot. * [karma](https://github.com/maubot/karma) - A user karma tracker bot.
* [xkcd](https://github.com/maubot/xkcd) - A bot to view xkcd comics. * [xkcd](https://github.com/maubot/xkcd) - A bot to view xkcd comics.
* [echo](https://github.com/maubot/echo) - A bot that echoes pings and other stuff.
### Upcoming ### Upcoming
* rss - A bot that posts new RSS entries to rooms. * rss - A bot that posts new RSS entries to rooms.
* dictionary - A bot to get the dictionary definitions of words. * dictionary - A bot to get the dictionary definitions of words.
* poll - A simple poll bot. * poll - A simple poll bot.
* echo - A very simple echo bot.
* reminder - A bot to ping you about something after a certain amount of time. * reminder - A bot to ping you about something after a certain amount of time.
* github - A GitHub client and webhook receiver bot. * github - A GitHub client and webhook receiver bot.
* wolfram - A Wolfram Alpha bot * wolfram - A Wolfram Alpha bot

View File

@ -5,19 +5,20 @@ cd /opt/maubot
# Replace database path in config. # Replace database path in config.
sed -i "s#sqlite:///maubot.db#sqlite:////data/maubot.db#" /data/config.yaml sed -i "s#sqlite:///maubot.db#sqlite:////data/maubot.db#" /data/config.yaml
sed -i "s#- ./plugins#- /data/plugins#" /data/config.yaml sed -i "s#- ./plugins#- /data/plugins#" /data/config.yaml
sed -i "s#upload: ./plugins#upload: /data/plugins#" /data/config.yaml
sed -i "s#trash: ./trash#trash: /data/trash#" /data/config.yaml
sed -i "s#db: ./plugins#trash: /data/dbs#" /data/config.yaml
sed -i "s#./logs/maubot.log#/var/log/maubot/maubot.log#" /data/config.yaml sed -i "s#./logs/maubot.log#/var/log/maubot/maubot.log#" /data/config.yaml
mkdir -p /var/log/maubot mkdir -p /var/log/maubot /data/plugins /data/trash /data/dbs
# Check that database is in the right state # Check that database is in the right state
alembic -x config=/data/config.yaml upgrade head alembic -x config=/data/config.yaml upgrade head
if [ ! -f /data/config.yaml ]; then if [ ! -f /data/config.yaml ]; then
cp example-config.yaml /data/config.yaml cp example-config.yaml /data/config.yaml
echo "Didn't find a config file." echo "Config file not found. Example config copied to /data/config.yaml"
echo "Copied default config file to /data/config.yaml" echo "Please modify the config file to your liking and restart the container."
echo "Modify that config file to your liking."
echo "Start the container again after that to generate the registration file."
exit exit
fi fi

View File

@ -5,27 +5,35 @@
# Postgres: postgres://username:password@hostname/dbname # Postgres: postgres://username:password@hostname/dbname
database: sqlite:///maubot.db database: sqlite:///maubot.db
# The directory where plugin databases should be stored.
plugin_db_directory: ./plugins
# If multiple directories have a plugin with the same name, the first directory is used.
plugin_directories: plugin_directories:
- ./plugins # The directory where uploaded new plugins should be stored.
upload: ./plugins
# The directories from which plugins should be loaded.
# Duplicate plugin IDs will be moved to the trash.
load:
- ./plugins
# The directory where old plugin versions and conflicting plugins should be moved.
# Set to "delete" to delete files immediately.
trash: ./trash
# The directory where plugin databases should be stored.
db: ./plugins
server: server:
# The IP and port to listen to. # The IP and port to listen to.
hostname: 0.0.0.0 hostname: 0.0.0.0
port: 29316 port: 29316
# The base management API path. # The base management API path.
base_path: /_matrix/maubot base_path: /_matrix/maubot/v1
# The base appservice API path. Use / for legacy appservice API and /_matrix/app/v1 for v1. # The base appservice API path. Use / for legacy appservice API and /_matrix/app/v1 for v1.
appservice_base_path: /_matrix/app/v1 appservice_base_path: /_matrix/app/v1
# The shared secret to authorize users of the API. # The shared secret to sign API access tokens.
# Set to "generate" to generate and save a new token at startup. # Set to "generate" to generate and save a new token at startup.
shared_secret: generate unshared_secret: generate
# List of administrator users. Plaintext passwords will be bcrypted on startup. Set empty password
# to prevent normal login. Root is a special user that can't have a password and will always exist.
admins: admins:
- "@admin:example.com" root: ""
# Python logging configuration. # Python logging configuration.
# #

View File

@ -13,21 +13,20 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import orm
import sqlalchemy as sql
import logging.config import logging.config
import argparse import argparse
import asyncio import asyncio
import signal
import copy import copy
import sys import sys
import signal
from .config import Config from .config import Config
from .db import Base, init as init_db from .db import init as init_db
from .server import MaubotServer from .server import MaubotServer
from .client import Client, init as init_client from .client import Client, init as init_client_class
from .loader import ZippedPluginLoader from .loader.zip import init as init_zip_loader
from .plugin import PluginInstance, init as init_plugin_instance_class from .instance import init as init_plugin_instance_class
from .management.api import init as init_management
from .__meta__ import __version__ from .__meta__ import __version__
parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.", parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.",
@ -45,22 +44,16 @@ config.update()
logging.config.dictConfig(copy.deepcopy(config["logging"])) logging.config.dictConfig(copy.deepcopy(config["logging"]))
log = logging.getLogger("maubot.init") log = logging.getLogger("maubot.init")
log.debug(f"Initializing maubot {__version__}") log.info(f"Initializing maubot {__version__}")
db_engine: sql.engine.Engine = sql.create_engine(config["database"])
db_factory = orm.sessionmaker(bind=db_engine)
db_session = orm.scoping.scoped_session(db_factory)
Base.metadata.bind = db_engine
Base.metadata.create_all()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
init_db(db_session) init_zip_loader(config)
init_client(loop) db_session = init_db(config)
init_plugin_instance_class(config) clients = init_client_class(db_session, loop)
server = MaubotServer(config, loop) plugins = init_plugin_instance_class(db_session, config, loop)
ZippedPluginLoader.load_all(*config["plugin_directories"]) management_api = init_management(config, loop)
plugins = PluginInstance.all() server = MaubotServer(config, management_api, loop)
for plugin in plugins: for plugin in plugins:
plugin.load() plugin.load()
@ -68,28 +61,34 @@ for plugin in plugins:
signal.signal(signal.SIGINT, signal.default_int_handler) signal.signal(signal.SIGINT, signal.default_int_handler)
signal.signal(signal.SIGTERM, signal.default_int_handler) signal.signal(signal.SIGTERM, signal.default_int_handler)
stop = False
async def periodic_commit(): async def periodic_commit():
while not stop: while True:
await asyncio.sleep(60) await asyncio.sleep(60)
db_session.commit() db_session.commit()
periodic_commit_task: asyncio.Future = None
try: try:
loop.run_until_complete(asyncio.gather( log.info("Starting server")
server.start(), loop.run_until_complete(server.start())
*[plugin.start() for plugin in plugins])) log.info("Starting clients and plugins")
log.debug("Startup actions complete, running forever") loop.run_until_complete(asyncio.gather(*[client.start() for client in clients], loop=loop))
loop.run_until_complete(periodic_commit()) log.info("Startup actions complete, running forever")
periodic_commit_task = asyncio.ensure_future(periodic_commit(), loop=loop)
loop.run_forever() loop.run_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
log.debug("Interrupt received, stopping HTTP clients/servers and saving database") log.info("Interrupt received, stopping HTTP clients/servers and saving database")
stop = True if periodic_commit_task is not None:
for client in Client.cache.values(): periodic_commit_task.cancel()
client.stop() log.debug("Stopping clients")
loop.run_until_complete(asyncio.gather(*[client.stop() for client in Client.cache.values()],
loop=loop))
db_session.commit() db_session.commit()
log.debug("Stopping server")
loop.run_until_complete(server.stop()) loop.run_until_complete(server.stop())
log.debug("Closing event loop")
loop.close()
log.debug("Everything stopped, shutting down") log.debug("Everything stopped, shutting down")
sys.exit(0) sys.exit(0)

View File

@ -1 +1 @@
__version__ = "0.1.0.dev5" __version__ = "0.1.0.dev6+management"

View File

@ -13,62 +13,142 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional from typing import Dict, List, Optional, Set, TYPE_CHECKING
from aiohttp import ClientSession
import asyncio import asyncio
import logging import logging
from sqlalchemy.orm import Session
from aiohttp import ClientSession
from mautrix.errors import MatrixInvalidToken, MatrixRequestError
from mautrix.types import (UserID, SyncToken, FilterID, ContentURI, StrippedStateEvent, Membership, from mautrix.types import (UserID, SyncToken, FilterID, ContentURI, StrippedStateEvent, Membership,
EventType, Filter, RoomFilter, RoomEventFilter) EventType, Filter, RoomFilter, RoomEventFilter)
from .db import DBClient from .db import DBClient
from .matrix import MaubotMatrixClient from .matrix import MaubotMatrixClient
if TYPE_CHECKING:
from .instance import PluginInstance
log = logging.getLogger("maubot.client") log = logging.getLogger("maubot.client")
class Client: class Client:
loop: asyncio.AbstractEventLoop db: Session = None
log: logging.Logger = None
loop: asyncio.AbstractEventLoop = None
cache: Dict[UserID, 'Client'] = {} cache: Dict[UserID, 'Client'] = {}
http_client: ClientSession = None http_client: ClientSession = None
references: Set['PluginInstance']
db_instance: DBClient db_instance: DBClient
client: MaubotMatrixClient client: MaubotMatrixClient
started: bool
def __init__(self, db_instance: DBClient) -> None: def __init__(self, db_instance: DBClient) -> None:
self.db_instance = db_instance self.db_instance = db_instance
self.cache[self.id] = self self.cache[self.id] = self
self.log = log.getChild(self.id) self.log = log.getChild(self.id)
self.references = set()
self.started = False
self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver, self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver,
token=self.access_token, client_session=self.http_client, token=self.access_token, client_session=self.http_client,
log=self.log, loop=self.loop, store=self.db_instance) log=self.log, loop=self.loop, store=self.db_instance)
if self.autojoin: if self.autojoin:
self.client.add_event_handler(self._handle_invite, EventType.ROOM_MEMBER) self.client.add_event_handler(self._handle_invite, EventType.ROOM_MEMBER)
def start(self) -> None: async def start(self, try_n: Optional[int] = 0) -> None:
asyncio.ensure_future(self._start(), loop=self.loop)
async def _start(self) -> None:
try: try:
if not self.filter_id: if try_n > 0:
self.filter_id = await self.client.create_filter(Filter( await asyncio.sleep(try_n * 10)
room=RoomFilter( await self._start(try_n)
timeline=RoomEventFilter(
limit=50,
),
),
))
if self.displayname != "disable":
await self.client.set_displayname(self.displayname)
if self.avatar_url != "disable":
await self.client.set_avatar_url(self.avatar_url)
await self.client.start(self.filter_id)
except Exception: except Exception:
self.log.exception("starting raised exception") self.log.exception("Failed to start")
def stop(self) -> None: async def _start(self, try_n: Optional[int] = 0) -> None:
if not self.enabled:
self.log.debug("Not starting disabled client")
return
elif self.started:
self.log.warning("Ignoring start() call to started client")
return
try:
user_id = await self.client.whoami()
except MatrixInvalidToken as e:
self.log.error(f"Invalid token: {e}. Disabling client")
self.db_instance.enabled = False
return
except MatrixRequestError:
if try_n >= 5:
self.log.exception("Failed to get /account/whoami, disabling client")
self.db_instance.enabled = False
else:
self.log.exception(f"Failed to get /account/whoami, "
f"retrying in {(try_n + 1) * 10}s")
_ = asyncio.ensure_future(self.start(try_n + 1), loop=self.loop)
return
if user_id != self.id:
self.log.error(f"User ID mismatch: expected {self.id}, but got {user_id}")
self.db_instance.enabled = False
return
if not self.filter_id:
self.db_instance.filter_id = await self.client.create_filter(Filter(
room=RoomFilter(
timeline=RoomEventFilter(
limit=50,
),
),
))
if self.displayname != "disable":
await self.client.set_displayname(self.displayname)
if self.avatar_url != "disable":
await self.client.set_avatar_url(self.avatar_url)
self.start_sync()
self.started = True
self.log.info("Client started, starting plugin instances...")
await self.start_plugins()
async def start_plugins(self) -> None:
await asyncio.gather(*[plugin.start() for plugin in self.references], loop=self.loop)
async def stop_plugins(self) -> None:
await asyncio.gather(*[plugin.stop() for plugin in self.references if plugin.started],
loop=self.loop)
def start_sync(self) -> None:
if self.sync:
self.client.start(self.filter_id)
def stop_sync(self) -> None:
self.client.stop() self.client.stop()
async def stop(self) -> None:
if self.started:
self.started = False
await self.stop_plugins()
self.stop_sync()
def delete(self) -> None:
try:
del self.cache[self.id]
except KeyError:
pass
self.db.delete(self.db_instance)
def to_dict(self) -> dict:
return {
"id": self.id,
"homeserver": self.homeserver,
"access_token": self.access_token,
"enabled": self.enabled,
"started": self.started,
"sync": self.sync,
"autojoin": self.autojoin,
"displayname": self.displayname,
"avatar_url": self.avatar_url,
"instances": [instance.to_dict() for instance in self.references],
}
@classmethod @classmethod
def get(cls, user_id: UserID, db_instance: Optional[DBClient] = None) -> Optional['Client']: def get(cls, user_id: UserID, db_instance: Optional[DBClient] = None) -> Optional['Client']:
try: try:
@ -87,6 +167,44 @@ class Client:
if evt.state_key == self.id and evt.content.membership == Membership.INVITE: if evt.state_key == self.id and evt.content.membership == Membership.INVITE:
await self.client.join_room(evt.room_id) await self.client.join_room(evt.room_id)
async def update_started(self, started: bool) -> None:
if started is None or started == self.started:
return
if started:
await self.start()
else:
await self.stop()
async def update_displayname(self, displayname: str) -> None:
if not displayname or displayname == self.displayname:
return
self.db_instance.displayname = displayname
await self.client.set_displayname(self.displayname)
async def update_avatar_url(self, avatar_url: ContentURI) -> None:
if not avatar_url or avatar_url == self.avatar_url:
return
self.db_instance.avatar_url = avatar_url
await self.client.set_avatar_url(self.avatar_url)
async def update_access_details(self, access_token: str, homeserver: str) -> None:
if not access_token and not homeserver:
return
elif access_token == self.access_token and homeserver == self.homeserver:
return
new_client = MaubotMatrixClient(mxid=self.id, base_url=homeserver or self.homeserver,
token=access_token or self.access_token, loop=self.loop,
client_session=self.http_client, log=self.log)
mxid = await new_client.whoami()
if mxid != self.id:
raise ValueError("MXID mismatch")
new_client.store = self.db_instance
self.stop_sync()
self.client = new_client
self.db_instance.homeserver = homeserver
self.db_instance.access_token = access_token
self.start_sync()
# region Properties # region Properties
@property @property
@ -101,34 +219,36 @@ class Client:
def access_token(self) -> str: def access_token(self) -> str:
return self.db_instance.access_token return self.db_instance.access_token
@access_token.setter @property
def access_token(self, value: str) -> None: def enabled(self) -> bool:
self.client.api.token = value return self.db_instance.enabled
self.db_instance.access_token = value
@enabled.setter
def enabled(self, value: bool) -> None:
self.db_instance.enabled = value
@property @property
def next_batch(self) -> SyncToken: def next_batch(self) -> SyncToken:
return self.db_instance.next_batch return self.db_instance.next_batch
@next_batch.setter
def next_batch(self, value: SyncToken) -> None:
self.db_instance.next_batch = value
@property @property
def filter_id(self) -> FilterID: def filter_id(self) -> FilterID:
return self.db_instance.filter_id return self.db_instance.filter_id
@filter_id.setter
def filter_id(self, value: FilterID) -> None:
self.db_instance.filter_id = value
@property @property
def sync(self) -> bool: def sync(self) -> bool:
return self.db_instance.sync return self.db_instance.sync
@sync.setter @sync.setter
def sync(self, value: bool) -> None: def sync(self, value: bool) -> None:
if value == self.db_instance.sync:
return
self.db_instance.sync = value self.db_instance.sync = value
if self.started:
if value:
self.start_sync()
else:
self.stop_sync()
@property @property
def autojoin(self) -> bool: def autojoin(self) -> bool:
@ -148,23 +268,15 @@ class Client:
def displayname(self) -> str: def displayname(self) -> str:
return self.db_instance.displayname return self.db_instance.displayname
@displayname.setter
def displayname(self, value: str) -> None:
self.db_instance.displayname = value
@property @property
def avatar_url(self) -> ContentURI: def avatar_url(self) -> ContentURI:
return self.db_instance.avatar_url return self.db_instance.avatar_url
@avatar_url.setter
def avatar_url(self, value: ContentURI) -> None:
self.db_instance.avatar_url = value
# endregion # endregion
def init(loop: asyncio.AbstractEventLoop) -> None: def init(db: Session, loop: asyncio.AbstractEventLoop) -> List[Client]:
Client.db = db
Client.http_client = ClientSession(loop=loop) Client.http_client = ClientSession(loop=loop)
Client.loop = loop Client.loop = loop
for client in Client.all(): return Client.all()
client.start()

View File

@ -15,9 +15,13 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import random import random
import string import string
import bcrypt
import re
from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper
bcrypt_regex = re.compile(r"^\$2[ayb]\$.{56}$")
class Config(BaseFileConfig): class Config(BaseFileConfig):
@staticmethod @staticmethod
@ -27,16 +31,35 @@ class Config(BaseFileConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None: def do_update(self, helper: ConfigUpdateHelper) -> None:
base, copy, _ = helper base, copy, _ = helper
copy("database") copy("database")
copy("plugin_directories") copy("plugin_directories.upload")
copy("plugin_db_directory") copy("plugin_directories.load")
copy("plugin_directories.trash")
copy("plugin_directories.db")
copy("server.hostname") copy("server.hostname")
copy("server.port") copy("server.port")
copy("server.listen") copy("server.listen")
copy("server.base_path") copy("server.appservice_base_path")
shared_secret = self["server.shared_secret"] shared_secret = self["server.unshared_secret"]
if shared_secret is None or shared_secret == "generate": if shared_secret is None or shared_secret == "generate":
base["server.shared_secret"] = self._new_token() base["server.unshared_secret"] = self._new_token()
else: else:
base["server.shared_secret"] = shared_secret base["server.unshared_secret"] = shared_secret
copy("admins") copy("admins")
for username, password in base["admins"].items():
if password and not bcrypt_regex.match(password):
if password == "password":
password = self._new_token()
base["admins"][username] = bcrypt.hashpw(password.encode("utf-8"),
bcrypt.gensalt()).decode("utf-8")
copy("logging") copy("logging")
def is_admin(self, user: str) -> bool:
return user == "root" or user in self["admins"]
def check_password(self, user: str, passwd: str) -> bool:
if user == "root":
return False
passwd_hash = self["admins"].get(user, None)
if not passwd_hash:
return False
return bcrypt.checkpw(passwd.encode("utf-8"), passwd_hash.encode("utf-8"))

View File

@ -13,40 +13,20 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Type from typing import cast
from sqlalchemy import (Column, String, Boolean, ForeignKey, Text, TypeDecorator)
from sqlalchemy.orm import Query, scoped_session from sqlalchemy import Column, String, Boolean, ForeignKey, Text
from sqlalchemy.orm import Query, Session, sessionmaker, scoped_session
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
import json import sqlalchemy as sql
from mautrix.types import UserID, FilterID, SyncToken, ContentURI from mautrix.types import UserID, FilterID, SyncToken, ContentURI
from mautrix.client.api.types.util import Serializable
from .command_spec import CommandSpec from .config import Config
Base: declarative_base = declarative_base() Base: declarative_base = declarative_base()
def make_serializable_alchemy(serializable_type: Type[Serializable]):
class SerializableAlchemy(TypeDecorator):
impl = Text
@property
def python_type(self):
return serializable_type
def process_literal_param(self, value: Serializable, _) -> str:
return json.dumps(value.serialize()) if value is not None else None
def process_bind_param(self, value: Serializable, _) -> str:
return json.dumps(value.serialize()) if value is not None else None
def process_result_value(self, value: str, _) -> serializable_type:
return serializable_type.deserialize(json.loads(value)) if value is not None else None
return SerializableAlchemy
class DBPlugin(Base): class DBPlugin(Base):
query: Query query: Query
__tablename__ = "plugin" __tablename__ = "plugin"
@ -67,6 +47,7 @@ class DBClient(Base):
id: UserID = Column(String(255), primary_key=True) id: UserID = Column(String(255), primary_key=True)
homeserver: str = Column(String(255), nullable=False) homeserver: str = Column(String(255), nullable=False)
access_token: str = Column(String(255), nullable=False) access_token: str = Column(String(255), nullable=False)
enabled: bool = Column(Boolean, nullable=False, default=False)
next_batch: SyncToken = Column(String(255), nullable=False, default="") next_batch: SyncToken = Column(String(255), nullable=False, default="")
filter_id: FilterID = Column(String(255), nullable=False, default="") filter_id: FilterID = Column(String(255), nullable=False, default="")
@ -78,20 +59,14 @@ class DBClient(Base):
avatar_url: ContentURI = Column(String(255), nullable=False, default="") avatar_url: ContentURI = Column(String(255), nullable=False, default="")
class DBCommandSpec(Base): def init(config: Config) -> Session:
query: Query db_engine: sql.engine.Engine = sql.create_engine(config["database"])
__tablename__ = "command_spec" db_factory = sessionmaker(bind=db_engine)
db_session = scoped_session(db_factory)
Base.metadata.bind = db_engine
Base.metadata.create_all()
plugin: str = Column(String(255), DBPlugin.query = db_session.query_property()
ForeignKey("plugin.id", onupdate="CASCADE", ondelete="CASCADE"), DBClient.query = db_session.query_property()
primary_key=True)
client: UserID = Column(String(255),
ForeignKey("client.id", onupdate="CASCADE", ondelete="CASCADE"),
primary_key=True)
spec: CommandSpec = Column(make_serializable_alchemy(CommandSpec), nullable=False)
return cast(Session, db_session)
def init(session: scoped_session) -> None:
DBPlugin.query = session.query_property()
DBClient.query = session.query_property()
DBCommandSpec.query = session.query_property()

View File

@ -14,8 +14,10 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional from typing import Dict, List, Optional
from sqlalchemy.orm import Session
from ruamel.yaml.comments import CommentedMap from ruamel.yaml.comments import CommentedMap
from ruamel.yaml import YAML from ruamel.yaml import YAML
from asyncio import AbstractEventLoop
import logging import logging
import io import io
@ -35,7 +37,9 @@ yaml.indent(4)
class PluginInstance: class PluginInstance:
db: Session = None
mb_config: Config = None mb_config: Config = None
loop: AbstractEventLoop = None
cache: Dict[str, 'PluginInstance'] = {} cache: Dict[str, 'PluginInstance'] = {}
plugin_directories: List[str] = [] plugin_directories: List[str] = []
@ -44,61 +48,99 @@ class PluginInstance:
client: Client client: Client
plugin: Plugin plugin: Plugin
config: BaseProxyConfig config: BaseProxyConfig
base_cfg: RecursiveDict[CommentedMap]
started: bool
def __init__(self, db_instance: DBPlugin): def __init__(self, db_instance: DBPlugin):
self.db_instance = db_instance self.db_instance = db_instance
self.log = logging.getLogger(f"maubot.plugin.{self.id}") self.log = logging.getLogger(f"maubot.plugin.{self.id}")
self.config = None self.config = None
self.started = False
self.cache[self.id] = self self.cache[self.id] = self
def to_dict(self) -> dict:
return {
"id": self.id,
"type": self.type,
"enabled": self.enabled,
"started": self.started,
"primary_user": self.primary_user,
}
def load(self) -> None: def load(self) -> None:
try: try:
self.loader = PluginLoader.find(self.type) self.loader = PluginLoader.find(self.type)
except KeyError: except KeyError:
self.log.error(f"Failed to find loader for type {self.type}") self.log.error(f"Failed to find loader for type {self.type}")
self.enabled = False self.db_instance.enabled = False
return return
self.client = Client.get(self.primary_user) self.client = Client.get(self.primary_user)
if not self.client: if not self.client:
self.log.error(f"Failed to get client for user {self.primary_user}") self.log.error(f"Failed to get client for user {self.primary_user}")
self.enabled = False self.db_instance.enabled = False
return
self.log.debug("Plugin instance dependencies loaded") self.log.debug("Plugin instance dependencies loaded")
self.loader.references.add(self)
self.client.references.add(self)
def delete(self) -> None:
if self.loader is not None:
self.loader.references.remove(self)
if self.client is not None:
self.client.references.remove(self)
try:
del self.cache[self.id]
except KeyError:
pass
self.db.delete(self.db_instance)
# TODO delete plugin db
def load_config(self) -> CommentedMap: def load_config(self) -> CommentedMap:
return yaml.load(self.db_instance.config) return yaml.load(self.db_instance.config)
def load_config_base(self) -> Optional[RecursiveDict[CommentedMap]]:
try:
base = self.loader.read_file("base-config.yaml")
return RecursiveDict(yaml.load(base.decode("utf-8")), CommentedMap)
except (FileNotFoundError, KeyError):
return None
def save_config(self, data: RecursiveDict[CommentedMap]) -> None: def save_config(self, data: RecursiveDict[CommentedMap]) -> None:
buf = io.StringIO() buf = io.StringIO()
yaml.dump(data, buf) yaml.dump(data, buf)
self.db_instance.config = buf.getvalue() self.db_instance.config = buf.getvalue()
async def start(self) -> None: async def start(self) -> None:
if not self.enabled: if self.started:
self.log.warning(f"Plugin disabled, not starting.") self.log.warning("Ignoring start() call to already started plugin")
return return
cls = self.loader.load() elif not self.enabled:
self.log.warning("Plugin disabled, not starting.")
return
cls = await self.loader.load()
config_class = cls.get_config_class() config_class = cls.get_config_class()
if config_class: if config_class:
self.config = config_class(self.load_config, self.load_config_base, try:
self.save_config) base = await self.loader.read_file("base-config.yaml")
self.plugin = cls(self.client.client, self.id, self.log, self.config, self.base_cfg = RecursiveDict(yaml.load(base.decode("utf-8")), CommentedMap)
self.mb_config["plugin_db_directory"]) except (FileNotFoundError, KeyError):
self.loader.references |= {self} self.base_cfg = None
await self.plugin.start() self.config = config_class(self.load_config, lambda: self.base_cfg, self.save_config)
self.plugin = cls(self.client.client, self.loop, self.client.http_client, self.id,
self.log, self.config, self.mb_config["plugin_directories.db"])
try:
await self.plugin.start()
except Exception:
self.log.exception("Failed to start instance")
self.db_instance.enabled = False
return
self.started = True
self.log.info(f"Started instance of {self.loader.id} v{self.loader.version} " self.log.info(f"Started instance of {self.loader.id} v{self.loader.version} "
f"with user {self.client.id}") f"with user {self.client.id}")
async def stop(self) -> None: async def stop(self) -> None:
if not self.started:
self.log.warning("Ignoring stop() call to non-running plugin")
return
self.log.debug("Stopping plugin instance...") self.log.debug("Stopping plugin instance...")
self.loader.references -= {self} self.started = False
await self.plugin.stop() try:
await self.plugin.stop()
except Exception:
self.log.exception("Failed to stop instance")
self.plugin = None self.plugin = None
@classmethod @classmethod
@ -116,6 +158,39 @@ class PluginInstance:
def all(cls) -> List['PluginInstance']: def all(cls) -> List['PluginInstance']:
return [cls.get(plugin.id, plugin) for plugin in DBPlugin.query.all()] return [cls.get(plugin.id, plugin) for plugin in DBPlugin.query.all()]
def update_id(self, new_id: str) -> None:
if new_id is not None and new_id != self.id:
self.db_instance.id = new_id
def update_config(self, config: str) -> None:
if not config or self.db_instance.config == config:
return
self.db_instance.config = config
if self.started and self.plugin is not None:
self.plugin.on_external_config_update()
async def update_primary_user(self, primary_user: UserID) -> bool:
if not primary_user or primary_user == self.primary_user:
return True
client = Client.get(primary_user)
if not client:
return False
await self.stop()
self.db_instance.primary_user = client.id
self.client.references.remove(self)
self.client = client
await self.start()
self.log.debug(f"Primary user switched to {self.client.id}")
return True
async def update_started(self, started: bool) -> None:
if started is not None and started != self.started:
await (self.start() if started else self.stop())
def update_enabled(self, enabled: bool) -> None:
if enabled is not None and enabled != self.enabled:
self.db_instance.enabled = enabled
# region Properties # region Properties
@property @property
@ -130,28 +205,19 @@ class PluginInstance:
def type(self) -> str: def type(self) -> str:
return self.db_instance.type return self.db_instance.type
@type.setter
def type(self, value: str) -> None:
self.db_instance.type = value
@property @property
def enabled(self) -> bool: def enabled(self) -> bool:
return self.db_instance.enabled return self.db_instance.enabled
@enabled.setter
def enabled(self, value: bool) -> None:
self.db_instance.enabled = value
@property @property
def primary_user(self) -> UserID: def primary_user(self) -> UserID:
return self.db_instance.primary_user return self.db_instance.primary_user
@primary_user.setter
def primary_user(self, value: UserID) -> None:
self.db_instance.primary_user = value
# endregion # endregion
def init(config: Config): def init(db: Session, config: Config, loop: AbstractEventLoop) -> List[PluginInstance]:
PluginInstance.db = db
PluginInstance.mb_config = config PluginInstance.mb_config = config
PluginInstance.loop = loop
return PluginInstance.all()

View File

@ -1,2 +1,2 @@
from .abc import PluginLoader, PluginClass from .abc import PluginLoader, PluginClass, IDConflictError
from .zip import ZippedPluginLoader, MaubotZipImportError from .zip import ZippedPluginLoader, MaubotZipImportError

View File

@ -15,11 +15,12 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import TypeVar, Type, Dict, Set, TYPE_CHECKING from typing import TypeVar, Type, Dict, Set, TYPE_CHECKING
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio
from ..plugin_base import Plugin from ..plugin_base import Plugin
if TYPE_CHECKING: if TYPE_CHECKING:
from ..plugin import PluginInstance from ..instance import PluginInstance
PluginClass = TypeVar("PluginClass", bound=Plugin) PluginClass = TypeVar("PluginClass", bound=Plugin)
@ -42,23 +43,42 @@ class PluginLoader(ABC):
def find(cls, plugin_id: str) -> 'PluginLoader': def find(cls, plugin_id: str) -> 'PluginLoader':
return cls.id_cache[plugin_id] return cls.id_cache[plugin_id]
def to_dict(self) -> dict:
return {
"id": self.id,
"version": self.version,
"instances": [instance.to_dict() for instance in self.references],
}
@property @property
@abstractmethod @abstractmethod
def source(self) -> str: def source(self) -> str:
pass pass
@abstractmethod @abstractmethod
def read_file(self, path: str) -> bytes: async def read_file(self, path: str) -> bytes:
pass
async def stop_instances(self) -> None:
await asyncio.gather(*[instance.stop() for instance
in self.references if instance.started])
async def start_instances(self) -> None:
await asyncio.gather(*[instance.start() for instance
in self.references if instance.enabled])
@abstractmethod
async def load(self) -> Type[PluginClass]:
pass pass
@abstractmethod @abstractmethod
def load(self) -> Type[PluginClass]: async def reload(self) -> Type[PluginClass]:
pass pass
@abstractmethod @abstractmethod
def reload(self) -> Type[PluginClass]: async def unload(self) -> None:
pass pass
@abstractmethod @abstractmethod
def unload(self) -> None: async def delete(self) -> None:
pass pass

View File

@ -13,8 +13,9 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Type from typing import Dict, List, Type, Tuple, Optional
from zipfile import ZipFile, BadZipFile from zipfile import ZipFile, BadZipFile
from time import time
import configparser import configparser
import logging import logging
import sys import sys
@ -22,6 +23,7 @@ import os
from ..lib.zipimport import zipimporter, ZipImportError from ..lib.zipimport import zipimporter, ZipImportError
from ..plugin_base import Plugin from ..plugin_base import Plugin
from ..config import Config
from .abc import PluginLoader, PluginClass, IDConflictError from .abc import PluginLoader, PluginClass, IDConflictError
@ -29,9 +31,23 @@ class MaubotZipImportError(Exception):
pass pass
class MaubotZipMetaError(MaubotZipImportError):
pass
class MaubotZipPreLoadError(MaubotZipImportError):
pass
class MaubotZipLoadError(MaubotZipImportError):
pass
class ZippedPluginLoader(PluginLoader): class ZippedPluginLoader(PluginLoader):
path_cache: Dict[str, 'ZippedPluginLoader'] = {} path_cache: Dict[str, 'ZippedPluginLoader'] = {}
log = logging.getLogger("maubot.loader.zip") log: logging.Logger = logging.getLogger("maubot.loader.zip")
trash_path: str = "delete"
directories: List[str] = []
path: str path: str
id: str id: str
@ -60,8 +76,15 @@ class ZippedPluginLoader(PluginLoader):
self.id_cache[self.id] = self self.id_cache[self.id] = self
self.log.debug(f"Preloaded plugin {self.id} from {self.path}") self.log.debug(f"Preloaded plugin {self.id} from {self.path}")
def to_dict(self) -> dict:
return {
**super().to_dict(),
"path": self.path
}
@classmethod @classmethod
def get(cls, path: str) -> 'ZippedPluginLoader': def get(cls, path: str) -> 'ZippedPluginLoader':
path = os.path.abspath(path)
try: try:
return cls.path_cache[path] return cls.path_cache[path]
except KeyError: except KeyError:
@ -77,22 +100,30 @@ class ZippedPluginLoader(PluginLoader):
f"id='{self.id}' " f"id='{self.id}' "
f"loaded={self._loaded is not None}>") f"loaded={self._loaded is not None}>")
def read_file(self, path: str) -> bytes: async def read_file(self, path: str) -> bytes:
return self._file.read(path) return self._file.read(path)
def _load_meta(self) -> None: @staticmethod
def _open_meta(source) -> Tuple[ZipFile, configparser.ConfigParser]:
try: try:
self._file = ZipFile(self.path) file = ZipFile(source)
data = self._file.read("maubot.ini") data = file.read("maubot.ini")
except FileNotFoundError as e: except FileNotFoundError as e:
raise MaubotZipImportError("Maubot plugin not found") from e raise MaubotZipMetaError("Maubot plugin not found") from e
except BadZipFile as e: except BadZipFile as e:
raise MaubotZipImportError("File is not a maubot plugin") from e raise MaubotZipMetaError("File is not a maubot plugin") from e
except KeyError as e: except KeyError as e:
raise MaubotZipImportError("File does not contain a maubot plugin definition") from e raise MaubotZipMetaError("File does not contain a maubot plugin definition") from e
config = configparser.ConfigParser() config = configparser.ConfigParser()
try: try:
config.read_string(data.decode("utf-8"), source=f"{self.path}/maubot.ini") config.read_string(data.decode("utf-8"))
except (configparser.Error, KeyError, IndexError, ValueError) as e:
raise MaubotZipMetaError("Maubot plugin definition in file is invalid") from e
return file, config
@classmethod
def _read_meta(cls, config: configparser.ConfigParser) -> Tuple[str, str, List[str], str, str]:
try:
meta = config["maubot"] meta = config["maubot"]
meta_id = meta["ID"] meta_id = meta["ID"]
version = meta["Version"] version = meta["Version"]
@ -102,67 +133,96 @@ class ZippedPluginLoader(PluginLoader):
if "/" in main_class: if "/" in main_class:
main_module, main_class = main_class.split("/")[:2] main_module, main_class = main_class.split("/")[:2]
except (configparser.Error, KeyError, IndexError, ValueError) as e: except (configparser.Error, KeyError, IndexError, ValueError) as e:
raise MaubotZipImportError("Maubot plugin definition in file is invalid") from e raise MaubotZipMetaError("Maubot plugin definition in file is invalid") from e
if self.id and meta_id != self.id: return meta_id, version, modules, main_class, main_module
raise MaubotZipImportError("Maubot plugin ID changed during reload")
self.id, self.version, self.modules = meta_id, version, modules @classmethod
self.main_class, self.main_module = main_class, main_module def verify_meta(cls, source) -> Tuple[str, str]:
_, config = cls._open_meta(source)
meta = cls._read_meta(config)
return meta[0], meta[1]
def _load_meta(self) -> None:
file, config = self._open_meta(self.path)
meta = self._read_meta(config)
if self.id and meta[0] != self.id:
raise MaubotZipMetaError("Maubot plugin ID changed during reload")
self.id, self.version, self.modules, self.main_class, self.main_module = meta
self._file = file
def _get_importer(self, reset_cache: bool = False) -> zipimporter: def _get_importer(self, reset_cache: bool = False) -> zipimporter:
try: try:
if not self._importer: if not self._importer or self._importer.archive != self.path:
self._importer = zipimporter(self.path) self._importer = zipimporter(self.path)
if reset_cache: if reset_cache:
self._importer.reset_cache() self._importer.reset_cache()
return self._importer return self._importer
except ZipImportError as e: except ZipImportError as e:
raise MaubotZipImportError("File not found or not a maubot plugin") from e raise MaubotZipMetaError("File not found or not a maubot plugin") from e
def _run_preload_checks(self, importer: zipimporter) -> None: def _run_preload_checks(self, importer: zipimporter) -> None:
try: try:
code = importer.get_code(self.main_module.replace(".", "/")) code = importer.get_code(self.main_module.replace(".", "/"))
if self.main_class not in code.co_names: if self.main_class not in code.co_names:
raise MaubotZipImportError( raise MaubotZipPreLoadError(
f"Main class {self.main_class} not in {self.main_module}") f"Main class {self.main_class} not in {self.main_module}")
except ZipImportError as e: except ZipImportError as e:
raise MaubotZipImportError( raise MaubotZipPreLoadError(
f"Main module {self.main_module} not found in file") from e f"Main module {self.main_module} not found in file") from e
for module in self.modules: for module in self.modules:
try: try:
importer.find_module(module) importer.find_module(module)
except ZipImportError as e: except ZipImportError as e:
raise MaubotZipImportError(f"Module {module} not found in file") from e raise MaubotZipPreLoadError(f"Module {module} not found in file") from e
def load(self, reset_cache: bool = False) -> Type[PluginClass]: async def load(self, reset_cache: bool = False) -> Type[PluginClass]:
try:
return self._load(reset_cache)
except MaubotZipImportError:
self.log.exception(f"Failed to load {self.id} v{self.version}")
raise
def _load(self, reset_cache: bool = False) -> Type[PluginClass]:
if self._loaded is not None and not reset_cache: if self._loaded is not None and not reset_cache:
return self._loaded return self._loaded
importer = self._get_importer(reset_cache=reset_cache) importer = self._get_importer(reset_cache=reset_cache)
self._run_preload_checks(importer) self._run_preload_checks(importer)
if reset_cache: if reset_cache:
self.log.debug(f"Preloaded plugin {self.id} from {self.path}") self.log.debug(f"Re-preloaded plugin {self.id} from {self.path}")
for module in self.modules: for module in self.modules:
importer.load_module(module) try:
main_mod = sys.modules[self.main_module] importer.load_module(module)
plugin = getattr(main_mod, self.main_class) except ZipImportError as e:
raise MaubotZipLoadError(f"Module {module} not found in file")
try:
main_mod = sys.modules[self.main_module]
except KeyError as e:
raise MaubotZipLoadError(f"Main module {self.main_module} of plugin not found") from e
try:
plugin = getattr(main_mod, self.main_class)
except AttributeError as e:
raise MaubotZipLoadError(f"Main class {self.main_class} of plugin not found") from e
if not issubclass(plugin, Plugin): if not issubclass(plugin, Plugin):
raise MaubotZipImportError("Main class of plugin does not extend maubot.Plugin") raise MaubotZipLoadError("Main class of plugin does not extend maubot.Plugin")
self._loaded = plugin self._loaded = plugin
self.log.debug(f"Loaded and imported plugin {self.id} from {self.path}") self.log.debug(f"Loaded and imported plugin {self.id} from {self.path}")
return plugin return plugin
def reload(self) -> Type[PluginClass]: async def reload(self, new_path: Optional[str] = None) -> Type[PluginClass]:
self.unload() await self.unload()
return self.load(reset_cache=True) if new_path is not None:
self.path = new_path
return await self.load(reset_cache=True)
def unload(self) -> None: async def unload(self) -> None:
for name, mod in list(sys.modules.items()): for name, mod in list(sys.modules.items()):
if getattr(mod, "__file__", "").startswith(self.path): if getattr(mod, "__file__", "").startswith(self.path):
del sys.modules[name] del sys.modules[name]
self._loaded = None self._loaded = None
self.log.debug(f"Unloaded plugin {self.id} at {self.path}") self.log.debug(f"Unloaded plugin {self.id} at {self.path}")
def destroy(self) -> None: async def delete(self) -> None:
self.unload() await self.unload()
try: try:
del self.path_cache[self.path] del self.path_cache[self.path]
except KeyError: except KeyError:
@ -171,24 +231,43 @@ class ZippedPluginLoader(PluginLoader):
del self.id_cache[self.id] del self.id_cache[self.id]
except KeyError: except KeyError:
pass pass
self.id = None
self.path = None
self.version = None
self.modules = None
if self._importer: if self._importer:
self._importer.remove_cache() self._importer.remove_cache()
self._importer = None self._importer = None
self._loaded = None self._loaded = None
os.remove(self.path)
self.id = None
self.path = None
self.version = None
self.modules = None
@classmethod @classmethod
def load_all(cls, *args: str) -> None: def trash(cls, file_path: str, new_name: Optional[str] = None, reason: str = "error") -> None:
if cls.trash_path == "delete":
os.remove(file_path)
else:
new_name = new_name or f"{int(time())}-{reason}-{os.path.basename(file_path)}"
os.rename(file_path, os.path.abspath(os.path.join(cls.trash_path, new_name)))
@classmethod
def load_all(cls):
cls.log.debug("Preloading plugins...") cls.log.debug("Preloading plugins...")
for directory in args: for directory in cls.directories:
for file in os.listdir(directory): for file in os.listdir(directory):
if not file.endswith(".mbp"): if not file.endswith(".mbp"):
continue continue
path = os.path.join(directory, file) path = os.path.abspath(os.path.join(directory, file))
try: try:
ZippedPluginLoader.get(path) cls.get(path)
except (MaubotZipImportError, IDConflictError): except MaubotZipImportError:
cls.log.exception(f"Failed to load plugin at {path}") cls.log.exception(f"Failed to load plugin at {path}, trashing...")
cls.trash(path)
except IDConflictError:
cls.log.error(f"Duplicate plugin ID at {path}, trashing...")
cls.trash(path)
def init(config: Config) -> None:
ZippedPluginLoader.trash_path = config["plugin_directories.trash"]
ZippedPluginLoader.directories = config["plugin_directories.load"]
ZippedPluginLoader.load_all()

View File

@ -0,0 +1,32 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from aiohttp import web
from asyncio import AbstractEventLoop
from ...config import Config
from .base import routes, set_config
from .middleware import auth, error
from .auth import web as _
from .plugin import web as _
from .instance import web as _
from .client import web as _
def init(cfg: Config, loop: AbstractEventLoop) -> web.Application:
set_config(cfg)
app = web.Application(loop=loop, middlewares=[auth, error])
app.add_routes(routes)
return app

View File

@ -0,0 +1,63 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from time import time
import json
from aiohttp import web
from mautrix.types import UserID
from mautrix.util.signed_token import sign_token, verify_token
from .base import routes, get_config
from .responses import ErrBadAuth, ErrBodyNotJSON
def is_valid_token(token: str) -> bool:
data = verify_token(get_config()["server.unshared_secret"], token)
if not data:
return False
return get_config().is_admin(data.get("user_id", None))
def create_token(user: UserID) -> str:
return sign_token(get_config()["server.unshared_secret"], {
"user_id": user,
})
@routes.post("/login")
async def login(request: web.Request) -> web.Response:
try:
data = await request.json()
except json.JSONDecodeError:
return ErrBodyNotJSON
secret = data.get("secret")
if secret and get_config()["server.unshared_secret"] == secret:
user = data.get("user") or "root"
return web.json_response({
"token": create_token(user),
"created_at": int(time()),
})
username = data.get("username")
password = data.get("password")
if get_config().check_password(username, password):
return web.json_response({
"token": create_token(username),
"created_at": int(time()),
})
return ErrBadAuth

View File

@ -0,0 +1,30 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from aiohttp import web
from ...config import Config
routes: web.RouteTableDef = web.RouteTableDef()
_config: Config = None
def set_config(config: Config) -> None:
global _config
_config = config
def get_config() -> Config:
return _config

View File

@ -0,0 +1,131 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional
from json import JSONDecodeError
from http import HTTPStatus
from aiohttp import web
from mautrix.types import UserID, SyncToken, FilterID
from mautrix.errors import MatrixRequestError, MatrixInvalidToken
from mautrix.client import Client as MatrixClient
from ...db import DBClient
from ...client import Client
from .base import routes
from .responses import (RespDeleted, ErrClientNotFound, ErrBodyNotJSON, ErrClientInUse,
ErrBadClientAccessToken, ErrBadClientAccessDetails, ErrMXIDMismatch,
ErrUserExists)
@routes.get("/clients")
async def get_clients(_: web.Request) -> web.Response:
return web.json_response([client.to_dict() for client in Client.cache.values()])
@routes.get("/client/{id}")
async def get_client(request: web.Request) -> web.Response:
user_id = request.match_info.get("id", None)
client = Client.get(user_id, None)
if not client:
return ErrClientNotFound
return web.json_response(client.to_dict())
async def _create_client(user_id: Optional[UserID], data: dict) -> web.Response:
homeserver = data.get("homeserver", None)
access_token = data.get("access_token", None)
new_client = MatrixClient(mxid="@not:a.mxid", base_url=homeserver, token=access_token,
loop=Client.loop, client_session=Client.http_client)
try:
mxid = await new_client.whoami()
except MatrixInvalidToken:
return ErrBadClientAccessToken
except MatrixRequestError:
return ErrBadClientAccessDetails
if user_id is None:
existing_client = Client.get(mxid, None)
if existing_client is not None:
return ErrUserExists
elif mxid != user_id:
return ErrMXIDMismatch
db_instance = DBClient(id=mxid, homeserver=homeserver, access_token=access_token,
enabled=data.get("enabled", True), next_batch=SyncToken(""),
filter_id=FilterID(""), sync=data.get("sync", True),
autojoin=data.get("autojoin", True),
displayname=data.get("displayname", ""),
avatar_url=data.get("avatar_url", ""))
client = Client(db_instance)
Client.db.add(db_instance)
Client.db.commit()
await client.start()
return web.json_response(client.to_dict())
async def _update_client(client: Client, data: dict) -> web.Response:
try:
await client.update_access_details(data.get("access_token", None),
data.get("homeserver", None))
except MatrixInvalidToken:
return ErrBadClientAccessToken
except MatrixRequestError:
return ErrBadClientAccessDetails
except ValueError:
return ErrMXIDMismatch
await client.update_avatar_url(data.get("avatar_url", None))
await client.update_displayname(data.get("displayname", None))
await client.update_started(data.get("started", None))
client.enabled = data.get("enabled", client.enabled)
client.autojoin = data.get("autojoin", client.autojoin)
client.sync = data.get("sync", client.sync)
return web.json_response(client.to_dict(), status=HTTPStatus.CREATED)
@routes.post("/client/new")
async def create_client(request: web.Request) -> web.Response:
try:
data = await request.json()
except JSONDecodeError:
return ErrBodyNotJSON
return await _create_client(None, data)
@routes.put("/client/{id}")
async def update_client(request: web.Request) -> web.Response:
user_id = request.match_info.get("id", None)
client = Client.get(user_id, None)
try:
data = await request.json()
except JSONDecodeError:
return ErrBodyNotJSON
if not client:
return await _create_client(user_id, data)
else:
return await _update_client(client, data)
@routes.delete("/client/{id}")
async def delete_client(request: web.Request) -> web.Response:
user_id = request.match_info.get("id", None)
client = Client.get(user_id, None)
if not client:
return ErrClientNotFound
if len(client.references) > 0:
return ErrClientInUse
if client.started:
await client.stop()
client.delete()
return RespDeleted

View File

@ -0,0 +1,101 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from json import JSONDecodeError
from http import HTTPStatus
from aiohttp import web
from ...db import DBPlugin
from ...instance import PluginInstance
from ...loader import PluginLoader
from ...client import Client
from .base import routes
from .responses import (ErrInstanceNotFound, ErrBodyNotJSON, RespDeleted, ErrPrimaryUserNotFound,
ErrPluginTypeRequired, ErrPrimaryUserRequired, ErrPluginTypeNotFound)
@routes.get("/instances")
async def get_instances(_: web.Request) -> web.Response:
return web.json_response([instance.to_dict() for instance in PluginInstance.cache.values()])
@routes.get("/instance/{id}")
async def get_instance(request: web.Request) -> web.Response:
instance_id = request.match_info.get("id", "").lower()
instance = PluginInstance.get(instance_id, None)
if not instance:
return ErrInstanceNotFound
return web.json_response(instance.to_dict())
async def _create_instance(instance_id: str, data: dict) -> web.Response:
plugin_type = data.get("type", None)
primary_user = data.get("primary_user", None)
if not plugin_type:
return ErrPluginTypeRequired
elif not primary_user:
return ErrPrimaryUserRequired
elif not Client.get(primary_user):
return ErrPrimaryUserNotFound
try:
PluginLoader.find(plugin_type)
except KeyError:
return ErrPluginTypeNotFound
db_instance = DBPlugin(id=instance_id, type=plugin_type, enabled=data.get("enabled", True),
primary_user=primary_user, config=data.get("config", ""))
instance = PluginInstance(db_instance)
instance.load()
PluginInstance.db.add(db_instance)
PluginInstance.db.commit()
await instance.start()
return web.json_response(instance.to_dict(), status=HTTPStatus.CREATED)
async def _update_instance(instance: PluginInstance, data: dict) -> web.Response:
if not await instance.update_primary_user(data.get("primary_user", None)):
return ErrPrimaryUserNotFound
instance.update_id(data.get("id", None))
instance.update_enabled(data.get("enabled", None))
instance.update_config(data.get("config", None))
await instance.update_started(data.get("started", None))
instance.db.commit()
return web.json_response(instance.to_dict())
@routes.put("/instance/{id}")
async def update_instance(request: web.Request) -> web.Response:
instance_id = request.match_info.get("id", "").lower()
instance = PluginInstance.get(instance_id, None)
try:
data = await request.json()
except JSONDecodeError:
return ErrBodyNotJSON
if not instance:
return await _create_instance(instance_id, data)
else:
return await _update_instance(instance, data)
@routes.delete("/instance/{id}")
async def delete_instance(request: web.Request) -> web.Response:
instance_id = request.match_info.get("id", "").lower()
instance = PluginInstance.get(instance_id, None)
if not instance:
return ErrInstanceNotFound
if instance.started:
await instance.stop()
instance.delete()
return RespDeleted

View File

@ -0,0 +1,58 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Callable, Awaitable
from aiohttp import web
from .responses import ErrNoToken, ErrInvalidToken, ErrPathNotFound, ErrMethodNotAllowed
from .auth import is_valid_token
Handler = Callable[[web.Request], Awaitable[web.Response]]
@web.middleware
async def auth(request: web.Request, handler: Handler) -> web.Response:
if request.path.endswith("/login"):
return await handler(request)
token = request.headers.get("Authorization", "")
if not token or not token.startswith("Bearer "):
return ErrNoToken
if not is_valid_token(token[len("Bearer "):]):
return ErrInvalidToken
return await handler(request)
@web.middleware
async def error(request: web.Request, handler: Handler) -> web.Response:
try:
return await handler(request)
except web.HTTPException as ex:
if ex.status_code == 404:
return ErrPathNotFound
elif ex.status_code == 405:
return ErrMethodNotAllowed
return web.json_response({
"error": f"Unhandled HTTP {ex.status}",
"errcode": f"unhandled_http_{ex.status}",
}, status=ex.status)
req_no = 0
def get_req_no():
global req_no
req_no += 1
return req_no

View File

@ -0,0 +1,130 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from http import HTTPStatus
from io import BytesIO
from time import time
import traceback
import os.path
import re
from aiohttp import web
from ...loader import PluginLoader, ZippedPluginLoader, MaubotZipImportError
from .responses import (ErrPluginNotFound, ErrPluginInUse, plugin_import_error,
plugin_reload_error, RespDeleted, RespOK, ErrUnsupportedPluginLoader)
from .base import routes, get_config
@routes.get("/plugins")
async def get_plugins(_) -> web.Response:
return web.json_response([plugin.to_dict() for plugin in PluginLoader.id_cache.values()])
@routes.get("/plugin/{id}")
async def get_plugin(request: web.Request) -> web.Response:
plugin_id = request.match_info.get("id", None)
plugin = PluginLoader.id_cache.get(plugin_id, None)
if not plugin:
return ErrPluginNotFound
return web.json_response(plugin.to_dict())
@routes.delete("/plugin/{id}")
async def delete_plugin(request: web.Request) -> web.Response:
plugin_id = request.match_info.get("id", None)
plugin = PluginLoader.id_cache.get(plugin_id, None)
if not plugin:
return ErrPluginNotFound
elif len(plugin.references) > 0:
return ErrPluginInUse
await plugin.delete()
return RespDeleted
@routes.post("/plugin/{id}/reload")
async def reload_plugin(request: web.Request) -> web.Response:
plugin_id = request.match_info.get("id", None)
plugin = PluginLoader.id_cache.get(plugin_id, None)
if not plugin:
return ErrPluginNotFound
await plugin.stop_instances()
try:
await plugin.reload()
except MaubotZipImportError as e:
return plugin_reload_error(str(e), traceback.format_exc())
await plugin.start_instances()
return RespOK
async def upload_new_plugin(content: bytes, pid: str, version: str) -> web.Response:
path = os.path.join(get_config()["plugin_directories.upload"], f"{pid}-v{version}.mbp")
with open(path, "wb") as p:
p.write(content)
try:
plugin = ZippedPluginLoader.get(path)
except MaubotZipImportError as e:
ZippedPluginLoader.trash(path)
return plugin_import_error(str(e), traceback.format_exc())
return web.json_response(plugin.to_dict(), status=HTTPStatus.CREATED)
async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes, new_version: str
) -> web.Response:
dirname = os.path.dirname(plugin.path)
old_filename = os.path.basename(plugin.path)
if plugin.version in old_filename:
filename = old_filename.replace(plugin.version, new_version)
if filename == old_filename:
filename = re.sub(f"{re.escape(plugin.version)}(-ts[0-9]+)?",
f"{new_version}-ts{int(time())}", old_filename)
else:
filename = old_filename.rstrip(".mbp")
filename = f"{filename}-v{new_version}.mbp"
path = os.path.join(dirname, filename)
with open(path, "wb") as p:
p.write(content)
old_path = plugin.path
await plugin.stop_instances()
try:
await plugin.reload(new_path=path)
except MaubotZipImportError as e:
try:
await plugin.reload(new_path=old_path)
await plugin.start_instances()
except MaubotZipImportError:
pass
return plugin_import_error(str(e), traceback.format_exc())
await plugin.start_instances()
ZippedPluginLoader.trash(old_path, reason="update")
return web.json_response(plugin.to_dict())
@routes.post("/plugins/upload")
async def upload_plugin(request: web.Request) -> web.Response:
content = await request.read()
file = BytesIO(content)
try:
pid, version = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e:
return plugin_import_error(str(e), traceback.format_exc())
plugin = PluginLoader.id_cache.get(pid, None)
if not plugin:
return await upload_new_plugin(content, pid, version)
elif isinstance(plugin, ZippedPluginLoader):
return await upload_replacement_plugin(plugin, content, version)
else:
return ErrUnsupportedPluginLoader

View File

@ -0,0 +1,145 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from http import HTTPStatus
from aiohttp import web
ErrBodyNotJSON = web.json_response({
"error": "Request body is not JSON",
"errcode": "body_not_json",
}, status=HTTPStatus.BAD_REQUEST)
ErrPluginTypeRequired = web.json_response({
"error": "Plugin type is required when creating plugin instances",
"errcode": "plugin_type_required",
}, status=HTTPStatus.BAD_REQUEST)
ErrPrimaryUserRequired = web.json_response({
"error": "Primary user is required when creating plugin instances",
"errcode": "primary_user_required",
}, status=HTTPStatus.BAD_REQUEST)
ErrBadClientAccessToken = web.json_response({
"error": "Invalid access token",
"errcode": "bad_client_access_token",
}, status=HTTPStatus.BAD_REQUEST)
ErrBadClientAccessDetails = web.json_response({
"error": "Invalid homeserver or access token",
"errcode": "bad_client_access_details"
}, status=HTTPStatus.BAD_REQUEST)
ErrMXIDMismatch = web.json_response({
"error": "The Matrix user ID of the client and the user ID of the access token don't match",
"errcode": "mxid_mismatch",
}, status=HTTPStatus.BAD_REQUEST)
ErrBadAuth = web.json_response({
"error": "Invalid username or password",
"errcode": "invalid_auth",
}, status=HTTPStatus.UNAUTHORIZED)
ErrNoToken = web.json_response({
"error": "Authorization token missing",
"errcode": "auth_token_missing",
}, status=HTTPStatus.UNAUTHORIZED)
ErrInvalidToken = web.json_response({
"error": "Invalid authorization token",
"errcode": "auth_token_invalid",
}, status=HTTPStatus.UNAUTHORIZED)
ErrPluginNotFound = web.json_response({
"error": "Plugin not found",
"errcode": "plugin_not_found",
}, status=HTTPStatus.NOT_FOUND)
ErrClientNotFound = web.json_response({
"error": "Client not found",
"errcode": "client_not_found",
}, status=HTTPStatus.NOT_FOUND)
ErrPrimaryUserNotFound = web.json_response({
"error": "Client for given primary user not found",
"errcode": "primary_user_not_found",
}, status=HTTPStatus.NOT_FOUND)
ErrInstanceNotFound = web.json_response({
"error": "Plugin instance not found",
"errcode": "instance_not_found",
}, status=HTTPStatus.NOT_FOUND)
ErrPluginTypeNotFound = web.json_response({
"error": "Given plugin type not found",
"errcode": "plugin_type_not_found",
}, status=HTTPStatus.NOT_FOUND)
ErrPathNotFound = web.json_response({
"error": "Resource not found",
"errcode": "resource_not_found",
}, status=HTTPStatus.NOT_FOUND)
ErrMethodNotAllowed = web.json_response({
"error": "Method not allowed",
"errcode": "method_not_allowed",
}, status=HTTPStatus.METHOD_NOT_ALLOWED)
ErrUserExists = web.json_response({
"error": "There is already a client with the user ID of that token",
"errcode": "user_exists",
}, status=HTTPStatus.CONFLICT)
ErrPluginInUse = web.json_response({
"error": "Plugin instances of this type still exist",
"errcode": "plugin_in_use",
}, status=HTTPStatus.PRECONDITION_FAILED)
ErrClientInUse = web.json_response({
"error": "Plugin instances with this client as their primary user still exist",
"errcode": "client_in_use",
}, status=HTTPStatus.PRECONDITION_FAILED)
def plugin_import_error(error: str, stacktrace: str) -> web.Response:
return web.json_response({
"error": error,
"stacktrace": stacktrace,
"errcode": "plugin_invalid",
}, status=HTTPStatus.BAD_REQUEST)
def plugin_reload_error(error: str, stacktrace: str) -> web.Response:
return web.json_response({
"error": error,
"stacktrace": stacktrace,
"errcode": "plugin_reload_fail",
}, status=HTTPStatus.INTERNAL_SERVER_ERROR)
ErrUnsupportedPluginLoader = web.json_response({
"error": "Existing plugin with same ID uses unsupported plugin loader",
"errcode": "unsupported_plugin_loader",
}, status=HTTPStatus.BAD_REQUEST)
ErrNotImplemented = web.json_response({
"error": "Not implemented",
"errcode": "not_implemented",
}, status=HTTPStatus.NOT_IMPLEMENTED)
RespOK = web.json_response({
"success": True,
}, status=HTTPStatus.OK)
RespDeleted = web.Response(status=HTTPStatus.NO_CONTENT)

View File

@ -0,0 +1,458 @@
openapi: 3.0.0
info:
title: Maubot Management
version: 0.1.0
description: The API to manage a [maubot](https://github.com/maubot/maubot) instance
license:
name: GNU Affero General Public License version 3
url: 'https://github.com/maubot/maubot/blob/master/LICENSE'
security:
- bearer: []
servers:
- url: /_matrix/maubot/v1
paths:
/login:
post:
operationId: login
summary: Log in with the unshared secret or username+password
tags: [Authentication]
requestBody:
content:
application/json:
schema:
type: object
description: Set either username+password or secret.
properties:
secret:
type: string
description: The unshared server secret for root login
username:
type: string
description: The username for normal login
password:
type: string
description: The password for normal login
responses:
200:
description: Logged in successfully
content:
application/json:
schema:
type: object
properties:
token:
type: string
401:
description: Invalid credentials
/plugins:
get:
operationId: get_plugins
summary: Get the list of installed plugins
tags: [Plugins]
responses:
200:
description: The list of plugins
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Plugin'
401:
$ref: '#/components/responses/Unauthorized'
/plugins/upload:
post:
operationId: upload_plugin
summary: Upload a new plugin
description: Upload a new plugin. If the plugin already exists, enabled instances will be restarted.
tags: [Plugins]
responses:
200:
description: Plugin uploaded and replaced current version successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Plugin'
201:
description: New plugin uploaded successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Plugin'
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
requestBody:
content:
application/zip:
schema:
type: string
format: binary
example: The plugin maubot archive (.mbp)
'/plugin/{id}':
parameters:
- name: id
in: path
description: The ID of the plugin to get
required: true
schema:
type: string
get:
operationId: get_plugin
summary: Get information about a specific plugin
tags: [Plugins]
responses:
200:
description: Plugin found
content:
application/json:
schema:
$ref: '#/components/schemas/Plugin'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/PluginNotFound'
delete:
operationId: delete_plugin
summary: Delete a plugin
description: Delete a plugin. All instances of the plugin must be deleted before deleting the plugin.
tags: [Plugins]
responses:
204:
description: Plugin deleted
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/PluginNotFound'
412:
description: One or more plugin instances of this type exist
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/plugin/{id}/reload:
parameters:
- name: id
in: path
description: The ID of the plugin to get
required: true
schema:
type: string
post:
operationId: reload_plugin
summary: Reload a plugin from disk
tags: [Plugins]
responses:
200:
description: Plugin reloaded
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/PluginNotFound'
/instances:
get:
operationId: get_instances
summary: Get all plugin instances
tags: [Plugin instances]
responses:
200:
description: The list of instances
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/PluginInstance'
401:
$ref: '#/components/responses/Unauthorized'
'/instance/{id}':
parameters:
- name: id
in: path
description: The ID of the instance to get
required: true
schema:
type: string
get:
operationId: get_instance
summary: Get information about a specific plugin instance
tags: [Plugin instances]
responses:
200:
description: Plugin instance found
content:
application/json:
schema:
$ref: '#/components/schemas/PluginInstance'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/InstanceNotFound'
delete:
operationId: delete_instance
summary: Delete a specific plugin instance
tags: [Plugin instances]
responses:
204:
description: Plugin instance deleted
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/InstanceNotFound'
put:
operationId: update_instance
summary: Create a plugin instance or edit the details of an existing plugin instance
tags: [Plugin instances]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PluginInstance'
responses:
200:
description: Plugin instance edited
201:
description: Plugin instance created
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
404:
description: The referenced client or plugin type could not be found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'/clients':
get:
operationId: get_clients
summary: Get the list of Matrix clients
tags: [Clients]
responses:
200:
description: The list of plugins
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/MatrixClient'
401:
$ref: '#/components/responses/Unauthorized'
/client/new:
post:
operationId: create_client
summary: Create a Matrix client
tags: [Clients]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
responses:
201:
description: Client created
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
409:
description: There is already a client with the user ID of that token.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'/client/{id}':
parameters:
- name: id
in: path
description: The Matrix user ID of the client to get
required: true
schema:
type: string
get:
operationId: get_client
summary: Get information about a specific Matrix client
tags: [Clients]
responses:
200:
description: Client found
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
put:
operationId: update_client
summary: Create or update a Matrix client
tags: [Clients]
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
responses:
200:
description: Client updated
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
201:
description: Client created
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
400:
$ref: '#/components/responses/BadRequest'
401:
$ref: '#/components/responses/Unauthorized'
delete:
operationId: delete_client
summary: Delete a Matrix client
tags: [Clients]
responses:
204:
description: Client deleted
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
412:
description: One or more plugin instances with this as their primary client exist
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
responses:
Unauthorized:
description: Invalid or missing access token
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
PluginNotFound:
description: Plugin not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
ClientNotFound:
description: Client not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
InstanceNotFound:
description: Plugin instance not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
BadRequest:
description: Bad request (e.g. bad request body)
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
securitySchemes:
bearer:
type: http
scheme: bearer
description: Required authentication for all endpoints
schemas:
Error:
type: object
properties:
error:
type: string
description: A human-readable error message
errcode:
type: string
description: A simple error code
Plugin:
type: object
properties:
id:
type: string
example: xyz.maubot.jesaribot
version:
type: string
example: 2.0.0
instances:
type: array
items:
$ref: '#/components/schemas/PluginInstance'
PluginInstance:
type: object
properties:
id:
type: string
example: jesaribot
type:
type: string
example: xyz.maubot.jesaribot
enabled:
type: boolean
example: true
started:
type: boolean
example: true
primary_user:
type: string
example: '@putkiteippi:maunium.net'
config:
type: string
example: "YAML"
MatrixClient:
type: object
properties:
id:
type: string
example: '@putkiteippi:maunium.net'
readOnly: true
homeserver:
type: string
example: 'https://maunium.net'
access_token:
type: string
enabled:
type: boolean
example: true
started:
type: boolean
example: true
sync:
type: boolean
example: true
autojoin:
type: boolean
example: true
displayname:
type: string
example: J. E. Saarinen
avatar_url:
type: string
example: 'mxc://maunium.net/FsPQQTntCCqhJMFtwArmJdaU'
instances:
type: array
readOnly: true
items:
$ref: '#/components/schemas/PluginInstance'

View File

@ -0,0 +1,70 @@
{
"extends": "react-app",
"plugins": [
"import"
],
"rules": {
"indent": ["error", 4, {
"ignoredNodes": [
"JSXAttribute",
"JSXSpreadAttribute"
],
"FunctionDeclaration": {"parameters": "first"},
"FunctionExpression": {"parameters": "first"},
"CallExpression": {"arguments": "first"},
"ArrayExpression": "first",
"ObjectExpression": "first",
"ImportDeclaration": "first"
}],
"react/jsx-indent-props": ["error", "first"],
"object-curly-newline": ["error", {
"consistent": true
}],
"object-curly-spacing": ["error", "always", {
"arraysInObjects": false,
"objectsInObjects": false
}],
"array-bracket-spacing": ["error", "never"],
"one-var": ["error", {
"initialized": "never",
"uninitialized": "always"
}],
"one-var-declaration-per-line": ["error", "initializations"],
"quotes": ["error", "double"],
"semi": ["error", "never"],
"comma-dangle": ["error", "always-multiline"],
"max-len": ["warn", 100],
"camelcase": ["error", {
"properties": "always"
}],
"space-before-function-paren": ["error", {
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}],
"func-style": ["warn", "declaration", {"allowArrowFunctions": true}],
"id-length": ["warn", {"max": 40, "exceptions": ["i", "j", "x", "y", "_"]}],
"arrow-body-style": ["error", "as-needed"],
"new-cap": ["warn", {
"newIsCap": true,
"capIsNew": true
}],
"no-empty": ["error", {
"allowEmptyCatch": true
}],
"eol-last": ["error", "always"],
"no-console": "off",
"import/no-nodejs-modules": "error",
"import/order": ["warn", {
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index"
],
"newlines-between": "never"
}]
}
}

5
maubot/management/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/node_modules
/build
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -0,0 +1,25 @@
{
"name": "maubot-manager",
"version": "0.1.0",
"private": true,
"dependencies": {
"node-sass": "^4.9.4",
"react": "^16.6.0",
"react-dom": "^16.6.0",
"react-scripts": "2.0.5"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"browserslist": [
"last 5 firefox versions",
"last 3 and_ff versions",
"last 5 chrome versions",
"last 3 and_chr versions",
"last 2 safari versions",
"last 2 ios_saf versions"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,34 @@
<!--
maubot - A plugin-based Matrix bot system.
Copyright (C) 2018 Tulir Asokan
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#50D367">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<title>Maubot Manager</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"short_name": "Maubot",
"name": "Maubot Manager",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 48x48 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#50D367",
"background_color": "#FAFAFA"
}

View File

@ -0,0 +1,33 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react"
class MaubotManager extends Component {
render() {
return (
<div className="maubot-manager">
<header>
</header>
<main>
</main>
</div>
)
}
}
export default MaubotManager

View File

@ -0,0 +1,21 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React from "react"
import ReactDOM from "react-dom"
import "./style/index.sass"
import MaubotManager from "./MaubotManager"
ReactDOM.render(<MaubotManager/>, document.getElementById("root"))

View File

@ -0,0 +1,53 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
body
font-family: $font-stack
margin: 0
padding: 0
font-size: 16px
background-color: $background-color
#root
position: fixed
top: 0
bottom: 0
right: 0
left: 0
//.lindeb
> header
position: absolute
top: 0
height: $header-height
left: 0
right: 0
> main
position: absolute
top: $header-height
bottom: 0
left: 0
right: 0
text-align: center
> .lindeb-content
text-align: left
display: inline-block
width: 100%
max-width: $max-width
box-sizing: border-box
padding: 0 1rem

View File

@ -0,0 +1,28 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
$main-color: darken(#50D367, 10%)
$dark-color: darken($main-color, 10%)
$light-color: lighten($main-color, 10%)
$alt-color: darken(#47B9D7, 10%)
$dark-alt-color: darken($alt-color, 10%)
$border-color: #CCC
$error-color: #D35067
$text-color: #212121
$background-color: #FAFAFA
$inverted-text-color: $background-color
$font-stack: sans-serif
$max-width: 42.5rem
$header-height: 3.5rem

View File

@ -0,0 +1,17 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
@import base/vars
@import base/body

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,8 @@
from typing import Type, Optional, TYPE_CHECKING from typing import Type, Optional, TYPE_CHECKING
from logging import Logger from logging import Logger
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from asyncio import AbstractEventLoop
from aiohttp import ClientSession
import os.path import os.path
from sqlalchemy.engine.base import Engine from sqlalchemy.engine.base import Engine
@ -26,22 +28,30 @@ if TYPE_CHECKING:
from .command_spec import CommandSpec from .command_spec import CommandSpec
from mautrix.util.config import BaseProxyConfig from mautrix.util.config import BaseProxyConfig
DatabaseNotConfigured = ValueError("A database for this maubot instance has not been configured.")
class Plugin(ABC): class Plugin(ABC):
client: 'MaubotMatrixClient' client: 'MaubotMatrixClient'
id: str id: str
log: Logger log: Logger
loop: AbstractEventLoop
config: Optional['BaseProxyConfig'] config: Optional['BaseProxyConfig']
def __init__(self, client: 'MaubotMatrixClient', plugin_instance_id: str, log: Logger, def __init__(self, client: 'MaubotMatrixClient', loop: AbstractEventLoop, http: ClientSession,
config: Optional['BaseProxyConfig'], db_base_path: str) -> None: plugin_instance_id: str, log: Logger, config: Optional['BaseProxyConfig'],
db_base_path: str) -> None:
self.client = client self.client = client
self.loop = loop
self.http = http
self.id = plugin_instance_id self.id = plugin_instance_id
self.log = log self.log = log
self.config = config self.config = config
self.__db_base_path = db_base_path self.__db_base_path = db_base_path
def request_db_engine(self) -> Engine: def request_db_engine(self) -> Optional[Engine]:
if not self.__db_base_path:
raise DatabaseNotConfigured
return sql.create_engine(f"sqlite:///{os.path.join(self.__db_base_path, self.id)}.db") return sql.create_engine(f"sqlite:///{os.path.join(self.__db_base_path, self.id)}.db")
def set_command_spec(self, spec: 'CommandSpec') -> None: def set_command_spec(self, spec: 'CommandSpec') -> None:
@ -58,3 +68,7 @@ class Plugin(ABC):
@classmethod @classmethod
def get_config_class(cls) -> Optional[Type['BaseProxyConfig']]: def get_config_class(cls) -> Optional[Type['BaseProxyConfig']]:
return None return None
def on_external_config_update(self) -> None:
if self.config:
self.config.load_and_update()

View File

@ -14,6 +14,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from aiohttp import web from aiohttp import web
import logging
import asyncio import asyncio
from mautrix.api import PathBuilder, Method from mautrix.api import PathBuilder, Method
@ -23,13 +24,17 @@ from .__meta__ import __version__
class MaubotServer: class MaubotServer:
def __init__(self, config: Config, loop: asyncio.AbstractEventLoop): log: logging.Logger = logging.getLogger("maubot.server")
def __init__(self, config: Config, management: web.Application,
loop: asyncio.AbstractEventLoop) -> None:
self.loop = loop or asyncio.get_event_loop() self.loop = loop or asyncio.get_event_loop()
self.app = web.Application(loop=self.loop) self.app = web.Application(loop=self.loop)
self.config = config self.config = config
path = PathBuilder(config["server.base_path"]) path = PathBuilder(config["server.base_path"])
self.add_route(Method.GET, path.version, self.version) self.add_route(Method.GET, path.version, self.version)
self.app.add_subapp(config["server.base_path"], management)
as_path = PathBuilder(config["server.appservice_base_path"]) as_path = PathBuilder(config["server.appservice_base_path"])
self.add_route(Method.PUT, as_path.transactions, self.handle_transaction) self.add_route(Method.PUT, as_path.transactions, self.handle_transaction)
@ -43,6 +48,7 @@ class MaubotServer:
await self.runner.setup() await self.runner.setup()
site = web.TCPSite(self.runner, self.config["server.hostname"], self.config["server.port"]) site = web.TCPSite(self.runner, self.config["server.hostname"], self.config["server.port"])
await site.start() await site.start()
self.log.info(f"Listening on {site.name}")
async def stop(self) -> None: async def stop(self) -> None:
await self.runner.cleanup() await self.runner.cleanup()

View File

@ -5,3 +5,4 @@ alembic
commonmark commonmark
ruamel.yaml ruamel.yaml
attrs attrs
bcrypt

View File

@ -28,6 +28,7 @@ setuptools.setup(
"commonmark>=0.8.1,<1", "commonmark>=0.8.1,<1",
"ruamel.yaml>=0.15.35,<0.16", "ruamel.yaml>=0.15.35,<0.16",
"attrs>=18.1.0,<19", "attrs>=18.1.0,<19",
"bcrypt>=3.1.4,<4",
], ],
classifiers=[ classifiers=[
@ -47,4 +48,8 @@ setuptools.setup(
data_files=[ data_files=[
(".", ["example-config.yaml"]), (".", ["example-config.yaml"]),
], ],
package_data={
"maubot": ["management/frontend/build/*", "management/frontend/build/static/css/*",
"management/frontend/build/static/js/*"],
},
) )