Merge branch 'master' into cli
This commit is contained in:
commit
4b3d47176d
@ -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/
|
||||||
|
14
Dockerfile
14
Dockerfile
@ -1,14 +1,26 @@
|
|||||||
|
FROM node:10 AS frontend-builder
|
||||||
|
|
||||||
|
COPY ./maubot/management/frontend /frontend
|
||||||
|
RUN cd /frontend && yarn --prod && yarn build
|
||||||
|
|
||||||
FROM alpine:3.8
|
FROM alpine:3.8
|
||||||
|
|
||||||
|
ENV UID=1337 \
|
||||||
|
GID=1337
|
||||||
|
|
||||||
COPY . /opt/maubot
|
COPY . /opt/maubot
|
||||||
|
COPY --from=frontend-builder /frontend/build /opt/maubot/frontend
|
||||||
WORKDIR /opt/maubot
|
WORKDIR /opt/maubot
|
||||||
RUN apk add --no-cache \
|
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 \
|
||||||
|
su-exec \
|
||||||
&& pip3 install -r requirements.txt
|
&& pip3 install -r requirements.txt
|
||||||
|
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
|
||||||
CMD ["/opt/maubot/docker-run.sh"]
|
CMD ["/opt/maubot/docker/run.sh"]
|
||||||
|
19
README.md
19
README.md
@ -1,6 +1,10 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
### [Wiki](https://github.com/maubot/maubot/wiki)
|
||||||
|
|
||||||
|
### [Management API spec](https://github.com/maubot/maubot/blob/master/maubot/management/api/spec.md)
|
||||||
|
|
||||||
## Discussion
|
## Discussion
|
||||||
Matrix room: [#maubot:maunium.net](https://matrix.to/#/#maubot:maunium.net)
|
Matrix room: [#maubot:maunium.net](https://matrix.to/#/#maubot:maunium.net)
|
||||||
|
|
||||||
@ -8,4 +12,17 @@ Matrix room: [#maubot:maunium.net](https://matrix.to/#/#maubot:maunium.net)
|
|||||||
* [jesaribot](https://github.com/maubot/jesaribot) - A simple bot that replies with an image when you say "jesari".
|
* [jesaribot](https://github.com/maubot/jesaribot) - A simple bot that replies with an image when you say "jesari".
|
||||||
* [sed](https://github.com/maubot/sed) - A bot to do sed-like replacements.
|
* [sed](https://github.com/maubot/sed) - A bot to do sed-like replacements.
|
||||||
* [factorial](https://github.com/maubot/factorial) - A bot to calculate unexpected factorials.
|
* [factorial](https://github.com/maubot/factorial) - A bot to calculate unexpected factorials.
|
||||||
* [dictionary](https://github.com/maubot/dictionary) - A bot that provides dictionary definitions for words.
|
* [media](https://github.com/maubot/media) - A bot that replies with the MXC URI of images you send it.
|
||||||
|
* [dice](https://github.com/maubot/dice) - A combined dice rolling and calculator bot.
|
||||||
|
* [karma](https://github.com/maubot/karma) - A user karma tracker bot.
|
||||||
|
* [xkcd](https://github.com/maubot/xkcd) - A bot to view xkcd comics.
|
||||||
|
* [echo](https://github.com/maubot/echo) - A bot that echoes pings and other stuff.
|
||||||
|
* [rss](https://github.com/maubot/rss) - A bot that posts RSS feed updates to Matrix.
|
||||||
|
|
||||||
|
### Upcoming
|
||||||
|
* dictionary - A bot to get the dictionary definitions of words.
|
||||||
|
* poll - A simple poll bot.
|
||||||
|
* reminder - A bot to ping you about something after a certain amount of time.
|
||||||
|
* github - A GitHub client and webhook receiver bot.
|
||||||
|
* wolfram - A Wolfram Alpha bot
|
||||||
|
* gitlab - A GitLab client and webhook receiver bot.
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
cd /opt/maubot
|
|
||||||
|
|
||||||
# Replace database path in config.
|
|
||||||
sed -i "s#sqlite:///maubot.db#sqlite:////data/maubot.db#" /data/config.yaml
|
|
||||||
sed -i "s#- ./plugins#- /data/plugins#" /data/config.yaml
|
|
||||||
sed -i "s#./logs/maubot.log#/var/log/maubot/maubot.log#" /data/config.yaml
|
|
||||||
|
|
||||||
mkdir -p /var/log/maubot
|
|
||||||
|
|
||||||
# Check that database is in the right state
|
|
||||||
alembic -x config=/data/config.yaml upgrade head
|
|
||||||
|
|
||||||
if [ ! -f /data/config.yaml ]; then
|
|
||||||
cp example-config.yaml /data/config.yaml
|
|
||||||
echo "Didn't find a config file."
|
|
||||||
echo "Copied default config file to /data/config.yaml"
|
|
||||||
echo "Modify that config file to your liking."
|
|
||||||
echo "Start the container again after that to generate the registration file."
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 -m maubot -c /data/config.yaml
|
|
79
docker/example-config.yaml
Normal file
79
docker/example-config.yaml
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# The full URI to the database. SQLite and Postgres are fully supported.
|
||||||
|
# Other DBMSes supported by SQLAlchemy may or may not work.
|
||||||
|
# Format examples:
|
||||||
|
# SQLite: sqlite:///filename.db
|
||||||
|
# Postgres: postgres://username:password@hostname/dbname
|
||||||
|
database: sqlite:////data/maubot.db
|
||||||
|
|
||||||
|
plugin_directories:
|
||||||
|
# The directory where uploaded new plugins should be stored.
|
||||||
|
upload: /data/plugins
|
||||||
|
# The directories from which plugins should be loaded.
|
||||||
|
# Duplicate plugin IDs will be moved to the trash.
|
||||||
|
load:
|
||||||
|
- /data/plugins
|
||||||
|
# The directory where old plugin versions and conflicting plugins should be moved.
|
||||||
|
# Set to "delete" to delete files immediately.
|
||||||
|
trash: /data/trash
|
||||||
|
# The directory where plugin databases should be stored.
|
||||||
|
db: /data/plugins
|
||||||
|
|
||||||
|
server:
|
||||||
|
# The IP and port to listen to.
|
||||||
|
hostname: 0.0.0.0
|
||||||
|
port: 29316
|
||||||
|
# The base management API path.
|
||||||
|
base_path: /_matrix/maubot/v1
|
||||||
|
# The base path for the UI.
|
||||||
|
ui_base_path: /_matrix/maubot
|
||||||
|
# Override path from where to load UI resources.
|
||||||
|
# Set to false to using pkg_resources to find the path.
|
||||||
|
override_resource_path: /opt/maubot/frontend
|
||||||
|
# The base appservice API path. Use / for legacy appservice API and /_matrix/app/v1 for v1.
|
||||||
|
appservice_base_path: /_matrix/app/v1
|
||||||
|
# The shared secret to sign API access tokens.
|
||||||
|
# Set to "generate" to generate and save a new token at startup.
|
||||||
|
unshared_secret: generate
|
||||||
|
|
||||||
|
# Shared registration secrets to allow registering new users from the management UI
|
||||||
|
registration_secrets:
|
||||||
|
example.com:
|
||||||
|
# Client-server API URL
|
||||||
|
url: https://example.com
|
||||||
|
# registration_shared_secret from synapse config
|
||||||
|
secret: synapse_shared_registration_secret
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
root: ""
|
||||||
|
|
||||||
|
# Python logging configuration.
|
||||||
|
#
|
||||||
|
# See section 16.7.2 of the Python documentation for more info:
|
||||||
|
# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema
|
||||||
|
logging:
|
||||||
|
version: 1
|
||||||
|
formatters:
|
||||||
|
precise:
|
||||||
|
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
|
||||||
|
handlers:
|
||||||
|
file:
|
||||||
|
class: logging.handlers.RotatingFileHandler
|
||||||
|
formatter: precise
|
||||||
|
filename: /var/log/maubot.log
|
||||||
|
maxBytes: 10485760
|
||||||
|
backupCount: 10
|
||||||
|
console:
|
||||||
|
class: logging.StreamHandler
|
||||||
|
formatter: precise
|
||||||
|
loggers:
|
||||||
|
maubot:
|
||||||
|
level: DEBUG
|
||||||
|
mautrix:
|
||||||
|
level: DEBUG
|
||||||
|
aiohttp:
|
||||||
|
level: INFO
|
||||||
|
root:
|
||||||
|
level: DEBUG
|
||||||
|
handlers: [file, console]
|
21
docker/run.sh
Executable file
21
docker/run.sh
Executable file
@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
function fixperms {
|
||||||
|
chown -R $UID:$GID /var/log /data /opt/maubot
|
||||||
|
}
|
||||||
|
|
||||||
|
cd /opt/maubot
|
||||||
|
|
||||||
|
if [ ! -f /data/config.yaml ]; then
|
||||||
|
cp docker/example-config.yaml /data/config.yaml
|
||||||
|
mkdir -p /var/log /data/plugins /data/trash /data/dbs
|
||||||
|
echo "Config file not found. Example config copied to /data/config.yaml"
|
||||||
|
echo "Please modify the config file to your liking and restart the container."
|
||||||
|
fixperms
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /var/log/maubot /data/plugins /data/trash /data/dbs
|
||||||
|
#alembic -x config=/data/config.yaml upgrade head
|
||||||
|
fixperms
|
||||||
|
exec su-exec $UID:$GID python3 -m maubot -c /data/config.yaml -b docker/example-config.yaml
|
@ -5,27 +5,48 @@
|
|||||||
# 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 path for the UI.
|
||||||
|
ui_base_path: /_matrix/maubot
|
||||||
|
# Override path from where to load UI resources.
|
||||||
|
# Set to false to using pkg_resources to find the path.
|
||||||
|
override_resource_path: false
|
||||||
# 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
|
||||||
|
|
||||||
|
# Shared registration secrets to allow registering new users from the management UI
|
||||||
|
registration_secrets:
|
||||||
|
example.com:
|
||||||
|
# Client-server API URL
|
||||||
|
url: https://example.com
|
||||||
|
# registration_shared_secret from synapse config
|
||||||
|
secret: synapse_shared_registration_secret
|
||||||
|
|
||||||
|
# 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.
|
||||||
#
|
#
|
||||||
|
@ -1,21 +0,0 @@
|
|||||||
# This is an example maubot plugin definition file.
|
|
||||||
# All plugins must include a file like this named "maubot.ini" in their root directory.
|
|
||||||
[maubot]
|
|
||||||
# The unique ID for the plugin. Java package naming style.
|
|
||||||
ID = xyz.maubot.plugin
|
|
||||||
# A PEP 440 compliant version string.
|
|
||||||
Version = 1.0.0
|
|
||||||
# The SPDX license identifier of the license of your project (see https://spdx.org/licenses/)
|
|
||||||
# Alternatively, you may enter the name of a license file. The file must be listed under ExtraFiles.
|
|
||||||
License = AGPL-3.0-or-later
|
|
||||||
# The comma-separated list of modules to load from the plugin archive.
|
|
||||||
# Submodules that are imported by modules listed here don't need to be listed separately.
|
|
||||||
# However, top-level modules must always be listed even if they're imported by other modules.
|
|
||||||
Modules = plugin
|
|
||||||
# The main class of the plugin. Format: module/Class
|
|
||||||
# If `module` is omitted, will default to last module specified in the module list.
|
|
||||||
# Even if `module` is not omitted here, it must be included in the modules list.
|
|
||||||
# The main class must extend maubot.Plugin
|
|
||||||
MainClass = PluginClass
|
|
||||||
# The comma-separated list of additional files to include in the plugin archive.
|
|
||||||
ExtraFiles = LICENSE
|
|
21
example-plugin/LICENSE
Normal file
21
example-plugin/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2018 Tulir Asokan
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
|
so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
2
example-plugin/build.sh
Normal file
2
example-plugin/build.sh
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
zip -9r helloworld.mbp maubot.yaml helloworld.py
|
14
example-plugin/helloworld.py
Normal file
14
example-plugin/helloworld.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from maubot import Plugin, MessageEvent
|
||||||
|
from mautrix.types import EventType
|
||||||
|
|
||||||
|
|
||||||
|
class HelloWorldBot(Plugin):
|
||||||
|
async def start(self) -> None:
|
||||||
|
self.client.add_event_handler(self.handler, EventType.ROOM_MESSAGE)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self.client.remove_event_handler(self.handler, EventType.ROOM_MESSAGE)
|
||||||
|
|
||||||
|
async def handler(self, event: MessageEvent) -> None:
|
||||||
|
if event.sender != self.client.mxid:
|
||||||
|
await event.reply("Hello, World!")
|
37
example-plugin/maubot.yaml
Normal file
37
example-plugin/maubot.yaml
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# This is an example maubot plugin definition file.
|
||||||
|
# All plugins must include a file like this named "maubot.yaml" in their root directory.
|
||||||
|
|
||||||
|
# The unique ID for the plugin. Java package naming style. (i.e. use your own domain, not xyz.maubot)
|
||||||
|
id: xyz.maubot.example
|
||||||
|
|
||||||
|
# A PEP 440 compliant version string.
|
||||||
|
version: 1.0.0
|
||||||
|
|
||||||
|
# The SPDX license identifier for the plugin. https://spdx.org/licenses/
|
||||||
|
# Optional, assumes all rights reserved if omitted.
|
||||||
|
license: MIT
|
||||||
|
|
||||||
|
# The list of modules to load from the plugin archive.
|
||||||
|
# Modules can be directories with an __init__.py file or simply python files.
|
||||||
|
# Submodules that are imported by modules listed here don't need to be listed separately.
|
||||||
|
# However, top-level modules must always be listed even if they're imported by other modules.
|
||||||
|
modules:
|
||||||
|
- helloworld
|
||||||
|
|
||||||
|
# The main class of the plugin. Format: module/Class
|
||||||
|
# If `module` is omitted, will default to last module specified in the module list.
|
||||||
|
# Even if `module` is not omitted here, it must be included in the modules list.
|
||||||
|
# The main class must extend maubot.Plugin
|
||||||
|
main_class: HelloWorldBot
|
||||||
|
|
||||||
|
# Extra files that the upcoming build tool should include in the mbp file.
|
||||||
|
#extra_files:
|
||||||
|
#- base-config.yaml
|
||||||
|
#- LICENSE
|
||||||
|
|
||||||
|
# List of dependencies
|
||||||
|
#dependencies:
|
||||||
|
#- foo
|
||||||
|
|
||||||
|
#soft_dependencies:
|
||||||
|
#- bar>=0.1
|
@ -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_mgmt_api, stop as stop_mgmt_api, init_log_listener
|
||||||
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.",
|
||||||
@ -44,23 +43,19 @@ config.load()
|
|||||||
config.update()
|
config.update()
|
||||||
|
|
||||||
logging.config.dictConfig(copy.deepcopy(config["logging"]))
|
logging.config.dictConfig(copy.deepcopy(config["logging"]))
|
||||||
|
init_log_listener()
|
||||||
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)
|
||||||
|
plugins = init_plugin_instance_class(db_session, config, loop)
|
||||||
|
management_api = init_mgmt_api(config, loop)
|
||||||
server = MaubotServer(config, loop)
|
server = MaubotServer(config, loop)
|
||||||
ZippedPluginLoader.load_all(*config["plugin_directories"])
|
server.app.add_subapp(config["server.base_path"], management_api)
|
||||||
plugins = PluginInstance.all()
|
|
||||||
|
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
plugin.load()
|
plugin.load()
|
||||||
@ -68,28 +63,39 @@ 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()
|
||||||
loop.run_until_complete(server.stop())
|
log.debug("Closing websockets")
|
||||||
|
loop.run_until_complete(stop_mgmt_api())
|
||||||
|
log.debug("Stopping server")
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(asyncio.wait_for(server.stop(), 5, loop=loop))
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.warning("Stopping server timed out")
|
||||||
|
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.dev4"
|
__version__ = "0.1.0.dev13"
|
||||||
|
203
maubot/client.py
203
maubot/client.py
@ -13,62 +13,143 @@
|
|||||||
#
|
#
|
||||||
# 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)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
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 +168,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 displayname is None 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 avatar_url is None 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(f"MXID mismatch: {mxid}")
|
||||||
|
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 +220,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 +269,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,8 +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/>.
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
import bcrypt
|
||||||
|
import re
|
||||||
|
|
||||||
from mautrix.util import BaseFileConfig, ConfigUpdateHelper
|
from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper
|
||||||
|
|
||||||
|
bcrypt_regex = re.compile(r"^\$2[ayb]\$.{56}$")
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseFileConfig):
|
class Config(BaseFileConfig):
|
||||||
@ -27,16 +31,39 @@ 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.base_path")
|
||||||
shared_secret = self["server.shared_secret"]
|
copy("server.ui_base_path")
|
||||||
|
copy("server.override_resource_path")
|
||||||
|
copy("server.appservice_base_path")
|
||||||
|
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("registration_secrets")
|
||||||
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()
|
|
||||||
|
256
maubot/instance.py
Normal file
256
maubot/instance.py
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
# 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 Dict, List, Optional
|
||||||
|
from asyncio import AbstractEventLoop
|
||||||
|
import logging
|
||||||
|
import io
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ruamel.yaml.comments import CommentedMap
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
from mautrix.util.config import BaseProxyConfig, RecursiveDict
|
||||||
|
from mautrix.types import UserID
|
||||||
|
|
||||||
|
from .db import DBPlugin
|
||||||
|
from .config import Config
|
||||||
|
from .client import Client
|
||||||
|
from .loader import PluginLoader
|
||||||
|
from .plugin_base import Plugin
|
||||||
|
|
||||||
|
log = logging.getLogger("maubot.instance")
|
||||||
|
|
||||||
|
yaml = YAML()
|
||||||
|
yaml.indent(4)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginInstance:
|
||||||
|
db: Session = None
|
||||||
|
mb_config: Config = None
|
||||||
|
loop: AbstractEventLoop = None
|
||||||
|
cache: Dict[str, 'PluginInstance'] = {}
|
||||||
|
plugin_directories: List[str] = []
|
||||||
|
|
||||||
|
log: logging.Logger
|
||||||
|
loader: PluginLoader
|
||||||
|
client: Client
|
||||||
|
plugin: Plugin
|
||||||
|
config: BaseProxyConfig
|
||||||
|
base_cfg: RecursiveDict[CommentedMap]
|
||||||
|
started: bool
|
||||||
|
|
||||||
|
def __init__(self, db_instance: DBPlugin):
|
||||||
|
self.db_instance = db_instance
|
||||||
|
self.log = log.getChild(self.id)
|
||||||
|
self.config = None
|
||||||
|
self.started = False
|
||||||
|
self.loader = None
|
||||||
|
self.client = None
|
||||||
|
self.plugin = None
|
||||||
|
self.base_cfg = None
|
||||||
|
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,
|
||||||
|
"config": self.db_instance.config,
|
||||||
|
}
|
||||||
|
|
||||||
|
def load(self) -> bool:
|
||||||
|
if not self.loader:
|
||||||
|
try:
|
||||||
|
self.loader = PluginLoader.find(self.type)
|
||||||
|
except KeyError:
|
||||||
|
self.log.error(f"Failed to find loader for type {self.type}")
|
||||||
|
self.db_instance.enabled = False
|
||||||
|
return False
|
||||||
|
if not self.client:
|
||||||
|
self.client = Client.get(self.primary_user)
|
||||||
|
if not self.client:
|
||||||
|
self.log.error(f"Failed to get client for user {self.primary_user}")
|
||||||
|
self.db_instance.enabled = False
|
||||||
|
return False
|
||||||
|
self.log.debug("Plugin instance dependencies loaded")
|
||||||
|
self.loader.references.add(self)
|
||||||
|
self.client.references.add(self)
|
||||||
|
return True
|
||||||
|
|
||||||
|
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)
|
||||||
|
self.db.commit()
|
||||||
|
# TODO delete plugin db
|
||||||
|
|
||||||
|
def load_config(self) -> CommentedMap:
|
||||||
|
return yaml.load(self.db_instance.config)
|
||||||
|
|
||||||
|
def save_config(self, data: RecursiveDict[CommentedMap]) -> None:
|
||||||
|
buf = io.StringIO()
|
||||||
|
yaml.dump(data, buf)
|
||||||
|
self.db_instance.config = buf.getvalue()
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self.started:
|
||||||
|
self.log.warning("Ignoring start() call to already started plugin")
|
||||||
|
return
|
||||||
|
elif not self.enabled:
|
||||||
|
self.log.warning("Plugin disabled, not starting.")
|
||||||
|
return
|
||||||
|
if not self.client or not self.loader:
|
||||||
|
self.log.warning("Missing plugin instance dependencies, attempting to load...")
|
||||||
|
if not self.load():
|
||||||
|
return
|
||||||
|
cls = await self.loader.load()
|
||||||
|
config_class = cls.get_config_class()
|
||||||
|
if config_class:
|
||||||
|
try:
|
||||||
|
base = await self.loader.read_file("base-config.yaml")
|
||||||
|
self.base_cfg = RecursiveDict(yaml.load(base.decode("utf-8")), CommentedMap)
|
||||||
|
except (FileNotFoundError, KeyError):
|
||||||
|
self.base_cfg = None
|
||||||
|
self.config = config_class(self.load_config, lambda: self.base_cfg, self.save_config)
|
||||||
|
self.plugin = cls(self.client.client, self.loop, self.client.http_client, self.id,
|
||||||
|
self.log, self.config, self.mb_config["plugin_directories.db"])
|
||||||
|
try:
|
||||||
|
await self.plugin.start()
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Failed to start instance")
|
||||||
|
self.db_instance.enabled = False
|
||||||
|
return
|
||||||
|
self.started = True
|
||||||
|
self.log.info(f"Started instance of {self.loader.meta.id} v{self.loader.meta.version} "
|
||||||
|
f"with user {self.client.id}")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if not self.started:
|
||||||
|
self.log.warning("Ignoring stop() call to non-running plugin")
|
||||||
|
return
|
||||||
|
self.log.debug("Stopping plugin instance...")
|
||||||
|
self.started = False
|
||||||
|
try:
|
||||||
|
await self.plugin.stop()
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Failed to stop instance")
|
||||||
|
self.plugin = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, instance_id: str, db_instance: Optional[DBPlugin] = None
|
||||||
|
) -> Optional['PluginInstance']:
|
||||||
|
try:
|
||||||
|
return cls.cache[instance_id]
|
||||||
|
except KeyError:
|
||||||
|
db_instance = db_instance or DBPlugin.query.get(instance_id)
|
||||||
|
if not db_instance:
|
||||||
|
return None
|
||||||
|
return PluginInstance(db_instance)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def all(cls) -> List['PluginInstance']:
|
||||||
|
return [cls.get(plugin.id, plugin) for plugin in DBPlugin.query.all()]
|
||||||
|
|
||||||
|
def update_id(self, new_id: str) -> None:
|
||||||
|
if new_id is not None and new_id != self.id:
|
||||||
|
self.db_instance.id = new_id
|
||||||
|
|
||||||
|
def update_config(self, config: str) -> None:
|
||||||
|
if not config or self.db_instance.config == config:
|
||||||
|
return
|
||||||
|
self.db_instance.config = config
|
||||||
|
if self.started and self.plugin is not None:
|
||||||
|
self.plugin.on_external_config_update()
|
||||||
|
|
||||||
|
async def update_primary_user(self, primary_user: UserID) -> bool:
|
||||||
|
if not primary_user or primary_user == self.primary_user:
|
||||||
|
return True
|
||||||
|
client = Client.get(primary_user)
|
||||||
|
if not client:
|
||||||
|
return False
|
||||||
|
await self.stop()
|
||||||
|
self.db_instance.primary_user = client.id
|
||||||
|
if self.client:
|
||||||
|
self.client.references.remove(self)
|
||||||
|
self.client = client
|
||||||
|
self.client.references.add(self)
|
||||||
|
await self.start()
|
||||||
|
self.log.debug(f"Primary user switched to {self.client.id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def update_type(self, type: str) -> bool:
|
||||||
|
if not type or type == self.type:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
loader = PluginLoader.find(type)
|
||||||
|
except KeyError:
|
||||||
|
return False
|
||||||
|
await self.stop()
|
||||||
|
self.db_instance.type = loader.meta.id
|
||||||
|
if self.loader:
|
||||||
|
self.loader.references.remove(self)
|
||||||
|
self.loader = loader
|
||||||
|
self.loader.references.add(self)
|
||||||
|
await self.start()
|
||||||
|
self.log.debug(f"Type switched to {self.loader.meta.id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def update_started(self, started: bool) -> None:
|
||||||
|
if started is not None and started != self.started:
|
||||||
|
await (self.start() if started else self.stop())
|
||||||
|
|
||||||
|
def update_enabled(self, enabled: bool) -> None:
|
||||||
|
if enabled is not None and enabled != self.enabled:
|
||||||
|
self.db_instance.enabled = enabled
|
||||||
|
|
||||||
|
# region Properties
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> str:
|
||||||
|
return self.db_instance.id
|
||||||
|
|
||||||
|
@id.setter
|
||||||
|
def id(self, value: str) -> None:
|
||||||
|
self.db_instance.id = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self) -> str:
|
||||||
|
return self.db_instance.type
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self.db_instance.enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_user(self) -> UserID:
|
||||||
|
return self.db_instance.primary_user
|
||||||
|
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
|
def init(db: Session, config: Config, loop: AbstractEventLoop) -> List[PluginInstance]:
|
||||||
|
PluginInstance.db = db
|
||||||
|
PluginInstance.mb_config = config
|
||||||
|
PluginInstance.loop = loop
|
||||||
|
return PluginInstance.all()
|
0
maubot/lib/__init__.py
Normal file
0
maubot/lib/__init__.py
Normal file
@ -1,2 +1,2 @@
|
|||||||
from .abc import PluginLoader, PluginClass
|
from .abc import PluginLoader, PluginClass, IDConflictError
|
||||||
from .zip import ZippedPluginLoader, MaubotZipImportError
|
from .zip import ZippedPluginLoader, MaubotZipImportError
|
||||||
|
@ -13,13 +13,19 @@
|
|||||||
#
|
#
|
||||||
# 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 TypeVar, Type, Dict, Set, TYPE_CHECKING
|
from typing import TypeVar, Type, Dict, Set, List, TYPE_CHECKING
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from attr import dataclass
|
||||||
|
from packaging.version import Version, InvalidVersion
|
||||||
|
from mautrix.client.api.types.util import (SerializableAttrs, SerializerError, serializer,
|
||||||
|
deserializer)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@ -28,12 +34,36 @@ class IDConflictError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@serializer(Version)
|
||||||
|
def serialize_version(version: Version) -> str:
|
||||||
|
return str(version)
|
||||||
|
|
||||||
|
|
||||||
|
@deserializer(Version)
|
||||||
|
def deserialize_version(version: str) -> Version:
|
||||||
|
try:
|
||||||
|
return Version(version)
|
||||||
|
except InvalidVersion as e:
|
||||||
|
raise SerializerError("Invalid version") from e
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PluginMeta(SerializableAttrs['PluginMeta']):
|
||||||
|
id: str
|
||||||
|
version: Version
|
||||||
|
license: str
|
||||||
|
modules: List[str]
|
||||||
|
main_class: str
|
||||||
|
extra_files: List[str] = []
|
||||||
|
dependencies: List[str] = []
|
||||||
|
soft_dependencies: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
class PluginLoader(ABC):
|
class PluginLoader(ABC):
|
||||||
id_cache: Dict[str, 'PluginLoader'] = {}
|
id_cache: Dict[str, 'PluginLoader'] = {}
|
||||||
|
|
||||||
|
meta: PluginMeta
|
||||||
references: Set['PluginInstance']
|
references: Set['PluginInstance']
|
||||||
id: str
|
|
||||||
version: str
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.references = set()
|
self.references = set()
|
||||||
@ -42,23 +72,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.meta.id,
|
||||||
|
"version": str(self.meta.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,30 +13,49 @@
|
|||||||
#
|
#
|
||||||
# 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
|
||||||
import configparser
|
from time import time
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from ruamel.yaml import YAML, YAMLError
|
||||||
|
from packaging.version import Version
|
||||||
|
from mautrix.client.api.types.util import SerializerError
|
||||||
|
|
||||||
from ..lib.zipimport import zipimporter, ZipImportError
|
from ..lib.zipimport import zipimporter, ZipImportError
|
||||||
from ..plugin_base import Plugin
|
from ..plugin_base import Plugin
|
||||||
from .abc import PluginLoader, PluginClass, IDConflictError
|
from ..config import Config
|
||||||
|
from .abc import PluginLoader, PluginClass, PluginMeta, IDConflictError
|
||||||
|
|
||||||
|
yaml = YAML()
|
||||||
|
|
||||||
|
|
||||||
class MaubotZipImportError(Exception):
|
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
|
meta: PluginMeta
|
||||||
version: str
|
|
||||||
modules: List[str]
|
|
||||||
main_class: str
|
main_class: str
|
||||||
main_module: str
|
main_module: str
|
||||||
_loaded: Type[PluginClass]
|
_loaded: Type[PluginClass]
|
||||||
@ -46,22 +65,31 @@ class ZippedPluginLoader(PluginLoader):
|
|||||||
def __init__(self, path: str) -> None:
|
def __init__(self, path: str) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.path = path
|
self.path = path
|
||||||
self.id = None
|
self.meta = None
|
||||||
self._loaded = None
|
self._loaded = None
|
||||||
self._importer = None
|
self._importer = None
|
||||||
|
self._file = None
|
||||||
self._load_meta()
|
self._load_meta()
|
||||||
self._run_preload_checks(self._get_importer())
|
self._run_preload_checks(self._get_importer())
|
||||||
try:
|
try:
|
||||||
existing = self.id_cache[self.id]
|
existing = self.id_cache[self.meta.id]
|
||||||
raise IDConflictError(f"Plugin with id {self.id} already loaded from {existing.source}")
|
raise IDConflictError(
|
||||||
|
f"Plugin with id {self.meta.id} already loaded from {existing.source}")
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
self.path_cache[self.path] = self
|
self.path_cache[self.path] = self
|
||||||
self.id_cache[self.id] = self
|
self.id_cache[self.meta.id] = self
|
||||||
self.log.debug(f"Preloaded plugin {self.id} from {self.path}")
|
self.log.debug(f"Preloaded plugin {self.meta.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:
|
||||||
@ -74,121 +102,169 @@ class ZippedPluginLoader(PluginLoader):
|
|||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return ("<ZippedPlugin "
|
return ("<ZippedPlugin "
|
||||||
f"path='{self.path}' "
|
f"path='{self.path}' "
|
||||||
f"id='{self.id}' "
|
f"meta={self.meta} "
|
||||||
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 _read_meta(source) -> Tuple[ZipFile, PluginMeta]:
|
||||||
try:
|
try:
|
||||||
self._file = ZipFile(self.path)
|
file = ZipFile(source)
|
||||||
data = self._file.read("maubot.ini")
|
data = file.read("maubot.yaml")
|
||||||
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()
|
|
||||||
try:
|
try:
|
||||||
config.read_string(data.decode("utf-8"), source=f"{self.path}/maubot.ini")
|
meta_dict = yaml.load(data)
|
||||||
meta = config["maubot"]
|
except (YAMLError, KeyError, IndexError, ValueError) as e:
|
||||||
meta_id = meta["ID"]
|
raise MaubotZipMetaError("Maubot plugin definition file is not valid YAML") from e
|
||||||
version = meta["Version"]
|
try:
|
||||||
modules = [mod.strip() for mod in meta["Modules"].split(",")]
|
meta = PluginMeta.deserialize(meta_dict)
|
||||||
main_class = meta["MainClass"]
|
except SerializerError as e:
|
||||||
main_module = modules[-1]
|
raise MaubotZipMetaError("Maubot plugin definition in file is invalid") from e
|
||||||
if "/" in main_class:
|
return file, meta
|
||||||
main_module, main_class = main_class.split("/")[:2]
|
|
||||||
except (configparser.Error, KeyError, IndexError, ValueError) as e:
|
@classmethod
|
||||||
raise MaubotZipImportError("Maubot plugin definition in file is invalid") from e
|
def verify_meta(cls, source) -> Tuple[str, Version]:
|
||||||
if self.id and meta_id != self.id:
|
_, meta = cls._read_meta(source)
|
||||||
raise MaubotZipImportError("Maubot plugin ID changed during reload")
|
return meta.id, meta.version
|
||||||
self.id, self.version, self.modules = meta_id, version, modules
|
|
||||||
self.main_class, self.main_module = main_class, main_module
|
def _load_meta(self) -> None:
|
||||||
|
file, meta = self._read_meta(self.path)
|
||||||
|
if self.meta and meta.id != self.meta.id:
|
||||||
|
raise MaubotZipMetaError("Maubot plugin ID changed during reload")
|
||||||
|
self.meta = meta
|
||||||
|
if "/" in meta.main_class:
|
||||||
|
self.main_module, self.main_class = meta.main_class.split("/")[:2]
|
||||||
|
else:
|
||||||
|
self.main_module = meta.modules[0]
|
||||||
|
self.main_class = meta.main_class
|
||||||
|
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.meta.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.meta.id} v{self.meta.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
|
||||||
|
self._load_meta()
|
||||||
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.meta.id} from {self.meta.path}")
|
||||||
for module in self.modules:
|
for module in self.meta.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:
|
||||||
|
raise MaubotZipLoadError(f"Module {module} not found in file")
|
||||||
|
except Exception:
|
||||||
|
raise MaubotZipLoadError(f"Failed to load module {module}")
|
||||||
|
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.meta.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.meta.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:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
del self.id_cache[self.id]
|
del self.id_cache[self.meta.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
|
||||||
|
self.trash(self.path, reason="delete")
|
||||||
|
self.meta = None
|
||||||
|
self.path = 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()
|
||||||
|
0
maubot/management/__init__.py
Normal file
0
maubot/management/__init__.py
Normal file
41
maubot/management/api/__init__.py
Normal file
41
maubot/management/api/__init__.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# 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, set_loop
|
||||||
|
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 _
|
||||||
|
from .client_proxy import web as _
|
||||||
|
from .client_auth import web as _
|
||||||
|
from .dev_open import web as _
|
||||||
|
from .log import stop_all as stop_log_sockets, init as init_log_listener
|
||||||
|
|
||||||
|
|
||||||
|
def init(cfg: Config, loop: AbstractEventLoop) -> web.Application:
|
||||||
|
set_config(cfg)
|
||||||
|
set_loop(loop)
|
||||||
|
app = web.Application(loop=loop, middlewares=[auth, error], client_max_size=100*1024*1024)
|
||||||
|
app.add_routes(routes)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
async def stop() -> None:
|
||||||
|
await stop_log_sockets()
|
92
maubot/management/api/auth.py
Normal file
92
maubot/management/api/auth.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
# 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 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 resp
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
"created_at": int(time()),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def get_token(request: web.Request) -> str:
|
||||||
|
token = request.headers.get("Authorization", "")
|
||||||
|
if not token or not token.startswith("Bearer "):
|
||||||
|
token = request.query.get("access_token", None)
|
||||||
|
else:
|
||||||
|
token = token[len("Bearer "):]
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def check_token(request: web.Request) -> Optional[web.Response]:
|
||||||
|
token = get_token(request)
|
||||||
|
if not token:
|
||||||
|
return resp.no_token
|
||||||
|
elif not is_valid_token(token):
|
||||||
|
return resp.invalid_token
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/auth/ping")
|
||||||
|
async def ping(request: web.Request) -> web.Response:
|
||||||
|
token = get_token(request)
|
||||||
|
if not token:
|
||||||
|
return resp.no_token
|
||||||
|
|
||||||
|
data = verify_token(get_config()["server.unshared_secret"], token)
|
||||||
|
if not data:
|
||||||
|
return resp.invalid_token
|
||||||
|
user = data.get("user_id", None)
|
||||||
|
if not get_config().is_admin(user):
|
||||||
|
return resp.invalid_token
|
||||||
|
return resp.pong(user)
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/auth/login")
|
||||||
|
async def login(request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return resp.body_not_json
|
||||||
|
secret = data.get("secret")
|
||||||
|
if secret and get_config()["server.unshared_secret"] == secret:
|
||||||
|
user = data.get("user") or "root"
|
||||||
|
return resp.logged_in(create_token(user))
|
||||||
|
|
||||||
|
username = data.get("username")
|
||||||
|
password = data.get("password")
|
||||||
|
if get_config().check_password(username, password):
|
||||||
|
return resp.logged_in(create_token(username))
|
||||||
|
|
||||||
|
return resp.bad_auth
|
49
maubot/management/api/base.py
Normal file
49
maubot/management/api/base.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# 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
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from ...__meta__ import __version__
|
||||||
|
from ...config import Config
|
||||||
|
|
||||||
|
routes: web.RouteTableDef = web.RouteTableDef()
|
||||||
|
_config: Config = None
|
||||||
|
_loop: asyncio.AbstractEventLoop = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_config(config: Config) -> None:
|
||||||
|
global _config
|
||||||
|
_config = config
|
||||||
|
|
||||||
|
|
||||||
|
def get_config() -> Config:
|
||||||
|
return _config
|
||||||
|
|
||||||
|
|
||||||
|
def set_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
global _loop
|
||||||
|
_loop = loop
|
||||||
|
|
||||||
|
|
||||||
|
def get_loop() -> asyncio.AbstractEventLoop:
|
||||||
|
return _loop
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/version")
|
||||||
|
async def version(_: web.Request) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"version": __version__
|
||||||
|
})
|
132
maubot/management/api/client.py
Normal file
132
maubot/management/api/client.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
# 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 aiohttp import web
|
||||||
|
|
||||||
|
from mautrix.types import UserID, SyncToken, FilterID
|
||||||
|
from mautrix.errors import MatrixRequestError, MatrixConnectionError, MatrixInvalidToken
|
||||||
|
from mautrix.client import Client as MatrixClient
|
||||||
|
|
||||||
|
from ...db import DBClient
|
||||||
|
from ...client import Client
|
||||||
|
from .base import routes
|
||||||
|
from .responses import resp
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/clients")
|
||||||
|
async def get_clients(_: web.Request) -> web.Response:
|
||||||
|
return resp.found([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 resp.client_not_found
|
||||||
|
return resp.found(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 resp.bad_client_access_token
|
||||||
|
except MatrixRequestError:
|
||||||
|
return resp.bad_client_access_details
|
||||||
|
except MatrixConnectionError:
|
||||||
|
return resp.bad_client_connection_details
|
||||||
|
if user_id is None:
|
||||||
|
existing_client = Client.get(mxid, None)
|
||||||
|
if existing_client is not None:
|
||||||
|
return resp.user_exists
|
||||||
|
elif mxid != user_id:
|
||||||
|
return resp.mxid_mismatch(mxid)
|
||||||
|
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 resp.created(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 resp.bad_client_access_token
|
||||||
|
except MatrixRequestError:
|
||||||
|
return resp.bad_client_access_details
|
||||||
|
except MatrixConnectionError:
|
||||||
|
return resp.bad_client_connection_details
|
||||||
|
except ValueError as e:
|
||||||
|
return resp.mxid_mismatch(str(e)[len("MXID mismatch: "):])
|
||||||
|
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 resp.updated(client.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/client/new")
|
||||||
|
async def create_client(request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except JSONDecodeError:
|
||||||
|
return resp.body_not_json
|
||||||
|
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 resp.body_not_json
|
||||||
|
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 resp.client_not_found
|
||||||
|
if len(client.references) > 0:
|
||||||
|
return resp.client_in_use
|
||||||
|
if client.started:
|
||||||
|
await client.stop()
|
||||||
|
client.delete()
|
||||||
|
return resp.deleted
|
121
maubot/management/api/client_auth.py
Normal file
121
maubot/management/api/client_auth.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
# 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 Dict, Tuple, NamedTuple, Optional
|
||||||
|
from json import JSONDecodeError
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from mautrix.api import HTTPAPI, Path, Method
|
||||||
|
from mautrix.errors import MatrixRequestError
|
||||||
|
|
||||||
|
from .base import routes, get_config, get_loop
|
||||||
|
from .responses import resp
|
||||||
|
|
||||||
|
|
||||||
|
def registration_secrets() -> Dict[str, Dict[str, str]]:
|
||||||
|
return get_config()["registration_secrets"]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_mac(secret: str, nonce: str, user: str, password: str, admin: bool = False):
|
||||||
|
mac = hmac.new(key=secret.encode("utf-8"), digestmod=hashlib.sha1)
|
||||||
|
mac.update(nonce.encode("utf-8"))
|
||||||
|
mac.update(b"\x00")
|
||||||
|
mac.update(user.encode("utf-8"))
|
||||||
|
mac.update(b"\x00")
|
||||||
|
mac.update(password.encode("utf-8"))
|
||||||
|
mac.update(b"\x00")
|
||||||
|
mac.update(b"admin" if admin else b"notadmin")
|
||||||
|
return mac.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/client/auth/servers")
|
||||||
|
async def get_registerable_servers(_: web.Request) -> web.Response:
|
||||||
|
return web.json_response(list(registration_secrets().keys()))
|
||||||
|
|
||||||
|
|
||||||
|
AuthRequestInfo = NamedTuple("AuthRequestInfo", api=HTTPAPI, secret=str, username=str, password=str)
|
||||||
|
|
||||||
|
|
||||||
|
async def read_client_auth_request(request: web.Request) -> Tuple[Optional[AuthRequestInfo],
|
||||||
|
Optional[web.Response]]:
|
||||||
|
server_name = request.match_info.get("server", None)
|
||||||
|
server = registration_secrets().get(server_name, None)
|
||||||
|
if not server:
|
||||||
|
return None, resp.server_not_found
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except JSONDecodeError:
|
||||||
|
return None, resp.body_not_json
|
||||||
|
try:
|
||||||
|
username = body["username"]
|
||||||
|
password = body["password"]
|
||||||
|
except KeyError:
|
||||||
|
return None, resp.username_or_password_missing
|
||||||
|
try:
|
||||||
|
base_url = server["url"]
|
||||||
|
secret = server["secret"]
|
||||||
|
except KeyError:
|
||||||
|
return None, resp.invalid_server
|
||||||
|
api = HTTPAPI(base_url, "", loop=get_loop())
|
||||||
|
return (api, secret, username, password), None
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/client/auth/{server}/register")
|
||||||
|
async def register(request: web.Request) -> web.Response:
|
||||||
|
info, err = await read_client_auth_request(request)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
api, secret, username, password = info
|
||||||
|
res = await api.request(Method.GET, Path.admin.register)
|
||||||
|
nonce = res["nonce"]
|
||||||
|
mac = generate_mac(secret, nonce, username, password)
|
||||||
|
try:
|
||||||
|
return web.json_response(await api.request(Method.POST, Path.admin.register, content={
|
||||||
|
"nonce": nonce,
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"admin": False,
|
||||||
|
"mac": mac,
|
||||||
|
}))
|
||||||
|
except MatrixRequestError as e:
|
||||||
|
return web.json_response({
|
||||||
|
"errcode": e.errcode,
|
||||||
|
"error": e.message,
|
||||||
|
}, status=e.http_status)
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/client/auth/{server}/login")
|
||||||
|
async def login(request: web.Request) -> web.Response:
|
||||||
|
info, err = await read_client_auth_request(request)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
api, _, username, password = info
|
||||||
|
try:
|
||||||
|
return web.json_response(await api.request(Method.POST, Path.login, content={
|
||||||
|
"type": "m.login.password",
|
||||||
|
"identifier": {
|
||||||
|
"type": "m.id.user",
|
||||||
|
"user": username,
|
||||||
|
},
|
||||||
|
"password": password,
|
||||||
|
"device_id": "maubot",
|
||||||
|
}))
|
||||||
|
except MatrixRequestError as e:
|
||||||
|
return web.json_response({
|
||||||
|
"errcode": e.errcode,
|
||||||
|
"error": e.message,
|
||||||
|
}, status=e.http_status)
|
53
maubot/management/api/client_proxy.py
Normal file
53
maubot/management/api/client_proxy.py
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/>.
|
||||||
|
from aiohttp import web, client as http
|
||||||
|
from ...client import Client
|
||||||
|
from .base import routes
|
||||||
|
from .responses import resp
|
||||||
|
|
||||||
|
PROXY_CHUNK_SIZE = 32 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@routes.view("/proxy/{id}/{path:_matrix/.+}")
|
||||||
|
async def proxy(request: web.Request) -> web.StreamResponse:
|
||||||
|
user_id = request.match_info.get("id", None)
|
||||||
|
client = Client.get(user_id, None)
|
||||||
|
if not client:
|
||||||
|
return resp.client_not_found
|
||||||
|
|
||||||
|
path = request.match_info.get("path", None)
|
||||||
|
query = request.query.copy()
|
||||||
|
try:
|
||||||
|
del query["access_token"]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
headers = request.headers.copy()
|
||||||
|
headers["Authorization"] = f"Bearer {client.access_token}"
|
||||||
|
if "X-Forwarded-For" not in headers:
|
||||||
|
peer = request.transport.get_extra_info("peername")
|
||||||
|
if peer is not None:
|
||||||
|
host, port = peer
|
||||||
|
headers["X-Forwarded-For"] = f"{host}:{port}"
|
||||||
|
|
||||||
|
data = await request.read()
|
||||||
|
async with http.request(request.method, f"{client.homeserver}/{path}", headers=headers,
|
||||||
|
params=query, data=data) as proxy_resp:
|
||||||
|
response = web.StreamResponse(status=proxy_resp.status, headers=proxy_resp.headers)
|
||||||
|
await response.prepare(request)
|
||||||
|
async for chunk in proxy_resp.content.iter_chunked(PROXY_CHUNK_SIZE):
|
||||||
|
await response.write(chunk)
|
||||||
|
await response.write_eof()
|
||||||
|
return response
|
64
maubot/management/api/dev_open.py
Normal file
64
maubot/management/api/dev_open.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# 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 string import Template
|
||||||
|
from subprocess import run
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from .base import routes
|
||||||
|
|
||||||
|
enabled = False
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/debug/open")
|
||||||
|
async def check_enabled(_: web.Request) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"enabled": enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
yaml = YAML()
|
||||||
|
|
||||||
|
with open(".dev-open-cfg.yaml", "r") as file:
|
||||||
|
cfg = yaml.load(file)
|
||||||
|
editor_command = Template(cfg["editor"])
|
||||||
|
pathmap = [(re.compile(item["find"]), item["replace"]) for item in cfg["pathmap"]]
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/debug/open")
|
||||||
|
async def open_file(request: web.Request) -> web.Response:
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
path = data["path"]
|
||||||
|
for find, replace in pathmap:
|
||||||
|
path = find.sub(replace, path)
|
||||||
|
cmd = editor_command.substitute(path=path, line=data["line"])
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return web.Response(status=400)
|
||||||
|
res = run(cmd, shell=True)
|
||||||
|
return web.json_response({
|
||||||
|
"return": res.returncode,
|
||||||
|
"stdout": res.stdout,
|
||||||
|
"stderr": res.stderr
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
enabled = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
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 resp
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/instances")
|
||||||
|
async def get_instances(_: web.Request) -> web.Response:
|
||||||
|
return resp.found([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 resp.instance_not_found
|
||||||
|
return resp.found(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 resp.plugin_type_required
|
||||||
|
elif not primary_user:
|
||||||
|
return resp.primary_user_required
|
||||||
|
elif not Client.get(primary_user):
|
||||||
|
return resp.primary_user_not_found
|
||||||
|
try:
|
||||||
|
PluginLoader.find(plugin_type)
|
||||||
|
except KeyError:
|
||||||
|
return resp.plugin_type_not_found
|
||||||
|
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 resp.created(instance.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_instance(instance: PluginInstance, data: dict) -> web.Response:
|
||||||
|
if not await instance.update_primary_user(data.get("primary_user", None)):
|
||||||
|
return resp.primary_user_not_found
|
||||||
|
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))
|
||||||
|
await instance.update_type(data.get("type", None))
|
||||||
|
instance.db.commit()
|
||||||
|
return resp.updated(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 resp.body_not_json
|
||||||
|
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 resp.instance_not_found
|
||||||
|
if instance.started:
|
||||||
|
await instance.stop()
|
||||||
|
instance.delete()
|
||||||
|
return resp.deleted
|
140
maubot/management/api/log.py
Normal file
140
maubot/management/api/log.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
# 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 Deque, List
|
||||||
|
from datetime import datetime
|
||||||
|
from collections import deque
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from .base import routes, get_loop
|
||||||
|
from .auth import is_valid_token
|
||||||
|
|
||||||
|
BUILTIN_ATTRS = {"args", "asctime", "created", "exc_info", "exc_text", "filename", "funcName",
|
||||||
|
"levelname", "levelno", "lineno", "module", "msecs", "message", "msg", "name",
|
||||||
|
"pathname", "process", "processName", "relativeCreated", "stack_info", "thread",
|
||||||
|
"threadName"}
|
||||||
|
INCLUDE_ATTRS = {"filename", "funcName", "levelname", "levelno", "lineno", "module", "name",
|
||||||
|
"pathname"}
|
||||||
|
EXCLUDE_ATTRS = BUILTIN_ATTRS - INCLUDE_ATTRS
|
||||||
|
MAX_LINES = 2048
|
||||||
|
|
||||||
|
|
||||||
|
class LogCollector(logging.Handler):
|
||||||
|
lines: Deque[dict]
|
||||||
|
formatter: logging.Formatter
|
||||||
|
listeners: List[web.WebSocketResponse]
|
||||||
|
|
||||||
|
def __init__(self, level=logging.NOTSET) -> None:
|
||||||
|
super().__init__(level)
|
||||||
|
self.lines = deque(maxlen=MAX_LINES)
|
||||||
|
self.formatter = logging.Formatter()
|
||||||
|
self.listeners = []
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
try:
|
||||||
|
self._emit(record)
|
||||||
|
except Exception as e:
|
||||||
|
print("Logging error:", e)
|
||||||
|
|
||||||
|
def _emit(self, record: logging.LogRecord) -> None:
|
||||||
|
# JSON conversion based on Marsel Mavletkulov's json-log-formatter (MIT license)
|
||||||
|
# https://github.com/marselester/json-log-formatter
|
||||||
|
content = {
|
||||||
|
name: value
|
||||||
|
for name, value in record.__dict__.items()
|
||||||
|
if name not in EXCLUDE_ATTRS
|
||||||
|
}
|
||||||
|
content["id"] = str(record.relativeCreated)
|
||||||
|
content["msg"] = record.getMessage()
|
||||||
|
content["time"] = datetime.fromtimestamp(record.created)
|
||||||
|
|
||||||
|
if record.exc_info:
|
||||||
|
content["exc_info"] = self.formatter.formatException(record.exc_info)
|
||||||
|
|
||||||
|
for name, value in content.items():
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
content[name] = value.astimezone().isoformat()
|
||||||
|
asyncio.ensure_future(self.send(content))
|
||||||
|
self.lines.append(content)
|
||||||
|
|
||||||
|
async def send(self, record: dict) -> None:
|
||||||
|
for ws in self.listeners:
|
||||||
|
try:
|
||||||
|
await ws.send_json(record)
|
||||||
|
except Exception as e:
|
||||||
|
print("Log sending error:", e)
|
||||||
|
|
||||||
|
|
||||||
|
handler = LogCollector()
|
||||||
|
log_root = logging.getLogger("maubot")
|
||||||
|
log = logging.getLogger("maubot.server.websocket")
|
||||||
|
sockets = []
|
||||||
|
|
||||||
|
|
||||||
|
def init() -> None:
|
||||||
|
log_root.addHandler(handler)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_all() -> None:
|
||||||
|
log_root.removeHandler(handler)
|
||||||
|
for socket in sockets:
|
||||||
|
try:
|
||||||
|
await socket.close(code=1012)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/logs")
|
||||||
|
async def log_websocket(request: web.Request) -> web.WebSocketResponse:
|
||||||
|
ws = web.WebSocketResponse()
|
||||||
|
await ws.prepare(request)
|
||||||
|
sockets.append(ws)
|
||||||
|
log.debug(f"Connection from {request.remote} opened")
|
||||||
|
authenticated = False
|
||||||
|
|
||||||
|
async def close_if_not_authenticated():
|
||||||
|
await asyncio.sleep(5, loop=get_loop())
|
||||||
|
if not authenticated:
|
||||||
|
await ws.close(code=4000)
|
||||||
|
log.debug(f"Connection from {request.remote} terminated due to no authentication")
|
||||||
|
|
||||||
|
asyncio.ensure_future(close_if_not_authenticated())
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for msg in ws:
|
||||||
|
if msg.type != web.WSMsgType.TEXT:
|
||||||
|
continue
|
||||||
|
if is_valid_token(msg.data):
|
||||||
|
await ws.send_json({"auth_success": True})
|
||||||
|
await ws.send_json({"history": list(handler.lines)})
|
||||||
|
if not authenticated:
|
||||||
|
log.debug(f"Connection from {request.remote} authenticated")
|
||||||
|
handler.listeners.append(ws)
|
||||||
|
authenticated = True
|
||||||
|
elif not authenticated:
|
||||||
|
await ws.send_json({"auth_success": False})
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if authenticated:
|
||||||
|
handler.listeners.remove(ws)
|
||||||
|
log.debug(f"Connection from {request.remote} closed")
|
||||||
|
sockets.remove(ws)
|
||||||
|
return ws
|
64
maubot/management/api/middleware.py
Normal file
64
maubot/management/api/middleware.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# 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
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from .responses import resp
|
||||||
|
from .auth import check_token
|
||||||
|
from .base import get_config
|
||||||
|
|
||||||
|
Handler = Callable[[web.Request], Awaitable[web.Response]]
|
||||||
|
log = logging.getLogger("maubot.server")
|
||||||
|
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def auth(request: web.Request, handler: Handler) -> web.Response:
|
||||||
|
subpath = request.path[len(get_config()["server.base_path"]):]
|
||||||
|
if subpath.startswith("/auth/") or subpath == "/logs":
|
||||||
|
return await handler(request)
|
||||||
|
err = check_token(request)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
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 resp.path_not_found
|
||||||
|
elif ex.status_code == 405:
|
||||||
|
return resp.method_not_allowed
|
||||||
|
return web.json_response({
|
||||||
|
"error": f"Unhandled HTTP {ex.status}",
|
||||||
|
"errcode": f"unhandled_http_{ex.status}",
|
||||||
|
}, status=ex.status)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error in handler")
|
||||||
|
return resp.internal_server_error
|
||||||
|
|
||||||
|
|
||||||
|
req_no = 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_req_no():
|
||||||
|
global req_no
|
||||||
|
req_no += 1
|
||||||
|
return req_no
|
151
maubot/management/api/plugin.py
Normal file
151
maubot/management/api/plugin.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
# 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 io import BytesIO
|
||||||
|
from time import time
|
||||||
|
import traceback
|
||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from packaging.version import Version
|
||||||
|
|
||||||
|
from ...loader import PluginLoader, ZippedPluginLoader, MaubotZipImportError
|
||||||
|
from .responses import resp
|
||||||
|
from .base import routes, get_config
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/plugins")
|
||||||
|
async def get_plugins(_) -> web.Response:
|
||||||
|
return resp.found([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 resp.plugin_not_found
|
||||||
|
return resp.found(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 resp.plugin_not_found
|
||||||
|
elif len(plugin.references) > 0:
|
||||||
|
return resp.plugin_in_use
|
||||||
|
await plugin.delete()
|
||||||
|
return resp.deleted
|
||||||
|
|
||||||
|
|
||||||
|
@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 resp.plugin_not_found
|
||||||
|
|
||||||
|
await plugin.stop_instances()
|
||||||
|
try:
|
||||||
|
await plugin.reload()
|
||||||
|
except MaubotZipImportError as e:
|
||||||
|
return resp.plugin_reload_error(str(e), traceback.format_exc())
|
||||||
|
await plugin.start_instances()
|
||||||
|
return resp.ok
|
||||||
|
|
||||||
|
|
||||||
|
@routes.put("/plugin/{id}")
|
||||||
|
async def put_plugin(request: web.Request) -> web.Response:
|
||||||
|
plugin_id = request.match_info.get("id", None)
|
||||||
|
content = await request.read()
|
||||||
|
file = BytesIO(content)
|
||||||
|
try:
|
||||||
|
pid, version = ZippedPluginLoader.verify_meta(file)
|
||||||
|
except MaubotZipImportError as e:
|
||||||
|
return resp.plugin_import_error(str(e), traceback.format_exc())
|
||||||
|
if pid != plugin_id:
|
||||||
|
return resp.pid_mismatch
|
||||||
|
plugin = PluginLoader.id_cache.get(plugin_id, 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 resp.unsupported_plugin_loader
|
||||||
|
|
||||||
|
|
||||||
|
@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 resp.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 not request.query.get("allow_override"):
|
||||||
|
return resp.plugin_exists
|
||||||
|
elif isinstance(plugin, ZippedPluginLoader):
|
||||||
|
return await upload_replacement_plugin(plugin, content, version)
|
||||||
|
else:
|
||||||
|
return resp.unsupported_plugin_loader
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_new_plugin(content: bytes, pid: str, version: Version) -> 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 resp.plugin_import_error(str(e), traceback.format_exc())
|
||||||
|
return resp.created(plugin.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes,
|
||||||
|
new_version: Version) -> web.Response:
|
||||||
|
dirname = os.path.dirname(plugin.path)
|
||||||
|
old_filename = os.path.basename(plugin.path)
|
||||||
|
if plugin.version in old_filename:
|
||||||
|
replacement = (new_version if plugin.version != new_version
|
||||||
|
else f"{new_version}-ts{int(time())}")
|
||||||
|
filename = re.sub(f"{re.escape(plugin.version)}(-ts[0-9]+)?",
|
||||||
|
replacement, 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 resp.plugin_import_error(str(e), traceback.format_exc())
|
||||||
|
await plugin.start_instances()
|
||||||
|
ZippedPluginLoader.trash(old_path, reason="update")
|
||||||
|
return resp.updated(plugin.to_dict())
|
266
maubot/management/api/responses.py
Normal file
266
maubot/management/api/responses.py
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
@property
|
||||||
|
def body_not_json(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Request body is not JSON",
|
||||||
|
"errcode": "body_not_json",
|
||||||
|
}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def plugin_type_required(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Plugin type is required when creating plugin instances",
|
||||||
|
"errcode": "plugin_type_required",
|
||||||
|
}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_user_required(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Primary user is required when creating plugin instances",
|
||||||
|
"errcode": "primary_user_required",
|
||||||
|
}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bad_client_access_token(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Invalid access token",
|
||||||
|
"errcode": "bad_client_access_token",
|
||||||
|
}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bad_client_access_details(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Invalid homeserver or access token",
|
||||||
|
"errcode": "bad_client_access_details"
|
||||||
|
}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bad_client_connection_details(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Could not connect to homeserver",
|
||||||
|
"errcode": "bad_client_connection_details"
|
||||||
|
}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
def mxid_mismatch(self, found: str) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "The Matrix user ID of the client and the user ID of the access token don't "
|
||||||
|
f"match. Access token is for user {found}",
|
||||||
|
"errcode": "mxid_mismatch",
|
||||||
|
}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pid_mismatch(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "The ID in the path does not match the ID of the uploaded plugin",
|
||||||
|
"errcode": "pid_mismatch",
|
||||||
|
}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def username_or_password_missing(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Username or password missing",
|
||||||
|
"errcode": "username_or_password_missing",
|
||||||
|
}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bad_auth(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Invalid username or password",
|
||||||
|
"errcode": "invalid_auth",
|
||||||
|
}, status=HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def no_token(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Authorization token missing",
|
||||||
|
"errcode": "auth_token_missing",
|
||||||
|
}, status=HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def invalid_token(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Invalid authorization token",
|
||||||
|
"errcode": "auth_token_invalid",
|
||||||
|
}, status=HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def plugin_not_found(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Plugin not found",
|
||||||
|
"errcode": "plugin_not_found",
|
||||||
|
}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_not_found(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Client not found",
|
||||||
|
"errcode": "client_not_found",
|
||||||
|
}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_user_not_found(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Client for given primary user not found",
|
||||||
|
"errcode": "primary_user_not_found",
|
||||||
|
}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def instance_not_found(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Plugin instance not found",
|
||||||
|
"errcode": "instance_not_found",
|
||||||
|
}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def plugin_type_not_found(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Given plugin type not found",
|
||||||
|
"errcode": "plugin_type_not_found",
|
||||||
|
}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path_not_found(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Resource not found",
|
||||||
|
"errcode": "resource_not_found",
|
||||||
|
}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def server_not_found(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Registration target server not found",
|
||||||
|
"errcode": "server_not_found",
|
||||||
|
}, status=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def method_not_allowed(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Method not allowed",
|
||||||
|
"errcode": "method_not_allowed",
|
||||||
|
}, status=HTTPStatus.METHOD_NOT_ALLOWED)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_exists(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "There is already a client with the user ID of that token",
|
||||||
|
"errcode": "user_exists",
|
||||||
|
}, status=HTTPStatus.CONFLICT)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def plugin_exists(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "A plugin with the same ID as the uploaded plugin already exists",
|
||||||
|
"errcode": "plugin_exists"
|
||||||
|
}, status=HTTPStatus.CONFLICT)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def plugin_in_use(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Plugin instances of this type still exist",
|
||||||
|
"errcode": "plugin_in_use",
|
||||||
|
}, status=HTTPStatus.PRECONDITION_FAILED)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_in_use(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Plugin instances with this client as their primary user still exist",
|
||||||
|
"errcode": "client_in_use",
|
||||||
|
}, status=HTTPStatus.PRECONDITION_FAILED)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
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)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
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)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def internal_server_error(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Internal server error",
|
||||||
|
"errcode": "internal_server_error",
|
||||||
|
}, status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def invalid_server(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Invalid registration server object in maubot configuration",
|
||||||
|
"errcode": "invalid_server",
|
||||||
|
}, status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unsupported_plugin_loader(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Existing plugin with same ID uses unsupported plugin loader",
|
||||||
|
"errcode": "unsupported_plugin_loader",
|
||||||
|
}, status=HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def not_implemented(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Not implemented",
|
||||||
|
"errcode": "not_implemented",
|
||||||
|
}, status=HTTPStatus.NOT_IMPLEMENTED)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
}, status=HTTPStatus.OK)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deleted(self) -> web.Response:
|
||||||
|
return web.Response(status=HTTPStatus.NO_CONTENT)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def found(data: dict) -> web.Response:
|
||||||
|
return web.json_response(data, status=HTTPStatus.OK)
|
||||||
|
|
||||||
|
def updated(self, data: dict) -> web.Response:
|
||||||
|
return self.found(data)
|
||||||
|
|
||||||
|
def logged_in(self, token: str) -> web.Response:
|
||||||
|
return self.found({
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
|
||||||
|
def pong(self, user: str) -> web.Response:
|
||||||
|
return self.found({
|
||||||
|
"username": user,
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def created(data: dict) -> web.Response:
|
||||||
|
return web.json_response(data, status=HTTPStatus.CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
resp = _Response()
|
93
maubot/management/api/spec.md
Normal file
93
maubot/management/api/spec.md
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# Maubot Management API
|
||||||
|
Most of the API is simple HTTP+JSON and has OpenAPI documentation (see
|
||||||
|
[spec.yaml](spec.yaml), [rendered](https://maubot.xyz/spec/)). However,
|
||||||
|
some parts of the API aren't documented in the OpenAPI document.
|
||||||
|
|
||||||
|
## Matrix API proxy
|
||||||
|
The full Matrix API can be accessed for each client with a request to
|
||||||
|
`/_matrix/maubot/v1/proxy/<user>/<path>`. `<user>` is the Matrix user
|
||||||
|
ID of the user to access the API as and `<path>` is the whole API
|
||||||
|
path to access (e.g. `/_matrix/client/r0/whoami`).
|
||||||
|
|
||||||
|
The body, headers, query parameters, etc are sent to the Matrix server
|
||||||
|
as-is, with a few exceptions:
|
||||||
|
* The `Authorization` header will be replaced with the access token
|
||||||
|
for the Matrix user from the maubot database.
|
||||||
|
* The `access_token` query parameter will be removed.
|
||||||
|
|
||||||
|
## Log viewing
|
||||||
|
1. Open websocket to `/_matrix/maubot/v1/logs`.
|
||||||
|
2. Send authentication token as a plain string.
|
||||||
|
3. Server will respond with `{"auth_success": true}` and then with
|
||||||
|
`{"history": ...}` where `...` is a list of log entries.
|
||||||
|
4. Server will send new log entries as JSON.
|
||||||
|
|
||||||
|
### Log entry object format
|
||||||
|
Log entries are a JSON-serialized form of Python log records.
|
||||||
|
|
||||||
|
Log entries will always have:
|
||||||
|
* `id` - A string that should uniquely identify the row. Currently
|
||||||
|
uses the `relativeCreated` field of Python logging records.
|
||||||
|
* `msg` - The text in the entry.
|
||||||
|
* `time` - The ISO date when the log entry was created.
|
||||||
|
|
||||||
|
Log entries should also always have:
|
||||||
|
* `levelname` - The log level (e.g. `DEBUG` or `ERROR`).
|
||||||
|
* `levelno` - The integer log level.
|
||||||
|
* `name` - The name of the logger. Common values:
|
||||||
|
* `maubot.client.<mxid>` - Client loggers (Matrix HTTP requests)
|
||||||
|
* `maubot.instance.<id>` - Plugin instance loggers
|
||||||
|
* `maubot.loader.zip` - The zip plugin loader (plugins don't
|
||||||
|
have their own logs)
|
||||||
|
* `module` - The Python module name where the log call happened.
|
||||||
|
* `pathname` - The full path of the file where the log call happened.
|
||||||
|
* `filename` - The file name portion of `pathname`
|
||||||
|
* `lineno` - The line in code where the log call happened.
|
||||||
|
* `funcName` - The name of the function where the log call happened.
|
||||||
|
|
||||||
|
Log entries might have:
|
||||||
|
* `exc_info` - The formatted exception info if an exception was logged.
|
||||||
|
* `matrix_http_request` - The info about a Matrix HTTP request. Subfields:
|
||||||
|
* `method` - The HTTP method used.
|
||||||
|
* `path` - The API path used.
|
||||||
|
* `content` - The content sent.
|
||||||
|
* `user` - The appservice user who the request was ran as.
|
||||||
|
|
||||||
|
## Debug file open
|
||||||
|
For debug and development purposes, the API and frontend support
|
||||||
|
clicking on lines in stack traces to open that line in the selected
|
||||||
|
editor.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
First, the directory where maubot is run from must have a
|
||||||
|
`.dev-open-cfg.yaml` file. The file should contain the following
|
||||||
|
fields:
|
||||||
|
* `editor` - The command to run to open a file.
|
||||||
|
* `$path` is replaced with the full file path.
|
||||||
|
* `$line` is replaced with the line number.
|
||||||
|
* `pathmap` - A list of find-and-replaces to execute on paths.
|
||||||
|
These are needed to map things like `.mbp` files to the extracted
|
||||||
|
sources on disk. Each pathmap entry should have:
|
||||||
|
* `find` - The regex to match.
|
||||||
|
* `replace` - The replacement. May insert capture groups with Python
|
||||||
|
syntax (e.g. `\1`)
|
||||||
|
|
||||||
|
Example file:
|
||||||
|
```yaml
|
||||||
|
editor: pycharm --line $line $path
|
||||||
|
pathmap:
|
||||||
|
- find: "maubot/plugins/xyz\\.maubot\\.(.+)-v.+(?:-ts[0-9]+)?.mbp"
|
||||||
|
replace: "mbplugins/\\1"
|
||||||
|
- find: "maubot/.venv/lib/python3.6/site-packages/mautrix"
|
||||||
|
replace: "mautrix-python/mautrix"
|
||||||
|
```
|
||||||
|
|
||||||
|
### API
|
||||||
|
Clients can `GET /_matrix/maubot/v1/debug/open` to check if the file
|
||||||
|
open endpoint has been set up. The response is a JSON object with a
|
||||||
|
single field `enabled`. If the value is true, the endpoint can be used.
|
||||||
|
|
||||||
|
To open files, clients can `POST /_matrix/maubot/v1/debug/open` with
|
||||||
|
a JSON body containing
|
||||||
|
* `path` - The full file path to open
|
||||||
|
* `line` - The line number to open
|
518
maubot/management/api/spec.yaml
Normal file
518
maubot/management/api/spec.yaml
Normal file
@ -0,0 +1,518 @@
|
|||||||
|
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:
|
||||||
|
/auth/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
|
||||||
|
/auth/ping:
|
||||||
|
post:
|
||||||
|
operationId: ping
|
||||||
|
summary: Check if the given token is valid
|
||||||
|
tags: [Authentication]
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Token is OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
401:
|
||||||
|
description: Token is not OK
|
||||||
|
|
||||||
|
/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]
|
||||||
|
parameters:
|
||||||
|
- name: allow_override
|
||||||
|
in: query
|
||||||
|
description: Set to allow overriding existing plugins
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/zip:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
example: The plugin maubot archive (.mbp)
|
||||||
|
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'
|
||||||
|
409:
|
||||||
|
description: Plugin already exists and allow_override was not specified.
|
||||||
|
'/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'
|
||||||
|
put:
|
||||||
|
operationId: put_plugin
|
||||||
|
summary: Upload a new or replacement plugin
|
||||||
|
description: |
|
||||||
|
Upload a new or replacement plugin with the specified ID.
|
||||||
|
A HTTP 400 will be returned if the ID of the uploaded plugin
|
||||||
|
doesn't match the ID in the path. If the plugin already
|
||||||
|
exists, enabled instances will be restarted.
|
||||||
|
tags: [Plugins]
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/zip:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
example: The plugin maubot archive (.mbp)
|
||||||
|
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'
|
||||||
|
/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'
|
67
maubot/management/frontend/.eslintrc.json
Normal file
67
maubot/management/frontend/.eslintrc.json
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"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],
|
||||||
|
"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*
|
27
maubot/management/frontend/.sass-lint.yml
Normal file
27
maubot/management/frontend/.sass-lint.yml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
options:
|
||||||
|
merge-default-rules: false
|
||||||
|
formatter: html
|
||||||
|
max-warnings: 50
|
||||||
|
|
||||||
|
files:
|
||||||
|
include: 'src/style/**/*.sass'
|
||||||
|
|
||||||
|
rules:
|
||||||
|
extends-before-mixins: 2
|
||||||
|
extends-before-declarations: 2
|
||||||
|
placeholder-in-extend: 2
|
||||||
|
mixins-before-declarations:
|
||||||
|
- 2
|
||||||
|
- exclude:
|
||||||
|
- breakpoint
|
||||||
|
- mq
|
||||||
|
no-warn: 1
|
||||||
|
no-debug: 1
|
||||||
|
hex-notation:
|
||||||
|
- 2
|
||||||
|
- style: uppercase
|
||||||
|
indentation:
|
||||||
|
- 2
|
||||||
|
- size: 4
|
||||||
|
property-sort-order:
|
||||||
|
- 0
|
34
maubot/management/frontend/package.json
Normal file
34
maubot/management/frontend/package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "maubot-manager",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-sass": "^4.9.4",
|
||||||
|
"react": "^16.6.0",
|
||||||
|
"react-ace": "^6.2.0",
|
||||||
|
"react-dom": "^16.6.0",
|
||||||
|
"react-json-tree": "^0.11.0",
|
||||||
|
"react-router-dom": "^4.3.1",
|
||||||
|
"react-scripts": "2.0.5",
|
||||||
|
"react-select": "^2.1.1"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"homepage": ".",
|
||||||
|
"devDependencies": {
|
||||||
|
"sass-lint": "^1.12.1",
|
||||||
|
"sass-lint-auto-fix": "^0.15.0"
|
||||||
|
}
|
||||||
|
}
|
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 |
38
maubot/management/frontend/public/index.html
Normal file
38
maubot/management/frontend/public/index.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<!--
|
||||||
|
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.png">
|
||||||
|
<link rel="stylesheet" type="text/css"
|
||||||
|
href="https://fonts.googleapis.com/css?family=Raleway:300,400,700">
|
||||||
|
<link rel="stylesheet" type="text/css"
|
||||||
|
href="https://cdn.jsdelivr.net/gh/tonsky/FiraCode@1.206/distr/fira_code.css">
|
||||||
|
<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"
|
||||||
|
}
|
208
maubot/management/frontend/src/api.js
Normal file
208
maubot/management/frontend/src/api.js
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
export const BASE_PATH = "/_matrix/maubot/v1"
|
||||||
|
|
||||||
|
function getHeaders(contentType = "application/json") {
|
||||||
|
return {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Authorization": `Bearer ${localStorage.accessToken}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultDelete(type, id) {
|
||||||
|
const resp = await fetch(`${BASE_PATH}/${type}/${id}`, {
|
||||||
|
headers: getHeaders(),
|
||||||
|
method: "DELETE",
|
||||||
|
})
|
||||||
|
if (resp.status === 204) {
|
||||||
|
return {
|
||||||
|
"success": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultPut(type, entry, id = undefined) {
|
||||||
|
const resp = await fetch(`${BASE_PATH}/${type}/${id || entry.id}`, {
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(entry),
|
||||||
|
method: "PUT",
|
||||||
|
})
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultGet(path) {
|
||||||
|
const resp = await fetch(`${BASE_PATH}${path}`, { headers: getHeaders() })
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username, password) {
|
||||||
|
const resp = await fetch(`${BASE_PATH}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ping() {
|
||||||
|
const response = await fetch(`${BASE_PATH}/auth/ping`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: getHeaders(),
|
||||||
|
})
|
||||||
|
const json = await response.json()
|
||||||
|
if (json.username) {
|
||||||
|
return json.username
|
||||||
|
} else if (json.errcode === "auth_token_missing" || json.errcode === "auth_token_invalid") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
throw json
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openLogSocket() {
|
||||||
|
let protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||||
|
const url = `${protocol}//${window.location.host}${BASE_PATH}/logs`
|
||||||
|
const wrapper = {
|
||||||
|
socket: null,
|
||||||
|
connected: false,
|
||||||
|
authenticated: false,
|
||||||
|
onLog: data => undefined,
|
||||||
|
onHistory: history => undefined,
|
||||||
|
fails: -1,
|
||||||
|
}
|
||||||
|
const openHandler = () => {
|
||||||
|
wrapper.socket.send(localStorage.accessToken)
|
||||||
|
wrapper.connected = true
|
||||||
|
}
|
||||||
|
const messageHandler = evt => {
|
||||||
|
// TODO use logs
|
||||||
|
const data = JSON.parse(evt.data)
|
||||||
|
if (data.auth_success !== undefined) {
|
||||||
|
if (data.auth_success) {
|
||||||
|
console.info("Websocket connection authentication successful")
|
||||||
|
wrapper.authenticated = true
|
||||||
|
wrapper.fails = -1
|
||||||
|
} else {
|
||||||
|
console.info("Websocket connection authentication failed")
|
||||||
|
}
|
||||||
|
} else if (data.history) {
|
||||||
|
wrapper.onHistory(data.history)
|
||||||
|
} else {
|
||||||
|
wrapper.onLog(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const closeHandler = evt => {
|
||||||
|
if (evt) {
|
||||||
|
if (evt.code === 4000) {
|
||||||
|
console.error("Websocket connection failed: access token invalid or not provided")
|
||||||
|
} else if (evt.code === 1012) {
|
||||||
|
console.info("Websocket connection closed: server is restarting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wrapper.connected = false
|
||||||
|
wrapper.socket = null
|
||||||
|
wrapper.fails++
|
||||||
|
const SECOND = 1000
|
||||||
|
setTimeout(() => {
|
||||||
|
wrapper.socket = new WebSocket(url)
|
||||||
|
wrapper.socket.onopen = openHandler
|
||||||
|
wrapper.socket.onmessage = messageHandler
|
||||||
|
wrapper.socket.onclose = closeHandler
|
||||||
|
}, Math.min(wrapper.fails * 5 * SECOND, 30 * SECOND))
|
||||||
|
}
|
||||||
|
|
||||||
|
closeHandler()
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
let _debugOpenFileEnabled = undefined
|
||||||
|
export const debugOpenFileEnabled = () => _debugOpenFileEnabled
|
||||||
|
export const updateDebugOpenFileEnabled = async () => {
|
||||||
|
const resp = await defaultGet("/debug/open")
|
||||||
|
_debugOpenFileEnabled = resp["enabled"] || false
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function debugOpenFile(path, line) {
|
||||||
|
const resp = await fetch(`${BASE_PATH}/debug/open`, {
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify({ path, line }),
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getInstances = () => defaultGet("/instances")
|
||||||
|
export const getInstance = id => defaultGet(`/instance/${id}`)
|
||||||
|
export const putInstance = (instance, id) => defaultPut("instance", instance, id)
|
||||||
|
export const deleteInstance = id => defaultDelete("instance", id)
|
||||||
|
|
||||||
|
export const getPlugins = () => defaultGet("/plugins")
|
||||||
|
export const getPlugin = id => defaultGet(`/plugin/${id}`)
|
||||||
|
export const deletePlugin = id => defaultDelete("plugin", id)
|
||||||
|
|
||||||
|
export async function uploadPlugin(data, id) {
|
||||||
|
let resp
|
||||||
|
if (id) {
|
||||||
|
resp = await fetch(`${BASE_PATH}/plugin/${id}`, {
|
||||||
|
headers: getHeaders("application/zip"),
|
||||||
|
body: data,
|
||||||
|
method: "PUT",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resp = await fetch(`${BASE_PATH}/plugins/upload`, {
|
||||||
|
headers: getHeaders("application/zip"),
|
||||||
|
body: data,
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getClients = () => defaultGet("/clients")
|
||||||
|
export const getClient = id => defaultGet(`/clients/${id}`)
|
||||||
|
|
||||||
|
export async function uploadAvatar(id, data, mime) {
|
||||||
|
const resp = await fetch(`${BASE_PATH}/proxy/${id}/_matrix/media/r0/upload`, {
|
||||||
|
headers: getHeaders(mime),
|
||||||
|
body: data,
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvatarURL({ id, avatar_url }) {
|
||||||
|
avatar_url = avatar_url || ""
|
||||||
|
if (avatar_url.startsWith("mxc://")) {
|
||||||
|
avatar_url = avatar_url.substr("mxc://".length)
|
||||||
|
}
|
||||||
|
return `${BASE_PATH}/proxy/${id}/_matrix/media/r0/download/${avatar_url}?access_token=${
|
||||||
|
localStorage.accessToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const putClient = client => defaultPut("client", client)
|
||||||
|
export const deleteClient = id => defaultDelete("client", id)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
BASE_PATH,
|
||||||
|
login, ping, openLogSocket, debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled,
|
||||||
|
getInstances, getInstance, putInstance, deleteInstance,
|
||||||
|
getPlugins, getPlugin, uploadPlugin, deletePlugin,
|
||||||
|
getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient,
|
||||||
|
}
|
66
maubot/management/frontend/src/components/PreferenceTable.js
Normal file
66
maubot/management/frontend/src/components/PreferenceTable.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// 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 Select from "react-select"
|
||||||
|
import Switch from "./Switch"
|
||||||
|
|
||||||
|
export const PrefTable = ({ children, wrapperClass }) => {
|
||||||
|
if (wrapperClass) {
|
||||||
|
return (
|
||||||
|
<div className={wrapperClass}>
|
||||||
|
<div className="preference-table">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="preference-table">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrefRow =
|
||||||
|
({ name, fullWidth = false, labelFor = undefined, changed = false, children }) => (
|
||||||
|
<div className={`entry ${fullWidth ? "full-width" : ""} ${changed ? "changed" : ""}`}>
|
||||||
|
<label htmlFor={labelFor}>{name}</label>
|
||||||
|
<div className="value">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const PrefInput = ({ rowName, value, origValue, fullWidth = false, ...args }) => (
|
||||||
|
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}
|
||||||
|
changed={origValue !== undefined && value !== origValue}>
|
||||||
|
<input {...args} value={value} id={rowName}/>
|
||||||
|
</PrefRow>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const PrefSwitch = ({ rowName, active, origActive, fullWidth = false, ...args }) => (
|
||||||
|
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}
|
||||||
|
changed={origActive !== undefined && active !== origActive}>
|
||||||
|
<Switch {...args} active={active} id={rowName}/>
|
||||||
|
</PrefRow>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const PrefSelect = ({ rowName, value, origValue, fullWidth = false, ...args }) => (
|
||||||
|
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}
|
||||||
|
changed={origValue !== undefined && value.id !== origValue}>
|
||||||
|
<Select className="select" {...args} id={rowName} value={value}/>
|
||||||
|
</PrefRow>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default PrefTable
|
28
maubot/management/frontend/src/components/PrivateRoute.js
Normal file
28
maubot/management/frontend/src/components/PrivateRoute.js
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/>.
|
||||||
|
import React from "react"
|
||||||
|
import { Route, Redirect } from "react-router-dom"
|
||||||
|
|
||||||
|
const PrivateRoute = ({ component, render, authed, to = "/login", ...args }) => (
|
||||||
|
<Route
|
||||||
|
{...args}
|
||||||
|
render={(props) => authed === true
|
||||||
|
? (component ? React.createElement(component, props) : render())
|
||||||
|
: <Redirect to={{ pathname: to }}/>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default PrivateRoute
|
11
maubot/management/frontend/src/components/Spinner.js
Normal file
11
maubot/management/frontend/src/components/Spinner.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
const Spinner = (props) => (
|
||||||
|
<div {...props} className={`spinner ${props["className"] || ""}`}>
|
||||||
|
<svg viewBox="25 25 50 50">
|
||||||
|
<circle cx="50" cy="50" r="20" fill="none" strokeWidth="2" strokeMiterlimit="10"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Spinner
|
57
maubot/management/frontend/src/components/Switch.js
Normal file
57
maubot/management/frontend/src/components/Switch.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// 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 Switch extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = {
|
||||||
|
active: props.active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
this.setState({
|
||||||
|
active: nextProps.active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle = () => {
|
||||||
|
if (this.props.onToggle) {
|
||||||
|
this.props.onToggle(!this.state.active)
|
||||||
|
} else {
|
||||||
|
this.setState({ active: !this.state.active })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleKeyboard = evt => (evt.key === " " || evt.key === "Enter") && this.toggle()
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="switch" data-active={this.state.active} onClick={this.toggle}
|
||||||
|
tabIndex="0" onKeyPress={this.toggleKeyboard} id={this.props.id}>
|
||||||
|
<div className="box">
|
||||||
|
<span className="text">
|
||||||
|
<span className="on">{this.props.onText || "On"}</span>
|
||||||
|
<span className="off">{this.props.offText || "Off"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Switch
|
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 App from "./pages/Main"
|
||||||
|
|
||||||
|
ReactDOM.render(<App/>, document.getElementById("root"))
|
63
maubot/management/frontend/src/pages/Login.js
Normal file
63
maubot/management/frontend/src/pages/Login.js
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/>.
|
||||||
|
import React, { Component } from "react"
|
||||||
|
import Spinner from "../components/Spinner"
|
||||||
|
import api from "../api"
|
||||||
|
|
||||||
|
class Login extends Component {
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context)
|
||||||
|
this.state = {
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inputChanged = event => this.setState({ [event.target.name]: event.target.value })
|
||||||
|
|
||||||
|
login = async () => {
|
||||||
|
this.setState({ loading: true })
|
||||||
|
const resp = await api.login(this.state.username, this.state.password)
|
||||||
|
if (resp.token) {
|
||||||
|
await this.props.onLogin(resp.token)
|
||||||
|
} else if (resp.error) {
|
||||||
|
this.setState({ error: resp.error, loading: false })
|
||||||
|
} else {
|
||||||
|
this.setState({ error: "Unknown error", loading: false })
|
||||||
|
console.log("Unknown error:", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <div className="login-wrapper">
|
||||||
|
<div className={`login ${this.state.error && "errored"}`}>
|
||||||
|
<h1>Maubot Manager</h1>
|
||||||
|
<input type="text" placeholder="Username" value={this.state.username}
|
||||||
|
name="username" onChange={this.inputChanged}/>
|
||||||
|
<input type="password" placeholder="Password" value={this.state.password}
|
||||||
|
name="password" onChange={this.inputChanged}/>
|
||||||
|
<button onClick={this.login}>
|
||||||
|
{this.state.loading ? <Spinner/> : "Log in"}
|
||||||
|
</button>
|
||||||
|
{this.state.error && <div className="error">{this.state.error}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login
|
75
maubot/management/frontend/src/pages/Main.js
Normal file
75
maubot/management/frontend/src/pages/Main.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// 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"
|
||||||
|
import { HashRouter as Router, Switch } from "react-router-dom"
|
||||||
|
import PrivateRoute from "../components/PrivateRoute"
|
||||||
|
import Spinner from "../components/Spinner"
|
||||||
|
import api from "../api"
|
||||||
|
import Dashboard from "./dashboard"
|
||||||
|
import Login from "./Login"
|
||||||
|
|
||||||
|
class Main extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = {
|
||||||
|
pinged: false,
|
||||||
|
authed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async componentWillMount() {
|
||||||
|
if (localStorage.accessToken) {
|
||||||
|
await this.ping()
|
||||||
|
}
|
||||||
|
this.setState({ pinged: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async ping() {
|
||||||
|
try {
|
||||||
|
const username = await api.ping()
|
||||||
|
if (username) {
|
||||||
|
localStorage.username = username
|
||||||
|
this.setState({ authed: true })
|
||||||
|
} else {
|
||||||
|
delete localStorage.accessToken
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
login = async (token) => {
|
||||||
|
localStorage.accessToken = token
|
||||||
|
await this.ping()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.pinged) {
|
||||||
|
return <Spinner className="maubot-loading"/>
|
||||||
|
}
|
||||||
|
return <Router>
|
||||||
|
<div className={`maubot-wrapper ${this.state.authed ? "authenticated" : ""}`}>
|
||||||
|
<Switch>
|
||||||
|
<PrivateRoute path="/login" render={() => <Login onLogin={this.login}/>}
|
||||||
|
authed={!this.state.authed} to="/"/>
|
||||||
|
<PrivateRoute path="/" component={Dashboard} authed={this.state.authed}/>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Main
|
@ -0,0 +1,82 @@
|
|||||||
|
import React, { Component } from "react"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
|
||||||
|
class BaseMainView extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = Object.assign(this.initialState, props.entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
const newState = Object.assign(this.initialState, nextProps.entry)
|
||||||
|
for (const key of this.entryKeys) {
|
||||||
|
if (this.props.entry[key] === nextProps.entry[key]) {
|
||||||
|
newState[key] = this.state[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setState(newState)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete = async () => {
|
||||||
|
if (!window.confirm(`Are you sure you want to delete ${this.state.id}?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setState({ deleting: true })
|
||||||
|
const resp = await this.deleteFunc(this.state.id)
|
||||||
|
if (resp.success) {
|
||||||
|
this.props.history.push("/")
|
||||||
|
this.props.onDelete()
|
||||||
|
} else {
|
||||||
|
this.setState({ deleting: false, error: resp.error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get entryKeys() {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
get initialState() {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasInstances() {
|
||||||
|
return this.state.instances && this.state.instances.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
get isNew() {
|
||||||
|
return !this.props.entry.id
|
||||||
|
}
|
||||||
|
|
||||||
|
inputChange = event => {
|
||||||
|
if (!event.target.name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setState({ [event.target.name]: event.target.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
reader.onload = evt => resolve(evt.target.result)
|
||||||
|
reader.onerror = err => reject(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderInstances = () => !this.isNew && (
|
||||||
|
<div className="instances">
|
||||||
|
<h3>{this.hasInstances ? "Instances" : "No instances :("}</h3>
|
||||||
|
{this.state.instances.map(instance => (
|
||||||
|
<Link className="instance" key={instance.id} to={`/instance/${instance.id}`}>
|
||||||
|
{instance.id}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
renderLogButton = (filter) => !this.isNew && <div className="buttons">
|
||||||
|
<button className="open-log" onClick={() => this.props.openLog(filter)}>View logs</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BaseMainView
|
228
maubot/management/frontend/src/pages/dashboard/Client.js
Normal file
228
maubot/management/frontend/src/pages/dashboard/Client.js
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
// 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 { NavLink, withRouter } from "react-router-dom"
|
||||||
|
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
|
||||||
|
import { ReactComponent as UploadButton } from "../../res/upload.svg"
|
||||||
|
import { PrefTable, PrefSwitch, PrefInput } from "../../components/PreferenceTable"
|
||||||
|
import Spinner from "../../components/Spinner"
|
||||||
|
import api from "../../api"
|
||||||
|
import BaseMainView from "./BaseMainView"
|
||||||
|
|
||||||
|
const ClientListEntry = ({ entry }) => {
|
||||||
|
const classes = ["client", "entry"]
|
||||||
|
if (!entry.enabled) {
|
||||||
|
classes.push("disabled")
|
||||||
|
} else if (!entry.started) {
|
||||||
|
classes.push("stopped")
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NavLink className={classes.join(" ")} to={`/client/${entry.id}`}>
|
||||||
|
<img className="avatar" src={api.getAvatarURL(entry)} alt=""/>
|
||||||
|
<span className="displayname">{entry.displayname || entry.id}</span>
|
||||||
|
<ChevronRight/>
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Client extends BaseMainView {
|
||||||
|
static ListEntry = ClientListEntry
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.deleteFunc = api.deleteClient
|
||||||
|
}
|
||||||
|
|
||||||
|
get entryKeys() {
|
||||||
|
return ["id", "displayname", "homeserver", "avatar_url", "access_token", "sync",
|
||||||
|
"autojoin", "enabled", "started"]
|
||||||
|
}
|
||||||
|
|
||||||
|
get initialState() {
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
displayname: "",
|
||||||
|
homeserver: "",
|
||||||
|
avatar_url: "",
|
||||||
|
access_token: "",
|
||||||
|
sync: true,
|
||||||
|
autojoin: true,
|
||||||
|
enabled: true,
|
||||||
|
started: false,
|
||||||
|
|
||||||
|
instances: [],
|
||||||
|
|
||||||
|
uploadingAvatar: false,
|
||||||
|
saving: false,
|
||||||
|
deleting: false,
|
||||||
|
startingOrStopping: false,
|
||||||
|
error: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get clientInState() {
|
||||||
|
const client = Object.assign({}, this.state)
|
||||||
|
delete client.uploadingAvatar
|
||||||
|
delete client.saving
|
||||||
|
delete client.deleting
|
||||||
|
delete client.startingOrStopping
|
||||||
|
delete client.error
|
||||||
|
delete client.instances
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
avatarUpload = async event => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
this.setState({
|
||||||
|
uploadingAvatar: true,
|
||||||
|
})
|
||||||
|
const data = await this.readFile(file)
|
||||||
|
const resp = await api.uploadAvatar(this.state.id, data, file.type)
|
||||||
|
this.setState({
|
||||||
|
uploadingAvatar: false,
|
||||||
|
avatar_url: resp.content_uri,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
save = async () => {
|
||||||
|
this.setState({ saving: true })
|
||||||
|
const resp = await api.putClient(this.clientInState)
|
||||||
|
if (resp.id) {
|
||||||
|
if (this.isNew) {
|
||||||
|
this.props.history.push(`/client/${resp.id}`)
|
||||||
|
} else {
|
||||||
|
this.setState({ saving: false, error: "" })
|
||||||
|
}
|
||||||
|
this.props.onChange(resp)
|
||||||
|
} else {
|
||||||
|
this.setState({ saving: false, error: resp.error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startOrStop = async () => {
|
||||||
|
this.setState({ startingOrStopping: true })
|
||||||
|
const resp = await api.putClient({
|
||||||
|
id: this.props.entry.id,
|
||||||
|
started: !this.props.entry.started,
|
||||||
|
})
|
||||||
|
if (resp.id) {
|
||||||
|
this.props.onChange(resp)
|
||||||
|
this.setState({ startingOrStopping: false, error: "" })
|
||||||
|
} else {
|
||||||
|
this.setState({ startingOrStopping: false, error: resp.error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get loading() {
|
||||||
|
return this.state.saving || this.state.startingOrStopping || this.state.deleting
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSidebar = () => !this.isNew && (
|
||||||
|
<div className="sidebar">
|
||||||
|
<div className={`avatar-container ${this.state.avatar_url ? "" : "no-avatar"}
|
||||||
|
${this.state.uploadingAvatar ? "uploading" : ""}`}>
|
||||||
|
<img className="avatar" src={api.getAvatarURL(this.state)} alt="Avatar"/>
|
||||||
|
<UploadButton className="upload"/>
|
||||||
|
<input className="file-selector" type="file" accept="image/png, image/jpeg"
|
||||||
|
onChange={this.avatarUpload} disabled={this.state.uploadingAvatar}
|
||||||
|
onDragEnter={evt => evt.target.parentElement.classList.add("drag")}
|
||||||
|
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
|
||||||
|
{this.state.uploadingAvatar && <Spinner/>}
|
||||||
|
</div>
|
||||||
|
<div className="started-container">
|
||||||
|
<span className={`started ${this.props.entry.started}
|
||||||
|
${this.props.entry.enabled ? "" : "disabled"}`}/>
|
||||||
|
<span className="text">
|
||||||
|
{this.props.entry.started ? "Started" :
|
||||||
|
(this.props.entry.enabled ? "Stopped" : "Disabled")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(this.props.entry.started || this.props.entry.enabled) && (
|
||||||
|
<button className="save" onClick={this.startOrStop} disabled={this.loading}>
|
||||||
|
{this.state.startingOrStopping ? <Spinner/>
|
||||||
|
: (this.props.entry.started ? "Stop" : "Start")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPreferences = () => (
|
||||||
|
<PrefTable>
|
||||||
|
<PrefInput rowName="User ID" type="text" disabled={!this.isNew} fullWidth={true}
|
||||||
|
name={this.isNew ? "id" : ""} className="id"
|
||||||
|
value={this.state.id} origValue={this.props.entry.id}
|
||||||
|
placeholder="@fancybot:example.com" onChange={this.inputChange}/>
|
||||||
|
<PrefInput rowName="Homeserver" type="text" name="homeserver"
|
||||||
|
value={this.state.homeserver} origValue={this.props.entry.homeserver}
|
||||||
|
placeholder="https://example.com" onChange={this.inputChange}/>
|
||||||
|
<PrefInput rowName="Access token" type="text" name="access_token"
|
||||||
|
value={this.state.access_token} origValue={this.props.entry.access_token}
|
||||||
|
placeholder="MDAxYWxvY2F0aW9uIG1hdHJpeC5sb2NhbAowMDEzaWRlbnRpZmllc"
|
||||||
|
onChange={this.inputChange}/>
|
||||||
|
<PrefInput rowName="Display name" type="text" name="displayname"
|
||||||
|
value={this.state.displayname} origValue={this.props.entry.displayname}
|
||||||
|
placeholder="My fancy bot" onChange={this.inputChange}/>
|
||||||
|
<PrefInput rowName="Avatar URL" type="text" name="avatar_url"
|
||||||
|
value={this.state.avatar_url} origValue={this.props.entry.avatar_url}
|
||||||
|
placeholder="mxc://example.com/mbmwyoTvPhEQPiCskcUsppko"
|
||||||
|
onChange={this.inputChange}/>
|
||||||
|
<PrefSwitch rowName="Sync"
|
||||||
|
active={this.state.sync} origActive={this.props.entry.sync}
|
||||||
|
onToggle={sync => this.setState({ sync })}/>
|
||||||
|
<PrefSwitch rowName="Autojoin"
|
||||||
|
active={this.state.autojoin} origActive={this.props.entry.autojoin}
|
||||||
|
onToggle={autojoin => this.setState({ autojoin })}/>
|
||||||
|
<PrefSwitch rowName="Enabled"
|
||||||
|
active={this.state.enabled} origActive={this.props.entry.enabled}
|
||||||
|
onToggle={enabled => this.setState({
|
||||||
|
enabled,
|
||||||
|
started: enabled && this.state.started,
|
||||||
|
})}/>
|
||||||
|
</PrefTable>
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPrefButtons = () => <>
|
||||||
|
<div className="buttons">
|
||||||
|
{!this.isNew && (
|
||||||
|
<button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`}
|
||||||
|
onClick={this.delete} disabled={this.loading || this.hasInstances}
|
||||||
|
title={this.hasInstances ? "Can't delete client that is in use" : ""}>
|
||||||
|
{this.state.deleting ? <Spinner/> : "Delete"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="save" onClick={this.save} disabled={this.loading}>
|
||||||
|
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{this.renderLogButton(this.state.id)}
|
||||||
|
<div className="error">{this.state.error}</div>
|
||||||
|
</>
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <>
|
||||||
|
<div className="client">
|
||||||
|
{this.renderSidebar()}
|
||||||
|
<div className="info">
|
||||||
|
{this.renderPreferences()}
|
||||||
|
{this.renderPrefButtons()}
|
||||||
|
{this.renderInstances()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(Client)
|
30
maubot/management/frontend/src/pages/dashboard/Home.js
Normal file
30
maubot/management/frontend/src/pages/dashboard/Home.js
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/>.
|
||||||
|
import React, { Component } from "react"
|
||||||
|
|
||||||
|
class Home extends Component {
|
||||||
|
render() {
|
||||||
|
return <>
|
||||||
|
<div className="home">
|
||||||
|
See sidebar to get started
|
||||||
|
</div>
|
||||||
|
<div className="buttons">
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home
|
183
maubot/management/frontend/src/pages/dashboard/Instance.js
Normal file
183
maubot/management/frontend/src/pages/dashboard/Instance.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
// 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 { NavLink, withRouter } from "react-router-dom"
|
||||||
|
import AceEditor from "react-ace"
|
||||||
|
import "brace/mode/yaml"
|
||||||
|
import "brace/theme/github"
|
||||||
|
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
|
||||||
|
import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/PreferenceTable"
|
||||||
|
import api from "../../api"
|
||||||
|
import Spinner from "../../components/Spinner"
|
||||||
|
import BaseMainView from "./BaseMainView"
|
||||||
|
|
||||||
|
const InstanceListEntry = ({ entry }) => (
|
||||||
|
<NavLink className="instance entry" to={`/instance/${entry.id}`}>
|
||||||
|
<span className="id">{entry.id}</span>
|
||||||
|
<ChevronRight/>
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
|
||||||
|
class Instance extends BaseMainView {
|
||||||
|
static ListEntry = InstanceListEntry
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.deleteFunc = api.deleteInstance
|
||||||
|
this.updateClientOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
get entryKeys() {
|
||||||
|
return ["id", "primary_user", "enabled", "started", "type", "config"]
|
||||||
|
}
|
||||||
|
|
||||||
|
get initialState() {
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
primary_user: "",
|
||||||
|
enabled: true,
|
||||||
|
started: true,
|
||||||
|
type: "",
|
||||||
|
config: "",
|
||||||
|
|
||||||
|
saving: false,
|
||||||
|
deleting: false,
|
||||||
|
error: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get instanceInState() {
|
||||||
|
const instance = Object.assign({}, this.state)
|
||||||
|
delete instance.saving
|
||||||
|
delete instance.deleting
|
||||||
|
delete instance.error
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
super.componentWillReceiveProps(nextProps)
|
||||||
|
this.updateClientOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
clientSelectEntry = client => client && {
|
||||||
|
id: client.id,
|
||||||
|
value: client.id,
|
||||||
|
label: (
|
||||||
|
<div className="select-client">
|
||||||
|
<img className="avatar" src={api.getAvatarURL(client)} alt=""/>
|
||||||
|
<span className="displayname">{client.displayname || client.id}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
updateClientOptions() {
|
||||||
|
this.clientOptions = Object.values(this.props.ctx.clients).map(this.clientSelectEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
save = async () => {
|
||||||
|
this.setState({ saving: true })
|
||||||
|
const resp = await api.putInstance(this.instanceInState, this.props.entry
|
||||||
|
? this.props.entry.id : undefined)
|
||||||
|
if (resp.id) {
|
||||||
|
if (this.isNew) {
|
||||||
|
this.props.history.push(`/instance/${resp.id}`)
|
||||||
|
} else {
|
||||||
|
if (resp.id !== this.props.entry.id) {
|
||||||
|
this.props.history.replace(`/instance/${resp.id}`)
|
||||||
|
}
|
||||||
|
this.setState({ saving: false, error: "" })
|
||||||
|
}
|
||||||
|
this.props.onChange(resp)
|
||||||
|
} else {
|
||||||
|
this.setState({ saving: false, error: resp.error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedClientEntry() {
|
||||||
|
return this.state.primary_user
|
||||||
|
? this.clientSelectEntry(this.props.ctx.clients[this.state.primary_user])
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedPluginEntry() {
|
||||||
|
return {
|
||||||
|
id: this.state.type,
|
||||||
|
value: this.state.type,
|
||||||
|
label: this.state.type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get typeOptions() {
|
||||||
|
return Object.values(this.props.ctx.plugins).map(plugin => plugin && {
|
||||||
|
id: plugin.id,
|
||||||
|
value: plugin.id,
|
||||||
|
label: plugin.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get loading() {
|
||||||
|
return this.state.deleting || this.state.saving
|
||||||
|
}
|
||||||
|
|
||||||
|
get isValid() {
|
||||||
|
return this.state.id && this.state.primary_user && this.state.type
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <div className="instance">
|
||||||
|
<PrefTable>
|
||||||
|
<PrefInput rowName="ID" type="text" name="id" value={this.state.id}
|
||||||
|
placeholder="fancybotinstance" onChange={this.inputChange}
|
||||||
|
disabled={!this.isNew} fullWidth={true} className="id"/>
|
||||||
|
<PrefSwitch rowName="Enabled"
|
||||||
|
active={this.state.enabled} origActive={this.props.entry.enabled}
|
||||||
|
onToggle={enabled => this.setState({ enabled })}/>
|
||||||
|
<PrefSwitch rowName="Running"
|
||||||
|
active={this.state.started} origActive={this.props.entry.started}
|
||||||
|
onToggle={started => this.setState({ started })}/>
|
||||||
|
<PrefSelect rowName="Primary user" options={this.clientOptions}
|
||||||
|
isSearchable={false} value={this.selectedClientEntry}
|
||||||
|
origValue={this.props.entry.primary_user}
|
||||||
|
onChange={({ id }) => this.setState({ primary_user: id })}/>
|
||||||
|
<PrefSelect rowName="Type" options={this.typeOptions} isSearchable={false}
|
||||||
|
value={this.selectedPluginEntry} origValue={this.props.entry.type}
|
||||||
|
onChange={({ id }) => this.setState({ type: id })}/>
|
||||||
|
</PrefTable>
|
||||||
|
{!this.isNew &&
|
||||||
|
<AceEditor mode="yaml" theme="github" onChange={config => this.setState({ config })}
|
||||||
|
name="config" value={this.state.config}
|
||||||
|
editorProps={{
|
||||||
|
fontSize: "10pt",
|
||||||
|
$blockScrolling: true,
|
||||||
|
}}/>}
|
||||||
|
<div className="buttons">
|
||||||
|
{!this.isNew && (
|
||||||
|
<button className="delete" onClick={this.delete} disabled={this.loading}>
|
||||||
|
{this.state.deleting ? <Spinner/> : "Delete"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className={`save ${this.isValid ? "" : "disabled-bg"}`}
|
||||||
|
onClick={this.save} disabled={this.loading || !this.isValid}>
|
||||||
|
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{this.renderLogButton(`instance.${this.state.id}`)}
|
||||||
|
<div className="error">{this.state.error}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(Instance)
|
190
maubot/management/frontend/src/pages/dashboard/Log.js
Normal file
190
maubot/management/frontend/src/pages/dashboard/Log.js
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
// 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, { PureComponent } from "react"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import JSONTree from "react-json-tree"
|
||||||
|
import api from "../../api"
|
||||||
|
import Modal from "./Modal"
|
||||||
|
|
||||||
|
class LogEntry extends PureComponent {
|
||||||
|
static contextType = Modal.Context
|
||||||
|
|
||||||
|
renderName() {
|
||||||
|
const line = this.props.line
|
||||||
|
if (line.nameLink) {
|
||||||
|
const modal = this.context
|
||||||
|
return (
|
||||||
|
<Link to={line.nameLink} onClick={modal.close}>
|
||||||
|
{line.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return line.name
|
||||||
|
}
|
||||||
|
|
||||||
|
renderContent() {
|
||||||
|
if (this.props.line.matrix_http_request) {
|
||||||
|
const req = this.props.line.matrix_http_request
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{req.method} {req.path}
|
||||||
|
<div className="content">
|
||||||
|
{Object.entries(req.content).length > 0
|
||||||
|
&& <JSONTree data={{ content: req.content }} hideRoot={true}/>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
return this.props.line.msg
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickOpen(path, line) {
|
||||||
|
return () => {
|
||||||
|
if (api.debugOpenFileEnabled()) {
|
||||||
|
api.debugOpenFile(path, line)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTimeTitle() {
|
||||||
|
return this.props.line.time.toDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTime() {
|
||||||
|
return <a className="time" title={this.renderTimeTitle()}
|
||||||
|
href={`file:///${this.props.line.pathname}:${this.props.line.lineno}`}
|
||||||
|
onClick={this.onClickOpen(this.props.line.pathname, this.props.line.lineno)}>
|
||||||
|
{this.props.line.time.toLocaleTimeString("en-GB")}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLevelName() {
|
||||||
|
return <span className="level">
|
||||||
|
{this.props.line.levelname}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
get unfocused() {
|
||||||
|
return this.props.focus && this.props.line.name !== this.props.focus
|
||||||
|
? "unfocused"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRow(content) {
|
||||||
|
return (
|
||||||
|
<div className={`row ${this.props.line.levelname.toLowerCase()} ${this.unfocused}`}>
|
||||||
|
{this.renderTime()}
|
||||||
|
{this.renderLevelName()}
|
||||||
|
<span className="logger">{this.renderName()}</span>
|
||||||
|
<span className="text">{content}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderExceptionInfo() {
|
||||||
|
if (!api.debugOpenFileEnabled()) {
|
||||||
|
return this.props.line.exc_info
|
||||||
|
}
|
||||||
|
const fileLinks = []
|
||||||
|
let str = this.props.line.exc_info.replace(
|
||||||
|
/File "(.+)", line ([0-9]+), in (.+)/g,
|
||||||
|
(_, file, line, method) => {
|
||||||
|
fileLinks.push(
|
||||||
|
<a href={`file:///${file}:${line}`} onClick={this.onClickOpen(file, line)}>
|
||||||
|
File "{file}", line {line}, in {method}
|
||||||
|
</a>,
|
||||||
|
)
|
||||||
|
return "||EDGE||"
|
||||||
|
})
|
||||||
|
fileLinks.reverse()
|
||||||
|
|
||||||
|
const result = []
|
||||||
|
let key = 0
|
||||||
|
for (const part of str.split("||EDGE||")) {
|
||||||
|
result.push(<React.Fragment key={key++}>
|
||||||
|
{part}
|
||||||
|
{fileLinks.pop()}
|
||||||
|
</React.Fragment>)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <>
|
||||||
|
{this.renderRow(this.renderContent())}
|
||||||
|
{this.props.line.exc_info && this.renderRow(this.renderExceptionInfo())}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Log extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
|
||||||
|
this.linesRef = React.createRef()
|
||||||
|
this.linesBottomRef = React.createRef()
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshotBeforeUpdate() {
|
||||||
|
if (this.linesRef.current && this.linesBottomRef.current) {
|
||||||
|
return Log.isVisible(this.linesRef.current, this.linesBottomRef.current)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
componentDidUpdate(_1, _2, wasVisible) {
|
||||||
|
if (wasVisible) {
|
||||||
|
Log.scrollParentToChild(this.linesRef.current, this.linesBottomRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (this.linesRef.current && this.linesBottomRef.current) {
|
||||||
|
Log.scrollParentToChild(this.linesRef.current, this.linesBottomRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static scrollParentToChild(parent, child) {
|
||||||
|
const parentRect = parent.getBoundingClientRect()
|
||||||
|
const childRect = child.getBoundingClientRect()
|
||||||
|
|
||||||
|
if (!Log.isVisible(parent, child)) {
|
||||||
|
parent.scrollBy({ top: (childRect.top + parent.scrollTop) - parentRect.top })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static isVisible(parent, child) {
|
||||||
|
const parentRect = parent.getBoundingClientRect()
|
||||||
|
const childRect = child.getBoundingClientRect()
|
||||||
|
return (childRect.top >= parentRect.top)
|
||||||
|
&& (childRect.top <= parentRect.top + parent.clientHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="log" ref={this.linesRef}>
|
||||||
|
<div className="lines">
|
||||||
|
{this.props.lines.map(data => <LogEntry key={data.id} line={data}
|
||||||
|
focus={this.props.focus}/>)}
|
||||||
|
</div>
|
||||||
|
<div ref={this.linesBottomRef}/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Log
|
52
maubot/management/frontend/src/pages/dashboard/Modal.js
Normal file
52
maubot/management/frontend/src/pages/dashboard/Modal.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// 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, createContext } from "react"
|
||||||
|
|
||||||
|
const rem = 16
|
||||||
|
|
||||||
|
class Modal extends Component {
|
||||||
|
static Context = createContext(null)
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = {
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
this.wrapper = { clientWidth: 9001 }
|
||||||
|
}
|
||||||
|
|
||||||
|
open = () => this.setState({ open: true })
|
||||||
|
close = () => this.setState({ open: false })
|
||||||
|
isOpen = () => this.state.open
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.state.open && (
|
||||||
|
<div className="modal-wrapper-wrapper" ref={ref => this.wrapper = ref}
|
||||||
|
onClick={() => this.wrapper.clientWidth > 45 * rem && this.close()}>
|
||||||
|
<div className="modal-wrapper" onClick={evt => evt.stopPropagation()}>
|
||||||
|
<button className="close" onClick={this.close}>Close</button>
|
||||||
|
<div className="modal">
|
||||||
|
<Modal.Context.Provider value={this}>
|
||||||
|
{this.props.children}
|
||||||
|
</Modal.Context.Provider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Modal
|
103
maubot/management/frontend/src/pages/dashboard/Plugin.js
Normal file
103
maubot/management/frontend/src/pages/dashboard/Plugin.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
// 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 { NavLink, withRouter } from "react-router-dom"
|
||||||
|
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
|
||||||
|
import { ReactComponent as UploadButton } from "../../res/upload.svg"
|
||||||
|
import PrefTable, { PrefInput } from "../../components/PreferenceTable"
|
||||||
|
import Spinner from "../../components/Spinner"
|
||||||
|
import api from "../../api"
|
||||||
|
import BaseMainView from "./BaseMainView"
|
||||||
|
|
||||||
|
const PluginListEntry = ({ entry }) => (
|
||||||
|
<NavLink className="plugin entry" to={`/plugin/${entry.id}`}>
|
||||||
|
<span className="id">{entry.id}</span>
|
||||||
|
<ChevronRight/>
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin extends BaseMainView {
|
||||||
|
static ListEntry = PluginListEntry
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.deleteFunc = api.deletePlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
get initialState() {
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
version: "",
|
||||||
|
|
||||||
|
instances: [],
|
||||||
|
|
||||||
|
uploading: false,
|
||||||
|
deleting: false,
|
||||||
|
error: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upload = async event => {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
this.setState({
|
||||||
|
uploadingAvatar: true,
|
||||||
|
})
|
||||||
|
const data = await this.readFile(file)
|
||||||
|
const resp = await api.uploadPlugin(data, this.state.id)
|
||||||
|
if (resp.id) {
|
||||||
|
if (this.isNew) {
|
||||||
|
this.props.history.push(`/plugin/${resp.id}`)
|
||||||
|
} else {
|
||||||
|
this.setState({ saving: false, error: "" })
|
||||||
|
}
|
||||||
|
this.props.onChange(resp)
|
||||||
|
} else {
|
||||||
|
this.setState({ saving: false, error: resp.error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <div className="plugin">
|
||||||
|
{!this.isNew && <PrefTable>
|
||||||
|
<PrefInput rowName="ID" type="text" value={this.state.id} disabled={true}
|
||||||
|
className="id"/>
|
||||||
|
<PrefInput rowName="Version" type="text" value={this.state.version}
|
||||||
|
disabled={true}/>
|
||||||
|
</PrefTable>}
|
||||||
|
<div className={`upload-box ${this.state.uploading ? "uploading" : ""}`}>
|
||||||
|
<UploadButton className="upload"/>
|
||||||
|
<input className="file-selector" type="file" accept="application/zip+mbp"
|
||||||
|
onChange={this.upload} disabled={this.state.uploading || this.state.deleting}
|
||||||
|
onDragEnter={evt => evt.target.parentElement.classList.add("drag")}
|
||||||
|
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
|
||||||
|
{this.state.uploading && <Spinner/>}
|
||||||
|
</div>
|
||||||
|
{!this.isNew && <div className="buttons">
|
||||||
|
<button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`}
|
||||||
|
onClick={this.delete} disabled={this.loading || this.hasInstances}
|
||||||
|
title={this.hasInstances ? "Can't delete plugin that is in use" : ""}>
|
||||||
|
{this.state.deleting ? <Spinner/> : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>}
|
||||||
|
{this.renderLogButton("loader.zip")}
|
||||||
|
<div className="error">{this.state.error}</div>
|
||||||
|
{this.renderInstances()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(Plugin)
|
232
maubot/management/frontend/src/pages/dashboard/index.js
Normal file
232
maubot/management/frontend/src/pages/dashboard/index.js
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
// 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"
|
||||||
|
import { Route, Switch, Link, withRouter } from "react-router-dom"
|
||||||
|
import api from "../../api"
|
||||||
|
import { ReactComponent as Plus } from "../../res/plus.svg"
|
||||||
|
import Instance from "./Instance"
|
||||||
|
import Client from "./Client"
|
||||||
|
import Plugin from "./Plugin"
|
||||||
|
import Home from "./Home"
|
||||||
|
import Log from "./Log"
|
||||||
|
import Modal from "./Modal"
|
||||||
|
|
||||||
|
class Dashboard extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = {
|
||||||
|
instances: {},
|
||||||
|
clients: {},
|
||||||
|
plugins: {},
|
||||||
|
sidebarOpen: false,
|
||||||
|
modalOpen: false,
|
||||||
|
logFocus: null,
|
||||||
|
logLines: [],
|
||||||
|
}
|
||||||
|
this.logModal = {
|
||||||
|
open: () => undefined,
|
||||||
|
isOpen: () => false,
|
||||||
|
}
|
||||||
|
window.maubot = this
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (this.props.location !== prevProps.location) {
|
||||||
|
this.setState({ sidebarOpen: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async componentWillMount() {
|
||||||
|
const [instanceList, clientList, pluginList] = await Promise.all([
|
||||||
|
api.getInstances(), api.getClients(), api.getPlugins(),
|
||||||
|
api.updateDebugOpenFileEnabled()])
|
||||||
|
const instances = {}
|
||||||
|
for (const instance of instanceList) {
|
||||||
|
instances[instance.id] = instance
|
||||||
|
}
|
||||||
|
const clients = {}
|
||||||
|
for (const client of clientList) {
|
||||||
|
clients[client.id] = client
|
||||||
|
}
|
||||||
|
const plugins = {}
|
||||||
|
for (const plugin of pluginList) {
|
||||||
|
plugins[plugin.id] = plugin
|
||||||
|
}
|
||||||
|
this.setState({ instances, clients, plugins })
|
||||||
|
|
||||||
|
const logs = await api.openLogSocket()
|
||||||
|
|
||||||
|
const processEntry = (entry) => {
|
||||||
|
entry.time = new Date(entry.time)
|
||||||
|
if (entry.name.startsWith("maubot.")) {
|
||||||
|
entry.name = entry.name.substr("maubot.".length)
|
||||||
|
}
|
||||||
|
if (entry.name.startsWith("client.")) {
|
||||||
|
entry.name = entry.name.substr("client.".length)
|
||||||
|
entry.nameLink = `/client/${entry.name}`
|
||||||
|
} else if (entry.name.startsWith("instance.")) {
|
||||||
|
entry.nameLink = `/instance/${entry.name.substr("instance.".length)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.onHistory = history => {
|
||||||
|
for (const data of history) {
|
||||||
|
processEntry(data)
|
||||||
|
}
|
||||||
|
this.setState({
|
||||||
|
logLines: history,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
logs.onLog = data => {
|
||||||
|
processEntry(data)
|
||||||
|
this.setState({
|
||||||
|
logLines: this.state.logLines.concat(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList(field, type) {
|
||||||
|
return this.state[field] && Object.values(this.state[field]).map(entry =>
|
||||||
|
React.createElement(type, { key: entry.id, entry }))
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(stateField, id) {
|
||||||
|
const data = Object.assign({}, this.state[stateField])
|
||||||
|
delete data[id]
|
||||||
|
this.setState({ [stateField]: data })
|
||||||
|
}
|
||||||
|
|
||||||
|
add(stateField, entry, oldID = undefined) {
|
||||||
|
const data = Object.assign({}, this.state[stateField])
|
||||||
|
if (oldID && oldID !== entry.id) {
|
||||||
|
delete data[oldID]
|
||||||
|
}
|
||||||
|
data[entry.id] = entry
|
||||||
|
this.setState({ [stateField]: data })
|
||||||
|
}
|
||||||
|
|
||||||
|
renderView(field, type, id) {
|
||||||
|
const entry = this.state[field][id]
|
||||||
|
if (!entry) {
|
||||||
|
return this.renderNotFound(field.slice(0, -1))
|
||||||
|
}
|
||||||
|
return React.createElement(type, {
|
||||||
|
entry,
|
||||||
|
onDelete: () => this.delete(field, id),
|
||||||
|
onChange: newEntry => this.add(field, newEntry, id),
|
||||||
|
openLog: this.openLog,
|
||||||
|
ctx: this.state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
openLog = filter => {
|
||||||
|
this.setState({
|
||||||
|
logFocus: typeof filter === "string" ? filter : null,
|
||||||
|
})
|
||||||
|
this.logModal.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNotFound = (thing = "path") => (
|
||||||
|
<div className="not-found">
|
||||||
|
Oops! I'm afraid that {thing} couldn't be found.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
renderMain() {
|
||||||
|
return <div className={`dashboard ${this.state.sidebarOpen ? "sidebar-open" : ""}`}>
|
||||||
|
<Link to="/" className="title">
|
||||||
|
<img src="favicon.png" alt=""/>
|
||||||
|
Maubot Manager
|
||||||
|
</Link>
|
||||||
|
<div className="user">
|
||||||
|
<span>{localStorage.username}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="sidebar">
|
||||||
|
<div className="buttons">
|
||||||
|
<button className="open-log" onClick={this.openLog}>
|
||||||
|
<span>View logs</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="instances list">
|
||||||
|
<div className="title">
|
||||||
|
<h2>Instances</h2>
|
||||||
|
<Link to="/new/instance"><Plus/></Link>
|
||||||
|
</div>
|
||||||
|
{this.renderList("instances", Instance.ListEntry)}
|
||||||
|
</div>
|
||||||
|
<div className="clients list">
|
||||||
|
<div className="title">
|
||||||
|
<h2>Clients</h2>
|
||||||
|
<Link to="/new/client"><Plus/></Link>
|
||||||
|
</div>
|
||||||
|
{this.renderList("clients", Client.ListEntry)}
|
||||||
|
</div>
|
||||||
|
<div className="plugins list">
|
||||||
|
<div className="title">
|
||||||
|
<h2>Plugins</h2>
|
||||||
|
<Link to="/new/plugin"><Plus/></Link>
|
||||||
|
</div>
|
||||||
|
{this.renderList("plugins", Plugin.ListEntry)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="topbar">
|
||||||
|
<div className={`hamburger ${this.state.sidebarOpen ? "active" : ""}`}
|
||||||
|
onClick={evt => this.setState({ sidebarOpen: !this.state.sidebarOpen })}>
|
||||||
|
<span/><span/><span/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="view">
|
||||||
|
<Switch>
|
||||||
|
<Route path="/" exact render={() => <Home openLog={this.openLog}/>}/>
|
||||||
|
<Route path="/new/instance" render={() =>
|
||||||
|
<Instance onChange={newEntry => this.add("instances", newEntry)}
|
||||||
|
entry={{}} ctx={this.state}/>}/>
|
||||||
|
<Route path="/new/client" render={() =>
|
||||||
|
<Client entry={{}} onChange={newEntry =>
|
||||||
|
this.add("clients", newEntry)}/>}/>
|
||||||
|
<Route path="/new/plugin" render={() =>
|
||||||
|
<Plugin entry={{}} onChange={newEntry =>
|
||||||
|
this.add("plugins", newEntry)}/>}/>
|
||||||
|
<Route path="/instance/:id" render={({ match }) =>
|
||||||
|
this.renderView("instances", Instance, match.params.id)}/>
|
||||||
|
<Route path="/client/:id" render={({ match }) =>
|
||||||
|
this.renderView("clients", Client, match.params.id)}/>
|
||||||
|
<Route path="/plugin/:id" render={({ match }) =>
|
||||||
|
this.renderView("plugins", Plugin, match.params.id)}/>
|
||||||
|
<Route render={() => this.renderNotFound()}/>
|
||||||
|
</Switch>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
renderModal() {
|
||||||
|
return <Modal ref={ref => this.logModal = ref}>
|
||||||
|
<Log lines={this.state.logLines} focus={this.state.logFocus}/>
|
||||||
|
</Modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <>
|
||||||
|
{this.renderMain()}
|
||||||
|
{this.renderModal()}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(Dashboard)
|
1
maubot/management/frontend/src/res/chevron-right.svg
Normal file
1
maubot/management/frontend/src/res/chevron-right.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/><path d="M0 0h24v24H0z" fill="none"/></svg>
|
After Width: | Height: | Size: 184 B |
5
maubot/management/frontend/src/res/plus.svg
Normal file
5
maubot/management/frontend/src/res/plus.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24">
|
||||||
|
<path fill="#000000" d="M17,13H13V17H11V13H7V11H11V7H13V11H17M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 432 B |
5
maubot/management/frontend/src/res/upload.svg
Normal file
5
maubot/management/frontend/src/res/upload.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24">
|
||||||
|
<path fill="#000000" d="M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 365 B |
6
maubot/management/frontend/src/setupProxy.js
Normal file
6
maubot/management/frontend/src/setupProxy.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const proxy = require("http-proxy-middleware")
|
||||||
|
|
||||||
|
module.exports = function(app) {
|
||||||
|
app.use(proxy("/_matrix/maubot/v1", { target: "http://localhost:29316" }))
|
||||||
|
app.use(proxy("/_matrix/maubot/v1/logs", { target: "http://localhost:29316", ws: true }))
|
||||||
|
}
|
39
maubot/management/frontend/src/style/base/body.sass
Normal file
39
maubot/management/frontend/src/style/base/body.sass
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
#root
|
||||||
|
position: fixed
|
||||||
|
top: 0
|
||||||
|
bottom: 0
|
||||||
|
right: 0
|
||||||
|
left: 0
|
||||||
|
|
||||||
|
.maubot-wrapper
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
bottom: 0
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
background-color: $background-dark
|
||||||
|
|
||||||
|
.maubot-loading
|
||||||
|
margin-top: 10rem
|
||||||
|
width: 10rem
|
120
maubot/management/frontend/src/style/base/elements.sass
Normal file
120
maubot/management/frontend/src/style/base/elements.sass
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
// 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 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 General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
=button($width: null, $height: null, $padding: .375rem 1rem)
|
||||||
|
font-family: $font-stack
|
||||||
|
padding: $padding
|
||||||
|
width: $width
|
||||||
|
height: $height
|
||||||
|
background-color: $background
|
||||||
|
border: none
|
||||||
|
border-radius: .25rem
|
||||||
|
color: $text-color
|
||||||
|
box-sizing: border-box
|
||||||
|
font-size: 1rem
|
||||||
|
|
||||||
|
&.disabled-bg
|
||||||
|
background-color: $background-dark
|
||||||
|
|
||||||
|
&:not(:disabled)
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: darken($background, 10%)
|
||||||
|
|
||||||
|
=link-button()
|
||||||
|
display: inline-block
|
||||||
|
text-align: center
|
||||||
|
text-decoration: none
|
||||||
|
|
||||||
|
=main-color-button()
|
||||||
|
background-color: $primary
|
||||||
|
color: $inverted-text-color
|
||||||
|
&:hover:not(:disabled)
|
||||||
|
background-color: $primary-dark
|
||||||
|
|
||||||
|
&:disabled.disabled-bg
|
||||||
|
background-color: $background-dark !important
|
||||||
|
color: $text-color
|
||||||
|
|
||||||
|
.button
|
||||||
|
+button
|
||||||
|
|
||||||
|
&.main-color
|
||||||
|
+main-color-button
|
||||||
|
|
||||||
|
=button-group()
|
||||||
|
width: 100%
|
||||||
|
display: flex
|
||||||
|
> button, > .button
|
||||||
|
flex: 1
|
||||||
|
|
||||||
|
&:first-of-type
|
||||||
|
margin-right: .5rem
|
||||||
|
|
||||||
|
&:last-of-type
|
||||||
|
margin-left: .5rem
|
||||||
|
|
||||||
|
&:first-of-type:last-of-type
|
||||||
|
margin: 0
|
||||||
|
|
||||||
|
=vertical-button-group()
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
> button, > .button
|
||||||
|
flex: 1
|
||||||
|
border-radius: 0
|
||||||
|
|
||||||
|
&:first-of-type
|
||||||
|
border-radius: .25rem .25rem 0 0
|
||||||
|
|
||||||
|
&:last-of-type
|
||||||
|
border-radius: 0 0 .25rem .25rem
|
||||||
|
|
||||||
|
&:first-of-type:last-of-type
|
||||||
|
border-radius: .25rem
|
||||||
|
|
||||||
|
=input($width: null, $height: null, $vertical-padding: .375rem, $horizontal-padding: 1rem, $font-size: 1rem)
|
||||||
|
font-family: $font-stack
|
||||||
|
border: 1px solid $border-color
|
||||||
|
background-color: $background
|
||||||
|
color: $text-color
|
||||||
|
width: $width
|
||||||
|
height: $height
|
||||||
|
box-sizing: border-box
|
||||||
|
border-radius: .25rem
|
||||||
|
padding: $vertical-padding $horizontal-padding
|
||||||
|
font-size: $font-size
|
||||||
|
resize: vertical
|
||||||
|
|
||||||
|
&:hover, &:focus
|
||||||
|
border-color: $primary
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
border-width: 2px
|
||||||
|
padding: calc(#{$vertical-padding} - 1px) calc(#{$horizontal-padding} - 1px)
|
||||||
|
|
||||||
|
.input, .textarea
|
||||||
|
+input
|
||||||
|
|
||||||
|
input
|
||||||
|
font-family: $font-stack
|
||||||
|
|
||||||
|
=notification($border: $error-dark, $background: transparentize($error-light, 0.5))
|
||||||
|
padding: 1rem
|
||||||
|
border-radius: .25rem
|
||||||
|
border: 2px solid $border
|
||||||
|
background-color: $background
|
32
maubot/management/frontend/src/style/base/vars.sass
Normal file
32
maubot/management/frontend/src/style/base/vars.sass
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/>.
|
||||||
|
|
||||||
|
$primary: #00C853
|
||||||
|
$primary-dark: #009624
|
||||||
|
$primary-light: #5EFC82
|
||||||
|
$secondary: #00B8D4
|
||||||
|
$secondary-dark: #0088A3
|
||||||
|
$secondary-light: #62EBFF
|
||||||
|
$error: #B71C1C
|
||||||
|
$error-dark: #7F0000
|
||||||
|
$error-light: #F05545
|
||||||
|
|
||||||
|
$border-color: #DDD
|
||||||
|
$text-color: #212121
|
||||||
|
$background: #FAFAFA
|
||||||
|
$background-dark: #E7E7E7
|
||||||
|
$inverted-text-color: $background
|
||||||
|
$font-stack: Raleway, sans-serif
|
31
maubot/management/frontend/src/style/index.sass
Normal file
31
maubot/management/frontend/src/style/index.sass
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// 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 lib/spinner
|
||||||
|
|
||||||
|
@import base/vars
|
||||||
|
@import base/body
|
||||||
|
@import base/elements
|
||||||
|
|
||||||
|
@import lib/preferencetable
|
||||||
|
@import lib/switch
|
||||||
|
|
||||||
|
@import pages/mixins/upload-container
|
||||||
|
@import pages/mixins/instancelist
|
||||||
|
|
||||||
|
@import pages/login
|
||||||
|
@import pages/dashboard
|
||||||
|
@import pages/modal
|
||||||
|
@import pages/log
|
@ -0,0 +1,84 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
.preference-table
|
||||||
|
display: flex
|
||||||
|
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
flex-wrap: wrap
|
||||||
|
|
||||||
|
> .entry
|
||||||
|
display: block
|
||||||
|
|
||||||
|
@media screen and (max-width: 55rem)
|
||||||
|
width: calc(100% - 1rem)
|
||||||
|
width: calc(50% - 1rem)
|
||||||
|
margin: .5rem
|
||||||
|
|
||||||
|
&.full-width
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
&.changed > label
|
||||||
|
font-weight: bold
|
||||||
|
&:after
|
||||||
|
content: "*"
|
||||||
|
|
||||||
|
> label, > .value
|
||||||
|
display: block
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
> label
|
||||||
|
font-size: 0.875rem
|
||||||
|
padding-bottom: .25rem
|
||||||
|
font-weight: lighter
|
||||||
|
|
||||||
|
> .value
|
||||||
|
> .switch
|
||||||
|
width: auto
|
||||||
|
height: 2rem
|
||||||
|
|
||||||
|
> .select
|
||||||
|
height: 2.5rem
|
||||||
|
box-sizing: border-box
|
||||||
|
|
||||||
|
> input
|
||||||
|
border: none
|
||||||
|
height: 2rem
|
||||||
|
width: 100%
|
||||||
|
color: $text-color
|
||||||
|
|
||||||
|
box-sizing: border-box
|
||||||
|
|
||||||
|
padding: .375rem 0
|
||||||
|
background-color: $background
|
||||||
|
|
||||||
|
font-size: 1rem
|
||||||
|
|
||||||
|
border-bottom: 1px solid $background
|
||||||
|
|
||||||
|
&.id:disabled
|
||||||
|
font-family: "Fira Code", monospace
|
||||||
|
font-weight: bold
|
||||||
|
|
||||||
|
&:not(:disabled)
|
||||||
|
border-bottom: 1px dotted $primary
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
border-bottom: 1px solid $primary
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
border-bottom: 2px solid $primary
|
65
maubot/management/frontend/src/style/lib/spinner.sass
Normal file
65
maubot/management/frontend/src/style/lib/spinner.sass
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
$green: #008744
|
||||||
|
$blue: #0057e7
|
||||||
|
$red: #d62d20
|
||||||
|
$yellow: #ffa700
|
||||||
|
|
||||||
|
.spinner
|
||||||
|
position: relative
|
||||||
|
margin: 0 auto
|
||||||
|
width: 5rem
|
||||||
|
|
||||||
|
&:before
|
||||||
|
content: ""
|
||||||
|
display: block
|
||||||
|
padding-top: 100%
|
||||||
|
|
||||||
|
svg
|
||||||
|
animation: rotate 2s linear infinite
|
||||||
|
height: 100%
|
||||||
|
transform-origin: center center
|
||||||
|
width: 100%
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
bottom: 0
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
margin: auto
|
||||||
|
|
||||||
|
circle
|
||||||
|
stroke-dasharray: 1, 200
|
||||||
|
stroke-dashoffset: 0
|
||||||
|
animation: dash 1.5s ease-in-out infinite, color 6s ease-in-out infinite
|
||||||
|
stroke-linecap: round
|
||||||
|
|
||||||
|
=white-spinner()
|
||||||
|
circle
|
||||||
|
stroke: white !important
|
||||||
|
|
||||||
|
=thick-spinner($thickness: 5)
|
||||||
|
svg > circle
|
||||||
|
stroke-width: $thickness
|
||||||
|
|
||||||
|
@keyframes rotate
|
||||||
|
100%
|
||||||
|
transform: rotate(360deg)
|
||||||
|
|
||||||
|
@keyframes dash
|
||||||
|
0%
|
||||||
|
stroke-dasharray: 1, 200
|
||||||
|
stroke-dashoffset: 0
|
||||||
|
50%
|
||||||
|
stroke-dasharray: 89, 200
|
||||||
|
stroke-dashoffset: -35px
|
||||||
|
100%
|
||||||
|
stroke-dasharray: 89, 200
|
||||||
|
stroke-dashoffset: -124px
|
||||||
|
|
||||||
|
@keyframes color
|
||||||
|
100%, 0%
|
||||||
|
stroke: $red
|
||||||
|
40%
|
||||||
|
stroke: $blue
|
||||||
|
66%
|
||||||
|
stroke: $green
|
||||||
|
80%, 90%
|
||||||
|
stroke: $yellow
|
77
maubot/management/frontend/src/style/lib/switch.sass
Normal file
77
maubot/management/frontend/src/style/lib/switch.sass
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
.switch
|
||||||
|
display: flex
|
||||||
|
|
||||||
|
width: 100%
|
||||||
|
height: 2rem
|
||||||
|
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
border: 1px solid $error-light
|
||||||
|
border-radius: .25rem
|
||||||
|
background-color: $background
|
||||||
|
|
||||||
|
box-sizing: border-box
|
||||||
|
|
||||||
|
> .box
|
||||||
|
display: flex
|
||||||
|
box-sizing: border-box
|
||||||
|
width: 50%
|
||||||
|
height: 100%
|
||||||
|
|
||||||
|
transition: .5s
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
border-radius: .15rem 0 0 .15rem
|
||||||
|
background-color: $error-light
|
||||||
|
color: $inverted-text-color
|
||||||
|
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
> .text
|
||||||
|
box-sizing: border-box
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
text-align: center
|
||||||
|
vertical-align: middle
|
||||||
|
|
||||||
|
color: $inverted-text-color
|
||||||
|
font-size: 1rem
|
||||||
|
|
||||||
|
user-select: none
|
||||||
|
|
||||||
|
.on
|
||||||
|
display: none
|
||||||
|
|
||||||
|
.off
|
||||||
|
display: inline
|
||||||
|
|
||||||
|
|
||||||
|
&[data-active=true]
|
||||||
|
border: 1px solid $primary
|
||||||
|
> .box
|
||||||
|
background-color: $primary
|
||||||
|
transform: translateX(100%)
|
||||||
|
|
||||||
|
border-radius: 0 .15rem .15rem 0
|
||||||
|
|
||||||
|
.on
|
||||||
|
display: inline
|
||||||
|
|
||||||
|
.off
|
||||||
|
display: none
|
@ -0,0 +1,62 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
> div.avatar-container
|
||||||
|
+upload-box
|
||||||
|
|
||||||
|
width: 8rem
|
||||||
|
height: 8rem
|
||||||
|
border-radius: 50%
|
||||||
|
|
||||||
|
@media screen and (max-width: 40rem)
|
||||||
|
margin: 0 auto 1rem
|
||||||
|
|
||||||
|
> img.avatar
|
||||||
|
position: absolute
|
||||||
|
display: block
|
||||||
|
max-width: 8rem
|
||||||
|
max-height: 8rem
|
||||||
|
user-select: none
|
||||||
|
|
||||||
|
> svg.upload
|
||||||
|
visibility: hidden
|
||||||
|
|
||||||
|
width: 6rem
|
||||||
|
height: 6rem
|
||||||
|
|
||||||
|
> input.file-selector
|
||||||
|
width: 8rem
|
||||||
|
height: 8rem
|
||||||
|
|
||||||
|
&:not(.uploading)
|
||||||
|
&:hover, &.drag
|
||||||
|
> img.avatar
|
||||||
|
opacity: .25
|
||||||
|
|
||||||
|
> svg.upload
|
||||||
|
visibility: visible
|
||||||
|
|
||||||
|
&.no-avatar
|
||||||
|
> img.avatar
|
||||||
|
visibility: hidden
|
||||||
|
|
||||||
|
> svg.upload
|
||||||
|
visibility: visible
|
||||||
|
opacity: .5
|
||||||
|
|
||||||
|
&.uploading
|
||||||
|
> img.avatar
|
||||||
|
opacity: .25
|
44
maubot/management/frontend/src/style/pages/client/index.sass
Normal file
44
maubot/management/frontend/src/style/pages/client/index.sass
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
> div.client
|
||||||
|
display: flex
|
||||||
|
|
||||||
|
> div.sidebar
|
||||||
|
vertical-align: top
|
||||||
|
text-align: center
|
||||||
|
width: 8rem
|
||||||
|
margin-right: 1rem
|
||||||
|
|
||||||
|
> div
|
||||||
|
margin-bottom: 1rem
|
||||||
|
|
||||||
|
@import avatar
|
||||||
|
@import started
|
||||||
|
|
||||||
|
> div.info
|
||||||
|
vertical-align: top
|
||||||
|
flex: 1
|
||||||
|
|
||||||
|
> div.instances
|
||||||
|
+instancelist
|
||||||
|
|
||||||
|
@media screen and (max-width: 40rem)
|
||||||
|
flex-wrap: wrap
|
||||||
|
|
||||||
|
> div.sidebar, > div.info
|
||||||
|
width: 100%
|
||||||
|
margin-right: 0
|
@ -0,0 +1,41 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
> div.started-container
|
||||||
|
display: inline-flex
|
||||||
|
|
||||||
|
> span.started
|
||||||
|
display: inline-block
|
||||||
|
height: 0
|
||||||
|
width: 0
|
||||||
|
border-radius: 50%
|
||||||
|
margin: .5rem
|
||||||
|
|
||||||
|
&.true
|
||||||
|
background-color: $primary
|
||||||
|
box-shadow: 0 0 .75rem .75rem $primary
|
||||||
|
|
||||||
|
&.false
|
||||||
|
background-color: $error-light
|
||||||
|
box-shadow: 0 0 .75rem .75rem $error-light
|
||||||
|
|
||||||
|
&.disabled
|
||||||
|
background-color: $border-color
|
||||||
|
box-shadow: 0 0 .75rem .75rem $border-color
|
||||||
|
|
||||||
|
> span.text
|
||||||
|
display: inline-block
|
||||||
|
margin-left: 1rem
|
@ -0,0 +1,19 @@
|
|||||||
|
.dashboard {
|
||||||
|
grid-template:
|
||||||
|
[row1-start] "title main" 3.5rem [row1-end]
|
||||||
|
[row2-start] "user main" 2.5rem [row2-end]
|
||||||
|
[row3-start] "sidebar main" auto [row3-end]
|
||||||
|
/ 15rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media screen and (max-width: 35rem) {
|
||||||
|
.dashboard {
|
||||||
|
grid-template:
|
||||||
|
[row1-start] "title topbar" 3.5rem [row1-end]
|
||||||
|
[row2-start] "user main" 2.5rem [row2-end]
|
||||||
|
[row3-start] "sidebar main" auto [row3-end]
|
||||||
|
/ 15rem 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
125
maubot/management/frontend/src/style/pages/dashboard.sass
Normal file
125
maubot/management/frontend/src/style/pages/dashboard.sass
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
// 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 "dashboard-grid"
|
||||||
|
|
||||||
|
.dashboard
|
||||||
|
display: grid
|
||||||
|
height: 100%
|
||||||
|
max-width: 60rem
|
||||||
|
margin: auto
|
||||||
|
box-shadow: 0 .5rem .5rem rgba(0, 0, 0, 0.5)
|
||||||
|
background-color: $background
|
||||||
|
|
||||||
|
> a.title
|
||||||
|
grid-area: title
|
||||||
|
background-color: white
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
|
||||||
|
font-size: 1.35rem
|
||||||
|
font-weight: bold
|
||||||
|
|
||||||
|
color: $text-color
|
||||||
|
text-decoration: none
|
||||||
|
|
||||||
|
> img
|
||||||
|
max-width: 2rem
|
||||||
|
margin-right: .5rem
|
||||||
|
|
||||||
|
> div.user
|
||||||
|
grid-area: user
|
||||||
|
background-color: white
|
||||||
|
border-bottom: 1px solid $border-color
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
span
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
background-color: $primary
|
||||||
|
color: $inverted-text-color
|
||||||
|
margin: .375rem .5rem
|
||||||
|
width: 100%
|
||||||
|
height: calc(100% - .375rem)
|
||||||
|
box-sizing: border-box
|
||||||
|
border-radius: .25rem
|
||||||
|
|
||||||
|
@import sidebar
|
||||||
|
@import topbar
|
||||||
|
|
||||||
|
@media screen and (max-width: 35rem)
|
||||||
|
&:not(.sidebar-open) > *
|
||||||
|
transform: translateX(-15rem)
|
||||||
|
> *
|
||||||
|
transition: transform 0.4s
|
||||||
|
|
||||||
|
> main.view
|
||||||
|
grid-area: main
|
||||||
|
border-left: 1px solid $border-color
|
||||||
|
|
||||||
|
overflow-y: auto
|
||||||
|
|
||||||
|
@import client/index
|
||||||
|
@import instance
|
||||||
|
@import plugin
|
||||||
|
|
||||||
|
> div
|
||||||
|
margin: 2rem 4rem
|
||||||
|
|
||||||
|
@media screen and (max-width: 50rem)
|
||||||
|
margin: 2rem 1rem
|
||||||
|
|
||||||
|
> div.not-found, > div.home
|
||||||
|
text-align: center
|
||||||
|
margin-top: 5rem
|
||||||
|
font-size: 1.5rem
|
||||||
|
|
||||||
|
div.buttons
|
||||||
|
+button-group
|
||||||
|
display: flex
|
||||||
|
margin: 1rem .5rem
|
||||||
|
width: calc(100% - 1rem)
|
||||||
|
|
||||||
|
button.open-log
|
||||||
|
+button
|
||||||
|
+main-color-button
|
||||||
|
|
||||||
|
div.error
|
||||||
|
+notification($error)
|
||||||
|
margin: 1rem .5rem
|
||||||
|
|
||||||
|
&:empty
|
||||||
|
display: none
|
||||||
|
|
||||||
|
button.delete
|
||||||
|
background-color: $error-light !important
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: $error !important
|
||||||
|
|
||||||
|
button.save, button.delete
|
||||||
|
+button
|
||||||
|
+main-color-button
|
||||||
|
width: 100%
|
||||||
|
height: 2.5rem
|
||||||
|
padding: 0
|
||||||
|
|
||||||
|
> .spinner
|
||||||
|
+thick-spinner
|
||||||
|
+white-spinner
|
||||||
|
width: 2rem
|
35
maubot/management/frontend/src/style/pages/instance.sass
Normal file
35
maubot/management/frontend/src/style/pages/instance.sass
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
> div.instance
|
||||||
|
> div.preference-table
|
||||||
|
.select-client
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
img.avatar
|
||||||
|
max-height: 1.375rem
|
||||||
|
border-radius: 50%
|
||||||
|
margin-right: .5rem
|
||||||
|
|
||||||
|
> div.ace_editor
|
||||||
|
z-index: 0
|
||||||
|
height: 15rem !important
|
||||||
|
width: calc(100% - 1rem) !important
|
||||||
|
font-size: 12px
|
||||||
|
font-family: "Fira Code", monospace
|
||||||
|
|
||||||
|
margin: .75rem .5rem 1.5rem
|
86
maubot/management/frontend/src/style/pages/log.sass
Normal file
86
maubot/management/frontend/src/style/pages/log.sass
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
// 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/>.
|
||||||
|
div.log
|
||||||
|
height: 100%
|
||||||
|
width: 100%
|
||||||
|
overflow: auto
|
||||||
|
|
||||||
|
> div.lines
|
||||||
|
text-align: left
|
||||||
|
font-size: 12px
|
||||||
|
max-height: 100%
|
||||||
|
min-width: 100%
|
||||||
|
font-family: "Fira Code", monospace
|
||||||
|
display: table
|
||||||
|
|
||||||
|
> div.row
|
||||||
|
display: table-row
|
||||||
|
white-space: pre
|
||||||
|
|
||||||
|
&.debug
|
||||||
|
background-color: $background
|
||||||
|
|
||||||
|
&:nth-child(odd)
|
||||||
|
background-color: $background-dark
|
||||||
|
|
||||||
|
&.info
|
||||||
|
background-color: #AAFAFA
|
||||||
|
|
||||||
|
&:nth-child(odd)
|
||||||
|
background-color: #66FAFA
|
||||||
|
|
||||||
|
&.warning, &.warn
|
||||||
|
background-color: #FABB77
|
||||||
|
|
||||||
|
&:nth-child(odd)
|
||||||
|
background-color: #FAAA55
|
||||||
|
|
||||||
|
&.error
|
||||||
|
background-color: #FAAAAA
|
||||||
|
|
||||||
|
&:nth-child(odd)
|
||||||
|
background-color: #FA9999
|
||||||
|
|
||||||
|
&.fatal
|
||||||
|
background-color: #CC44CC
|
||||||
|
|
||||||
|
&:nth-child(odd)
|
||||||
|
background-color: #AA44AA
|
||||||
|
|
||||||
|
&.unfocused
|
||||||
|
opacity: .25
|
||||||
|
|
||||||
|
> span
|
||||||
|
padding: .125rem .25rem
|
||||||
|
display: table-cell
|
||||||
|
|
||||||
|
&:first-child
|
||||||
|
padding-left: 0
|
||||||
|
|
||||||
|
&:last-child
|
||||||
|
padding-right: 0
|
||||||
|
|
||||||
|
a
|
||||||
|
color: inherit
|
||||||
|
text-decoration: none
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
text-decoration: underline
|
||||||
|
|
||||||
|
> span.text
|
||||||
|
> div.content > *
|
||||||
|
background-color: inherit !important
|
||||||
|
margin: 0 !important
|
62
maubot/management/frontend/src/style/pages/login.sass
Normal file
62
maubot/management/frontend/src/style/pages/login.sass
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
.maubot-wrapper:not(.authenticated)
|
||||||
|
background-color: $primary
|
||||||
|
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
.login
|
||||||
|
width: 25rem
|
||||||
|
height: 23rem
|
||||||
|
display: inline-block
|
||||||
|
box-sizing: border-box
|
||||||
|
background-color: white
|
||||||
|
border-radius: .25rem
|
||||||
|
margin-top: 3rem
|
||||||
|
|
||||||
|
@media screen and (max-width: 27rem)
|
||||||
|
margin: 3rem 1rem 0
|
||||||
|
width: calc(100% - 2rem)
|
||||||
|
|
||||||
|
h1
|
||||||
|
color: $primary
|
||||||
|
margin: 3rem 0
|
||||||
|
|
||||||
|
input, button
|
||||||
|
margin: .5rem 2.5rem
|
||||||
|
height: 3rem
|
||||||
|
width: calc(100% - 5rem)
|
||||||
|
box-sizing: border-box
|
||||||
|
|
||||||
|
input
|
||||||
|
+input
|
||||||
|
|
||||||
|
button
|
||||||
|
+button($width: calc(100% - 5rem), $height: 3rem, $padding: 0)
|
||||||
|
+main-color-button
|
||||||
|
|
||||||
|
.spinner
|
||||||
|
+white-spinner
|
||||||
|
+thick-spinner
|
||||||
|
width: 2rem
|
||||||
|
|
||||||
|
&.errored
|
||||||
|
height: 26.5rem
|
||||||
|
|
||||||
|
.error
|
||||||
|
+notification($error)
|
||||||
|
margin: .5rem 2.5rem
|
@ -0,0 +1,40 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
=instancelist()
|
||||||
|
margin: 1rem 0
|
||||||
|
|
||||||
|
display: flex
|
||||||
|
flex-wrap: wrap
|
||||||
|
|
||||||
|
> h3
|
||||||
|
margin: .5rem
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
> a.instance
|
||||||
|
display: block
|
||||||
|
width: calc(50% - 1rem)
|
||||||
|
padding: .375rem .5rem
|
||||||
|
margin: .5rem
|
||||||
|
background-color: white
|
||||||
|
border-radius: .25rem
|
||||||
|
color: $text-color
|
||||||
|
text-decoration: none
|
||||||
|
box-sizing: border-box
|
||||||
|
border: 1px solid $primary
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: $primary
|
@ -0,0 +1,43 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
=upload-box()
|
||||||
|
position: relative
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
|
||||||
|
> svg.upload
|
||||||
|
position: absolute
|
||||||
|
display: block
|
||||||
|
|
||||||
|
padding: 1rem
|
||||||
|
user-select: none
|
||||||
|
|
||||||
|
|
||||||
|
> input.file-selector
|
||||||
|
position: absolute
|
||||||
|
user-select: none
|
||||||
|
opacity: 0
|
||||||
|
|
||||||
|
> div.spinner
|
||||||
|
+thick-spinner
|
||||||
|
|
||||||
|
&:not(.uploading)
|
||||||
|
> input.file-selector
|
||||||
|
cursor: pointer
|
71
maubot/management/frontend/src/style/pages/modal.sass
Normal file
71
maubot/management/frontend/src/style/pages/modal.sass
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
// 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/>.
|
||||||
|
div.modal-wrapper-wrapper
|
||||||
|
z-index: 9001
|
||||||
|
position: fixed
|
||||||
|
top: 0
|
||||||
|
bottom: 0
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
background-color: rgba(0, 0, 0, 0.5)
|
||||||
|
|
||||||
|
--modal-margin: 2.5rem
|
||||||
|
--button-height: 0rem
|
||||||
|
|
||||||
|
@media screen and (max-width: 45rem)
|
||||||
|
--modal-margin: 1rem
|
||||||
|
--button-height: 2.5rem
|
||||||
|
|
||||||
|
@media screen and (max-width: 35rem)
|
||||||
|
--modal-margin: 0rem
|
||||||
|
--button-height: 3rem
|
||||||
|
|
||||||
|
button.close
|
||||||
|
+button
|
||||||
|
|
||||||
|
display: none
|
||||||
|
|
||||||
|
width: 100%
|
||||||
|
height: var(--button-height)
|
||||||
|
border-radius: .25rem .25rem 0 0
|
||||||
|
|
||||||
|
@media screen and (max-width: 45rem)
|
||||||
|
display: block
|
||||||
|
@media screen and (max-width: 35rem)
|
||||||
|
border-radius: 0
|
||||||
|
|
||||||
|
div.modal-wrapper
|
||||||
|
width: calc(100% - 2 * var(--modal-margin))
|
||||||
|
height: calc(100% - 2 * var(--modal-margin) - var(--button-height))
|
||||||
|
margin: var(--modal-margin)
|
||||||
|
border-radius: .25rem
|
||||||
|
|
||||||
|
@media screen and (max-width: 35rem)
|
||||||
|
border-radius: 0
|
||||||
|
|
||||||
|
div.modal
|
||||||
|
padding: 1rem
|
||||||
|
height: 100%
|
||||||
|
width: 100%
|
||||||
|
background-color: $background
|
||||||
|
box-sizing: border-box
|
||||||
|
border-radius: .25rem
|
||||||
|
|
||||||
|
@media screen and (max-width: 45rem)
|
||||||
|
border-radius: 0 0 .25rem .25rem
|
||||||
|
@media screen and (max-width: 35rem)
|
||||||
|
border-radius: 0
|
||||||
|
padding: .5rem
|
53
maubot/management/frontend/src/style/pages/plugin.sass
Normal file
53
maubot/management/frontend/src/style/pages/plugin.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/>.
|
||||||
|
|
||||||
|
> .plugin
|
||||||
|
> .upload-box
|
||||||
|
+upload-box
|
||||||
|
|
||||||
|
width: calc(100% - 1rem)
|
||||||
|
height: 10rem
|
||||||
|
margin: .5rem
|
||||||
|
border-radius: .5rem
|
||||||
|
box-sizing: border-box
|
||||||
|
|
||||||
|
border: .25rem dotted $primary
|
||||||
|
|
||||||
|
> svg.upload
|
||||||
|
width: 8rem
|
||||||
|
height: 8rem
|
||||||
|
|
||||||
|
opacity: .5
|
||||||
|
|
||||||
|
> input.file-selector
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
|
||||||
|
&:not(.uploading):hover, &:not(.uploading).drag
|
||||||
|
border: .25rem solid $primary
|
||||||
|
background-color: $primary-light
|
||||||
|
|
||||||
|
> svg.upload
|
||||||
|
opacity: 1
|
||||||
|
|
||||||
|
&.uploading
|
||||||
|
> svg.upload
|
||||||
|
visibility: hidden
|
||||||
|
> input.file-selector
|
||||||
|
cursor: default
|
||||||
|
|
||||||
|
> div.instances
|
||||||
|
+instancelist
|
77
maubot/management/frontend/src/style/pages/sidebar.sass
Normal file
77
maubot/management/frontend/src/style/pages/sidebar.sass
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
> nav.sidebar
|
||||||
|
grid-area: sidebar
|
||||||
|
background-color: white
|
||||||
|
|
||||||
|
padding: .5rem
|
||||||
|
|
||||||
|
overflow-y: auto
|
||||||
|
|
||||||
|
div.buttons
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
button
|
||||||
|
+button
|
||||||
|
background-color: white
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
div.list
|
||||||
|
&:not(:last-of-type)
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
|
||||||
|
div.title
|
||||||
|
h2
|
||||||
|
display: inline-block
|
||||||
|
margin: 0 0 .25rem 0
|
||||||
|
font-size: 1.25rem
|
||||||
|
|
||||||
|
a
|
||||||
|
display: inline-block
|
||||||
|
float: right
|
||||||
|
|
||||||
|
a.entry
|
||||||
|
display: block
|
||||||
|
color: $text-color
|
||||||
|
text-decoration: none
|
||||||
|
padding: .25rem
|
||||||
|
border-radius: .25rem
|
||||||
|
height: 2rem
|
||||||
|
box-sizing: border-box
|
||||||
|
|
||||||
|
&:not(:hover) > svg
|
||||||
|
display: none
|
||||||
|
|
||||||
|
> svg
|
||||||
|
float: right
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: $primary-light
|
||||||
|
|
||||||
|
&.active
|
||||||
|
background-color: $primary
|
||||||
|
color: white
|
||||||
|
|
||||||
|
&.client
|
||||||
|
img.avatar
|
||||||
|
max-height: 1.5rem
|
||||||
|
border-radius: 100%
|
||||||
|
vertical-align: middle
|
||||||
|
|
||||||
|
span.displayname, span.id
|
||||||
|
margin-left: .25rem
|
||||||
|
vertical-align: middle
|
75
maubot/management/frontend/src/style/pages/topbar.sass
Normal file
75
maubot/management/frontend/src/style/pages/topbar.sass
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
.topbar
|
||||||
|
background-color: $primary
|
||||||
|
|
||||||
|
display: flex
|
||||||
|
justify-items: center
|
||||||
|
align-items: center
|
||||||
|
padding: 0 .75rem
|
||||||
|
|
||||||
|
@media screen and (min-width: calc(35rem + 1px))
|
||||||
|
display: none
|
||||||
|
|
||||||
|
// Hamburger menu based on "Pure CSS Hamburger fold-out menu" codepen by Erik Terwan (MIT license)
|
||||||
|
// https://codepen.io/erikterwan/pen/EVzeRP
|
||||||
|
|
||||||
|
.hamburger
|
||||||
|
display: block
|
||||||
|
user-select: none
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
> span
|
||||||
|
display: block
|
||||||
|
width: 29px
|
||||||
|
height: 4px
|
||||||
|
margin-bottom: 5px
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
background: white
|
||||||
|
border-radius: 3px
|
||||||
|
|
||||||
|
z-index: 1
|
||||||
|
|
||||||
|
transform-origin: 4px 0
|
||||||
|
|
||||||
|
transition: transform 0.4s cubic-bezier(0.77, 0.2, 0.05, 1.0), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1.0), opacity 0.55s ease
|
||||||
|
|
||||||
|
&:nth-of-type(1)
|
||||||
|
transform-origin: 0 0
|
||||||
|
|
||||||
|
&:nth-of-type(3)
|
||||||
|
transform-origin: 0 100%
|
||||||
|
|
||||||
|
transform: translateY(2px)
|
||||||
|
transition: transform 0.4s cubic-bezier(0.77, 0.2, 0.05, 1.0)
|
||||||
|
|
||||||
|
&.active
|
||||||
|
transform: translateX(1px) translateY(4px)
|
||||||
|
|
||||||
|
&.active > span
|
||||||
|
opacity: 1
|
||||||
|
|
||||||
|
&:nth-of-type(1)
|
||||||
|
transform: rotate(45deg) translate(-2px, -1px)
|
||||||
|
|
||||||
|
&:nth-of-type(2)
|
||||||
|
opacity: 0
|
||||||
|
transform: rotate(0deg) scale(0.2, 0.2)
|
||||||
|
|
||||||
|
&:nth-of-type(3)
|
||||||
|
transform: rotate(-45deg) translate(0, -1px)
|
10952
maubot/management/frontend/yarn.lock
Normal file
10952
maubot/management/frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@ -13,18 +13,34 @@
|
|||||||
#
|
#
|
||||||
# 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, Union, Callable, Awaitable
|
from typing import Dict, List, Union, Callable, Awaitable, Optional, Tuple
|
||||||
|
from markdown.extensions import Extension
|
||||||
|
import markdown as md
|
||||||
import attr
|
import attr
|
||||||
import commonmark
|
|
||||||
|
|
||||||
from mautrix import Client as MatrixClient
|
from mautrix import Client as MatrixClient
|
||||||
|
from mautrix.util.formatter import parse_html
|
||||||
from mautrix.client import EventHandler
|
from mautrix.client import EventHandler
|
||||||
from mautrix.types import (EventType, MessageEvent, Event, EventID, RoomID, MessageEventContent,
|
from mautrix.types import (EventType, MessageEvent, Event, EventID, RoomID, MessageEventContent,
|
||||||
MessageType, TextMessageEventContent, Format)
|
MessageType, TextMessageEventContent, Format, RelatesTo)
|
||||||
|
|
||||||
from .command_spec import ParsedCommand, CommandSpec
|
from .command_spec import ParsedCommand, CommandSpec
|
||||||
|
|
||||||
|
|
||||||
|
class EscapeHTML(Extension):
|
||||||
|
def extendMarkdown(self, md):
|
||||||
|
md.preprocessors.deregister("html_block")
|
||||||
|
md.inlinePatterns.deregister("html")
|
||||||
|
|
||||||
|
|
||||||
|
escape_html = EscapeHTML()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_markdown(markdown: str, allow_html: bool = False) -> Tuple[str, str]:
|
||||||
|
html = md.markdown(markdown, extensions=[escape_html] if not allow_html else [])
|
||||||
|
return parse_html(html), html
|
||||||
|
|
||||||
|
|
||||||
class MaubotMessageEvent(MessageEvent):
|
class MaubotMessageEvent(MessageEvent):
|
||||||
_client: MatrixClient
|
_client: MatrixClient
|
||||||
|
|
||||||
@ -35,24 +51,20 @@ class MaubotMessageEvent(MessageEvent):
|
|||||||
|
|
||||||
def respond(self, content: Union[str, MessageEventContent],
|
def respond(self, content: Union[str, MessageEventContent],
|
||||||
event_type: EventType = EventType.ROOM_MESSAGE,
|
event_type: EventType = EventType.ROOM_MESSAGE,
|
||||||
markdown: bool = True) -> Awaitable[EventID]:
|
markdown: bool = True, reply: bool = False) -> Awaitable[EventID]:
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
content = TextMessageEventContent(msgtype=MessageType.NOTICE, body=content)
|
content = TextMessageEventContent(msgtype=MessageType.NOTICE, body=content)
|
||||||
if markdown:
|
if markdown:
|
||||||
content.format = Format.HTML
|
content.format = Format.HTML
|
||||||
content.formatted_body = commonmark.commonmark(content.body)
|
content.body, content.formatted_body = parse_markdown(content.body)
|
||||||
|
if reply:
|
||||||
|
content.set_reply(self)
|
||||||
return self._client.send_message_event(self.room_id, event_type, content)
|
return self._client.send_message_event(self.room_id, event_type, content)
|
||||||
|
|
||||||
def reply(self, content: Union[str, MessageEventContent],
|
def reply(self, content: Union[str, MessageEventContent],
|
||||||
event_type: EventType = EventType.ROOM_MESSAGE,
|
event_type: EventType = EventType.ROOM_MESSAGE,
|
||||||
markdown: bool = True) -> Awaitable[EventID]:
|
markdown: bool = True) -> Awaitable[EventID]:
|
||||||
if isinstance(content, str):
|
return self.respond(content, event_type, markdown, reply=True)
|
||||||
content = TextMessageEventContent(msgtype=MessageType.NOTICE, body=content)
|
|
||||||
if markdown:
|
|
||||||
content.format = Format.HTML
|
|
||||||
content.formatted_body = commonmark.commonmark(content.body)
|
|
||||||
content.set_reply(self)
|
|
||||||
return self._client.send_message_event(self.room_id, event_type, content)
|
|
||||||
|
|
||||||
def mark_read(self) -> Awaitable[None]:
|
def mark_read(self) -> Awaitable[None]:
|
||||||
return self._client.send_receipt(self.room_id, self.event_id, "m.read")
|
return self._client.send_receipt(self.room_id, self.event_id, "m.read")
|
||||||
@ -67,6 +79,14 @@ class MaubotMatrixClient(MatrixClient):
|
|||||||
|
|
||||||
self.add_event_handler(self._command_event_handler, EventType.ROOM_MESSAGE)
|
self.add_event_handler(self._command_event_handler, EventType.ROOM_MESSAGE)
|
||||||
|
|
||||||
|
def send_markdown(self, room_id: RoomID, markdown: str, msgtype: MessageType = MessageType.TEXT,
|
||||||
|
relates_to: Optional[RelatesTo] = None, **kwargs) -> Awaitable[EventID]:
|
||||||
|
content = TextMessageEventContent(msgtype=msgtype, format=Format.HTML)
|
||||||
|
content.body, content.formatted_body = parse_markdown(markdown)
|
||||||
|
if relates_to:
|
||||||
|
content.relates_to = relates_to
|
||||||
|
return self.send_message(room_id, content, **kwargs)
|
||||||
|
|
||||||
def set_command_spec(self, plugin_id: str, spec: CommandSpec) -> None:
|
def set_command_spec(self, plugin_id: str, spec: CommandSpec) -> None:
|
||||||
self.command_specs[plugin_id] = spec
|
self.command_specs[plugin_id] = spec
|
||||||
self._reparse_command_specs()
|
self._reparse_command_specs()
|
||||||
@ -84,7 +104,7 @@ class MaubotMatrixClient(MatrixClient):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def _command_event_handler(self, evt: MessageEvent) -> None:
|
async def _command_event_handler(self, evt: MessageEvent) -> None:
|
||||||
if evt.sender == self.mxid or evt.content.msgtype != MessageType.TEXT:
|
if evt.sender == self.mxid or evt.content.msgtype == MessageType.NOTICE:
|
||||||
return
|
return
|
||||||
for command in self.commands:
|
for command in self.commands:
|
||||||
if command.match(evt):
|
if command.match(evt):
|
||||||
|
157
maubot/plugin.py
157
maubot/plugin.py
@ -1,157 +0,0 @@
|
|||||||
# 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 Dict, List, Optional
|
|
||||||
from ruamel.yaml.comments import CommentedMap
|
|
||||||
from ruamel.yaml import YAML
|
|
||||||
import logging
|
|
||||||
import io
|
|
||||||
|
|
||||||
from mautrix.util import BaseProxyConfig, RecursiveDict
|
|
||||||
from mautrix.types import UserID
|
|
||||||
|
|
||||||
from .db import DBPlugin
|
|
||||||
from .config import Config
|
|
||||||
from .client import Client
|
|
||||||
from .loader import PluginLoader
|
|
||||||
from .plugin_base import Plugin
|
|
||||||
|
|
||||||
log = logging.getLogger("maubot.plugin")
|
|
||||||
|
|
||||||
yaml = YAML()
|
|
||||||
yaml.indent(4)
|
|
||||||
|
|
||||||
|
|
||||||
class PluginInstance:
|
|
||||||
mb_config: Config = None
|
|
||||||
cache: Dict[str, 'PluginInstance'] = {}
|
|
||||||
plugin_directories: List[str] = []
|
|
||||||
|
|
||||||
log: logging.Logger
|
|
||||||
loader: PluginLoader
|
|
||||||
client: Client
|
|
||||||
plugin: Plugin
|
|
||||||
config: BaseProxyConfig
|
|
||||||
|
|
||||||
def __init__(self, db_instance: DBPlugin):
|
|
||||||
self.db_instance = db_instance
|
|
||||||
self.log = logging.getLogger(f"maubot.plugin.{self.id}")
|
|
||||||
self.config = None
|
|
||||||
self.cache[self.id] = self
|
|
||||||
|
|
||||||
def load(self) -> None:
|
|
||||||
try:
|
|
||||||
self.loader = PluginLoader.find(self.type)
|
|
||||||
except KeyError:
|
|
||||||
self.log.error(f"Failed to find loader for type {self.type}")
|
|
||||||
self.enabled = False
|
|
||||||
return
|
|
||||||
self.client = Client.get(self.primary_user)
|
|
||||||
if not self.client:
|
|
||||||
self.log.error(f"Failed to get client for user {self.primary_user}")
|
|
||||||
self.enabled = False
|
|
||||||
self.log.debug("Plugin instance dependencies loaded")
|
|
||||||
|
|
||||||
def load_config(self) -> CommentedMap:
|
|
||||||
return yaml.load(self.db_instance.config)
|
|
||||||
|
|
||||||
def load_config_base(self) -> Optional[RecursiveDict[CommentedMap]]:
|
|
||||||
try:
|
|
||||||
base = self.loader.read_file("base-config.yaml")
|
|
||||||
return RecursiveDict(yaml.load(base.decode("utf-8")), CommentedMap)
|
|
||||||
except (FileNotFoundError, KeyError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def save_config(self, data: RecursiveDict[CommentedMap]) -> None:
|
|
||||||
buf = io.StringIO()
|
|
||||||
yaml.dump(data, buf)
|
|
||||||
self.db_instance.config = buf.getvalue()
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
if not self.enabled:
|
|
||||||
self.log.warning(f"Plugin disabled, not starting.")
|
|
||||||
return
|
|
||||||
cls = self.loader.load()
|
|
||||||
config_class = cls.get_config_class()
|
|
||||||
if config_class:
|
|
||||||
self.config = config_class(self.load_config, self.load_config_base,
|
|
||||||
self.save_config)
|
|
||||||
self.plugin = cls(self.client.client, self.id, self.log, self.config,
|
|
||||||
self.mb_config["plugin_db_directory"])
|
|
||||||
self.loader.references |= {self}
|
|
||||||
await self.plugin.start()
|
|
||||||
self.log.info(f"Started instance of {self.loader.id} v{self.loader.version} "
|
|
||||||
f"with user {self.client.id}")
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
self.log.debug("Stopping plugin instance...")
|
|
||||||
self.loader.references -= {self}
|
|
||||||
await self.plugin.stop()
|
|
||||||
self.plugin = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get(cls, instance_id: str, db_instance: Optional[DBPlugin] = None
|
|
||||||
) -> Optional['PluginInstance']:
|
|
||||||
try:
|
|
||||||
return cls.cache[instance_id]
|
|
||||||
except KeyError:
|
|
||||||
db_instance = db_instance or DBPlugin.query.get(instance_id)
|
|
||||||
if not db_instance:
|
|
||||||
return None
|
|
||||||
return PluginInstance(db_instance)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def all(cls) -> List['PluginInstance']:
|
|
||||||
return [cls.get(plugin.id, plugin) for plugin in DBPlugin.query.all()]
|
|
||||||
|
|
||||||
# region Properties
|
|
||||||
|
|
||||||
@property
|
|
||||||
def id(self) -> str:
|
|
||||||
return self.db_instance.id
|
|
||||||
|
|
||||||
@id.setter
|
|
||||||
def id(self, value: str) -> None:
|
|
||||||
self.db_instance.id = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def type(self) -> str:
|
|
||||||
return self.db_instance.type
|
|
||||||
|
|
||||||
@type.setter
|
|
||||||
def type(self, value: str) -> None:
|
|
||||||
self.db_instance.type = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def enabled(self) -> bool:
|
|
||||||
return self.db_instance.enabled
|
|
||||||
|
|
||||||
@enabled.setter
|
|
||||||
def enabled(self, value: bool) -> None:
|
|
||||||
self.db_instance.enabled = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def primary_user(self) -> UserID:
|
|
||||||
return self.db_instance.primary_user
|
|
||||||
|
|
||||||
@primary_user.setter
|
|
||||||
def primary_user(self, value: UserID) -> None:
|
|
||||||
self.db_instance.primary_user = value
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
|
|
||||||
|
|
||||||
def init(config: Config):
|
|
||||||
PluginInstance.mb_config = config
|
|
@ -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
|
||||||
@ -24,24 +26,32 @@ import sqlalchemy as sql
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .client import MaubotMatrixClient
|
from .client import MaubotMatrixClient
|
||||||
from .command_spec import CommandSpec
|
from .command_spec import CommandSpec
|
||||||
from mautrix.util 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()
|
||||||
|
@ -13,28 +13,81 @@
|
|||||||
#
|
#
|
||||||
# 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
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.abc import AbstractAccessLogger
|
||||||
|
import pkg_resources
|
||||||
|
|
||||||
from mautrix.api import PathBuilder, Method
|
from mautrix.api import PathBuilder, Method
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .__meta__ import __version__
|
from .__meta__ import __version__
|
||||||
|
|
||||||
|
|
||||||
class MaubotServer:
|
class AccessLogger(AbstractAccessLogger):
|
||||||
def __init__(self, config: Config, loop: asyncio.AbstractEventLoop):
|
def log(self, request: web.Request, response: web.Response, time: int):
|
||||||
self.loop = loop or asyncio.get_event_loop()
|
self.logger.info(f'{request.remote} "{request.method} {request.path} '
|
||||||
self.app = web.Application(loop=self.loop)
|
f'{response.status} {response.body_length} '
|
||||||
self.config = config
|
f'in {round(time, 4)}s"')
|
||||||
|
|
||||||
path = PathBuilder(config["server.base_path"])
|
|
||||||
self.add_route(Method.GET, path.version, self.version)
|
class MaubotServer:
|
||||||
|
log: logging.Logger = logging.getLogger("maubot.server")
|
||||||
|
|
||||||
|
def __init__(self, config: Config, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
self.loop = loop or asyncio.get_event_loop()
|
||||||
|
self.app = web.Application(loop=self.loop, client_max_size=100*1024*1024)
|
||||||
|
self.config = config
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
self.runner = web.AppRunner(self.app)
|
self.setup_management_ui()
|
||||||
|
|
||||||
|
self.runner = web.AppRunner(self.app, access_log_class=AccessLogger)
|
||||||
|
|
||||||
|
def setup_management_ui(self) -> None:
|
||||||
|
ui_base = self.config["server.ui_base_path"]
|
||||||
|
if ui_base == "/":
|
||||||
|
ui_base = ""
|
||||||
|
directory = (self.config["server.override_resource_path"]
|
||||||
|
or pkg_resources.resource_filename("maubot", "management/frontend/build"))
|
||||||
|
self.app.router.add_static(f"{ui_base}/static", f"{directory}/static")
|
||||||
|
self.setup_static_root_files(directory, ui_base)
|
||||||
|
|
||||||
|
with open(f"{directory}/index.html", "r") as file:
|
||||||
|
index_html = file.read()
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def frontend_404_middleware(request: web.Request, handler) -> web.Response:
|
||||||
|
if hasattr(handler, "__self__") and isinstance(handler.__self__, web.StaticResource):
|
||||||
|
try:
|
||||||
|
return await handler(request)
|
||||||
|
except web.HTTPNotFound:
|
||||||
|
return web.Response(body=index_html, content_type="text/html")
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
async def ui_base_redirect(_: web.Request) -> web.Response:
|
||||||
|
raise web.HTTPFound(f"{ui_base}/")
|
||||||
|
|
||||||
|
self.app.middlewares.append(frontend_404_middleware)
|
||||||
|
self.app.router.add_get(f"{ui_base}/", lambda _: web.Response(body=index_html,
|
||||||
|
content_type="text/html"))
|
||||||
|
self.app.router.add_get(ui_base, ui_base_redirect)
|
||||||
|
|
||||||
|
def setup_static_root_files(self, directory: str, ui_base: str) -> None:
|
||||||
|
files = {
|
||||||
|
"asset-manifest.json": "application/json",
|
||||||
|
"manifest.json": "application/json",
|
||||||
|
"favicon.png": "image/png",
|
||||||
|
}
|
||||||
|
for file, mime in files.items():
|
||||||
|
with open(f"{directory}/{file}", "rb") as stream:
|
||||||
|
data = stream.read()
|
||||||
|
self.app.router.add_get(f"{ui_base}/{file}", lambda _: web.Response(body=data,
|
||||||
|
content_type=mime))
|
||||||
|
|
||||||
def add_route(self, method: Method, path: PathBuilder, handler) -> None:
|
def add_route(self, method: Method, path: PathBuilder, handler) -> None:
|
||||||
self.app.router.add_route(method.value, str(path), handler)
|
self.app.router.add_route(method.value, str(path), handler)
|
||||||
@ -43,8 +96,10 @@ 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.shutdown()
|
||||||
await self.runner.cleanup()
|
await self.runner.cleanup()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -2,6 +2,8 @@ mautrix
|
|||||||
aiohttp
|
aiohttp
|
||||||
SQLAlchemy
|
SQLAlchemy
|
||||||
alembic
|
alembic
|
||||||
commonmark
|
Markdown
|
||||||
ruamel.yaml
|
ruamel.yaml
|
||||||
attrs
|
attrs
|
||||||
|
bcrypt
|
||||||
|
packaging
|
||||||
|
10
setup.py
10
setup.py
@ -21,13 +21,15 @@ setuptools.setup(
|
|||||||
packages=setuptools.find_packages(),
|
packages=setuptools.find_packages(),
|
||||||
|
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"mautrix>=0.4,<0.5",
|
"mautrix>=0.4.dev20,<0.5",
|
||||||
"aiohttp>=3.0.1,<4",
|
"aiohttp>=3.0.1,<4",
|
||||||
"SQLAlchemy>=1.2.3,<2",
|
"SQLAlchemy>=1.2.3,<2",
|
||||||
"alembic>=1.0.0,<2",
|
"alembic>=1.0.0,<2",
|
||||||
"commonmark>=0.8.1,<1",
|
"Markdown>=3.0.0,<4",
|
||||||
"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",
|
||||||
|
"packaging>=10",
|
||||||
],
|
],
|
||||||
|
|
||||||
classifiers=[
|
classifiers=[
|
||||||
@ -48,4 +50,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