commit
a7a4c07411
@ -1,15 +1,16 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.py]
|
||||
max_line_length = 99
|
||||
|
||||
[*.{yaml,yml,py}]
|
||||
indent_style = space
|
||||
[*.json]
|
||||
indent_size = 2
|
||||
|
||||
[spec.yaml]
|
||||
indent_size = 2
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,8 +8,9 @@ pip-selfcheck.json
|
||||
__pycache__
|
||||
|
||||
*.db
|
||||
*.yaml
|
||||
/*.yaml
|
||||
!example-config.yaml
|
||||
|
||||
logs/
|
||||
plugins/
|
||||
trash/
|
||||
|
@ -6,6 +6,8 @@ RUN apk add --no-cache \
|
||||
py3-aiohttp \
|
||||
py3-sqlalchemy \
|
||||
py3-attrs \
|
||||
py3-bcrypt \
|
||||
py3-cffi \
|
||||
ca-certificates \
|
||||
&& pip3 install -r requirements.txt
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
# maubot
|
||||
A plugin-based [Matrix](https://matrix.org) bot system written in Python.
|
||||
|
||||
Management API spec: [maubot.xyz/spec](https://maubot.xyz/spec)
|
||||
|
||||
## Discussion
|
||||
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.
|
||||
* [karma](https://github.com/maubot/karma) - A user karma tracker bot.
|
||||
* [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
|
||||
* rss - A bot that posts new RSS entries to rooms.
|
||||
* dictionary - A bot to get the dictionary definitions of words.
|
||||
* 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.
|
||||
* github - A GitHub client and webhook receiver bot.
|
||||
* wolfram - A Wolfram Alpha bot
|
||||
|
@ -5,19 +5,20 @@ cd /opt/maubot
|
||||
# Replace database path in config.
|
||||
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#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
|
||||
|
||||
mkdir -p /var/log/maubot
|
||||
mkdir -p /var/log/maubot /data/plugins /data/trash /data/dbs
|
||||
|
||||
# Check that database is in the right state
|
||||
alembic -x config=/data/config.yaml upgrade head
|
||||
|
||||
if [ ! -f /data/config.yaml ]; then
|
||||
cp example-config.yaml /data/config.yaml
|
||||
echo "Didn't find a config file."
|
||||
echo "Copied default config file to /data/config.yaml"
|
||||
echo "Modify that config file to your liking."
|
||||
echo "Start the container again after that to generate the registration file."
|
||||
echo "Config file not found. Example config copied to /data/config.yaml"
|
||||
echo "Please modify the config file to your liking and restart the container."
|
||||
exit
|
||||
fi
|
||||
|
||||
|
@ -5,27 +5,35 @@
|
||||
# Postgres: postgres://username:password@hostname/dbname
|
||||
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:
|
||||
- ./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:
|
||||
# The IP and port to listen to.
|
||||
hostname: 0.0.0.0
|
||||
port: 29316
|
||||
# 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.
|
||||
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.
|
||||
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:
|
||||
- "@admin:example.com"
|
||||
root: ""
|
||||
|
||||
# Python logging configuration.
|
||||
#
|
||||
|
@ -13,21 +13,20 @@
|
||||
#
|
||||
# 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 sqlalchemy import orm
|
||||
import sqlalchemy as sql
|
||||
import logging.config
|
||||
import argparse
|
||||
import asyncio
|
||||
import signal
|
||||
import copy
|
||||
import sys
|
||||
import signal
|
||||
|
||||
from .config import Config
|
||||
from .db import Base, init as init_db
|
||||
from .db import init as init_db
|
||||
from .server import MaubotServer
|
||||
from .client import Client, init as init_client
|
||||
from .loader import ZippedPluginLoader
|
||||
from .plugin import PluginInstance, init as init_plugin_instance_class
|
||||
from .client import Client, init as init_client_class
|
||||
from .loader.zip import init as init_zip_loader
|
||||
from .instance import init as init_plugin_instance_class
|
||||
from .management.api import init as init_management
|
||||
from .__meta__ import __version__
|
||||
|
||||
parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.",
|
||||
@ -45,22 +44,16 @@ config.update()
|
||||
|
||||
logging.config.dictConfig(copy.deepcopy(config["logging"]))
|
||||
log = logging.getLogger("maubot.init")
|
||||
log.debug(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()
|
||||
log.info(f"Initializing maubot {__version__}")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
init_db(db_session)
|
||||
init_client(loop)
|
||||
init_plugin_instance_class(config)
|
||||
server = MaubotServer(config, loop)
|
||||
ZippedPluginLoader.load_all(*config["plugin_directories"])
|
||||
plugins = PluginInstance.all()
|
||||
init_zip_loader(config)
|
||||
db_session = init_db(config)
|
||||
clients = init_client_class(db_session, loop)
|
||||
plugins = init_plugin_instance_class(db_session, config, loop)
|
||||
management_api = init_management(config, loop)
|
||||
server = MaubotServer(config, management_api, loop)
|
||||
|
||||
for plugin in plugins:
|
||||
plugin.load()
|
||||
@ -68,28 +61,34 @@ for plugin in plugins:
|
||||
signal.signal(signal.SIGINT, signal.default_int_handler)
|
||||
signal.signal(signal.SIGTERM, signal.default_int_handler)
|
||||
|
||||
stop = False
|
||||
|
||||
|
||||
async def periodic_commit():
|
||||
while not stop:
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
db_session.commit()
|
||||
|
||||
|
||||
periodic_commit_task: asyncio.Future = None
|
||||
|
||||
try:
|
||||
loop.run_until_complete(asyncio.gather(
|
||||
server.start(),
|
||||
*[plugin.start() for plugin in plugins]))
|
||||
log.debug("Startup actions complete, running forever")
|
||||
loop.run_until_complete(periodic_commit())
|
||||
log.info("Starting server")
|
||||
loop.run_until_complete(server.start())
|
||||
log.info("Starting clients and plugins")
|
||||
loop.run_until_complete(asyncio.gather(*[client.start() for client in clients], loop=loop))
|
||||
log.info("Startup actions complete, running forever")
|
||||
periodic_commit_task = asyncio.ensure_future(periodic_commit(), loop=loop)
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
log.debug("Interrupt received, stopping HTTP clients/servers and saving database")
|
||||
stop = True
|
||||
for client in Client.cache.values():
|
||||
client.stop()
|
||||
log.info("Interrupt received, stopping HTTP clients/servers and saving database")
|
||||
if periodic_commit_task is not None:
|
||||
periodic_commit_task.cancel()
|
||||
log.debug("Stopping clients")
|
||||
loop.run_until_complete(asyncio.gather(*[client.stop() for client in Client.cache.values()],
|
||||
loop=loop))
|
||||
db_session.commit()
|
||||
log.debug("Stopping server")
|
||||
loop.run_until_complete(server.stop())
|
||||
log.debug("Closing event loop")
|
||||
loop.close()
|
||||
log.debug("Everything stopped, shutting down")
|
||||
sys.exit(0)
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "0.1.0.dev5"
|
||||
__version__ = "0.1.0.dev6+management"
|
||||
|
202
maubot/client.py
202
maubot/client.py
@ -13,62 +13,142 @@
|
||||
#
|
||||
# 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 Dict, List, Optional
|
||||
from aiohttp import ClientSession
|
||||
from typing import Dict, List, Optional, Set, TYPE_CHECKING
|
||||
import asyncio
|
||||
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,
|
||||
EventType, Filter, RoomFilter, RoomEventFilter)
|
||||
|
||||
from .db import DBClient
|
||||
from .matrix import MaubotMatrixClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .instance import PluginInstance
|
||||
|
||||
log = logging.getLogger("maubot.client")
|
||||
|
||||
|
||||
class Client:
|
||||
loop: asyncio.AbstractEventLoop
|
||||
db: Session = None
|
||||
log: logging.Logger = None
|
||||
loop: asyncio.AbstractEventLoop = None
|
||||
cache: Dict[UserID, 'Client'] = {}
|
||||
http_client: ClientSession = None
|
||||
|
||||
references: Set['PluginInstance']
|
||||
db_instance: DBClient
|
||||
client: MaubotMatrixClient
|
||||
started: bool
|
||||
|
||||
def __init__(self, db_instance: DBClient) -> None:
|
||||
self.db_instance = db_instance
|
||||
self.cache[self.id] = self
|
||||
self.log = log.getChild(self.id)
|
||||
self.references = set()
|
||||
self.started = False
|
||||
self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver,
|
||||
token=self.access_token, client_session=self.http_client,
|
||||
log=self.log, loop=self.loop, store=self.db_instance)
|
||||
if self.autojoin:
|
||||
self.client.add_event_handler(self._handle_invite, EventType.ROOM_MEMBER)
|
||||
|
||||
def start(self) -> None:
|
||||
asyncio.ensure_future(self._start(), loop=self.loop)
|
||||
|
||||
async def _start(self) -> None:
|
||||
async def start(self, try_n: Optional[int] = 0) -> None:
|
||||
try:
|
||||
if not self.filter_id:
|
||||
self.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)
|
||||
await self.client.start(self.filter_id)
|
||||
if try_n > 0:
|
||||
await asyncio.sleep(try_n * 10)
|
||||
await self._start(try_n)
|
||||
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()
|
||||
|
||||
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
|
||||
def get(cls, user_id: UserID, db_instance: Optional[DBClient] = None) -> Optional['Client']:
|
||||
try:
|
||||
@ -87,6 +167,44 @@ class Client:
|
||||
if evt.state_key == self.id and evt.content.membership == Membership.INVITE:
|
||||
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
|
||||
|
||||
@property
|
||||
@ -101,34 +219,36 @@ class Client:
|
||||
def access_token(self) -> str:
|
||||
return self.db_instance.access_token
|
||||
|
||||
@access_token.setter
|
||||
def access_token(self, value: str) -> None:
|
||||
self.client.api.token = value
|
||||
self.db_instance.access_token = value
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.db_instance.enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool) -> None:
|
||||
self.db_instance.enabled = value
|
||||
|
||||
@property
|
||||
def next_batch(self) -> SyncToken:
|
||||
return self.db_instance.next_batch
|
||||
|
||||
@next_batch.setter
|
||||
def next_batch(self, value: SyncToken) -> None:
|
||||
self.db_instance.next_batch = value
|
||||
|
||||
@property
|
||||
def filter_id(self) -> FilterID:
|
||||
return self.db_instance.filter_id
|
||||
|
||||
@filter_id.setter
|
||||
def filter_id(self, value: FilterID) -> None:
|
||||
self.db_instance.filter_id = value
|
||||
|
||||
@property
|
||||
def sync(self) -> bool:
|
||||
return self.db_instance.sync
|
||||
|
||||
@sync.setter
|
||||
def sync(self, value: bool) -> None:
|
||||
if value == self.db_instance.sync:
|
||||
return
|
||||
self.db_instance.sync = value
|
||||
if self.started:
|
||||
if value:
|
||||
self.start_sync()
|
||||
else:
|
||||
self.stop_sync()
|
||||
|
||||
@property
|
||||
def autojoin(self) -> bool:
|
||||
@ -148,23 +268,15 @@ class Client:
|
||||
def displayname(self) -> str:
|
||||
return self.db_instance.displayname
|
||||
|
||||
@displayname.setter
|
||||
def displayname(self, value: str) -> None:
|
||||
self.db_instance.displayname = value
|
||||
|
||||
@property
|
||||
def avatar_url(self) -> ContentURI:
|
||||
return self.db_instance.avatar_url
|
||||
|
||||
@avatar_url.setter
|
||||
def avatar_url(self, value: ContentURI) -> None:
|
||||
self.db_instance.avatar_url = value
|
||||
|
||||
# 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.loop = loop
|
||||
for client in Client.all():
|
||||
client.start()
|
||||
return Client.all()
|
||||
|
@ -15,9 +15,13 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import random
|
||||
import string
|
||||
import bcrypt
|
||||
import re
|
||||
|
||||
from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper
|
||||
|
||||
bcrypt_regex = re.compile(r"^\$2[ayb]\$.{56}$")
|
||||
|
||||
|
||||
class Config(BaseFileConfig):
|
||||
@staticmethod
|
||||
@ -27,16 +31,35 @@ class Config(BaseFileConfig):
|
||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
||||
base, copy, _ = helper
|
||||
copy("database")
|
||||
copy("plugin_directories")
|
||||
copy("plugin_db_directory")
|
||||
copy("plugin_directories.upload")
|
||||
copy("plugin_directories.load")
|
||||
copy("plugin_directories.trash")
|
||||
copy("plugin_directories.db")
|
||||
copy("server.hostname")
|
||||
copy("server.port")
|
||||
copy("server.listen")
|
||||
copy("server.base_path")
|
||||
shared_secret = self["server.shared_secret"]
|
||||
copy("server.appservice_base_path")
|
||||
shared_secret = self["server.unshared_secret"]
|
||||
if shared_secret is None or shared_secret == "generate":
|
||||
base["server.shared_secret"] = self._new_token()
|
||||
base["server.unshared_secret"] = self._new_token()
|
||||
else:
|
||||
base["server.shared_secret"] = shared_secret
|
||||
base["server.unshared_secret"] = shared_secret
|
||||
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")
|
||||
|
||||
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"))
|
||||
|
57
maubot/db.py
57
maubot/db.py
@ -13,40 +13,20 @@
|
||||
#
|
||||
# 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 Type
|
||||
from sqlalchemy import (Column, String, Boolean, ForeignKey, Text, TypeDecorator)
|
||||
from sqlalchemy.orm import Query, scoped_session
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, ForeignKey, Text
|
||||
from sqlalchemy.orm import Query, Session, sessionmaker, scoped_session
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
import json
|
||||
import sqlalchemy as sql
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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):
|
||||
query: Query
|
||||
__tablename__ = "plugin"
|
||||
@ -67,6 +47,7 @@ class DBClient(Base):
|
||||
id: UserID = Column(String(255), primary_key=True)
|
||||
homeserver: 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="")
|
||||
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="")
|
||||
|
||||
|
||||
class DBCommandSpec(Base):
|
||||
query: Query
|
||||
__tablename__ = "command_spec"
|
||||
def init(config: Config) -> Session:
|
||||
db_engine: sql.engine.Engine = sql.create_engine(config["database"])
|
||||
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),
|
||||
ForeignKey("plugin.id", onupdate="CASCADE", ondelete="CASCADE"),
|
||||
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)
|
||||
DBPlugin.query = db_session.query_property()
|
||||
DBClient.query = db_session.query_property()
|
||||
|
||||
|
||||
def init(session: scoped_session) -> None:
|
||||
DBPlugin.query = session.query_property()
|
||||
DBClient.query = session.query_property()
|
||||
DBCommandSpec.query = session.query_property()
|
||||
return cast(Session, db_session)
|
||||
|
@ -14,8 +14,10 @@
|
||||
# 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 Dict, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from ruamel.yaml.comments import CommentedMap
|
||||
from ruamel.yaml import YAML
|
||||
from asyncio import AbstractEventLoop
|
||||
import logging
|
||||
import io
|
||||
|
||||
@ -35,7 +37,9 @@ yaml.indent(4)
|
||||
|
||||
|
||||
class PluginInstance:
|
||||
db: Session = None
|
||||
mb_config: Config = None
|
||||
loop: AbstractEventLoop = None
|
||||
cache: Dict[str, 'PluginInstance'] = {}
|
||||
plugin_directories: List[str] = []
|
||||
|
||||
@ -44,61 +48,99 @@ class PluginInstance:
|
||||
client: Client
|
||||
plugin: Plugin
|
||||
config: BaseProxyConfig
|
||||
base_cfg: RecursiveDict[CommentedMap]
|
||||
started: bool
|
||||
|
||||
def __init__(self, db_instance: DBPlugin):
|
||||
self.db_instance = db_instance
|
||||
self.log = logging.getLogger(f"maubot.plugin.{self.id}")
|
||||
self.config = None
|
||||
self.started = False
|
||||
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:
|
||||
try:
|
||||
self.loader = PluginLoader.find(self.type)
|
||||
except KeyError:
|
||||
self.log.error(f"Failed to find loader for type {self.type}")
|
||||
self.enabled = False
|
||||
self.db_instance.enabled = False
|
||||
return
|
||||
self.client = Client.get(self.primary_user)
|
||||
if not self.client:
|
||||
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.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:
|
||||
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:
|
||||
buf = io.StringIO()
|
||||
yaml.dump(data, buf)
|
||||
self.db_instance.config = buf.getvalue()
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self.enabled:
|
||||
self.log.warning(f"Plugin disabled, not starting.")
|
||||
if self.started:
|
||||
self.log.warning("Ignoring start() call to already started plugin")
|
||||
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()
|
||||
if config_class:
|
||||
self.config = config_class(self.load_config, self.load_config_base,
|
||||
self.save_config)
|
||||
self.plugin = cls(self.client.client, self.id, self.log, self.config,
|
||||
self.mb_config["plugin_db_directory"])
|
||||
self.loader.references |= {self}
|
||||
await self.plugin.start()
|
||||
try:
|
||||
base = await self.loader.read_file("base-config.yaml")
|
||||
self.base_cfg = RecursiveDict(yaml.load(base.decode("utf-8")), CommentedMap)
|
||||
except (FileNotFoundError, KeyError):
|
||||
self.base_cfg = None
|
||||
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} "
|
||||
f"with user {self.client.id}")
|
||||
|
||||
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.loader.references -= {self}
|
||||
await self.plugin.stop()
|
||||
self.started = False
|
||||
try:
|
||||
await self.plugin.stop()
|
||||
except Exception:
|
||||
self.log.exception("Failed to stop instance")
|
||||
self.plugin = None
|
||||
|
||||
@classmethod
|
||||
@ -116,6 +158,39 @@ class PluginInstance:
|
||||
def all(cls) -> List['PluginInstance']:
|
||||
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
|
||||
|
||||
@property
|
||||
@ -130,28 +205,19 @@ class PluginInstance:
|
||||
def type(self) -> str:
|
||||
return self.db_instance.type
|
||||
|
||||
@type.setter
|
||||
def type(self, value: str) -> None:
|
||||
self.db_instance.type = value
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.db_instance.enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool) -> None:
|
||||
self.db_instance.enabled = value
|
||||
|
||||
@property
|
||||
def primary_user(self) -> UserID:
|
||||
return self.db_instance.primary_user
|
||||
|
||||
@primary_user.setter
|
||||
def primary_user(self, value: UserID) -> None:
|
||||
self.db_instance.primary_user = value
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
def init(config: Config):
|
||||
def init(db: Session, config: Config, loop: AbstractEventLoop) -> List[PluginInstance]:
|
||||
PluginInstance.db = db
|
||||
PluginInstance.mb_config = config
|
||||
PluginInstance.loop = loop
|
||||
return PluginInstance.all()
|
@ -1,2 +1,2 @@
|
||||
from .abc import PluginLoader, PluginClass
|
||||
from .abc import PluginLoader, PluginClass, IDConflictError
|
||||
from .zip import ZippedPluginLoader, MaubotZipImportError
|
||||
|
@ -15,11 +15,12 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import TypeVar, Type, Dict, Set, TYPE_CHECKING
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
|
||||
from ..plugin_base import Plugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..plugin import PluginInstance
|
||||
from ..instance import PluginInstance
|
||||
|
||||
PluginClass = TypeVar("PluginClass", bound=Plugin)
|
||||
|
||||
@ -42,23 +43,42 @@ class PluginLoader(ABC):
|
||||
def find(cls, plugin_id: str) -> 'PluginLoader':
|
||||
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
|
||||
@abstractmethod
|
||||
def source(self) -> str:
|
||||
pass
|
||||
|
||||
@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
|
||||
|
||||
@abstractmethod
|
||||
def load(self) -> Type[PluginClass]:
|
||||
async def reload(self) -> Type[PluginClass]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def reload(self) -> Type[PluginClass]:
|
||||
async def unload(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def unload(self) -> None:
|
||||
async def delete(self) -> None:
|
||||
pass
|
||||
|
@ -13,8 +13,9 @@
|
||||
#
|
||||
# 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 Dict, List, Type
|
||||
from typing import Dict, List, Type, Tuple, Optional
|
||||
from zipfile import ZipFile, BadZipFile
|
||||
from time import time
|
||||
import configparser
|
||||
import logging
|
||||
import sys
|
||||
@ -22,6 +23,7 @@ import os
|
||||
|
||||
from ..lib.zipimport import zipimporter, ZipImportError
|
||||
from ..plugin_base import Plugin
|
||||
from ..config import Config
|
||||
from .abc import PluginLoader, PluginClass, IDConflictError
|
||||
|
||||
|
||||
@ -29,9 +31,23 @@ class MaubotZipImportError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MaubotZipMetaError(MaubotZipImportError):
|
||||
pass
|
||||
|
||||
|
||||
class MaubotZipPreLoadError(MaubotZipImportError):
|
||||
pass
|
||||
|
||||
|
||||
class MaubotZipLoadError(MaubotZipImportError):
|
||||
pass
|
||||
|
||||
|
||||
class ZippedPluginLoader(PluginLoader):
|
||||
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
|
||||
id: str
|
||||
@ -60,8 +76,15 @@ class ZippedPluginLoader(PluginLoader):
|
||||
self.id_cache[self.id] = self
|
||||
self.log.debug(f"Preloaded plugin {self.id} from {self.path}")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
**super().to_dict(),
|
||||
"path": self.path
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get(cls, path: str) -> 'ZippedPluginLoader':
|
||||
path = os.path.abspath(path)
|
||||
try:
|
||||
return cls.path_cache[path]
|
||||
except KeyError:
|
||||
@ -77,22 +100,30 @@ class ZippedPluginLoader(PluginLoader):
|
||||
f"id='{self.id}' "
|
||||
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)
|
||||
|
||||
def _load_meta(self) -> None:
|
||||
@staticmethod
|
||||
def _open_meta(source) -> Tuple[ZipFile, configparser.ConfigParser]:
|
||||
try:
|
||||
self._file = ZipFile(self.path)
|
||||
data = self._file.read("maubot.ini")
|
||||
file = ZipFile(source)
|
||||
data = file.read("maubot.ini")
|
||||
except FileNotFoundError as e:
|
||||
raise MaubotZipImportError("Maubot plugin not found") from e
|
||||
raise MaubotZipMetaError("Maubot plugin not found") from 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:
|
||||
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()
|
||||
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_id = meta["ID"]
|
||||
version = meta["Version"]
|
||||
@ -102,67 +133,96 @@ class ZippedPluginLoader(PluginLoader):
|
||||
if "/" in main_class:
|
||||
main_module, main_class = main_class.split("/")[:2]
|
||||
except (configparser.Error, KeyError, IndexError, ValueError) as e:
|
||||
raise MaubotZipImportError("Maubot plugin definition in file is invalid") from e
|
||||
if self.id and meta_id != self.id:
|
||||
raise MaubotZipImportError("Maubot plugin ID changed during reload")
|
||||
self.id, self.version, self.modules = meta_id, version, modules
|
||||
self.main_class, self.main_module = main_class, main_module
|
||||
raise MaubotZipMetaError("Maubot plugin definition in file is invalid") from e
|
||||
return meta_id, version, modules, main_class, main_module
|
||||
|
||||
@classmethod
|
||||
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:
|
||||
try:
|
||||
if not self._importer:
|
||||
if not self._importer or self._importer.archive != self.path:
|
||||
self._importer = zipimporter(self.path)
|
||||
if reset_cache:
|
||||
self._importer.reset_cache()
|
||||
return self._importer
|
||||
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:
|
||||
try:
|
||||
code = importer.get_code(self.main_module.replace(".", "/"))
|
||||
if self.main_class not in code.co_names:
|
||||
raise MaubotZipImportError(
|
||||
raise MaubotZipPreLoadError(
|
||||
f"Main class {self.main_class} not in {self.main_module}")
|
||||
except ZipImportError as e:
|
||||
raise MaubotZipImportError(
|
||||
raise MaubotZipPreLoadError(
|
||||
f"Main module {self.main_module} not found in file") from e
|
||||
for module in self.modules:
|
||||
try:
|
||||
importer.find_module(module)
|
||||
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:
|
||||
return self._loaded
|
||||
importer = self._get_importer(reset_cache=reset_cache)
|
||||
self._run_preload_checks(importer)
|
||||
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:
|
||||
importer.load_module(module)
|
||||
main_mod = sys.modules[self.main_module]
|
||||
plugin = getattr(main_mod, self.main_class)
|
||||
try:
|
||||
importer.load_module(module)
|
||||
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):
|
||||
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.log.debug(f"Loaded and imported plugin {self.id} from {self.path}")
|
||||
return plugin
|
||||
|
||||
def reload(self) -> Type[PluginClass]:
|
||||
self.unload()
|
||||
return self.load(reset_cache=True)
|
||||
async def reload(self, new_path: Optional[str] = None) -> Type[PluginClass]:
|
||||
await self.unload()
|
||||
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()):
|
||||
if getattr(mod, "__file__", "").startswith(self.path):
|
||||
del sys.modules[name]
|
||||
self._loaded = None
|
||||
self.log.debug(f"Unloaded plugin {self.id} at {self.path}")
|
||||
|
||||
def destroy(self) -> None:
|
||||
self.unload()
|
||||
async def delete(self) -> None:
|
||||
await self.unload()
|
||||
try:
|
||||
del self.path_cache[self.path]
|
||||
except KeyError:
|
||||
@ -171,24 +231,43 @@ class ZippedPluginLoader(PluginLoader):
|
||||
del self.id_cache[self.id]
|
||||
except KeyError:
|
||||
pass
|
||||
self.id = None
|
||||
self.path = None
|
||||
self.version = None
|
||||
self.modules = None
|
||||
if self._importer:
|
||||
self._importer.remove_cache()
|
||||
self._importer = None
|
||||
self._loaded = None
|
||||
os.remove(self.path)
|
||||
self.id = None
|
||||
self.path = None
|
||||
self.version = None
|
||||
self.modules = None
|
||||
|
||||
@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...")
|
||||
for directory in args:
|
||||
for directory in cls.directories:
|
||||
for file in os.listdir(directory):
|
||||
if not file.endswith(".mbp"):
|
||||
continue
|
||||
path = os.path.join(directory, file)
|
||||
path = os.path.abspath(os.path.join(directory, file))
|
||||
try:
|
||||
ZippedPluginLoader.get(path)
|
||||
except (MaubotZipImportError, IDConflictError):
|
||||
cls.log.exception(f"Failed to load plugin at {path}")
|
||||
cls.get(path)
|
||||
except MaubotZipImportError:
|
||||
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()
|
||||
|
32
maubot/management/api/__init__.py
Normal file
32
maubot/management/api/__init__.py
Normal 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
|
63
maubot/management/api/auth.py
Normal file
63
maubot/management/api/auth.py
Normal 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
|
30
maubot/management/api/base.py
Normal file
30
maubot/management/api/base.py
Normal 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
|
131
maubot/management/api/client.py
Normal file
131
maubot/management/api/client.py
Normal 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
|
101
maubot/management/api/instance.py
Normal file
101
maubot/management/api/instance.py
Normal 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
|
58
maubot/management/api/middleware.py
Normal file
58
maubot/management/api/middleware.py
Normal 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
|
130
maubot/management/api/plugin.py
Normal file
130
maubot/management/api/plugin.py
Normal 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
|
145
maubot/management/api/responses.py
Normal file
145
maubot/management/api/responses.py
Normal 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)
|
458
maubot/management/api/spec.yaml
Normal file
458
maubot/management/api/spec.yaml
Normal 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'
|
70
maubot/management/frontend/.eslintrc.json
Normal file
70
maubot/management/frontend/.eslintrc.json
Normal 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
5
maubot/management/frontend/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/node_modules
|
||||
/build
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
25
maubot/management/frontend/package.json
Normal file
25
maubot/management/frontend/package.json
Normal 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"
|
||||
]
|
||||
}
|
BIN
maubot/management/frontend/public/favicon.ico
Normal file
BIN
maubot/management/frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
maubot/management/frontend/public/favicon.png
Normal file
BIN
maubot/management/frontend/public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
34
maubot/management/frontend/public/index.html
Normal file
34
maubot/management/frontend/public/index.html
Normal 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>
|
15
maubot/management/frontend/public/manifest.json
Normal file
15
maubot/management/frontend/public/manifest.json
Normal 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"
|
||||
}
|
33
maubot/management/frontend/src/MaubotManager.js
Normal file
33
maubot/management/frontend/src/MaubotManager.js
Normal 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
|
21
maubot/management/frontend/src/index.js
Normal file
21
maubot/management/frontend/src/index.js
Normal 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"))
|
53
maubot/management/frontend/src/style/base/body.sass
Normal file
53
maubot/management/frontend/src/style/base/body.sass
Normal 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
|
28
maubot/management/frontend/src/style/base/vars.sass
Normal file
28
maubot/management/frontend/src/style/base/vars.sass
Normal 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
|
17
maubot/management/frontend/src/style/index.sass
Normal file
17
maubot/management/frontend/src/style/index.sass
Normal 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
|
10103
maubot/management/frontend/yarn.lock
Normal file
10103
maubot/management/frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,8 @@
|
||||
from typing import Type, Optional, TYPE_CHECKING
|
||||
from logging import Logger
|
||||
from abc import ABC, abstractmethod
|
||||
from asyncio import AbstractEventLoop
|
||||
from aiohttp import ClientSession
|
||||
import os.path
|
||||
|
||||
from sqlalchemy.engine.base import Engine
|
||||
@ -26,22 +28,30 @@ if TYPE_CHECKING:
|
||||
from .command_spec import CommandSpec
|
||||
from mautrix.util.config import BaseProxyConfig
|
||||
|
||||
DatabaseNotConfigured = ValueError("A database for this maubot instance has not been configured.")
|
||||
|
||||
|
||||
class Plugin(ABC):
|
||||
client: 'MaubotMatrixClient'
|
||||
id: str
|
||||
log: Logger
|
||||
loop: AbstractEventLoop
|
||||
config: Optional['BaseProxyConfig']
|
||||
|
||||
def __init__(self, client: 'MaubotMatrixClient', plugin_instance_id: str, log: Logger,
|
||||
config: Optional['BaseProxyConfig'], db_base_path: str) -> None:
|
||||
def __init__(self, client: 'MaubotMatrixClient', loop: AbstractEventLoop, http: ClientSession,
|
||||
plugin_instance_id: str, log: Logger, config: Optional['BaseProxyConfig'],
|
||||
db_base_path: str) -> None:
|
||||
self.client = client
|
||||
self.loop = loop
|
||||
self.http = http
|
||||
self.id = plugin_instance_id
|
||||
self.log = log
|
||||
self.config = config
|
||||
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")
|
||||
|
||||
def set_command_spec(self, spec: 'CommandSpec') -> None:
|
||||
@ -58,3 +68,7 @@ class Plugin(ABC):
|
||||
@classmethod
|
||||
def get_config_class(cls) -> Optional[Type['BaseProxyConfig']]:
|
||||
return None
|
||||
|
||||
def on_external_config_update(self) -> None:
|
||||
if self.config:
|
||||
self.config.load_and_update()
|
||||
|
@ -14,6 +14,7 @@
|
||||
# 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
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from mautrix.api import PathBuilder, Method
|
||||
@ -23,13 +24,17 @@ from .__meta__ import __version__
|
||||
|
||||
|
||||
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.app = web.Application(loop=self.loop)
|
||||
self.config = config
|
||||
|
||||
path = PathBuilder(config["server.base_path"])
|
||||
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"])
|
||||
self.add_route(Method.PUT, as_path.transactions, self.handle_transaction)
|
||||
@ -43,6 +48,7 @@ class MaubotServer:
|
||||
await self.runner.setup()
|
||||
site = web.TCPSite(self.runner, self.config["server.hostname"], self.config["server.port"])
|
||||
await site.start()
|
||||
self.log.info(f"Listening on {site.name}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
await self.runner.cleanup()
|
||||
|
@ -5,3 +5,4 @@ alembic
|
||||
commonmark
|
||||
ruamel.yaml
|
||||
attrs
|
||||
bcrypt
|
||||
|
5
setup.py
5
setup.py
@ -28,6 +28,7 @@ setuptools.setup(
|
||||
"commonmark>=0.8.1,<1",
|
||||
"ruamel.yaml>=0.15.35,<0.16",
|
||||
"attrs>=18.1.0,<19",
|
||||
"bcrypt>=3.1.4,<4",
|
||||
],
|
||||
|
||||
classifiers=[
|
||||
@ -47,4 +48,8 @@ setuptools.setup(
|
||||
data_files=[
|
||||
(".", ["example-config.yaml"]),
|
||||
],
|
||||
package_data={
|
||||
"maubot": ["management/frontend/build/*", "management/frontend/build/static/css/*",
|
||||
"management/frontend/build/static/js/*"],
|
||||
},
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user