Merge branch 'master' into cli

This commit is contained in:
Tulir Asokan 2018-12-12 16:07:45 +02:00
commit 4b3d47176d
93 changed files with 17023 additions and 450 deletions

View File

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

3
.gitignore vendored
View File

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

View File

@ -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"]

View File

@ -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.

View File

@ -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

View 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
View 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

View File

@ -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.
# #

View File

@ -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
View 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
View File

@ -0,0 +1,2 @@
#!/bin/bash
zip -9r helloworld.mbp maubot.yaml helloworld.py

View 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!")

View 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

View File

@ -13,21 +13,20 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import orm
import sqlalchemy as sql
import logging.config import logging.config
import argparse import argparse
import asyncio import asyncio
import signal
import copy import copy
import sys import sys
import signal
from .config import Config from .config import Config
from .db import Base, init as init_db from .db import init as init_db
from .server import MaubotServer from .server import MaubotServer
from .client import Client, init as init_client from .client import Client, init as init_client_class
from .loader import ZippedPluginLoader from .loader.zip import init as init_zip_loader
from .plugin import PluginInstance, init as init_plugin_instance_class from .instance import init as init_plugin_instance_class
from .management.api import init as init_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)

View File

@ -1 +1 @@
__version__ = "0.1.0.dev4" __version__ = "0.1.0.dev13"

View File

@ -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()

View File

@ -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"))

View File

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

256
maubot/instance.py Normal file
View 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
View File

View File

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

View File

@ -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

View File

@ -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()

View File

View 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()

View 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

View 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__
})

View 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

View 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)

View File

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

View 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

View File

@ -0,0 +1,101 @@
# maubot - A plugin-based Matrix bot system.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from json import JSONDecodeError
from http import HTTPStatus
from aiohttp import web
from ...db import DBPlugin
from ...instance import PluginInstance
from ...loader import PluginLoader
from ...client import Client
from .base import routes
from .responses import 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

View 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

View 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

View 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())

View 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()

View 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

View 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'

View 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
View File

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

View 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

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View 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>

View File

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

View File

@ -0,0 +1,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,
}

View 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

View File

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

View 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

View 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

View File

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

View File

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

View 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

View File

@ -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

View 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)

View File

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

View 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)

View 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

View 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

View 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)

View 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)

View 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

View 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

View 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

View 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 }))
}

View 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

View 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

View File

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

View 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

View File

@ -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

View 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

View 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

View 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/>.
> 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

View 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

View 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/>.
> 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

View File

@ -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;
}
}

View 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

View 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

View 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

View 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

View File

@ -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

View File

@ -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

View 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

View File

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

View 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

View 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)

File diff suppressed because it is too large Load Diff

View File

@ -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):

View File

@ -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

View File

@ -16,6 +16,8 @@
from typing import Type, Optional, TYPE_CHECKING from typing import Type, Optional, TYPE_CHECKING
from logging import Logger from logging import Logger
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from asyncio import AbstractEventLoop
from aiohttp import ClientSession
import os.path import os.path
from sqlalchemy.engine.base import Engine from sqlalchemy.engine.base import Engine
@ -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()

View File

@ -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

View File

@ -2,6 +2,8 @@ mautrix
aiohttp aiohttp
SQLAlchemy SQLAlchemy
alembic alembic
commonmark Markdown
ruamel.yaml ruamel.yaml
attrs attrs
bcrypt
packaging

View File

@ -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/*"],
},
) )