commit
a7a4c07411
@ -1,15 +1,16 @@
|
|||||||
root = true
|
root = true
|
||||||
|
|
||||||
[*]
|
[*]
|
||||||
indent_style = tab
|
indent_style = space
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
|
||||||
[*.py]
|
|
||||||
max_line_length = 99
|
max_line_length = 99
|
||||||
|
|
||||||
[*.{yaml,yml,py}]
|
[*.json]
|
||||||
indent_style = space
|
indent_size = 2
|
||||||
|
|
||||||
|
[spec.yaml]
|
||||||
|
indent_size = 2
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,8 +8,9 @@ pip-selfcheck.json
|
|||||||
__pycache__
|
__pycache__
|
||||||
|
|
||||||
*.db
|
*.db
|
||||||
*.yaml
|
/*.yaml
|
||||||
!example-config.yaml
|
!example-config.yaml
|
||||||
|
|
||||||
logs/
|
logs/
|
||||||
plugins/
|
plugins/
|
||||||
|
trash/
|
||||||
|
@ -6,6 +6,8 @@ RUN apk add --no-cache \
|
|||||||
py3-aiohttp \
|
py3-aiohttp \
|
||||||
py3-sqlalchemy \
|
py3-sqlalchemy \
|
||||||
py3-attrs \
|
py3-attrs \
|
||||||
|
py3-bcrypt \
|
||||||
|
py3-cffi \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
&& pip3 install -r requirements.txt
|
&& pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
# maubot
|
# maubot
|
||||||
A plugin-based [Matrix](https://matrix.org) bot system written in Python.
|
A plugin-based [Matrix](https://matrix.org) bot system written in Python.
|
||||||
|
|
||||||
|
Management API spec: [maubot.xyz/spec](https://maubot.xyz/spec)
|
||||||
|
|
||||||
## Discussion
|
## Discussion
|
||||||
Matrix room: [#maubot:maunium.net](https://matrix.to/#/#maubot:maunium.net)
|
Matrix room: [#maubot:maunium.net](https://matrix.to/#/#maubot:maunium.net)
|
||||||
|
|
||||||
@ -13,12 +15,12 @@ Matrix room: [#maubot:maunium.net](https://matrix.to/#/#maubot:maunium.net)
|
|||||||
* [dice](https://github.com/maubot/dice) - A combined dice rolling and calculator bot.
|
* [dice](https://github.com/maubot/dice) - A combined dice rolling and calculator bot.
|
||||||
* [karma](https://github.com/maubot/karma) - A user karma tracker bot.
|
* [karma](https://github.com/maubot/karma) - A user karma tracker bot.
|
||||||
* [xkcd](https://github.com/maubot/xkcd) - A bot to view xkcd comics.
|
* [xkcd](https://github.com/maubot/xkcd) - A bot to view xkcd comics.
|
||||||
|
* [echo](https://github.com/maubot/echo) - A bot that echoes pings and other stuff.
|
||||||
|
|
||||||
### Upcoming
|
### Upcoming
|
||||||
* rss - A bot that posts new RSS entries to rooms.
|
* rss - A bot that posts new RSS entries to rooms.
|
||||||
* dictionary - A bot to get the dictionary definitions of words.
|
* dictionary - A bot to get the dictionary definitions of words.
|
||||||
* poll - A simple poll bot.
|
* poll - A simple poll bot.
|
||||||
* echo - A very simple echo bot.
|
|
||||||
* reminder - A bot to ping you about something after a certain amount of time.
|
* reminder - A bot to ping you about something after a certain amount of time.
|
||||||
* github - A GitHub client and webhook receiver bot.
|
* github - A GitHub client and webhook receiver bot.
|
||||||
* wolfram - A Wolfram Alpha bot
|
* wolfram - A Wolfram Alpha bot
|
||||||
|
@ -5,19 +5,20 @@ cd /opt/maubot
|
|||||||
# Replace database path in config.
|
# Replace database path in config.
|
||||||
sed -i "s#sqlite:///maubot.db#sqlite:////data/maubot.db#" /data/config.yaml
|
sed -i "s#sqlite:///maubot.db#sqlite:////data/maubot.db#" /data/config.yaml
|
||||||
sed -i "s#- ./plugins#- /data/plugins#" /data/config.yaml
|
sed -i "s#- ./plugins#- /data/plugins#" /data/config.yaml
|
||||||
|
sed -i "s#upload: ./plugins#upload: /data/plugins#" /data/config.yaml
|
||||||
|
sed -i "s#trash: ./trash#trash: /data/trash#" /data/config.yaml
|
||||||
|
sed -i "s#db: ./plugins#trash: /data/dbs#" /data/config.yaml
|
||||||
sed -i "s#./logs/maubot.log#/var/log/maubot/maubot.log#" /data/config.yaml
|
sed -i "s#./logs/maubot.log#/var/log/maubot/maubot.log#" /data/config.yaml
|
||||||
|
|
||||||
mkdir -p /var/log/maubot
|
mkdir -p /var/log/maubot /data/plugins /data/trash /data/dbs
|
||||||
|
|
||||||
# Check that database is in the right state
|
# Check that database is in the right state
|
||||||
alembic -x config=/data/config.yaml upgrade head
|
alembic -x config=/data/config.yaml upgrade head
|
||||||
|
|
||||||
if [ ! -f /data/config.yaml ]; then
|
if [ ! -f /data/config.yaml ]; then
|
||||||
cp example-config.yaml /data/config.yaml
|
cp example-config.yaml /data/config.yaml
|
||||||
echo "Didn't find a config file."
|
echo "Config file not found. Example config copied to /data/config.yaml"
|
||||||
echo "Copied default config file to /data/config.yaml"
|
echo "Please modify the config file to your liking and restart the container."
|
||||||
echo "Modify that config file to your liking."
|
|
||||||
echo "Start the container again after that to generate the registration file."
|
|
||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -5,27 +5,35 @@
|
|||||||
# Postgres: postgres://username:password@hostname/dbname
|
# Postgres: postgres://username:password@hostname/dbname
|
||||||
database: sqlite:///maubot.db
|
database: sqlite:///maubot.db
|
||||||
|
|
||||||
# The directory where plugin databases should be stored.
|
|
||||||
plugin_db_directory: ./plugins
|
|
||||||
|
|
||||||
# If multiple directories have a plugin with the same name, the first directory is used.
|
|
||||||
plugin_directories:
|
plugin_directories:
|
||||||
- ./plugins
|
# The directory where uploaded new plugins should be stored.
|
||||||
|
upload: ./plugins
|
||||||
|
# The directories from which plugins should be loaded.
|
||||||
|
# Duplicate plugin IDs will be moved to the trash.
|
||||||
|
load:
|
||||||
|
- ./plugins
|
||||||
|
# The directory where old plugin versions and conflicting plugins should be moved.
|
||||||
|
# Set to "delete" to delete files immediately.
|
||||||
|
trash: ./trash
|
||||||
|
# The directory where plugin databases should be stored.
|
||||||
|
db: ./plugins
|
||||||
|
|
||||||
server:
|
server:
|
||||||
# The IP and port to listen to.
|
# The IP and port to listen to.
|
||||||
hostname: 0.0.0.0
|
hostname: 0.0.0.0
|
||||||
port: 29316
|
port: 29316
|
||||||
# The base management API path.
|
# The base management API path.
|
||||||
base_path: /_matrix/maubot
|
base_path: /_matrix/maubot/v1
|
||||||
# The base appservice API path. Use / for legacy appservice API and /_matrix/app/v1 for v1.
|
# The base appservice API path. Use / for legacy appservice API and /_matrix/app/v1 for v1.
|
||||||
appservice_base_path: /_matrix/app/v1
|
appservice_base_path: /_matrix/app/v1
|
||||||
# The shared secret to authorize users of the API.
|
# The shared secret to sign API access tokens.
|
||||||
# Set to "generate" to generate and save a new token at startup.
|
# Set to "generate" to generate and save a new token at startup.
|
||||||
shared_secret: generate
|
unshared_secret: generate
|
||||||
|
|
||||||
|
# List of administrator users. Plaintext passwords will be bcrypted on startup. Set empty password
|
||||||
|
# to prevent normal login. Root is a special user that can't have a password and will always exist.
|
||||||
admins:
|
admins:
|
||||||
- "@admin:example.com"
|
root: ""
|
||||||
|
|
||||||
# Python logging configuration.
|
# Python logging configuration.
|
||||||
#
|
#
|
||||||
|
@ -13,21 +13,20 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from sqlalchemy import orm
|
|
||||||
import sqlalchemy as sql
|
|
||||||
import logging.config
|
import logging.config
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import signal
|
||||||
import copy
|
import copy
|
||||||
import sys
|
import sys
|
||||||
import signal
|
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .db import Base, init as init_db
|
from .db import init as init_db
|
||||||
from .server import MaubotServer
|
from .server import MaubotServer
|
||||||
from .client import Client, init as init_client
|
from .client import Client, init as init_client_class
|
||||||
from .loader import ZippedPluginLoader
|
from .loader.zip import init as init_zip_loader
|
||||||
from .plugin import PluginInstance, init as init_plugin_instance_class
|
from .instance import init as init_plugin_instance_class
|
||||||
|
from .management.api import init as init_management
|
||||||
from .__meta__ import __version__
|
from .__meta__ import __version__
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.",
|
parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.",
|
||||||
@ -45,22 +44,16 @@ config.update()
|
|||||||
|
|
||||||
logging.config.dictConfig(copy.deepcopy(config["logging"]))
|
logging.config.dictConfig(copy.deepcopy(config["logging"]))
|
||||||
log = logging.getLogger("maubot.init")
|
log = logging.getLogger("maubot.init")
|
||||||
log.debug(f"Initializing maubot {__version__}")
|
log.info(f"Initializing maubot {__version__}")
|
||||||
|
|
||||||
db_engine: sql.engine.Engine = sql.create_engine(config["database"])
|
|
||||||
db_factory = orm.sessionmaker(bind=db_engine)
|
|
||||||
db_session = orm.scoping.scoped_session(db_factory)
|
|
||||||
Base.metadata.bind = db_engine
|
|
||||||
Base.metadata.create_all()
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
init_db(db_session)
|
init_zip_loader(config)
|
||||||
init_client(loop)
|
db_session = init_db(config)
|
||||||
init_plugin_instance_class(config)
|
clients = init_client_class(db_session, loop)
|
||||||
server = MaubotServer(config, loop)
|
plugins = init_plugin_instance_class(db_session, config, loop)
|
||||||
ZippedPluginLoader.load_all(*config["plugin_directories"])
|
management_api = init_management(config, loop)
|
||||||
plugins = PluginInstance.all()
|
server = MaubotServer(config, management_api, loop)
|
||||||
|
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
plugin.load()
|
plugin.load()
|
||||||
@ -68,28 +61,34 @@ for plugin in plugins:
|
|||||||
signal.signal(signal.SIGINT, signal.default_int_handler)
|
signal.signal(signal.SIGINT, signal.default_int_handler)
|
||||||
signal.signal(signal.SIGTERM, signal.default_int_handler)
|
signal.signal(signal.SIGTERM, signal.default_int_handler)
|
||||||
|
|
||||||
stop = False
|
|
||||||
|
|
||||||
|
|
||||||
async def periodic_commit():
|
async def periodic_commit():
|
||||||
while not stop:
|
while True:
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
periodic_commit_task: asyncio.Future = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop.run_until_complete(asyncio.gather(
|
log.info("Starting server")
|
||||||
server.start(),
|
loop.run_until_complete(server.start())
|
||||||
*[plugin.start() for plugin in plugins]))
|
log.info("Starting clients and plugins")
|
||||||
log.debug("Startup actions complete, running forever")
|
loop.run_until_complete(asyncio.gather(*[client.start() for client in clients], loop=loop))
|
||||||
loop.run_until_complete(periodic_commit())
|
log.info("Startup actions complete, running forever")
|
||||||
|
periodic_commit_task = asyncio.ensure_future(periodic_commit(), loop=loop)
|
||||||
loop.run_forever()
|
loop.run_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
log.debug("Interrupt received, stopping HTTP clients/servers and saving database")
|
log.info("Interrupt received, stopping HTTP clients/servers and saving database")
|
||||||
stop = True
|
if periodic_commit_task is not None:
|
||||||
for client in Client.cache.values():
|
periodic_commit_task.cancel()
|
||||||
client.stop()
|
log.debug("Stopping clients")
|
||||||
|
loop.run_until_complete(asyncio.gather(*[client.stop() for client in Client.cache.values()],
|
||||||
|
loop=loop))
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
log.debug("Stopping server")
|
||||||
loop.run_until_complete(server.stop())
|
loop.run_until_complete(server.stop())
|
||||||
|
log.debug("Closing event loop")
|
||||||
|
loop.close()
|
||||||
log.debug("Everything stopped, shutting down")
|
log.debug("Everything stopped, shutting down")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
@ -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
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Set, TYPE_CHECKING
|
||||||
from aiohttp import ClientSession
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
|
from mautrix.errors import MatrixInvalidToken, MatrixRequestError
|
||||||
from mautrix.types import (UserID, SyncToken, FilterID, ContentURI, StrippedStateEvent, Membership,
|
from mautrix.types import (UserID, SyncToken, FilterID, ContentURI, StrippedStateEvent, Membership,
|
||||||
EventType, Filter, RoomFilter, RoomEventFilter)
|
EventType, Filter, RoomFilter, RoomEventFilter)
|
||||||
|
|
||||||
from .db import DBClient
|
from .db import DBClient
|
||||||
from .matrix import MaubotMatrixClient
|
from .matrix import MaubotMatrixClient
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .instance import PluginInstance
|
||||||
|
|
||||||
log = logging.getLogger("maubot.client")
|
log = logging.getLogger("maubot.client")
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
class Client:
|
||||||
loop: asyncio.AbstractEventLoop
|
db: Session = None
|
||||||
|
log: logging.Logger = None
|
||||||
|
loop: asyncio.AbstractEventLoop = None
|
||||||
cache: Dict[UserID, 'Client'] = {}
|
cache: Dict[UserID, 'Client'] = {}
|
||||||
http_client: ClientSession = None
|
http_client: ClientSession = None
|
||||||
|
|
||||||
|
references: Set['PluginInstance']
|
||||||
db_instance: DBClient
|
db_instance: DBClient
|
||||||
client: MaubotMatrixClient
|
client: MaubotMatrixClient
|
||||||
|
started: bool
|
||||||
|
|
||||||
def __init__(self, db_instance: DBClient) -> None:
|
def __init__(self, db_instance: DBClient) -> None:
|
||||||
self.db_instance = db_instance
|
self.db_instance = db_instance
|
||||||
self.cache[self.id] = self
|
self.cache[self.id] = self
|
||||||
self.log = log.getChild(self.id)
|
self.log = log.getChild(self.id)
|
||||||
|
self.references = set()
|
||||||
|
self.started = False
|
||||||
self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver,
|
self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver,
|
||||||
token=self.access_token, client_session=self.http_client,
|
token=self.access_token, client_session=self.http_client,
|
||||||
log=self.log, loop=self.loop, store=self.db_instance)
|
log=self.log, loop=self.loop, store=self.db_instance)
|
||||||
if self.autojoin:
|
if self.autojoin:
|
||||||
self.client.add_event_handler(self._handle_invite, EventType.ROOM_MEMBER)
|
self.client.add_event_handler(self._handle_invite, EventType.ROOM_MEMBER)
|
||||||
|
|
||||||
def start(self) -> None:
|
async def start(self, try_n: Optional[int] = 0) -> None:
|
||||||
asyncio.ensure_future(self._start(), loop=self.loop)
|
|
||||||
|
|
||||||
async def _start(self) -> None:
|
|
||||||
try:
|
try:
|
||||||
if not self.filter_id:
|
if try_n > 0:
|
||||||
self.filter_id = await self.client.create_filter(Filter(
|
await asyncio.sleep(try_n * 10)
|
||||||
room=RoomFilter(
|
await self._start(try_n)
|
||||||
timeline=RoomEventFilter(
|
|
||||||
limit=50,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
if self.displayname != "disable":
|
|
||||||
await self.client.set_displayname(self.displayname)
|
|
||||||
if self.avatar_url != "disable":
|
|
||||||
await self.client.set_avatar_url(self.avatar_url)
|
|
||||||
await self.client.start(self.filter_id)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception("starting raised exception")
|
self.log.exception("Failed to start")
|
||||||
|
|
||||||
def stop(self) -> None:
|
async def _start(self, try_n: Optional[int] = 0) -> None:
|
||||||
|
if not self.enabled:
|
||||||
|
self.log.debug("Not starting disabled client")
|
||||||
|
return
|
||||||
|
elif self.started:
|
||||||
|
self.log.warning("Ignoring start() call to started client")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
user_id = await self.client.whoami()
|
||||||
|
except MatrixInvalidToken as e:
|
||||||
|
self.log.error(f"Invalid token: {e}. Disabling client")
|
||||||
|
self.db_instance.enabled = False
|
||||||
|
return
|
||||||
|
except MatrixRequestError:
|
||||||
|
if try_n >= 5:
|
||||||
|
self.log.exception("Failed to get /account/whoami, disabling client")
|
||||||
|
self.db_instance.enabled = False
|
||||||
|
else:
|
||||||
|
self.log.exception(f"Failed to get /account/whoami, "
|
||||||
|
f"retrying in {(try_n + 1) * 10}s")
|
||||||
|
_ = asyncio.ensure_future(self.start(try_n + 1), loop=self.loop)
|
||||||
|
return
|
||||||
|
if user_id != self.id:
|
||||||
|
self.log.error(f"User ID mismatch: expected {self.id}, but got {user_id}")
|
||||||
|
self.db_instance.enabled = False
|
||||||
|
return
|
||||||
|
if not self.filter_id:
|
||||||
|
self.db_instance.filter_id = await self.client.create_filter(Filter(
|
||||||
|
room=RoomFilter(
|
||||||
|
timeline=RoomEventFilter(
|
||||||
|
limit=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
if self.displayname != "disable":
|
||||||
|
await self.client.set_displayname(self.displayname)
|
||||||
|
if self.avatar_url != "disable":
|
||||||
|
await self.client.set_avatar_url(self.avatar_url)
|
||||||
|
self.start_sync()
|
||||||
|
self.started = True
|
||||||
|
self.log.info("Client started, starting plugin instances...")
|
||||||
|
await self.start_plugins()
|
||||||
|
|
||||||
|
async def start_plugins(self) -> None:
|
||||||
|
await asyncio.gather(*[plugin.start() for plugin in self.references], loop=self.loop)
|
||||||
|
|
||||||
|
async def stop_plugins(self) -> None:
|
||||||
|
await asyncio.gather(*[plugin.stop() for plugin in self.references if plugin.started],
|
||||||
|
loop=self.loop)
|
||||||
|
|
||||||
|
def start_sync(self) -> None:
|
||||||
|
if self.sync:
|
||||||
|
self.client.start(self.filter_id)
|
||||||
|
|
||||||
|
def stop_sync(self) -> None:
|
||||||
self.client.stop()
|
self.client.stop()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self.started:
|
||||||
|
self.started = False
|
||||||
|
await self.stop_plugins()
|
||||||
|
self.stop_sync()
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
try:
|
||||||
|
del self.cache[self.id]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
self.db.delete(self.db_instance)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"homeserver": self.homeserver,
|
||||||
|
"access_token": self.access_token,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"started": self.started,
|
||||||
|
"sync": self.sync,
|
||||||
|
"autojoin": self.autojoin,
|
||||||
|
"displayname": self.displayname,
|
||||||
|
"avatar_url": self.avatar_url,
|
||||||
|
"instances": [instance.to_dict() for instance in self.references],
|
||||||
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get(cls, user_id: UserID, db_instance: Optional[DBClient] = None) -> Optional['Client']:
|
def get(cls, user_id: UserID, db_instance: Optional[DBClient] = None) -> Optional['Client']:
|
||||||
try:
|
try:
|
||||||
@ -87,6 +167,44 @@ class Client:
|
|||||||
if evt.state_key == self.id and evt.content.membership == Membership.INVITE:
|
if evt.state_key == self.id and evt.content.membership == Membership.INVITE:
|
||||||
await self.client.join_room(evt.room_id)
|
await self.client.join_room(evt.room_id)
|
||||||
|
|
||||||
|
async def update_started(self, started: bool) -> None:
|
||||||
|
if started is None or started == self.started:
|
||||||
|
return
|
||||||
|
if started:
|
||||||
|
await self.start()
|
||||||
|
else:
|
||||||
|
await self.stop()
|
||||||
|
|
||||||
|
async def update_displayname(self, displayname: str) -> None:
|
||||||
|
if not displayname or displayname == self.displayname:
|
||||||
|
return
|
||||||
|
self.db_instance.displayname = displayname
|
||||||
|
await self.client.set_displayname(self.displayname)
|
||||||
|
|
||||||
|
async def update_avatar_url(self, avatar_url: ContentURI) -> None:
|
||||||
|
if not avatar_url or avatar_url == self.avatar_url:
|
||||||
|
return
|
||||||
|
self.db_instance.avatar_url = avatar_url
|
||||||
|
await self.client.set_avatar_url(self.avatar_url)
|
||||||
|
|
||||||
|
async def update_access_details(self, access_token: str, homeserver: str) -> None:
|
||||||
|
if not access_token and not homeserver:
|
||||||
|
return
|
||||||
|
elif access_token == self.access_token and homeserver == self.homeserver:
|
||||||
|
return
|
||||||
|
new_client = MaubotMatrixClient(mxid=self.id, base_url=homeserver or self.homeserver,
|
||||||
|
token=access_token or self.access_token, loop=self.loop,
|
||||||
|
client_session=self.http_client, log=self.log)
|
||||||
|
mxid = await new_client.whoami()
|
||||||
|
if mxid != self.id:
|
||||||
|
raise ValueError("MXID mismatch")
|
||||||
|
new_client.store = self.db_instance
|
||||||
|
self.stop_sync()
|
||||||
|
self.client = new_client
|
||||||
|
self.db_instance.homeserver = homeserver
|
||||||
|
self.db_instance.access_token = access_token
|
||||||
|
self.start_sync()
|
||||||
|
|
||||||
# region Properties
|
# region Properties
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -101,34 +219,36 @@ class Client:
|
|||||||
def access_token(self) -> str:
|
def access_token(self) -> str:
|
||||||
return self.db_instance.access_token
|
return self.db_instance.access_token
|
||||||
|
|
||||||
@access_token.setter
|
@property
|
||||||
def access_token(self, value: str) -> None:
|
def enabled(self) -> bool:
|
||||||
self.client.api.token = value
|
return self.db_instance.enabled
|
||||||
self.db_instance.access_token = value
|
|
||||||
|
@enabled.setter
|
||||||
|
def enabled(self, value: bool) -> None:
|
||||||
|
self.db_instance.enabled = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def next_batch(self) -> SyncToken:
|
def next_batch(self) -> SyncToken:
|
||||||
return self.db_instance.next_batch
|
return self.db_instance.next_batch
|
||||||
|
|
||||||
@next_batch.setter
|
|
||||||
def next_batch(self, value: SyncToken) -> None:
|
|
||||||
self.db_instance.next_batch = value
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filter_id(self) -> FilterID:
|
def filter_id(self) -> FilterID:
|
||||||
return self.db_instance.filter_id
|
return self.db_instance.filter_id
|
||||||
|
|
||||||
@filter_id.setter
|
|
||||||
def filter_id(self, value: FilterID) -> None:
|
|
||||||
self.db_instance.filter_id = value
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sync(self) -> bool:
|
def sync(self) -> bool:
|
||||||
return self.db_instance.sync
|
return self.db_instance.sync
|
||||||
|
|
||||||
@sync.setter
|
@sync.setter
|
||||||
def sync(self, value: bool) -> None:
|
def sync(self, value: bool) -> None:
|
||||||
|
if value == self.db_instance.sync:
|
||||||
|
return
|
||||||
self.db_instance.sync = value
|
self.db_instance.sync = value
|
||||||
|
if self.started:
|
||||||
|
if value:
|
||||||
|
self.start_sync()
|
||||||
|
else:
|
||||||
|
self.stop_sync()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def autojoin(self) -> bool:
|
def autojoin(self) -> bool:
|
||||||
@ -148,23 +268,15 @@ class Client:
|
|||||||
def displayname(self) -> str:
|
def displayname(self) -> str:
|
||||||
return self.db_instance.displayname
|
return self.db_instance.displayname
|
||||||
|
|
||||||
@displayname.setter
|
|
||||||
def displayname(self, value: str) -> None:
|
|
||||||
self.db_instance.displayname = value
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def avatar_url(self) -> ContentURI:
|
def avatar_url(self) -> ContentURI:
|
||||||
return self.db_instance.avatar_url
|
return self.db_instance.avatar_url
|
||||||
|
|
||||||
@avatar_url.setter
|
|
||||||
def avatar_url(self, value: ContentURI) -> None:
|
|
||||||
self.db_instance.avatar_url = value
|
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
def init(loop: asyncio.AbstractEventLoop) -> None:
|
def init(db: Session, loop: asyncio.AbstractEventLoop) -> List[Client]:
|
||||||
|
Client.db = db
|
||||||
Client.http_client = ClientSession(loop=loop)
|
Client.http_client = ClientSession(loop=loop)
|
||||||
Client.loop = loop
|
Client.loop = loop
|
||||||
for client in Client.all():
|
return Client.all()
|
||||||
client.start()
|
|
||||||
|
@ -15,9 +15,13 @@
|
|||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
import bcrypt
|
||||||
|
import re
|
||||||
|
|
||||||
from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper
|
from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper
|
||||||
|
|
||||||
|
bcrypt_regex = re.compile(r"^\$2[ayb]\$.{56}$")
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseFileConfig):
|
class Config(BaseFileConfig):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -27,16 +31,35 @@ class Config(BaseFileConfig):
|
|||||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
||||||
base, copy, _ = helper
|
base, copy, _ = helper
|
||||||
copy("database")
|
copy("database")
|
||||||
copy("plugin_directories")
|
copy("plugin_directories.upload")
|
||||||
copy("plugin_db_directory")
|
copy("plugin_directories.load")
|
||||||
|
copy("plugin_directories.trash")
|
||||||
|
copy("plugin_directories.db")
|
||||||
copy("server.hostname")
|
copy("server.hostname")
|
||||||
copy("server.port")
|
copy("server.port")
|
||||||
copy("server.listen")
|
copy("server.listen")
|
||||||
copy("server.base_path")
|
copy("server.appservice_base_path")
|
||||||
shared_secret = self["server.shared_secret"]
|
shared_secret = self["server.unshared_secret"]
|
||||||
if shared_secret is None or shared_secret == "generate":
|
if shared_secret is None or shared_secret == "generate":
|
||||||
base["server.shared_secret"] = self._new_token()
|
base["server.unshared_secret"] = self._new_token()
|
||||||
else:
|
else:
|
||||||
base["server.shared_secret"] = shared_secret
|
base["server.unshared_secret"] = shared_secret
|
||||||
copy("admins")
|
copy("admins")
|
||||||
|
for username, password in base["admins"].items():
|
||||||
|
if password and not bcrypt_regex.match(password):
|
||||||
|
if password == "password":
|
||||||
|
password = self._new_token()
|
||||||
|
base["admins"][username] = bcrypt.hashpw(password.encode("utf-8"),
|
||||||
|
bcrypt.gensalt()).decode("utf-8")
|
||||||
copy("logging")
|
copy("logging")
|
||||||
|
|
||||||
|
def is_admin(self, user: str) -> bool:
|
||||||
|
return user == "root" or user in self["admins"]
|
||||||
|
|
||||||
|
def check_password(self, user: str, passwd: str) -> bool:
|
||||||
|
if user == "root":
|
||||||
|
return False
|
||||||
|
passwd_hash = self["admins"].get(user, None)
|
||||||
|
if not passwd_hash:
|
||||||
|
return False
|
||||||
|
return bcrypt.checkpw(passwd.encode("utf-8"), passwd_hash.encode("utf-8"))
|
||||||
|
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
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import Type
|
from typing import cast
|
||||||
from sqlalchemy import (Column, String, Boolean, ForeignKey, Text, TypeDecorator)
|
|
||||||
from sqlalchemy.orm import Query, scoped_session
|
from sqlalchemy import Column, String, Boolean, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import Query, Session, sessionmaker, scoped_session
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
import json
|
import sqlalchemy as sql
|
||||||
|
|
||||||
from mautrix.types import UserID, FilterID, SyncToken, ContentURI
|
from mautrix.types import UserID, FilterID, SyncToken, ContentURI
|
||||||
from mautrix.client.api.types.util import Serializable
|
|
||||||
|
|
||||||
from .command_spec import CommandSpec
|
from .config import Config
|
||||||
|
|
||||||
Base: declarative_base = declarative_base()
|
Base: declarative_base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
def make_serializable_alchemy(serializable_type: Type[Serializable]):
|
|
||||||
class SerializableAlchemy(TypeDecorator):
|
|
||||||
impl = Text
|
|
||||||
|
|
||||||
@property
|
|
||||||
def python_type(self):
|
|
||||||
return serializable_type
|
|
||||||
|
|
||||||
def process_literal_param(self, value: Serializable, _) -> str:
|
|
||||||
return json.dumps(value.serialize()) if value is not None else None
|
|
||||||
|
|
||||||
def process_bind_param(self, value: Serializable, _) -> str:
|
|
||||||
return json.dumps(value.serialize()) if value is not None else None
|
|
||||||
|
|
||||||
def process_result_value(self, value: str, _) -> serializable_type:
|
|
||||||
return serializable_type.deserialize(json.loads(value)) if value is not None else None
|
|
||||||
|
|
||||||
return SerializableAlchemy
|
|
||||||
|
|
||||||
|
|
||||||
class DBPlugin(Base):
|
class DBPlugin(Base):
|
||||||
query: Query
|
query: Query
|
||||||
__tablename__ = "plugin"
|
__tablename__ = "plugin"
|
||||||
@ -67,6 +47,7 @@ class DBClient(Base):
|
|||||||
id: UserID = Column(String(255), primary_key=True)
|
id: UserID = Column(String(255), primary_key=True)
|
||||||
homeserver: str = Column(String(255), nullable=False)
|
homeserver: str = Column(String(255), nullable=False)
|
||||||
access_token: str = Column(String(255), nullable=False)
|
access_token: str = Column(String(255), nullable=False)
|
||||||
|
enabled: bool = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
next_batch: SyncToken = Column(String(255), nullable=False, default="")
|
next_batch: SyncToken = Column(String(255), nullable=False, default="")
|
||||||
filter_id: FilterID = Column(String(255), nullable=False, default="")
|
filter_id: FilterID = Column(String(255), nullable=False, default="")
|
||||||
@ -78,20 +59,14 @@ class DBClient(Base):
|
|||||||
avatar_url: ContentURI = Column(String(255), nullable=False, default="")
|
avatar_url: ContentURI = Column(String(255), nullable=False, default="")
|
||||||
|
|
||||||
|
|
||||||
class DBCommandSpec(Base):
|
def init(config: Config) -> Session:
|
||||||
query: Query
|
db_engine: sql.engine.Engine = sql.create_engine(config["database"])
|
||||||
__tablename__ = "command_spec"
|
db_factory = sessionmaker(bind=db_engine)
|
||||||
|
db_session = scoped_session(db_factory)
|
||||||
|
Base.metadata.bind = db_engine
|
||||||
|
Base.metadata.create_all()
|
||||||
|
|
||||||
plugin: str = Column(String(255),
|
DBPlugin.query = db_session.query_property()
|
||||||
ForeignKey("plugin.id", onupdate="CASCADE", ondelete="CASCADE"),
|
DBClient.query = db_session.query_property()
|
||||||
primary_key=True)
|
|
||||||
client: UserID = Column(String(255),
|
|
||||||
ForeignKey("client.id", onupdate="CASCADE", ondelete="CASCADE"),
|
|
||||||
primary_key=True)
|
|
||||||
spec: CommandSpec = Column(make_serializable_alchemy(CommandSpec), nullable=False)
|
|
||||||
|
|
||||||
|
return cast(Session, db_session)
|
||||||
def init(session: scoped_session) -> None:
|
|
||||||
DBPlugin.query = session.query_property()
|
|
||||||
DBClient.query = session.query_property()
|
|
||||||
DBCommandSpec.query = session.query_property()
|
|
||||||
|
@ -14,8 +14,10 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
from ruamel.yaml.comments import CommentedMap
|
from ruamel.yaml.comments import CommentedMap
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
from asyncio import AbstractEventLoop
|
||||||
import logging
|
import logging
|
||||||
import io
|
import io
|
||||||
|
|
||||||
@ -35,7 +37,9 @@ yaml.indent(4)
|
|||||||
|
|
||||||
|
|
||||||
class PluginInstance:
|
class PluginInstance:
|
||||||
|
db: Session = None
|
||||||
mb_config: Config = None
|
mb_config: Config = None
|
||||||
|
loop: AbstractEventLoop = None
|
||||||
cache: Dict[str, 'PluginInstance'] = {}
|
cache: Dict[str, 'PluginInstance'] = {}
|
||||||
plugin_directories: List[str] = []
|
plugin_directories: List[str] = []
|
||||||
|
|
||||||
@ -44,61 +48,99 @@ class PluginInstance:
|
|||||||
client: Client
|
client: Client
|
||||||
plugin: Plugin
|
plugin: Plugin
|
||||||
config: BaseProxyConfig
|
config: BaseProxyConfig
|
||||||
|
base_cfg: RecursiveDict[CommentedMap]
|
||||||
|
started: bool
|
||||||
|
|
||||||
def __init__(self, db_instance: DBPlugin):
|
def __init__(self, db_instance: DBPlugin):
|
||||||
self.db_instance = db_instance
|
self.db_instance = db_instance
|
||||||
self.log = logging.getLogger(f"maubot.plugin.{self.id}")
|
self.log = logging.getLogger(f"maubot.plugin.{self.id}")
|
||||||
self.config = None
|
self.config = None
|
||||||
|
self.started = False
|
||||||
self.cache[self.id] = self
|
self.cache[self.id] = self
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"type": self.type,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"started": self.started,
|
||||||
|
"primary_user": self.primary_user,
|
||||||
|
}
|
||||||
|
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
try:
|
try:
|
||||||
self.loader = PluginLoader.find(self.type)
|
self.loader = PluginLoader.find(self.type)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
self.log.error(f"Failed to find loader for type {self.type}")
|
self.log.error(f"Failed to find loader for type {self.type}")
|
||||||
self.enabled = False
|
self.db_instance.enabled = False
|
||||||
return
|
return
|
||||||
self.client = Client.get(self.primary_user)
|
self.client = Client.get(self.primary_user)
|
||||||
if not self.client:
|
if not self.client:
|
||||||
self.log.error(f"Failed to get client for user {self.primary_user}")
|
self.log.error(f"Failed to get client for user {self.primary_user}")
|
||||||
self.enabled = False
|
self.db_instance.enabled = False
|
||||||
|
return
|
||||||
self.log.debug("Plugin instance dependencies loaded")
|
self.log.debug("Plugin instance dependencies loaded")
|
||||||
|
self.loader.references.add(self)
|
||||||
|
self.client.references.add(self)
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
if self.loader is not None:
|
||||||
|
self.loader.references.remove(self)
|
||||||
|
if self.client is not None:
|
||||||
|
self.client.references.remove(self)
|
||||||
|
try:
|
||||||
|
del self.cache[self.id]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
self.db.delete(self.db_instance)
|
||||||
|
# TODO delete plugin db
|
||||||
|
|
||||||
def load_config(self) -> CommentedMap:
|
def load_config(self) -> CommentedMap:
|
||||||
return yaml.load(self.db_instance.config)
|
return yaml.load(self.db_instance.config)
|
||||||
|
|
||||||
def load_config_base(self) -> Optional[RecursiveDict[CommentedMap]]:
|
|
||||||
try:
|
|
||||||
base = self.loader.read_file("base-config.yaml")
|
|
||||||
return RecursiveDict(yaml.load(base.decode("utf-8")), CommentedMap)
|
|
||||||
except (FileNotFoundError, KeyError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def save_config(self, data: RecursiveDict[CommentedMap]) -> None:
|
def save_config(self, data: RecursiveDict[CommentedMap]) -> None:
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
yaml.dump(data, buf)
|
yaml.dump(data, buf)
|
||||||
self.db_instance.config = buf.getvalue()
|
self.db_instance.config = buf.getvalue()
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
if not self.enabled:
|
if self.started:
|
||||||
self.log.warning(f"Plugin disabled, not starting.")
|
self.log.warning("Ignoring start() call to already started plugin")
|
||||||
return
|
return
|
||||||
cls = self.loader.load()
|
elif not self.enabled:
|
||||||
|
self.log.warning("Plugin disabled, not starting.")
|
||||||
|
return
|
||||||
|
cls = await self.loader.load()
|
||||||
config_class = cls.get_config_class()
|
config_class = cls.get_config_class()
|
||||||
if config_class:
|
if config_class:
|
||||||
self.config = config_class(self.load_config, self.load_config_base,
|
try:
|
||||||
self.save_config)
|
base = await self.loader.read_file("base-config.yaml")
|
||||||
self.plugin = cls(self.client.client, self.id, self.log, self.config,
|
self.base_cfg = RecursiveDict(yaml.load(base.decode("utf-8")), CommentedMap)
|
||||||
self.mb_config["plugin_db_directory"])
|
except (FileNotFoundError, KeyError):
|
||||||
self.loader.references |= {self}
|
self.base_cfg = None
|
||||||
await self.plugin.start()
|
self.config = config_class(self.load_config, lambda: self.base_cfg, self.save_config)
|
||||||
|
self.plugin = cls(self.client.client, self.loop, self.client.http_client, self.id,
|
||||||
|
self.log, self.config, self.mb_config["plugin_directories.db"])
|
||||||
|
try:
|
||||||
|
await self.plugin.start()
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Failed to start instance")
|
||||||
|
self.db_instance.enabled = False
|
||||||
|
return
|
||||||
|
self.started = True
|
||||||
self.log.info(f"Started instance of {self.loader.id} v{self.loader.version} "
|
self.log.info(f"Started instance of {self.loader.id} v{self.loader.version} "
|
||||||
f"with user {self.client.id}")
|
f"with user {self.client.id}")
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
|
if not self.started:
|
||||||
|
self.log.warning("Ignoring stop() call to non-running plugin")
|
||||||
|
return
|
||||||
self.log.debug("Stopping plugin instance...")
|
self.log.debug("Stopping plugin instance...")
|
||||||
self.loader.references -= {self}
|
self.started = False
|
||||||
await self.plugin.stop()
|
try:
|
||||||
|
await self.plugin.stop()
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Failed to stop instance")
|
||||||
self.plugin = None
|
self.plugin = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -116,6 +158,39 @@ class PluginInstance:
|
|||||||
def all(cls) -> List['PluginInstance']:
|
def all(cls) -> List['PluginInstance']:
|
||||||
return [cls.get(plugin.id, plugin) for plugin in DBPlugin.query.all()]
|
return [cls.get(plugin.id, plugin) for plugin in DBPlugin.query.all()]
|
||||||
|
|
||||||
|
def update_id(self, new_id: str) -> None:
|
||||||
|
if new_id is not None and new_id != self.id:
|
||||||
|
self.db_instance.id = new_id
|
||||||
|
|
||||||
|
def update_config(self, config: str) -> None:
|
||||||
|
if not config or self.db_instance.config == config:
|
||||||
|
return
|
||||||
|
self.db_instance.config = config
|
||||||
|
if self.started and self.plugin is not None:
|
||||||
|
self.plugin.on_external_config_update()
|
||||||
|
|
||||||
|
async def update_primary_user(self, primary_user: UserID) -> bool:
|
||||||
|
if not primary_user or primary_user == self.primary_user:
|
||||||
|
return True
|
||||||
|
client = Client.get(primary_user)
|
||||||
|
if not client:
|
||||||
|
return False
|
||||||
|
await self.stop()
|
||||||
|
self.db_instance.primary_user = client.id
|
||||||
|
self.client.references.remove(self)
|
||||||
|
self.client = client
|
||||||
|
await self.start()
|
||||||
|
self.log.debug(f"Primary user switched to {self.client.id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def update_started(self, started: bool) -> None:
|
||||||
|
if started is not None and started != self.started:
|
||||||
|
await (self.start() if started else self.stop())
|
||||||
|
|
||||||
|
def update_enabled(self, enabled: bool) -> None:
|
||||||
|
if enabled is not None and enabled != self.enabled:
|
||||||
|
self.db_instance.enabled = enabled
|
||||||
|
|
||||||
# region Properties
|
# region Properties
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -130,28 +205,19 @@ class PluginInstance:
|
|||||||
def type(self) -> str:
|
def type(self) -> str:
|
||||||
return self.db_instance.type
|
return self.db_instance.type
|
||||||
|
|
||||||
@type.setter
|
|
||||||
def type(self, value: str) -> None:
|
|
||||||
self.db_instance.type = value
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enabled(self) -> bool:
|
def enabled(self) -> bool:
|
||||||
return self.db_instance.enabled
|
return self.db_instance.enabled
|
||||||
|
|
||||||
@enabled.setter
|
|
||||||
def enabled(self, value: bool) -> None:
|
|
||||||
self.db_instance.enabled = value
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def primary_user(self) -> UserID:
|
def primary_user(self) -> UserID:
|
||||||
return self.db_instance.primary_user
|
return self.db_instance.primary_user
|
||||||
|
|
||||||
@primary_user.setter
|
|
||||||
def primary_user(self, value: UserID) -> None:
|
|
||||||
self.db_instance.primary_user = value
|
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
def init(config: Config):
|
def init(db: Session, config: Config, loop: AbstractEventLoop) -> List[PluginInstance]:
|
||||||
|
PluginInstance.db = db
|
||||||
PluginInstance.mb_config = config
|
PluginInstance.mb_config = config
|
||||||
|
PluginInstance.loop = loop
|
||||||
|
return PluginInstance.all()
|
@ -1,2 +1,2 @@
|
|||||||
from .abc import PluginLoader, PluginClass
|
from .abc import PluginLoader, PluginClass, IDConflictError
|
||||||
from .zip import ZippedPluginLoader, MaubotZipImportError
|
from .zip import ZippedPluginLoader, MaubotZipImportError
|
||||||
|
@ -15,11 +15,12 @@
|
|||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import TypeVar, Type, Dict, Set, TYPE_CHECKING
|
from typing import TypeVar, Type, Dict, Set, TYPE_CHECKING
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from ..plugin_base import Plugin
|
from ..plugin_base import Plugin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..plugin import PluginInstance
|
from ..instance import PluginInstance
|
||||||
|
|
||||||
PluginClass = TypeVar("PluginClass", bound=Plugin)
|
PluginClass = TypeVar("PluginClass", bound=Plugin)
|
||||||
|
|
||||||
@ -42,23 +43,42 @@ class PluginLoader(ABC):
|
|||||||
def find(cls, plugin_id: str) -> 'PluginLoader':
|
def find(cls, plugin_id: str) -> 'PluginLoader':
|
||||||
return cls.id_cache[plugin_id]
|
return cls.id_cache[plugin_id]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"version": self.version,
|
||||||
|
"instances": [instance.to_dict() for instance in self.references],
|
||||||
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def source(self) -> str:
|
def source(self) -> str:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def read_file(self, path: str) -> bytes:
|
async def read_file(self, path: str) -> bytes:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def stop_instances(self) -> None:
|
||||||
|
await asyncio.gather(*[instance.stop() for instance
|
||||||
|
in self.references if instance.started])
|
||||||
|
|
||||||
|
async def start_instances(self) -> None:
|
||||||
|
await asyncio.gather(*[instance.start() for instance
|
||||||
|
in self.references if instance.enabled])
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def load(self) -> Type[PluginClass]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def load(self) -> Type[PluginClass]:
|
async def reload(self) -> Type[PluginClass]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def reload(self) -> Type[PluginClass]:
|
async def unload(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def unload(self) -> None:
|
async def delete(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
@ -13,8 +13,9 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import Dict, List, Type
|
from typing import Dict, List, Type, Tuple, Optional
|
||||||
from zipfile import ZipFile, BadZipFile
|
from zipfile import ZipFile, BadZipFile
|
||||||
|
from time import time
|
||||||
import configparser
|
import configparser
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
@ -22,6 +23,7 @@ import os
|
|||||||
|
|
||||||
from ..lib.zipimport import zipimporter, ZipImportError
|
from ..lib.zipimport import zipimporter, ZipImportError
|
||||||
from ..plugin_base import Plugin
|
from ..plugin_base import Plugin
|
||||||
|
from ..config import Config
|
||||||
from .abc import PluginLoader, PluginClass, IDConflictError
|
from .abc import PluginLoader, PluginClass, IDConflictError
|
||||||
|
|
||||||
|
|
||||||
@ -29,9 +31,23 @@ class MaubotZipImportError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MaubotZipMetaError(MaubotZipImportError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MaubotZipPreLoadError(MaubotZipImportError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MaubotZipLoadError(MaubotZipImportError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ZippedPluginLoader(PluginLoader):
|
class ZippedPluginLoader(PluginLoader):
|
||||||
path_cache: Dict[str, 'ZippedPluginLoader'] = {}
|
path_cache: Dict[str, 'ZippedPluginLoader'] = {}
|
||||||
log = logging.getLogger("maubot.loader.zip")
|
log: logging.Logger = logging.getLogger("maubot.loader.zip")
|
||||||
|
trash_path: str = "delete"
|
||||||
|
directories: List[str] = []
|
||||||
|
|
||||||
path: str
|
path: str
|
||||||
id: str
|
id: str
|
||||||
@ -60,8 +76,15 @@ class ZippedPluginLoader(PluginLoader):
|
|||||||
self.id_cache[self.id] = self
|
self.id_cache[self.id] = self
|
||||||
self.log.debug(f"Preloaded plugin {self.id} from {self.path}")
|
self.log.debug(f"Preloaded plugin {self.id} from {self.path}")
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
**super().to_dict(),
|
||||||
|
"path": self.path
|
||||||
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get(cls, path: str) -> 'ZippedPluginLoader':
|
def get(cls, path: str) -> 'ZippedPluginLoader':
|
||||||
|
path = os.path.abspath(path)
|
||||||
try:
|
try:
|
||||||
return cls.path_cache[path]
|
return cls.path_cache[path]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@ -77,22 +100,30 @@ class ZippedPluginLoader(PluginLoader):
|
|||||||
f"id='{self.id}' "
|
f"id='{self.id}' "
|
||||||
f"loaded={self._loaded is not None}>")
|
f"loaded={self._loaded is not None}>")
|
||||||
|
|
||||||
def read_file(self, path: str) -> bytes:
|
async def read_file(self, path: str) -> bytes:
|
||||||
return self._file.read(path)
|
return self._file.read(path)
|
||||||
|
|
||||||
def _load_meta(self) -> None:
|
@staticmethod
|
||||||
|
def _open_meta(source) -> Tuple[ZipFile, configparser.ConfigParser]:
|
||||||
try:
|
try:
|
||||||
self._file = ZipFile(self.path)
|
file = ZipFile(source)
|
||||||
data = self._file.read("maubot.ini")
|
data = file.read("maubot.ini")
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise MaubotZipImportError("Maubot plugin not found") from e
|
raise MaubotZipMetaError("Maubot plugin not found") from e
|
||||||
except BadZipFile as e:
|
except BadZipFile as e:
|
||||||
raise MaubotZipImportError("File is not a maubot plugin") from e
|
raise MaubotZipMetaError("File is not a maubot plugin") from e
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
raise MaubotZipImportError("File does not contain a maubot plugin definition") from e
|
raise MaubotZipMetaError("File does not contain a maubot plugin definition") from e
|
||||||
config = configparser.ConfigParser()
|
config = configparser.ConfigParser()
|
||||||
try:
|
try:
|
||||||
config.read_string(data.decode("utf-8"), source=f"{self.path}/maubot.ini")
|
config.read_string(data.decode("utf-8"))
|
||||||
|
except (configparser.Error, KeyError, IndexError, ValueError) as e:
|
||||||
|
raise MaubotZipMetaError("Maubot plugin definition in file is invalid") from e
|
||||||
|
return file, config
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _read_meta(cls, config: configparser.ConfigParser) -> Tuple[str, str, List[str], str, str]:
|
||||||
|
try:
|
||||||
meta = config["maubot"]
|
meta = config["maubot"]
|
||||||
meta_id = meta["ID"]
|
meta_id = meta["ID"]
|
||||||
version = meta["Version"]
|
version = meta["Version"]
|
||||||
@ -102,67 +133,96 @@ class ZippedPluginLoader(PluginLoader):
|
|||||||
if "/" in main_class:
|
if "/" in main_class:
|
||||||
main_module, main_class = main_class.split("/")[:2]
|
main_module, main_class = main_class.split("/")[:2]
|
||||||
except (configparser.Error, KeyError, IndexError, ValueError) as e:
|
except (configparser.Error, KeyError, IndexError, ValueError) as e:
|
||||||
raise MaubotZipImportError("Maubot plugin definition in file is invalid") from e
|
raise MaubotZipMetaError("Maubot plugin definition in file is invalid") from e
|
||||||
if self.id and meta_id != self.id:
|
return meta_id, version, modules, main_class, main_module
|
||||||
raise MaubotZipImportError("Maubot plugin ID changed during reload")
|
|
||||||
self.id, self.version, self.modules = meta_id, version, modules
|
@classmethod
|
||||||
self.main_class, self.main_module = main_class, main_module
|
def verify_meta(cls, source) -> Tuple[str, str]:
|
||||||
|
_, config = cls._open_meta(source)
|
||||||
|
meta = cls._read_meta(config)
|
||||||
|
return meta[0], meta[1]
|
||||||
|
|
||||||
|
def _load_meta(self) -> None:
|
||||||
|
file, config = self._open_meta(self.path)
|
||||||
|
meta = self._read_meta(config)
|
||||||
|
if self.id and meta[0] != self.id:
|
||||||
|
raise MaubotZipMetaError("Maubot plugin ID changed during reload")
|
||||||
|
self.id, self.version, self.modules, self.main_class, self.main_module = meta
|
||||||
|
self._file = file
|
||||||
|
|
||||||
def _get_importer(self, reset_cache: bool = False) -> zipimporter:
|
def _get_importer(self, reset_cache: bool = False) -> zipimporter:
|
||||||
try:
|
try:
|
||||||
if not self._importer:
|
if not self._importer or self._importer.archive != self.path:
|
||||||
self._importer = zipimporter(self.path)
|
self._importer = zipimporter(self.path)
|
||||||
if reset_cache:
|
if reset_cache:
|
||||||
self._importer.reset_cache()
|
self._importer.reset_cache()
|
||||||
return self._importer
|
return self._importer
|
||||||
except ZipImportError as e:
|
except ZipImportError as e:
|
||||||
raise MaubotZipImportError("File not found or not a maubot plugin") from e
|
raise MaubotZipMetaError("File not found or not a maubot plugin") from e
|
||||||
|
|
||||||
def _run_preload_checks(self, importer: zipimporter) -> None:
|
def _run_preload_checks(self, importer: zipimporter) -> None:
|
||||||
try:
|
try:
|
||||||
code = importer.get_code(self.main_module.replace(".", "/"))
|
code = importer.get_code(self.main_module.replace(".", "/"))
|
||||||
if self.main_class not in code.co_names:
|
if self.main_class not in code.co_names:
|
||||||
raise MaubotZipImportError(
|
raise MaubotZipPreLoadError(
|
||||||
f"Main class {self.main_class} not in {self.main_module}")
|
f"Main class {self.main_class} not in {self.main_module}")
|
||||||
except ZipImportError as e:
|
except ZipImportError as e:
|
||||||
raise MaubotZipImportError(
|
raise MaubotZipPreLoadError(
|
||||||
f"Main module {self.main_module} not found in file") from e
|
f"Main module {self.main_module} not found in file") from e
|
||||||
for module in self.modules:
|
for module in self.modules:
|
||||||
try:
|
try:
|
||||||
importer.find_module(module)
|
importer.find_module(module)
|
||||||
except ZipImportError as e:
|
except ZipImportError as e:
|
||||||
raise MaubotZipImportError(f"Module {module} not found in file") from e
|
raise MaubotZipPreLoadError(f"Module {module} not found in file") from e
|
||||||
|
|
||||||
def load(self, reset_cache: bool = False) -> Type[PluginClass]:
|
async def load(self, reset_cache: bool = False) -> Type[PluginClass]:
|
||||||
|
try:
|
||||||
|
return self._load(reset_cache)
|
||||||
|
except MaubotZipImportError:
|
||||||
|
self.log.exception(f"Failed to load {self.id} v{self.version}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _load(self, reset_cache: bool = False) -> Type[PluginClass]:
|
||||||
if self._loaded is not None and not reset_cache:
|
if self._loaded is not None and not reset_cache:
|
||||||
return self._loaded
|
return self._loaded
|
||||||
importer = self._get_importer(reset_cache=reset_cache)
|
importer = self._get_importer(reset_cache=reset_cache)
|
||||||
self._run_preload_checks(importer)
|
self._run_preload_checks(importer)
|
||||||
if reset_cache:
|
if reset_cache:
|
||||||
self.log.debug(f"Preloaded plugin {self.id} from {self.path}")
|
self.log.debug(f"Re-preloaded plugin {self.id} from {self.path}")
|
||||||
for module in self.modules:
|
for module in self.modules:
|
||||||
importer.load_module(module)
|
try:
|
||||||
main_mod = sys.modules[self.main_module]
|
importer.load_module(module)
|
||||||
plugin = getattr(main_mod, self.main_class)
|
except ZipImportError as e:
|
||||||
|
raise MaubotZipLoadError(f"Module {module} not found in file")
|
||||||
|
try:
|
||||||
|
main_mod = sys.modules[self.main_module]
|
||||||
|
except KeyError as e:
|
||||||
|
raise MaubotZipLoadError(f"Main module {self.main_module} of plugin not found") from e
|
||||||
|
try:
|
||||||
|
plugin = getattr(main_mod, self.main_class)
|
||||||
|
except AttributeError as e:
|
||||||
|
raise MaubotZipLoadError(f"Main class {self.main_class} of plugin not found") from e
|
||||||
if not issubclass(plugin, Plugin):
|
if not issubclass(plugin, Plugin):
|
||||||
raise MaubotZipImportError("Main class of plugin does not extend maubot.Plugin")
|
raise MaubotZipLoadError("Main class of plugin does not extend maubot.Plugin")
|
||||||
self._loaded = plugin
|
self._loaded = plugin
|
||||||
self.log.debug(f"Loaded and imported plugin {self.id} from {self.path}")
|
self.log.debug(f"Loaded and imported plugin {self.id} from {self.path}")
|
||||||
return plugin
|
return plugin
|
||||||
|
|
||||||
def reload(self) -> Type[PluginClass]:
|
async def reload(self, new_path: Optional[str] = None) -> Type[PluginClass]:
|
||||||
self.unload()
|
await self.unload()
|
||||||
return self.load(reset_cache=True)
|
if new_path is not None:
|
||||||
|
self.path = new_path
|
||||||
|
return await self.load(reset_cache=True)
|
||||||
|
|
||||||
def unload(self) -> None:
|
async def unload(self) -> None:
|
||||||
for name, mod in list(sys.modules.items()):
|
for name, mod in list(sys.modules.items()):
|
||||||
if getattr(mod, "__file__", "").startswith(self.path):
|
if getattr(mod, "__file__", "").startswith(self.path):
|
||||||
del sys.modules[name]
|
del sys.modules[name]
|
||||||
self._loaded = None
|
self._loaded = None
|
||||||
self.log.debug(f"Unloaded plugin {self.id} at {self.path}")
|
self.log.debug(f"Unloaded plugin {self.id} at {self.path}")
|
||||||
|
|
||||||
def destroy(self) -> None:
|
async def delete(self) -> None:
|
||||||
self.unload()
|
await self.unload()
|
||||||
try:
|
try:
|
||||||
del self.path_cache[self.path]
|
del self.path_cache[self.path]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@ -171,24 +231,43 @@ class ZippedPluginLoader(PluginLoader):
|
|||||||
del self.id_cache[self.id]
|
del self.id_cache[self.id]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
self.id = None
|
|
||||||
self.path = None
|
|
||||||
self.version = None
|
|
||||||
self.modules = None
|
|
||||||
if self._importer:
|
if self._importer:
|
||||||
self._importer.remove_cache()
|
self._importer.remove_cache()
|
||||||
self._importer = None
|
self._importer = None
|
||||||
self._loaded = None
|
self._loaded = None
|
||||||
|
os.remove(self.path)
|
||||||
|
self.id = None
|
||||||
|
self.path = None
|
||||||
|
self.version = None
|
||||||
|
self.modules = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load_all(cls, *args: str) -> None:
|
def trash(cls, file_path: str, new_name: Optional[str] = None, reason: str = "error") -> None:
|
||||||
|
if cls.trash_path == "delete":
|
||||||
|
os.remove(file_path)
|
||||||
|
else:
|
||||||
|
new_name = new_name or f"{int(time())}-{reason}-{os.path.basename(file_path)}"
|
||||||
|
os.rename(file_path, os.path.abspath(os.path.join(cls.trash_path, new_name)))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_all(cls):
|
||||||
cls.log.debug("Preloading plugins...")
|
cls.log.debug("Preloading plugins...")
|
||||||
for directory in args:
|
for directory in cls.directories:
|
||||||
for file in os.listdir(directory):
|
for file in os.listdir(directory):
|
||||||
if not file.endswith(".mbp"):
|
if not file.endswith(".mbp"):
|
||||||
continue
|
continue
|
||||||
path = os.path.join(directory, file)
|
path = os.path.abspath(os.path.join(directory, file))
|
||||||
try:
|
try:
|
||||||
ZippedPluginLoader.get(path)
|
cls.get(path)
|
||||||
except (MaubotZipImportError, IDConflictError):
|
except MaubotZipImportError:
|
||||||
cls.log.exception(f"Failed to load plugin at {path}")
|
cls.log.exception(f"Failed to load plugin at {path}, trashing...")
|
||||||
|
cls.trash(path)
|
||||||
|
except IDConflictError:
|
||||||
|
cls.log.error(f"Duplicate plugin ID at {path}, trashing...")
|
||||||
|
cls.trash(path)
|
||||||
|
|
||||||
|
|
||||||
|
def init(config: Config) -> None:
|
||||||
|
ZippedPluginLoader.trash_path = config["plugin_directories.trash"]
|
||||||
|
ZippedPluginLoader.directories = config["plugin_directories.load"]
|
||||||
|
ZippedPluginLoader.load_all()
|
||||||
|
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 typing import Type, Optional, TYPE_CHECKING
|
||||||
from logging import Logger
|
from logging import Logger
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from asyncio import AbstractEventLoop
|
||||||
|
from aiohttp import ClientSession
|
||||||
import os.path
|
import os.path
|
||||||
|
|
||||||
from sqlalchemy.engine.base import Engine
|
from sqlalchemy.engine.base import Engine
|
||||||
@ -26,22 +28,30 @@ if TYPE_CHECKING:
|
|||||||
from .command_spec import CommandSpec
|
from .command_spec import CommandSpec
|
||||||
from mautrix.util.config import BaseProxyConfig
|
from mautrix.util.config import BaseProxyConfig
|
||||||
|
|
||||||
|
DatabaseNotConfigured = ValueError("A database for this maubot instance has not been configured.")
|
||||||
|
|
||||||
|
|
||||||
class Plugin(ABC):
|
class Plugin(ABC):
|
||||||
client: 'MaubotMatrixClient'
|
client: 'MaubotMatrixClient'
|
||||||
id: str
|
id: str
|
||||||
log: Logger
|
log: Logger
|
||||||
|
loop: AbstractEventLoop
|
||||||
config: Optional['BaseProxyConfig']
|
config: Optional['BaseProxyConfig']
|
||||||
|
|
||||||
def __init__(self, client: 'MaubotMatrixClient', plugin_instance_id: str, log: Logger,
|
def __init__(self, client: 'MaubotMatrixClient', loop: AbstractEventLoop, http: ClientSession,
|
||||||
config: Optional['BaseProxyConfig'], db_base_path: str) -> None:
|
plugin_instance_id: str, log: Logger, config: Optional['BaseProxyConfig'],
|
||||||
|
db_base_path: str) -> None:
|
||||||
self.client = client
|
self.client = client
|
||||||
|
self.loop = loop
|
||||||
|
self.http = http
|
||||||
self.id = plugin_instance_id
|
self.id = plugin_instance_id
|
||||||
self.log = log
|
self.log = log
|
||||||
self.config = config
|
self.config = config
|
||||||
self.__db_base_path = db_base_path
|
self.__db_base_path = db_base_path
|
||||||
|
|
||||||
def request_db_engine(self) -> Engine:
|
def request_db_engine(self) -> Optional[Engine]:
|
||||||
|
if not self.__db_base_path:
|
||||||
|
raise DatabaseNotConfigured
|
||||||
return sql.create_engine(f"sqlite:///{os.path.join(self.__db_base_path, self.id)}.db")
|
return sql.create_engine(f"sqlite:///{os.path.join(self.__db_base_path, self.id)}.db")
|
||||||
|
|
||||||
def set_command_spec(self, spec: 'CommandSpec') -> None:
|
def set_command_spec(self, spec: 'CommandSpec') -> None:
|
||||||
@ -58,3 +68,7 @@ class Plugin(ABC):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_config_class(cls) -> Optional[Type['BaseProxyConfig']]:
|
def get_config_class(cls) -> Optional[Type['BaseProxyConfig']]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def on_external_config_update(self) -> None:
|
||||||
|
if self.config:
|
||||||
|
self.config.load_and_update()
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from mautrix.api import PathBuilder, Method
|
from mautrix.api import PathBuilder, Method
|
||||||
@ -23,13 +24,17 @@ from .__meta__ import __version__
|
|||||||
|
|
||||||
|
|
||||||
class MaubotServer:
|
class MaubotServer:
|
||||||
def __init__(self, config: Config, loop: asyncio.AbstractEventLoop):
|
log: logging.Logger = logging.getLogger("maubot.server")
|
||||||
|
|
||||||
|
def __init__(self, config: Config, management: web.Application,
|
||||||
|
loop: asyncio.AbstractEventLoop) -> None:
|
||||||
self.loop = loop or asyncio.get_event_loop()
|
self.loop = loop or asyncio.get_event_loop()
|
||||||
self.app = web.Application(loop=self.loop)
|
self.app = web.Application(loop=self.loop)
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
path = PathBuilder(config["server.base_path"])
|
path = PathBuilder(config["server.base_path"])
|
||||||
self.add_route(Method.GET, path.version, self.version)
|
self.add_route(Method.GET, path.version, self.version)
|
||||||
|
self.app.add_subapp(config["server.base_path"], management)
|
||||||
|
|
||||||
as_path = PathBuilder(config["server.appservice_base_path"])
|
as_path = PathBuilder(config["server.appservice_base_path"])
|
||||||
self.add_route(Method.PUT, as_path.transactions, self.handle_transaction)
|
self.add_route(Method.PUT, as_path.transactions, self.handle_transaction)
|
||||||
@ -43,6 +48,7 @@ class MaubotServer:
|
|||||||
await self.runner.setup()
|
await self.runner.setup()
|
||||||
site = web.TCPSite(self.runner, self.config["server.hostname"], self.config["server.port"])
|
site = web.TCPSite(self.runner, self.config["server.hostname"], self.config["server.port"])
|
||||||
await site.start()
|
await site.start()
|
||||||
|
self.log.info(f"Listening on {site.name}")
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
await self.runner.cleanup()
|
await self.runner.cleanup()
|
||||||
|
@ -5,3 +5,4 @@ alembic
|
|||||||
commonmark
|
commonmark
|
||||||
ruamel.yaml
|
ruamel.yaml
|
||||||
attrs
|
attrs
|
||||||
|
bcrypt
|
||||||
|
5
setup.py
5
setup.py
@ -28,6 +28,7 @@ setuptools.setup(
|
|||||||
"commonmark>=0.8.1,<1",
|
"commonmark>=0.8.1,<1",
|
||||||
"ruamel.yaml>=0.15.35,<0.16",
|
"ruamel.yaml>=0.15.35,<0.16",
|
||||||
"attrs>=18.1.0,<19",
|
"attrs>=18.1.0,<19",
|
||||||
|
"bcrypt>=3.1.4,<4",
|
||||||
],
|
],
|
||||||
|
|
||||||
classifiers=[
|
classifiers=[
|
||||||
@ -47,4 +48,8 @@ setuptools.setup(
|
|||||||
data_files=[
|
data_files=[
|
||||||
(".", ["example-config.yaml"]),
|
(".", ["example-config.yaml"]),
|
||||||
],
|
],
|
||||||
|
package_data={
|
||||||
|
"maubot": ["management/frontend/build/*", "management/frontend/build/static/css/*",
|
||||||
|
"management/frontend/build/static/js/*"],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user