diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml
new file mode 100644
index 0000000..9847632
--- /dev/null
+++ b/.github/workflows/python-lint.yml
@@ -0,0 +1,26 @@
+name: Python lint
+
+on: [push, pull_request]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-python@v3
+ with:
+ python-version: "3.10"
+ - uses: isort/isort-action@master
+ with:
+ sortPaths: "./maubot"
+ - uses: psf/black@stable
+ with:
+ src: "./maubot"
+ version: "22.1.0"
+ - name: pre-commit
+ run: |
+ pip install pre-commit
+ pre-commit run -av trailing-whitespace
+ pre-commit run -av end-of-file-fixer
+ pre-commit run -av check-yaml
+ pre-commit run -av check-added-large-files
diff --git a/README.md b/README.md
index 90b6e0d..ed28f2e 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,11 @@
# maubot
+![Languages](https://img.shields.io/github/languages/top/maubot/maubot.svg)
+[![License](https://img.shields.io/github/license/maubot/maubot.svg)](LICENSE)
+[![Release](https://img.shields.io/github/release/maubot/maubot/all.svg)](https://github.com/maubot/maubot/releases)
+[![GitLab CI](https://mau.dev/maubot/maubot/badges/master/pipeline.svg)](https://mau.dev/maubot/maubot/container_registry)
+[![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
+[![Imports](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/)
+
A plugin-based [Matrix](https://matrix.org) bot system written in Python.
## Documentation
diff --git a/dev-requirements.txt b/dev-requirements.txt
new file mode 100644
index 0000000..232f724
--- /dev/null
+++ b/dev-requirements.txt
@@ -0,0 +1,3 @@
+pre-commit>=2.10.1,<3
+isort>=5.10.1,<6
+black==22.1.0
diff --git a/maubot/__init__.py b/maubot/__init__.py
index 71755b9..5106b46 100644
--- a/maubot/__init__.py
+++ b/maubot/__init__.py
@@ -1,4 +1,4 @@
+from .__meta__ import __version__
+from .matrix import MaubotMatrixClient as Client, MaubotMessageEvent as MessageEvent
from .plugin_base import Plugin
from .plugin_server import PluginWebApp
-from .matrix import MaubotMatrixClient as Client, MaubotMessageEvent as MessageEvent
-from .__meta__ import __version__
diff --git a/maubot/__main__.py b/maubot/__main__.py
index f07b58c..a29f347 100644
--- a/maubot/__main__.py
+++ b/maubot/__main__.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2021 Tulir Asokan
+# Copyright (C) 2022 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
@@ -17,15 +17,15 @@ import asyncio
from mautrix.util.program import Program
+from .__meta__ import __version__
+from .client import Client, init as init_client_class
from .config import Config
from .db import init as init_db
-from .server import MaubotServer
-from .client import Client, init as init_client_class
-from .loader.zip import init as init_zip_loader
from .instance import init as init_plugin_instance_class
-from .management.api import init as init_mgmt_api
from .lib.future_awaitable import FutureAwaitable
-from .__meta__ import __version__
+from .loader.zip import init as init_zip_loader
+from .management.api import init as init_mgmt_api
+from .server import MaubotServer
class Maubot(Program):
@@ -41,6 +41,7 @@ class Maubot(Program):
def prepare_log_websocket(self) -> None:
from .management.api.log import init, stop_all
+
init(self.loop)
self.add_shutdown_actions(FutureAwaitable(stop_all))
diff --git a/maubot/cli/__main__.py b/maubot/cli/__main__.py
index 1ffd665..3bdbe0e 100644
--- a/maubot/cli/__main__.py
+++ b/maubot/cli/__main__.py
@@ -1,2 +1,3 @@
from . import app
+
app()
diff --git a/maubot/cli/base.py b/maubot/cli/base.py
index 1aaeec8..b35db53 100644
--- a/maubot/cli/base.py
+++ b/maubot/cli/base.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
diff --git a/maubot/cli/cliq/__init__.py b/maubot/cli/cliq/__init__.py
index cba14a4..10ede9f 100644
--- a/maubot/cli/cliq/__init__.py
+++ b/maubot/cli/cliq/__init__.py
@@ -1,2 +1,2 @@
from .cliq import command, option
-from .validators import SPDXValidator, VersionValidator, PathValidator
+from .validators import PathValidator, SPDXValidator, VersionValidator
diff --git a/maubot/cli/cliq/cliq.py b/maubot/cli/cliq/cliq.py
index a65e77a..2883441 100644
--- a/maubot/cli/cliq/cliq.py
+++ b/maubot/cli/cliq/cliq.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2021 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,22 +13,23 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Any, Callable, Union, Optional, Type
-import functools
-import traceback
-import inspect
+from __future__ import annotations
+
+from typing import Any, Callable
import asyncio
+import functools
+import inspect
+import traceback
-import aiohttp
-
+from colorama import Fore
from prompt_toolkit.validation import Validator
from questionary import prompt
-from colorama import Fore
+import aiohttp
import click
from ..base import app
from ..config import get_token
-from .validators import Required, ClickValidator
+from .validators import ClickValidator, Required
def with_http(func):
@@ -105,7 +106,7 @@ def command(help: str) -> Callable[[Callable], Callable]:
return decorator
-def yesno(val: str) -> Optional[bool]:
+def yesno(val: str) -> bool | None:
if not val:
return None
elif isinstance(val, bool):
@@ -119,11 +120,20 @@ def yesno(val: str) -> Optional[bool]:
yesno.__name__ = "yes/no"
-def option(short: str, long: str, message: str = None, help: str = None,
- click_type: Union[str, Callable[[str], Any]] = None, inq_type: str = None,
- validator: Type[Validator] = None, required: bool = False,
- default: Union[str, bool, None] = None, is_flag: bool = False, prompt: bool = True,
- required_unless: Union[str, list, dict] = None) -> Callable[[Callable], Callable]:
+def option(
+ short: str,
+ long: str,
+ message: str = None,
+ help: str = None,
+ click_type: str | Callable[[str], Any] = None,
+ inq_type: str = None,
+ validator: type[Validator] = None,
+ required: bool = False,
+ default: str | bool | None = None,
+ is_flag: bool = False,
+ prompt: bool = True,
+ required_unless: str | list | dict = None,
+) -> Callable[[Callable], Callable]:
if not message:
message = long[2].upper() + long[3:]
@@ -139,9 +149,9 @@ def option(short: str, long: str, message: str = None, help: str = None,
if not hasattr(func, "__inquirer_questions__"):
func.__inquirer_questions__ = {}
q = {
- "type": (inq_type if isinstance(inq_type, str)
- else ("input" if not is_flag
- else "confirm")),
+ "type": (
+ inq_type if isinstance(inq_type, str) else ("input" if not is_flag else "confirm")
+ ),
"name": long[2:],
"message": message,
}
diff --git a/maubot/cli/cliq/validators.py b/maubot/cli/cliq/validators.py
index 9a57914..46d3c92 100644
--- a/maubot/cli/cliq/validators.py
+++ b/maubot/cli/cliq/validators.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -16,9 +16,9 @@
from typing import Callable
import os
-from packaging.version import Version, InvalidVersion
-from prompt_toolkit.validation import Validator, ValidationError
+from packaging.version import InvalidVersion, Version
from prompt_toolkit.document import Document
+from prompt_toolkit.validation import ValidationError, Validator
import click
from ..util import spdx as spdxlib
diff --git a/maubot/cli/commands/__init__.py b/maubot/cli/commands/__init__.py
index c535234..145646b 100644
--- a/maubot/cli/commands/__init__.py
+++ b/maubot/cli/commands/__init__.py
@@ -1 +1 @@
-from . import upload, build, login, init, logs, auth
+from . import auth, build, init, login, logs, upload
diff --git a/maubot/cli/commands/auth.py b/maubot/cli/commands/auth.py
index eceb0c5..59fcb20 100644
--- a/maubot/cli/commands/auth.py
+++ b/maubot/cli/commands/auth.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2021 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,8 +13,8 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-import webbrowser
import json
+import webbrowser
from colorama import Fore
from yarl import URL
@@ -26,12 +26,16 @@ from ..cliq import cliq
history_count: int = 10
friendly_errors = {
- "server_not_found": "Registration target server not found.\n\n"
- "To log in or register through maubot, you must add the server to the\n"
- "homeservers section in the config. If you only want to log in,\n"
- "leave the `secret` field empty.",
- "registration_no_sso": "The register operation is only for registering with a password.\n\n"
- "To register with SSO, simply leave out the --register flag.",
+ "server_not_found": (
+ "Registration target server not found.\n\n"
+ "To log in or register through maubot, you must add the server to the\n"
+ "homeservers section in the config. If you only want to log in,\n"
+ "leave the `secret` field empty."
+ ),
+ "registration_no_sso": (
+ "The register operation is only for registering with a password.\n\n"
+ "To register with SSO, simply leave out the --register flag."
+ ),
}
@@ -46,26 +50,58 @@ async def list_servers(server: str, sess: aiohttp.ClientSession) -> None:
@cliq.command(help="Log into a Matrix account via the Maubot server")
@cliq.option("-h", "--homeserver", help="The homeserver to log into", required_unless="list")
-@cliq.option("-u", "--username", help="The username to log in with",
- required_unless=["list", "sso"])
-@cliq.option("-p", "--password", help="The password to log in with", inq_type="password",
- required_unless=["list", "sso"])
-@cliq.option("-s", "--server", help="The maubot instance to log in through", default="",
- required=False, prompt=False)
-@click.option("-r", "--register", help="Register instead of logging in", is_flag=True,
- default=False)
-@click.option("-c", "--update-client", help="Instead of returning the access token, "
- "create or update a client in maubot using it",
- is_flag=True, default=False)
+@cliq.option(
+ "-u", "--username", help="The username to log in with", required_unless=["list", "sso"]
+)
+@cliq.option(
+ "-p",
+ "--password",
+ help="The password to log in with",
+ inq_type="password",
+ required_unless=["list", "sso"],
+)
+@cliq.option(
+ "-s",
+ "--server",
+ help="The maubot instance to log in through",
+ default="",
+ required=False,
+ prompt=False,
+)
+@click.option(
+ "-r", "--register", help="Register instead of logging in", is_flag=True, default=False
+)
+@click.option(
+ "-c",
+ "--update-client",
+ help="Instead of returning the access token, " "create or update a client in maubot using it",
+ is_flag=True,
+ default=False,
+)
@click.option("-l", "--list", help="List available homeservers", is_flag=True, default=False)
-@click.option("-o", "--sso", help="Use single sign-on instead of password login",
- is_flag=True, default=False)
-@click.option("-n", "--device-name", help="The initial e2ee device displayname (only for login)",
- default="Maubot", required=False)
+@click.option(
+ "-o", "--sso", help="Use single sign-on instead of password login", is_flag=True, default=False
+)
+@click.option(
+ "-n",
+ "--device-name",
+ help="The initial e2ee device displayname (only for login)",
+ default="Maubot",
+ required=False,
+)
@cliq.with_authenticated_http
-async def auth(homeserver: str, username: str, password: str, server: str, register: bool,
- list: bool, update_client: bool, device_name: str, sso: bool,
- sess: aiohttp.ClientSession) -> None:
+async def auth(
+ homeserver: str,
+ username: str,
+ password: str,
+ server: str,
+ register: bool,
+ list: bool,
+ update_client: bool,
+ device_name: str,
+ sso: bool,
+ sess: aiohttp.ClientSession,
+) -> None:
if list:
await list_servers(server, sess)
return
@@ -88,8 +124,9 @@ async def auth(homeserver: str, username: str, password: str, server: str, regis
await print_response(resp, is_register=register)
-async def wait_sso(resp: aiohttp.ClientResponse, sess: aiohttp.ClientSession,
- server: str, homeserver: str) -> None:
+async def wait_sso(
+ resp: aiohttp.ClientResponse, sess: aiohttp.ClientSession, server: str, homeserver: str
+) -> None:
data = await resp.json()
sso_url, reg_id = data["sso_url"], data["id"]
print(f"{Fore.GREEN}Opening {Fore.CYAN}{sso_url}{Fore.RESET}")
@@ -110,9 +147,11 @@ async def print_response(resp: aiohttp.ClientResponse, is_register: bool) -> Non
elif resp.status in (201, 202):
data = await resp.json()
action = "created" if resp.status == 201 else "updated"
- print(f"{Fore.GREEN}Successfully {action} client for "
- f"{Fore.CYAN}{data['id']}{Fore.GREEN} / "
- f"{Fore.CYAN}{data['device_id']}{Fore.GREEN}.{Fore.RESET}")
+ print(
+ f"{Fore.GREEN}Successfully {action} client for "
+ f"{Fore.CYAN}{data['id']}{Fore.GREEN} / "
+ f"{Fore.CYAN}{data['device_id']}{Fore.GREEN}.{Fore.RESET}"
+ )
else:
await print_error(resp, is_register)
diff --git a/maubot/cli/commands/build.py b/maubot/cli/commands/build.py
index cdc5b3a..ec3ac26 100644
--- a/maubot/cli/commands/build.py
+++ b/maubot/cli/commands/build.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,26 +13,28 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Optional, Union, IO
+from __future__ import annotations
+
+from typing import IO
from io import BytesIO
-import zipfile
import asyncio
import glob
import os
+import zipfile
-from ruamel.yaml import YAML, YAMLError
from aiohttp import ClientSession
-from questionary import prompt
from colorama import Fore
+from questionary import prompt
+from ruamel.yaml import YAML, YAMLError
import click
from mautrix.types import SerializerError
from ...loader import PluginMeta
-from ..cliq.validators import PathValidator
from ..base import app
-from ..config import get_token
from ..cliq import cliq
+from ..cliq.validators import PathValidator
+from ..config import get_token
from .upload import upload_file
yaml = YAML()
@@ -44,7 +46,7 @@ def zipdir(zip, dir):
zip.write(os.path.join(root, file))
-def read_meta(path: str) -> Optional[PluginMeta]:
+def read_meta(path: str) -> PluginMeta | None:
try:
with open(os.path.join(path, "maubot.yaml")) as meta_file:
try:
@@ -65,7 +67,7 @@ def read_meta(path: str) -> Optional[PluginMeta]:
return meta
-def read_output_path(output: str, meta: PluginMeta) -> Optional[str]:
+def read_output_path(output: str, meta: PluginMeta) -> str | None:
directory = os.getcwd()
filename = f"{meta.id}-v{meta.version}.mbp"
if not output:
@@ -73,18 +75,15 @@ def read_output_path(output: str, meta: PluginMeta) -> Optional[str]:
elif os.path.isdir(output):
output = os.path.join(output, filename)
elif os.path.exists(output):
- override = prompt({
- "type": "confirm",
- "name": "override",
- "message": f"{output} exists, override?"
- })["override"]
+ q = [{"type": "confirm", "name": "override", "message": f"{output} exists, override?"}]
+ override = prompt(q)["override"]
if not override:
return None
os.remove(output)
return os.path.abspath(output)
-def write_plugin(meta: PluginMeta, output: Union[str, IO]) -> None:
+def write_plugin(meta: PluginMeta, output: str | IO) -> None:
with zipfile.ZipFile(output, "w") as zip:
meta_dump = BytesIO()
yaml.dump(meta.serialize(), meta_dump)
@@ -104,7 +103,7 @@ def write_plugin(meta: PluginMeta, output: Union[str, IO]) -> None:
@cliq.with_authenticated_http
-async def upload_plugin(output: Union[str, IO], *, server: str, sess: ClientSession) -> None:
+async def upload_plugin(output: str | IO, *, server: str, sess: ClientSession) -> None:
server, token = get_token(server)
if not token:
return
@@ -115,14 +114,20 @@ async def upload_plugin(output: Union[str, IO], *, server: str, sess: ClientSess
await upload_file(sess, output, server)
-@app.command(short_help="Build a maubot plugin",
- help="Build a maubot plugin. First parameter is the path to root of the plugin "
- "to build. You can also use --output to specify output file.")
+@app.command(
+ short_help="Build a maubot plugin",
+ help=(
+ "Build a maubot plugin. First parameter is the path to root of the plugin "
+ "to build. You can also use --output to specify output file."
+ ),
+)
@click.argument("path", default=os.getcwd())
-@click.option("-o", "--output", help="Path to output built plugin to",
- type=PathValidator.click_type)
-@click.option("-u", "--upload", help="Upload plugin to server after building", is_flag=True,
- default=False)
+@click.option(
+ "-o", "--output", help="Path to output built plugin to", type=PathValidator.click_type
+)
+@click.option(
+ "-u", "--upload", help="Upload plugin to server after building", is_flag=True, default=False
+)
@click.option("-s", "--server", help="Server to upload built plugin to")
def build(path: str, output: str, upload: bool, server: str) -> None:
meta = read_meta(path)
diff --git a/maubot/cli/commands/init.py b/maubot/cli/commands/init.py
index 7372a2d..d24def9 100644
--- a/maubot/cli/commands/init.py
+++ b/maubot/cli/commands/init.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,11 +13,11 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from pkg_resources import resource_string
import os
-from packaging.version import Version
from jinja2 import Template
+from packaging.version import Version
+from pkg_resources import resource_string
from .. import cliq
from ..cliq import SPDXValidator, VersionValidator
@@ -40,26 +40,55 @@ def load_templates():
@cliq.command(help="Initialize a new maubot plugin")
-@cliq.option("-n", "--name", help="The name of the project", required=True,
- default=os.path.basename(os.getcwd()))
-@cliq.option("-i", "--id", message="ID", required=True,
- help="The maubot plugin ID (Java package name format)")
-@cliq.option("-v", "--version", help="Initial version for project (PEP-440 format)",
- default="0.1.0", validator=VersionValidator, required=True)
-@cliq.option("-l", "--license", validator=SPDXValidator, default="AGPL-3.0-or-later",
- help="The license for the project (SPDX identifier)", required=False)
-@cliq.option("-c", "--config", message="Should the plugin include a config?",
- help="Include a config in the plugin stub", default=False, is_flag=True)
+@cliq.option(
+ "-n",
+ "--name",
+ help="The name of the project",
+ required=True,
+ default=os.path.basename(os.getcwd()),
+)
+@cliq.option(
+ "-i",
+ "--id",
+ message="ID",
+ required=True,
+ help="The maubot plugin ID (Java package name format)",
+)
+@cliq.option(
+ "-v",
+ "--version",
+ help="Initial version for project (PEP-440 format)",
+ default="0.1.0",
+ validator=VersionValidator,
+ required=True,
+)
+@cliq.option(
+ "-l",
+ "--license",
+ validator=SPDXValidator,
+ default="AGPL-3.0-or-later",
+ help="The license for the project (SPDX identifier)",
+ required=False,
+)
+@cliq.option(
+ "-c",
+ "--config",
+ message="Should the plugin include a config?",
+ help="Include a config in the plugin stub",
+ default=False,
+ is_flag=True,
+)
def init(name: str, id: str, version: Version, license: str, config: bool) -> None:
load_templates()
main_class = name[0].upper() + name[1:]
- meta = meta_template.render(id=id, version=str(version), license=license, config=config,
- main_class=main_class)
+ meta = meta_template.render(
+ id=id, version=str(version), license=license, config=config, main_class=main_class
+ )
with open("maubot.yaml", "w") as file:
file.write(meta)
if license:
with open("LICENSE", "w") as file:
- file.write(spdx.get(license)["text"])
+ file.write(spdx.get(license)["licenseText"])
if not os.path.isdir(name):
os.mkdir(name)
mod = mod_template.render(config=config, name=main_class)
diff --git a/maubot/cli/commands/login.py b/maubot/cli/commands/login.py
index 554dd2d..8aac0f5 100644
--- a/maubot/cli/commands/login.py
+++ b/maubot/cli/commands/login.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2021 Tulir Asokan
+# Copyright (C) 2022 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
@@ -20,17 +20,39 @@ from colorama import Fore
from yarl import URL
import aiohttp
-from ..config import save_config, config
from ..cliq import cliq
+from ..config import config, save_config
@cliq.command(help="Log in to a Maubot instance")
-@cliq.option("-u", "--username", help="The username of your account", default=os.environ.get("USER", None), required=True)
-@cliq.option("-p", "--password", help="The password to your account", inq_type="password", required=True)
-@cliq.option("-s", "--server", help="The server to log in to", default="http://localhost:29316", required=True)
-@cliq.option("-a", "--alias", help="Alias to reference the server without typing the full URL", default="", required=False)
+@cliq.option(
+ "-u",
+ "--username",
+ help="The username of your account",
+ default=os.environ.get("USER", None),
+ required=True,
+)
+@cliq.option(
+ "-p", "--password", help="The password to your account", inq_type="password", required=True
+)
+@cliq.option(
+ "-s",
+ "--server",
+ help="The server to log in to",
+ default="http://localhost:29316",
+ required=True,
+)
+@cliq.option(
+ "-a",
+ "--alias",
+ help="Alias to reference the server without typing the full URL",
+ default="",
+ required=False,
+)
@cliq.with_http
-async def login(server: str, username: str, password: str, alias: str, sess: aiohttp.ClientSession) -> None:
+async def login(
+ server: str, username: str, password: str, alias: str, sess: aiohttp.ClientSession
+) -> None:
data = {
"username": username,
"password": password,
diff --git a/maubot/cli/commands/logs.py b/maubot/cli/commands/logs.py
index 8d0a578..98879ee 100644
--- a/maubot/cli/commands/logs.py
+++ b/maubot/cli/commands/logs.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -16,14 +16,14 @@
from datetime import datetime
import asyncio
+from aiohttp import ClientSession, WSMessage, WSMsgType
from colorama import Fore
-from aiohttp import WSMsgType, WSMessage, ClientSession
import click
from mautrix.types import Obj
-from ..config import get_token
from ..base import app
+from ..config import get_token
history_count: int = 10
@@ -50,7 +50,7 @@ def logs(server: str, tail: int) -> None:
def parsedate(entry: Obj) -> None:
i = entry.time.index("+")
i = entry.time.index(":", i)
- entry.time = entry.time[:i] + entry.time[i + 1:]
+ entry.time = entry.time[:i] + entry.time[i + 1 :]
entry.time = datetime.strptime(entry.time, "%Y-%m-%dT%H:%M:%S.%f%z")
@@ -66,13 +66,16 @@ levelcolors = {
def print_entry(entry: dict) -> None:
entry = Obj(**entry)
parsedate(entry)
- print("{levelcolor}[{date}] [{level}@{logger}] {message}{resetcolor}"
- .format(date=entry.time.strftime("%Y-%m-%d %H:%M:%S"),
- level=entry.levelname,
- levelcolor=levelcolors.get(entry.levelname, ""),
- resetcolor=Fore.RESET,
- logger=entry.name,
- message=entry.msg))
+ print(
+ "{levelcolor}[{date}] [{level}@{logger}] {message}{resetcolor}".format(
+ date=entry.time.strftime("%Y-%m-%d %H:%M:%S"),
+ level=entry.levelname,
+ levelcolor=levelcolors.get(entry.levelname, ""),
+ resetcolor=Fore.RESET,
+ logger=entry.name,
+ message=entry.msg,
+ )
+ )
if entry.exc_info:
print(entry.exc_info)
diff --git a/maubot/cli/commands/upload.py b/maubot/cli/commands/upload.py
index 67a1e30..3c2cf1e 100644
--- a/maubot/cli/commands/upload.py
+++ b/maubot/cli/commands/upload.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2021 Tulir Asokan
+# Copyright (C) 2022 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
@@ -43,8 +43,10 @@ async def upload_file(sess: aiohttp.ClientSession, file: IO, server: str) -> Non
async with sess.post(url, data=file, headers=headers) as resp:
if resp.status in (200, 201):
data = await resp.json()
- print(f"{Fore.GREEN}Plugin {Fore.CYAN}{data['id']} v{data['version']}{Fore.GREEN} "
- f"uploaded to {Fore.CYAN}{server}{Fore.GREEN} successfully.{Fore.RESET}")
+ print(
+ f"{Fore.GREEN}Plugin {Fore.CYAN}{data['id']} v{data['version']}{Fore.GREEN} "
+ f"uploaded to {Fore.CYAN}{server}{Fore.GREEN} successfully.{Fore.RESET}"
+ )
else:
try:
err = await resp.json()
diff --git a/maubot/cli/config.py b/maubot/cli/config.py
index 550c326..a1adc2f 100644
--- a/maubot/cli/config.py
+++ b/maubot/cli/config.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,13 +13,15 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Tuple, Optional, Dict, Any
+from __future__ import annotations
+
+from typing import Any
import json
import os
from colorama import Fore
-config: Dict[str, Any] = {
+config: dict[str, Any] = {
"servers": {},
"aliases": {},
"default_server": None,
@@ -27,9 +29,9 @@ config: Dict[str, Any] = {
configdir = os.environ.get("XDG_CONFIG_HOME", os.path.join(os.environ.get("HOME"), ".config"))
-def get_default_server() -> Tuple[Optional[str], Optional[str]]:
+def get_default_server() -> tuple[str | None, str | None]:
try:
- server: Optional[str] = config["default_server"]
+ server: str < None = config["default_server"]
except KeyError:
server = None
if server is None:
@@ -38,7 +40,7 @@ def get_default_server() -> Tuple[Optional[str], Optional[str]]:
return server, _get_token(server)
-def get_token(server: str) -> Tuple[Optional[str], Optional[str]]:
+def get_token(server: str) -> tuple[str | None, str | None]:
if not server:
return get_default_server()
if server in config["aliases"]:
@@ -46,14 +48,14 @@ def get_token(server: str) -> Tuple[Optional[str], Optional[str]]:
return server, _get_token(server)
-def _resolve_alias(alias: str) -> Optional[str]:
+def _resolve_alias(alias: str) -> str | None:
try:
return config["aliases"][alias]
except KeyError:
return None
-def _get_token(server: str) -> Optional[str]:
+def _get_token(server: str) -> str | None:
try:
return config["servers"][server]
except KeyError:
diff --git a/maubot/cli/res/spdx.json.zip b/maubot/cli/res/spdx.json.zip
index 4cd4701..98de1b0 100644
Binary files a/maubot/cli/res/spdx.json.zip and b/maubot/cli/res/spdx.json.zip differ
diff --git a/maubot/cli/util/spdx.py b/maubot/cli/util/spdx.py
index aca303d..10508b3 100644
--- a/maubot/cli/util/spdx.py
+++ b/maubot/cli/util/spdx.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,12 +13,14 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Dict, Optional
-import zipfile
-import pkg_resources
-import json
+from __future__ import annotations
-spdx_list: Optional[Dict[str, Dict[str, str]]] = None
+import json
+import zipfile
+
+import pkg_resources
+
+spdx_list: dict[str, dict[str, str]] | None = None
def load() -> None:
@@ -31,7 +33,7 @@ def load() -> None:
spdx_list = json.load(file)
-def get(id: str) -> Dict[str, str]:
+def get(id: str) -> dict[str, str]:
if not spdx_list:
load()
return spdx_list[id.lower()]
diff --git a/maubot/client.py b/maubot/client.py
index 0ebbaf7..fa6c851 100644
--- a/maubot/client.py
+++ b/maubot/client.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2021 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,32 +13,46 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Dict, Iterable, Optional, Set, Callable, Any, Awaitable, Union, TYPE_CHECKING
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable
import asyncio
import logging
from aiohttp import ClientSession
-from mautrix.errors import MatrixInvalidToken
-from mautrix.types import (UserID, SyncToken, FilterID, ContentURI, StrippedStateEvent, Membership,
- StateEvent, EventType, Filter, RoomFilter, RoomEventFilter, EventFilter,
- PresenceState, StateFilter, DeviceID)
from mautrix.client import InternalEventType
from mautrix.client.state_store.sqlalchemy import SQLStateStore as BaseSQLStateStore
+from mautrix.errors import MatrixInvalidToken
+from mautrix.types import (
+ ContentURI,
+ DeviceID,
+ EventFilter,
+ EventType,
+ Filter,
+ FilterID,
+ Membership,
+ PresenceState,
+ RoomEventFilter,
+ RoomFilter,
+ StateEvent,
+ StateFilter,
+ StrippedStateEvent,
+ SyncToken,
+ UserID,
+)
-from .lib.store_proxy import SyncStoreProxy
from .db import DBClient
+from .lib.store_proxy import SyncStoreProxy
from .matrix import MaubotMatrixClient
try:
- from mautrix.crypto import OlmMachine, StateStore as CryptoStateStore, PgCryptoStore
+ from mautrix.crypto import OlmMachine, PgCryptoStore, StateStore as CryptoStateStore
from mautrix.util.async_db import Database as AsyncDatabase
-
class SQLStateStore(BaseSQLStateStore, CryptoStateStore):
pass
-
crypto_import_error = None
except ImportError as e:
OlmMachine = CryptoStateStore = PgCryptoStore = AsyncDatabase = None
@@ -46,8 +60,8 @@ except ImportError as e:
crypto_import_error = e
if TYPE_CHECKING:
- from .instance import PluginInstance
from .config import Config
+ from .instance import PluginInstance
log = logging.getLogger("maubot.client")
@@ -55,20 +69,20 @@ log = logging.getLogger("maubot.client")
class Client:
log: logging.Logger = None
loop: asyncio.AbstractEventLoop = None
- cache: Dict[UserID, 'Client'] = {}
+ cache: dict[UserID, Client] = {}
http_client: ClientSession = None
- global_state_store: Union['BaseSQLStateStore', 'CryptoStateStore'] = SQLStateStore()
- crypto_db: Optional['AsyncDatabase'] = None
+ global_state_store: BaseSQLStateStore | CryptoStateStore = SQLStateStore()
+ crypto_db: AsyncDatabase | None = None
- references: Set['PluginInstance']
+ references: set[PluginInstance]
db_instance: DBClient
client: MaubotMatrixClient
- crypto: Optional['OlmMachine']
- crypto_store: Optional['PgCryptoStore']
+ crypto: OlmMachine | None
+ crypto_store: PgCryptoStore | None
started: bool
- remote_displayname: Optional[str]
- remote_avatar_url: Optional[ContentURI]
+ remote_displayname: str | None
+ remote_avatar_url: ContentURI | None
def __init__(self, db_instance: DBClient) -> None:
self.db_instance = db_instance
@@ -79,11 +93,17 @@ class Client:
self.sync_ok = True
self.remote_displayname = None
self.remote_avatar_url = None
- self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver,
- token=self.access_token, client_session=self.http_client,
- log=self.log, loop=self.loop, device_id=self.device_id,
- sync_store=SyncStoreProxy(self.db_instance),
- state_store=self.global_state_store)
+ self.client = MaubotMatrixClient(
+ mxid=self.id,
+ base_url=self.homeserver,
+ token=self.access_token,
+ client_session=self.http_client,
+ log=self.log,
+ loop=self.loop,
+ device_id=self.device_id,
+ sync_store=SyncStoreProxy(self.db_instance),
+ state_store=self.global_state_store,
+ )
if self.enable_crypto:
self._prepare_crypto()
else:
@@ -104,8 +124,10 @@ class Client:
return False
elif not OlmMachine:
global crypto_import_error
- self.log.warning("Client has device ID, but encryption dependencies not installed",
- exc_info=crypto_import_error)
+ self.log.warning(
+ "Client has device ID, but encryption dependencies not installed",
+ exc_info=crypto_import_error,
+ )
# Clear the stack trace after it's logged once to avoid spamming logs
crypto_import_error = None
return False
@@ -115,8 +137,9 @@ class Client:
return True
def _prepare_crypto(self) -> None:
- self.crypto_store = PgCryptoStore(account_id=self.id, pickle_key="mau.crypto",
- db=self.crypto_db)
+ self.crypto_store = PgCryptoStore(
+ account_id=self.id, pickle_key="mau.crypto", db=self.crypto_db
+ )
self.crypto = OlmMachine(self.client, self.crypto_store, self.global_state_store)
self.client.crypto = self.crypto
@@ -133,13 +156,13 @@ class Client:
for event_type, func in handlers:
self.client.remove_event_handler(event_type, func)
- def _set_sync_ok(self, ok: bool) -> Callable[[Dict[str, Any]], Awaitable[None]]:
- async def handler(data: Dict[str, Any]) -> None:
+ def _set_sync_ok(self, ok: bool) -> Callable[[dict[str, Any]], Awaitable[None]]:
+ async def handler(data: dict[str, Any]) -> None:
self.sync_ok = ok
return handler
- async def start(self, try_n: Optional[int] = 0) -> None:
+ async def start(self, try_n: int | None = 0) -> None:
try:
if try_n > 0:
await asyncio.sleep(try_n * 10)
@@ -152,15 +175,16 @@ class Client:
await self.crypto_store.open()
crypto_device_id = await self.crypto_store.get_device_id()
if crypto_device_id and crypto_device_id != self.device_id:
- self.log.warning("Mismatching device ID in crypto store and main database, "
- "resetting encryption")
+ self.log.warning(
+ "Mismatching device ID in crypto store and main database, " "resetting encryption"
+ )
await self.crypto_store.delete()
crypto_device_id = None
await self.crypto.load()
if not crypto_device_id:
await self.crypto_store.put_device_id(self.device_id)
- async def _start(self, try_n: Optional[int] = 0) -> None:
+ async def _start(self, try_n: int | None = 0) -> None:
if not self.enabled:
self.log.debug("Not starting disabled client")
return
@@ -179,8 +203,9 @@ class Client:
self.log.exception("Failed to get /account/whoami, disabling client")
self.db_instance.enabled = False
else:
- self.log.warning(f"Failed to get /account/whoami, "
- f"retrying in {(try_n + 1) * 10}s: {e}")
+ self.log.warning(
+ f"Failed to get /account/whoami, " f"retrying in {(try_n + 1) * 10}s: {e}"
+ )
_ = asyncio.ensure_future(self.start(try_n + 1), loop=self.loop)
return
if whoami.user_id != self.id:
@@ -188,25 +213,30 @@ class Client:
self.db_instance.enabled = False
return
elif whoami.device_id and self.device_id and whoami.device_id != self.device_id:
- self.log.error(f"Device ID mismatch: expected {self.device_id}, "
- f"but got {whoami.device_id}")
+ self.log.error(
+ f"Device ID mismatch: expected {self.device_id}, " f"but got {whoami.device_id}"
+ )
self.db_instance.enabled = False
return
if not self.filter_id:
- self.db_instance.edit(filter_id=await self.client.create_filter(Filter(
- room=RoomFilter(
- timeline=RoomEventFilter(
- limit=50,
- lazy_load_members=True,
- ),
- state=StateFilter(
- lazy_load_members=True,
+ self.db_instance.edit(
+ filter_id=await self.client.create_filter(
+ Filter(
+ room=RoomFilter(
+ timeline=RoomEventFilter(
+ limit=50,
+ lazy_load_members=True,
+ ),
+ state=StateFilter(
+ lazy_load_members=True,
+ ),
+ ),
+ presence=EventFilter(
+ not_types=[EventType.PRESENCE],
+ ),
)
- ),
- presence=EventFilter(
- not_types=[EventType.PRESENCE],
- ),
- )))
+ )
+ )
if self.displayname != "disable":
await self.client.set_displayname(self.displayname)
if self.avatar_url != "disable":
@@ -258,8 +288,9 @@ class Client:
"homeserver": self.homeserver,
"access_token": self.access_token,
"device_id": self.device_id,
- "fingerprint": (self.crypto.account.fingerprint if self.crypto and self.crypto.account
- else None),
+ "fingerprint": (
+ self.crypto.account.fingerprint if self.crypto and self.crypto.account else None
+ ),
"enabled": self.enabled,
"started": self.started,
"sync": self.sync,
@@ -274,7 +305,7 @@ class Client:
}
@classmethod
- def get(cls, user_id: UserID, db_instance: Optional[DBClient] = None) -> Optional['Client']:
+ def get(cls, user_id: UserID, db_instance: DBClient | None = None) -> Client | None:
try:
return cls.cache[user_id]
except KeyError:
@@ -284,7 +315,7 @@ class Client:
return Client(db_instance)
@classmethod
- def all(cls) -> Iterable['Client']:
+ def all(cls) -> Iterable[Client]:
return (cls.get(user.id, user) for user in DBClient.all())
async def _handle_tombstone(self, evt: StateEvent) -> None:
@@ -324,8 +355,12 @@ class Client:
else:
await self._update_remote_profile()
- async def update_access_details(self, access_token: Optional[str], homeserver: Optional[str],
- device_id: Optional[str] = None) -> None:
+ async def update_access_details(
+ self,
+ access_token: str | None,
+ homeserver: str | None,
+ device_id: str | None = None,
+ ) -> None:
if not access_token and not homeserver:
return
if device_id is None:
@@ -338,10 +373,16 @@ class Client:
and device_id == self.device_id
):
return
- new_client = MaubotMatrixClient(mxid=self.id, base_url=homeserver or self.homeserver,
- token=access_token or self.access_token, loop=self.loop,
- device_id=device_id, client_session=self.http_client,
- log=self.log, state_store=self.global_state_store)
+ new_client = MaubotMatrixClient(
+ mxid=self.id,
+ base_url=homeserver or self.homeserver,
+ token=access_token or self.access_token,
+ loop=self.loop,
+ device_id=device_id,
+ client_session=self.http_client,
+ log=self.log,
+ state_store=self.global_state_store,
+ )
whoami = await new_client.whoami()
if whoami.user_id != self.id:
raise ValueError(f"MXID mismatch: {whoami.user_id}")
@@ -455,7 +496,7 @@ class Client:
# endregion
-def init(config: 'Config', loop: asyncio.AbstractEventLoop) -> Iterable[Client]:
+def init(config: "Config", loop: asyncio.AbstractEventLoop) -> Iterable[Client]:
Client.http_client = ClientSession(loop=loop)
Client.loop = loop
diff --git a/maubot/config.py b/maubot/config.py
index 92f5aa9..c817ccc 100644
--- a/maubot/config.py
+++ b/maubot/config.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -14,9 +14,10 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
import random
-import string
-import bcrypt
import re
+import string
+
+import bcrypt
from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper
@@ -64,8 +65,9 @@ class Config(BaseFileConfig):
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")
+ base["admins"][username] = bcrypt.hashpw(
+ password.encode("utf-8"), bcrypt.gensalt()
+ ).decode("utf-8")
copy("api_features.login")
copy("api_features.plugin")
copy("api_features.plugin_upload")
diff --git a/maubot/db.py b/maubot/db.py
index 3817882..9f388d3 100644
--- a/maubot/db.py
+++ b/maubot/db.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -17,13 +17,13 @@ from typing import Iterable, Optional
import logging
import sys
-from sqlalchemy import Column, String, Boolean, ForeignKey, Text
+from sqlalchemy import Boolean, Column, ForeignKey, String, Text
from sqlalchemy.engine.base import Engine
import sqlalchemy as sql
-from mautrix.types import UserID, FilterID, DeviceID, SyncToken, ContentURI
-from mautrix.util.db import Base
from mautrix.client.state_store.sqlalchemy import RoomState, UserProfile
+from mautrix.types import ContentURI, DeviceID, FilterID, SyncToken, UserID
+from mautrix.util.db import Base
from .config import Config
@@ -34,17 +34,19 @@ class DBPlugin(Base):
id: str = Column(String(255), primary_key=True)
type: str = Column(String(255), nullable=False)
enabled: bool = Column(Boolean, nullable=False, default=False)
- primary_user: UserID = Column(String(255),
- ForeignKey("client.id", onupdate="CASCADE", ondelete="RESTRICT"),
- nullable=False)
- config: str = Column(Text, nullable=False, default='')
+ primary_user: UserID = Column(
+ String(255),
+ ForeignKey("client.id", onupdate="CASCADE", ondelete="RESTRICT"),
+ nullable=False,
+ )
+ config: str = Column(Text, nullable=False, default="")
@classmethod
- def all(cls) -> Iterable['DBPlugin']:
+ def all(cls) -> Iterable["DBPlugin"]:
return cls._select_all()
@classmethod
- def get(cls, id: str) -> Optional['DBPlugin']:
+ def get(cls, id: str) -> Optional["DBPlugin"]:
return cls._select_one_or_none(cls.c.id == id)
@@ -68,11 +70,11 @@ class DBClient(Base):
avatar_url: ContentURI = Column(String(255), nullable=False, default="")
@classmethod
- def all(cls) -> Iterable['DBClient']:
+ def all(cls) -> Iterable["DBClient"]:
return cls._select_all()
@classmethod
- def get(cls, id: str) -> Optional['DBClient']:
+ def get(cls, id: str) -> Optional["DBClient"]:
return cls._select_one_or_none(cls.c.id == id)
@@ -87,15 +89,20 @@ def init(config: Config) -> Engine:
log = logging.getLogger("maubot.db")
if db.has_table("client") and db.has_table("plugin"):
- log.warning("alembic_version table not found, but client and plugin tables found. "
- "Assuming pre-Alembic database and inserting version.")
- db.execute("CREATE TABLE IF NOT EXISTS alembic_version ("
- " version_num VARCHAR(32) PRIMARY KEY"
- ");")
+ log.warning(
+ "alembic_version table not found, but client and plugin tables found. "
+ "Assuming pre-Alembic database and inserting version."
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS alembic_version ("
+ " version_num VARCHAR(32) PRIMARY KEY"
+ ");"
+ )
db.execute("INSERT INTO alembic_version VALUES ('d295f8dcfa64');")
else:
- log.critical("alembic_version table not found. "
- "Did you forget to `alembic upgrade head`?")
+ log.critical(
+ "alembic_version table not found. " "Did you forget to `alembic upgrade head`?"
+ )
sys.exit(10)
return db
diff --git a/maubot/handlers/__init__.py b/maubot/handlers/__init__.py
index 1d9da7e..e8567a2 100644
--- a/maubot/handlers/__init__.py
+++ b/maubot/handlers/__init__.py
@@ -1 +1 @@
-from . import event, command, web
+from . import command, event, web
diff --git a/maubot/handlers/command.py b/maubot/handlers/command.py
index 37f8174..495bc27 100644
--- a/maubot/handlers/command.py
+++ b/maubot/handlers/command.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,29 +13,46 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import (Union, Callable, Sequence, Pattern, Awaitable, NewType, Optional, Any, List,
- Dict, Tuple, Set, Iterable)
+from typing import (
+ Any,
+ Awaitable,
+ Callable,
+ Dict,
+ Iterable,
+ List,
+ NewType,
+ Optional,
+ Pattern,
+ Sequence,
+ Set,
+ Tuple,
+ Union,
+)
from abc import ABC, abstractmethod
import asyncio
import functools
import inspect
import re
-from mautrix.types import MessageType, EventType
+from mautrix.types import EventType, MessageType
from ..matrix import MaubotMessageEvent
from . import event
PrefixType = Optional[Union[str, Callable[[], str], Callable[[Any], str]]]
-AliasesType = Union[List[str], Tuple[str, ...], Set[str], Callable[[str], bool],
- Callable[[Any, str], bool]]
-CommandHandlerFunc = NewType("CommandHandlerFunc",
- Callable[[MaubotMessageEvent, Any], Awaitable[Any]])
-CommandHandlerDecorator = NewType("CommandHandlerDecorator",
- Callable[[Union['CommandHandler', CommandHandlerFunc]],
- 'CommandHandler'])
-PassiveCommandHandlerDecorator = NewType("PassiveCommandHandlerDecorator",
- Callable[[CommandHandlerFunc], CommandHandlerFunc])
+AliasesType = Union[
+ List[str], Tuple[str, ...], Set[str], Callable[[str], bool], Callable[[Any, str], bool]
+]
+CommandHandlerFunc = NewType(
+ "CommandHandlerFunc", Callable[[MaubotMessageEvent, Any], Awaitable[Any]]
+)
+CommandHandlerDecorator = NewType(
+ "CommandHandlerDecorator",
+ Callable[[Union["CommandHandler", CommandHandlerFunc]], "CommandHandler"],
+)
+PassiveCommandHandlerDecorator = NewType(
+ "PassiveCommandHandlerDecorator", Callable[[CommandHandlerFunc], CommandHandlerFunc]
+)
def _split_in_two(val: str, split_by: str) -> List[str]:
@@ -67,15 +84,26 @@ class CommandHandler:
return self.__bound_copies__[instance]
except KeyError:
new_ch = type(self)(self.__mb_func__)
- keys = ["parent", "subcommands", "arguments", "help", "get_name", "is_command_match",
- "require_subcommand", "arg_fallthrough", "event_handler", "event_type",
- "msgtypes"]
+ keys = [
+ "parent",
+ "subcommands",
+ "arguments",
+ "help",
+ "get_name",
+ "is_command_match",
+ "require_subcommand",
+ "arg_fallthrough",
+ "event_handler",
+ "event_type",
+ "msgtypes",
+ ]
for key in keys:
key = f"__mb_{key}__"
setattr(new_ch, key, getattr(self, key))
new_ch.__bound_instance__ = instance
- new_ch.__mb_subcommands__ = [subcmd.__get__(instance, instancetype)
- for subcmd in self.__mb_subcommands__]
+ new_ch.__mb_subcommands__ = [
+ subcmd.__get__(instance, instancetype) for subcmd in self.__mb_subcommands__
+ ]
self.__bound_copies__[instance] = new_ch
return new_ch
@@ -83,8 +111,13 @@ class CommandHandler:
def __command_match_unset(self, val: str) -> bool:
raise NotImplementedError("Hmm")
- async def __call__(self, evt: MaubotMessageEvent, *, _existing_args: Dict[str, Any] = None,
- remaining_val: str = None) -> Any:
+ async def __call__(
+ self,
+ evt: MaubotMessageEvent,
+ *,
+ _existing_args: Dict[str, Any] = None,
+ remaining_val: str = None,
+ ) -> Any:
if evt.sender == evt.client.mxid or evt.content.msgtype not in self.__mb_msgtypes__:
return
if remaining_val is None:
@@ -120,21 +153,25 @@ class CommandHandler:
return await self.__mb_func__(self.__bound_instance__, evt, **call_args)
return await self.__mb_func__(evt, **call_args)
- async def __call_subcommand__(self, evt: MaubotMessageEvent, call_args: Dict[str, Any],
- remaining_val: str) -> Tuple[bool, Any]:
+ async def __call_subcommand__(
+ self, evt: MaubotMessageEvent, call_args: Dict[str, Any], remaining_val: str
+ ) -> Tuple[bool, Any]:
command, remaining_val = _split_in_two(remaining_val.strip(), " ")
for subcommand in self.__mb_subcommands__:
if subcommand.__mb_is_command_match__(subcommand.__bound_instance__, command):
- return True, await subcommand(evt, _existing_args=call_args,
- remaining_val=remaining_val)
+ return True, await subcommand(
+ evt, _existing_args=call_args, remaining_val=remaining_val
+ )
return False, None
- async def __parse_args__(self, evt: MaubotMessageEvent, call_args: Dict[str, Any],
- remaining_val: str) -> Tuple[bool, str]:
+ async def __parse_args__(
+ self, evt: MaubotMessageEvent, call_args: Dict[str, Any], remaining_val: str
+ ) -> Tuple[bool, str]:
for arg in self.__mb_arguments__:
try:
- remaining_val, call_args[arg.name] = arg.match(remaining_val.strip(), evt=evt,
- instance=self.__bound_instance__)
+ remaining_val, call_args[arg.name] = arg.match(
+ remaining_val.strip(), evt=evt, instance=self.__bound_instance__
+ )
if arg.required and call_args[arg.name] is None:
raise ValueError("Argument required")
except ArgumentSyntaxError as e:
@@ -155,8 +192,9 @@ class CommandHandler:
@property
def __mb_usage_args__(self) -> str:
- arg_usage = " ".join(f"<{arg.label}>" if arg.required else f"[{arg.label}]"
- for arg in self.__mb_arguments__)
+ arg_usage = " ".join(
+ f"<{arg.label}>" if arg.required else f"[{arg.label}]" for arg in self.__mb_arguments__
+ )
if self.__mb_subcommands__ and self.__mb_arg_fallthrough__:
arg_usage += " " + self.__mb_usage_subcommand__
return arg_usage
@@ -172,15 +210,19 @@ class CommandHandler:
@property
def __mb_prefix__(self) -> str:
if self.__mb_parent__:
- return (f"!{self.__mb_parent__.__mb_get_name__(self.__bound_instance__)} "
- f"{self.__mb_name__}")
+ return (
+ f"!{self.__mb_parent__.__mb_get_name__(self.__bound_instance__)} "
+ f"{self.__mb_name__}"
+ )
return f"!{self.__mb_name__}"
@property
def __mb_usage_inline__(self) -> str:
if not self.__mb_arg_fallthrough__:
- return (f"* {self.__mb_name__} {self.__mb_usage_args__} - {self.__mb_help__}\n"
- f"* {self.__mb_name__} {self.__mb_usage_subcommand__}")
+ return (
+ f"* {self.__mb_name__} {self.__mb_usage_args__} - {self.__mb_help__}\n"
+ f"* {self.__mb_name__} {self.__mb_usage_subcommand__}"
+ )
return f"* {self.__mb_name__} {self.__mb_usage_args__} - {self.__mb_help__}"
@property
@@ -192,8 +234,10 @@ class CommandHandler:
if not self.__mb_arg_fallthrough__:
if not self.__mb_arguments__:
return f"**Usage:** {self.__mb_prefix__} [subcommand] [...]"
- return (f"**Usage:** {self.__mb_prefix__} {self.__mb_usage_args__}"
- f" _OR_ {self.__mb_prefix__} {self.__mb_usage_subcommand__}")
+ return (
+ f"**Usage:** {self.__mb_prefix__} {self.__mb_usage_args__}"
+ f" _OR_ {self.__mb_prefix__} {self.__mb_usage_subcommand__}"
+ )
return f"**Usage:** {self.__mb_prefix__} {self.__mb_usage_args__}"
@property
@@ -202,14 +246,25 @@ class CommandHandler:
return f"{self.__mb_usage_without_subcommands__} \n{self.__mb_subcommands_list__}"
return self.__mb_usage_without_subcommands__
- def subcommand(self, name: PrefixType = None, *, help: str = None, aliases: AliasesType = None,
- required_subcommand: bool = True, arg_fallthrough: bool = True,
- ) -> CommandHandlerDecorator:
+ def subcommand(
+ self,
+ name: PrefixType = None,
+ *,
+ help: str = None,
+ aliases: AliasesType = None,
+ required_subcommand: bool = True,
+ arg_fallthrough: bool = True,
+ ) -> CommandHandlerDecorator:
def decorator(func: Union[CommandHandler, CommandHandlerFunc]) -> CommandHandler:
if not isinstance(func, CommandHandler):
func = CommandHandler(func)
- new(name, help=help, aliases=aliases, require_subcommand=required_subcommand,
- arg_fallthrough=arg_fallthrough)(func)
+ new(
+ name,
+ help=help,
+ aliases=aliases,
+ require_subcommand=required_subcommand,
+ arg_fallthrough=arg_fallthrough,
+ )(func)
func.__mb_parent__ = self
func.__mb_event_handler__ = False
self.__mb_subcommands__.append(func)
@@ -218,10 +273,17 @@ class CommandHandler:
return decorator
-def new(name: PrefixType = None, *, help: str = None, aliases: AliasesType = None,
- event_type: EventType = EventType.ROOM_MESSAGE, msgtypes: Iterable[MessageType] = None,
- require_subcommand: bool = True, arg_fallthrough: bool = True,
- must_consume_args: bool = True) -> CommandHandlerDecorator:
+def new(
+ name: PrefixType = None,
+ *,
+ help: str = None,
+ aliases: AliasesType = None,
+ event_type: EventType = EventType.ROOM_MESSAGE,
+ msgtypes: Iterable[MessageType] = None,
+ require_subcommand: bool = True,
+ arg_fallthrough: bool = True,
+ must_consume_args: bool = True,
+) -> CommandHandlerDecorator:
def decorator(func: Union[CommandHandler, CommandHandlerFunc]) -> CommandHandler:
if not isinstance(func, CommandHandler):
func = CommandHandler(func)
@@ -242,8 +304,9 @@ def new(name: PrefixType = None, *, help: str = None, aliases: AliasesType = Non
else:
func.__mb_is_command_match__ = aliases
elif isinstance(aliases, (list, set, tuple)):
- func.__mb_is_command_match__ = lambda self, val: (val == func.__mb_get_name__(self)
- or val in aliases)
+ func.__mb_is_command_match__ = lambda self, val: (
+ val == func.__mb_get_name__(self) or val in aliases
+ )
else:
func.__mb_is_command_match__ = lambda self, val: val == func.__mb_get_name__(self)
# Decorators are executed last to first, so we reverse the argument list.
@@ -267,8 +330,9 @@ class ArgumentSyntaxError(ValueError):
class Argument(ABC):
- def __init__(self, name: str, label: str = None, *, required: bool = False,
- pass_raw: bool = False) -> None:
+ def __init__(
+ self, name: str, label: str = None, *, required: bool = False, pass_raw: bool = False
+ ) -> None:
self.name = name
self.label = label or name
self.required = required
@@ -286,8 +350,15 @@ class Argument(ABC):
class RegexArgument(Argument):
- def __init__(self, name: str, label: str = None, *, required: bool = False,
- pass_raw: bool = False, matches: str = None) -> None:
+ def __init__(
+ self,
+ name: str,
+ label: str = None,
+ *,
+ required: bool = False,
+ pass_raw: bool = False,
+ matches: str = None,
+ ) -> None:
super().__init__(name, label, required=required, pass_raw=pass_raw)
matches = f"^{matches}" if self.pass_raw else f"^{matches}$"
self.regex = re.compile(matches)
@@ -298,14 +369,23 @@ class RegexArgument(Argument):
val = re.split(r"\s", val, 1)[0]
match = self.regex.match(val)
if match:
- return (orig_val[:match.start()] + orig_val[match.end():],
- match.groups() or val[match.start():match.end()])
+ return (
+ orig_val[: match.start()] + orig_val[match.end() :],
+ match.groups() or val[match.start() : match.end()],
+ )
return orig_val, None
class CustomArgument(Argument):
- def __init__(self, name: str, label: str = None, *, required: bool = False,
- pass_raw: bool = False, matcher: Callable[[str], Any]) -> None:
+ def __init__(
+ self,
+ name: str,
+ label: str = None,
+ *,
+ required: bool = False,
+ pass_raw: bool = False,
+ matcher: Callable[[str], Any],
+ ) -> None:
super().__init__(name, label, required=required, pass_raw=pass_raw)
self.matcher = matcher
@@ -316,7 +396,7 @@ class CustomArgument(Argument):
val = re.split(r"\s", val, 1)[0]
res = self.matcher(val)
if res is not None:
- return orig_val[len(val):], res
+ return orig_val[len(val) :], res
return orig_val, None
@@ -325,12 +405,18 @@ class SimpleArgument(Argument):
if self.pass_raw:
return "", val
res = re.split(r"\s", val, 1)[0]
- return val[len(res):], res
+ return val[len(res) :], res
-def argument(name: str, label: str = None, *, required: bool = True, matches: Optional[str] = None,
- parser: Optional[Callable[[str], Any]] = None, pass_raw: bool = False
- ) -> CommandHandlerDecorator:
+def argument(
+ name: str,
+ label: str = None,
+ *,
+ required: bool = True,
+ matches: Optional[str] = None,
+ parser: Optional[Callable[[str], Any]] = None,
+ pass_raw: bool = False,
+) -> CommandHandlerDecorator:
if matches:
return RegexArgument(name, label, required=required, matches=matches, pass_raw=pass_raw)
elif parser:
@@ -339,11 +425,17 @@ def argument(name: str, label: str = None, *, required: bool = True, matches: Op
return SimpleArgument(name, label, required=required, pass_raw=pass_raw)
-def passive(regex: Union[str, Pattern], *, msgtypes: Sequence[MessageType] = (MessageType.TEXT,),
- field: Callable[[MaubotMessageEvent], str] = lambda evt: evt.content.body,
- event_type: EventType = EventType.ROOM_MESSAGE, multiple: bool = False,
- case_insensitive: bool = False, multiline: bool = False, dot_all: bool = False
- ) -> PassiveCommandHandlerDecorator:
+def passive(
+ regex: Union[str, Pattern],
+ *,
+ msgtypes: Sequence[MessageType] = (MessageType.TEXT,),
+ field: Callable[[MaubotMessageEvent], str] = lambda evt: evt.content.body,
+ event_type: EventType = EventType.ROOM_MESSAGE,
+ multiple: bool = False,
+ case_insensitive: bool = False,
+ multiline: bool = False,
+ dot_all: bool = False,
+) -> PassiveCommandHandlerDecorator:
if not isinstance(regex, Pattern):
flags = re.RegexFlag.UNICODE
if case_insensitive:
@@ -372,12 +464,14 @@ def passive(regex: Union[str, Pattern], *, msgtypes: Sequence[MessageType] = (Me
return
data = field(evt)
if multiple:
- val = [(data[match.pos:match.endpos], *match.groups())
- for match in regex.finditer(data)]
+ val = [
+ (data[match.pos : match.endpos], *match.groups())
+ for match in regex.finditer(data)
+ ]
else:
match = regex.search(data)
if match:
- val = (data[match.pos:match.endpos], *match.groups())
+ val = (data[match.pos : match.endpos], *match.groups())
else:
val = None
if val:
diff --git a/maubot/handlers/event.py b/maubot/handlers/event.py
index be02706..a9f8ac8 100644
--- a/maubot/handlers/event.py
+++ b/maubot/handlers/event.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,16 +13,17 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Callable, Union, NewType
+from __future__ import annotations
+
+from typing import Callable, NewType
-from mautrix.types import EventType
from mautrix.client import EventHandler, InternalEventType
+from mautrix.types import EventType
EventHandlerDecorator = NewType("EventHandlerDecorator", Callable[[EventHandler], EventHandler])
-def on(var: Union[EventType, InternalEventType, EventHandler]
- ) -> Union[EventHandlerDecorator, EventHandler]:
+def on(var: EventType | InternalEventType | EventHandler) -> EventHandlerDecorator | EventHandler:
def decorator(func: EventHandler) -> EventHandler:
func.__mb_event_handler__ = True
if isinstance(var, (EventType, InternalEventType)):
diff --git a/maubot/handlers/web.py b/maubot/handlers/web.py
index cf53d68..f170124 100644
--- a/maubot/handlers/web.py
+++ b/maubot/handlers/web.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,9 +13,9 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Callable, Any, Awaitable
+from typing import Any, Awaitable, Callable
-from aiohttp import web, hdrs
+from aiohttp import hdrs, web
WebHandler = Callable[[web.Request], Awaitable[web.StreamResponse]]
WebHandlerDecorator = Callable[[WebHandler], WebHandler]
diff --git a/maubot/instance.py b/maubot/instance.py
index 8d1dea2..7d7900b 100644
--- a/maubot/instance.py
+++ b/maubot/instance.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,22 +13,24 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Dict, List, Optional, Iterable, TYPE_CHECKING
-from asyncio import AbstractEventLoop
-import os.path
-import logging
-import io
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Iterable
+from asyncio import AbstractEventLoop
+import io
+import logging
+import os.path
-from ruamel.yaml.comments import CommentedMap
from ruamel.yaml import YAML
+from ruamel.yaml.comments import CommentedMap
import sqlalchemy as sql
-from mautrix.util.config import BaseProxyConfig, RecursiveDict
from mautrix.types import UserID
+from mautrix.util.config import BaseProxyConfig, RecursiveDict
-from .db import DBPlugin
-from .config import Config
from .client import Client
+from .config import Config
+from .db import DBPlugin
from .loader import PluginLoader, ZippedPluginLoader
from .plugin_base import Plugin
@@ -43,23 +45,23 @@ yaml.width = 200
class PluginInstance:
- webserver: 'MaubotServer' = None
+ webserver: MaubotServer = None
mb_config: Config = None
loop: AbstractEventLoop = None
- cache: Dict[str, 'PluginInstance'] = {}
- plugin_directories: List[str] = []
+ cache: dict[str, PluginInstance] = {}
+ plugin_directories: list[str] = []
log: logging.Logger
loader: PluginLoader
client: Client
plugin: Plugin
config: BaseProxyConfig
- base_cfg: Optional[RecursiveDict[CommentedMap]]
- base_cfg_str: Optional[str]
+ base_cfg: RecursiveDict[CommentedMap] | None
+ base_cfg_str: str | None
inst_db: sql.engine.Engine
- inst_db_tables: Dict[str, sql.Table]
- inst_webapp: Optional['PluginWebApp']
- inst_webapp_url: Optional[str]
+ inst_db_tables: dict[str, sql.Table]
+ inst_webapp: PluginWebApp | None
+ inst_webapp_url: str | None
started: bool
def __init__(self, db_instance: DBPlugin):
@@ -87,11 +89,12 @@ class PluginInstance:
"primary_user": self.primary_user,
"config": self.db_instance.config,
"base_config": self.base_cfg_str,
- "database": (self.inst_db is not None
- and self.mb_config["api_features.instance_database"]),
+ "database": (
+ self.inst_db is not None and self.mb_config["api_features.instance_database"]
+ ),
}
- def get_db_tables(self) -> Dict[str, sql.Table]:
+ def get_db_tables(self) -> dict[str, sql.Table]:
if not self.inst_db_tables:
metadata = sql.MetaData()
metadata.reflect(self.inst_db)
@@ -147,7 +150,8 @@ class PluginInstance:
self.inst_db.dispose()
ZippedPluginLoader.trash(
os.path.join(self.mb_config["plugin_directories.db"], f"{self.id}.db"),
- reason="deleted")
+ reason="deleted",
+ )
if self.inst_webapp:
self.disable_webapp()
@@ -194,13 +198,23 @@ class PluginInstance:
if self.base_cfg:
base_cfg_func = self.base_cfg.clone
else:
+
def base_cfg_func() -> None:
return None
+
self.config = config_class(self.load_config, base_cfg_func, self.save_config)
- self.plugin = cls(client=self.client.client, loop=self.loop, http=self.client.http_client,
- instance_id=self.id, log=self.log, config=self.config,
- database=self.inst_db, loader=self.loader, webapp=self.inst_webapp,
- webapp_url=self.inst_webapp_url)
+ self.plugin = cls(
+ client=self.client.client,
+ loop=self.loop,
+ http=self.client.http_client,
+ instance_id=self.id,
+ log=self.log,
+ config=self.config,
+ database=self.inst_db,
+ loader=self.loader,
+ webapp=self.inst_webapp,
+ webapp_url=self.inst_webapp_url,
+ )
try:
await self.plugin.internal_start()
except Exception:
@@ -209,8 +223,10 @@ class PluginInstance:
return
self.started = True
self.inst_db_tables = None
- self.log.info(f"Started instance of {self.loader.meta.id} v{self.loader.meta.version} "
- f"with user {self.client.id}")
+ 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:
@@ -226,8 +242,7 @@ class PluginInstance:
self.inst_db_tables = None
@classmethod
- def get(cls, instance_id: str, db_instance: Optional[DBPlugin] = None
- ) -> Optional['PluginInstance']:
+ def get(cls, instance_id: str, db_instance: DBPlugin | None = None) -> PluginInstance | None:
try:
return cls.cache[instance_id]
except KeyError:
@@ -237,7 +252,7 @@ class PluginInstance:
return PluginInstance(db_instance)
@classmethod
- def all(cls) -> Iterable['PluginInstance']:
+ def all(cls) -> Iterable[PluginInstance]:
return (cls.get(plugin.id, plugin) for plugin in DBPlugin.all())
def update_id(self, new_id: str) -> None:
@@ -317,8 +332,9 @@ class PluginInstance:
# endregion
-def init(config: Config, webserver: 'MaubotServer', loop: AbstractEventLoop
- ) -> Iterable[PluginInstance]:
+def init(
+ config: Config, webserver: MaubotServer, loop: AbstractEventLoop
+) -> Iterable[PluginInstance]:
PluginInstance.mb_config = config
PluginInstance.loop = loop
PluginInstance.webserver = webserver
diff --git a/maubot/lib/color_log.py b/maubot/lib/color_log.py
index 284cf74..104e9f7 100644
--- a/maubot/lib/color_log.py
+++ b/maubot/lib/color_log.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,8 +13,13 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from mautrix.util.logging.color import (ColorFormatter as BaseColorFormatter, PREFIX, MAU_COLOR,
- MXID_COLOR, RESET)
+from mautrix.util.logging.color import (
+ MAU_COLOR,
+ MXID_COLOR,
+ PREFIX,
+ RESET,
+ ColorFormatter as BaseColorFormatter,
+)
INST_COLOR = PREFIX + "35m" # magenta
LOADER_COLOR = PREFIX + "36m" # blue
diff --git a/maubot/lib/future_awaitable.py b/maubot/lib/future_awaitable.py
index b55dcb6..388eae9 100644
--- a/maubot/lib/future_awaitable.py
+++ b/maubot/lib/future_awaitable.py
@@ -1,4 +1,5 @@
-from typing import Callable, Awaitable, Generator, Any
+from typing import Any, Awaitable, Callable, Generator
+
class FutureAwaitable:
def __init__(self, func: Callable[[], Awaitable[None]]) -> None:
@@ -6,4 +7,3 @@ class FutureAwaitable:
def __await__(self) -> Generator[Any, None, None]:
return self._func().__await__()
-
diff --git a/maubot/lib/store_proxy.py b/maubot/lib/store_proxy.py
index 6e402aa..d8fa234 100644
--- a/maubot/lib/store_proxy.py
+++ b/maubot/lib/store_proxy.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
diff --git a/maubot/lib/zipimport.py b/maubot/lib/zipimport.py
index f9a0ca7..963f14f 100644
--- a/maubot/lib/zipimport.py
+++ b/maubot/lib/zipimport.py
@@ -18,26 +18,28 @@ used by the builtin import mechanism for sys.path items that are paths
to Zip archives.
"""
-from importlib import _bootstrap_external
from importlib import _bootstrap # for _verbose_message
-import _imp # for check_hash_based_pycs
-import _io # for open
+from importlib import _bootstrap_external
import marshal # for loads
import sys # for modules
import time # for mktime
-__all__ = ['ZipImportError', 'zipimporter']
+import _imp # for check_hash_based_pycs
+import _io # for open
+
+__all__ = ["ZipImportError", "zipimporter"]
def _unpack_uint32(data):
"""Convert 4 bytes in little-endian to an integer."""
assert len(data) == 4
- return int.from_bytes(data, 'little')
+ return int.from_bytes(data, "little")
+
def _unpack_uint16(data):
"""Convert 2 bytes in little-endian to an integer."""
assert len(data) == 2
- return int.from_bytes(data, 'little')
+ return int.from_bytes(data, "little")
path_sep = _bootstrap_external.path_sep
@@ -47,15 +49,17 @@ alt_path_sep = _bootstrap_external.path_separators[1:]
class ZipImportError(ImportError):
pass
+
# _read_directory() cache
_zip_directory_cache = {}
_module_type = type(sys)
END_CENTRAL_DIR_SIZE = 22
-STRING_END_ARCHIVE = b'PK\x05\x06'
+STRING_END_ARCHIVE = b"PK\x05\x06"
MAX_COMMENT_LEN = (1 << 16) - 1
+
class zipimporter:
"""zipimporter(archivepath) -> zipimporter object
@@ -77,9 +81,10 @@ class zipimporter:
def __init__(self, path):
if not isinstance(path, str):
import os
+
path = os.fsdecode(path)
if not path:
- raise ZipImportError('archive path is empty', path=path)
+ raise ZipImportError("archive path is empty", path=path)
if alt_path_sep:
path = path.replace(alt_path_sep, path_sep)
@@ -92,14 +97,14 @@ class zipimporter:
# Back up one path element.
dirname, basename = _bootstrap_external._path_split(path)
if dirname == path:
- raise ZipImportError('not a Zip file', path=path)
+ raise ZipImportError("not a Zip file", path=path)
path = dirname
prefix.append(basename)
else:
# it exists
if (st.st_mode & 0o170000) != 0o100000: # stat.S_ISREG
# it's a not file
- raise ZipImportError('not a Zip file', path=path)
+ raise ZipImportError("not a Zip file", path=path)
break
try:
@@ -154,11 +159,10 @@ class zipimporter:
# This is possibly a portion of a namespace
# package. Return the string representing its path,
# without a trailing separator.
- return None, [f'{self.archive}{path_sep}{modpath}']
+ return None, [f"{self.archive}{path_sep}{modpath}"]
return None, []
-
# Check whether we can satisfy the import of the module named by
# 'fullname'. Return self if we can, None if we can't.
def find_module(self, fullname, path=None):
@@ -172,7 +176,6 @@ class zipimporter:
"""
return self.find_loader(fullname, path)[0]
-
def get_code(self, fullname):
"""get_code(fullname) -> code object.
@@ -182,7 +185,6 @@ class zipimporter:
code, ispackage, modpath = _get_module_code(self, fullname)
return code
-
def get_data(self, pathname):
"""get_data(pathname) -> string with file data.
@@ -194,15 +196,14 @@ class zipimporter:
key = pathname
if pathname.startswith(self.archive + path_sep):
- key = pathname[len(self.archive + path_sep):]
+ key = pathname[len(self.archive + path_sep) :]
try:
toc_entry = self._files[key]
except KeyError:
- raise OSError(0, '', key)
+ raise OSError(0, "", key)
return _get_data(self.archive, toc_entry)
-
# Return a string matching __file__ for the named module
def get_filename(self, fullname):
"""get_filename(fullname) -> filename string.
@@ -214,7 +215,6 @@ class zipimporter:
code, ispackage, modpath = _get_module_code(self, fullname)
return modpath
-
def get_source(self, fullname):
"""get_source(fullname) -> source string.
@@ -228,9 +228,9 @@ class zipimporter:
path = _get_module_path(self, fullname)
if mi:
- fullpath = _bootstrap_external._path_join(path, '__init__.py')
+ fullpath = _bootstrap_external._path_join(path, "__init__.py")
else:
- fullpath = f'{path}.py'
+ fullpath = f"{path}.py"
try:
toc_entry = self._files[fullpath]
@@ -239,7 +239,6 @@ class zipimporter:
return None
return _get_data(self.archive, toc_entry).decode()
-
# Return a bool signifying whether the module is a package or not.
def is_package(self, fullname):
"""is_package(fullname) -> bool.
@@ -252,7 +251,6 @@ class zipimporter:
raise ZipImportError(f"can't find module {fullname!r}", name=fullname)
return mi
-
# Load and return the module named by 'fullname'.
def load_module(self, fullname):
"""load_module(fullname) -> module.
@@ -276,7 +274,7 @@ class zipimporter:
fullpath = _bootstrap_external._path_join(self.archive, path)
mod.__path__ = [fullpath]
- if not hasattr(mod, '__builtins__'):
+ if not hasattr(mod, "__builtins__"):
mod.__builtins__ = __builtins__
_bootstrap_external._fix_up_module(mod.__dict__, fullname, modpath)
exec(code, mod.__dict__)
@@ -287,11 +285,10 @@ class zipimporter:
try:
mod = sys.modules[fullname]
except KeyError:
- raise ImportError(f'Loaded module {fullname!r} not found in sys.modules')
- _bootstrap._verbose_message('import {} # loaded from Zip {}', fullname, modpath)
+ raise ImportError(f"Loaded module {fullname!r} not found in sys.modules")
+ _bootstrap._verbose_message("import {} # loaded from Zip {}", fullname, modpath)
return mod
-
def get_resource_reader(self, fullname):
"""Return the ResourceReader for a package in a zip file.
@@ -305,11 +302,11 @@ class zipimporter:
return None
if not _ZipImportResourceReader._registered:
from importlib.abc import ResourceReader
+
ResourceReader.register(_ZipImportResourceReader)
_ZipImportResourceReader._registered = True
return _ZipImportResourceReader(self, fullname)
-
def __repr__(self):
return f''
@@ -320,16 +317,17 @@ class zipimporter:
# are swapped by initzipimport() if we run in optimized mode. Also,
# '/' is replaced by path_sep there.
_zip_searchorder = (
- (path_sep + '__init__.pyc', True, True),
- (path_sep + '__init__.py', False, True),
- ('.pyc', True, False),
- ('.py', False, False),
+ (path_sep + "__init__.pyc", True, True),
+ (path_sep + "__init__.py", False, True),
+ (".pyc", True, False),
+ (".py", False, False),
)
# Given a module name, return the potential file path in the
# archive (without extension).
def _get_module_path(self, fullname):
- return self.prefix + fullname.rpartition('.')[2]
+ return self.prefix + fullname.rpartition(".")[2]
+
# Does this path represent a directory?
def _is_dir(self, path):
@@ -340,6 +338,7 @@ def _is_dir(self, path):
# If dirpath is present in self._files, we have a directory.
return dirpath in self._files
+
# Return some information about a module.
def _get_module_info(self, fullname):
path = _get_module_path(self, fullname)
@@ -374,7 +373,7 @@ def _get_module_info(self, fullname):
# data_size and file_offset are 0.
def _read_directory(archive):
try:
- fp = _io.open(archive, 'rb')
+ fp = _io.open(archive, "rb")
except OSError:
raise ZipImportError(f"can't open Zip file: {archive!r}", path=archive)
@@ -394,36 +393,33 @@ def _read_directory(archive):
fp.seek(0, 2)
file_size = fp.tell()
except OSError:
- raise ZipImportError(f"can't read Zip file: {archive!r}",
- path=archive)
- max_comment_start = max(file_size - MAX_COMMENT_LEN -
- END_CENTRAL_DIR_SIZE, 0)
+ raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
+ max_comment_start = max(file_size - MAX_COMMENT_LEN - END_CENTRAL_DIR_SIZE, 0)
try:
fp.seek(max_comment_start)
data = fp.read()
except OSError:
- raise ZipImportError(f"can't read Zip file: {archive!r}",
- path=archive)
+ raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
pos = data.rfind(STRING_END_ARCHIVE)
if pos < 0:
- raise ZipImportError(f'not a Zip file: {archive!r}',
- path=archive)
- buffer = data[pos:pos+END_CENTRAL_DIR_SIZE]
+ raise ZipImportError(f"not a Zip file: {archive!r}", path=archive)
+ buffer = data[pos : pos + END_CENTRAL_DIR_SIZE]
if len(buffer) != END_CENTRAL_DIR_SIZE:
- raise ZipImportError(f"corrupt Zip file: {archive!r}",
- path=archive)
+ raise ZipImportError(f"corrupt Zip file: {archive!r}", path=archive)
header_position = file_size - len(data) + pos
header_size = _unpack_uint32(buffer[12:16])
header_offset = _unpack_uint32(buffer[16:20])
if header_position < header_size:
- raise ZipImportError(f'bad central directory size: {archive!r}', path=archive)
+ raise ZipImportError(f"bad central directory size: {archive!r}", path=archive)
if header_position < header_offset:
- raise ZipImportError(f'bad central directory offset: {archive!r}', path=archive)
+ raise ZipImportError(f"bad central directory offset: {archive!r}", path=archive)
header_position -= header_size
arc_offset = header_position - header_offset
if arc_offset < 0:
- raise ZipImportError(f'bad central directory size or offset: {archive!r}', path=archive)
+ raise ZipImportError(
+ f"bad central directory size or offset: {archive!r}", path=archive
+ )
files = {}
# Start of Central Directory
@@ -435,12 +431,12 @@ def _read_directory(archive):
while True:
buffer = fp.read(46)
if len(buffer) < 4:
- raise EOFError('EOF read where not expected')
+ raise EOFError("EOF read where not expected")
# Start of file header
- if buffer[:4] != b'PK\x01\x02':
- break # Bad: Central Dir File Header
+ if buffer[:4] != b"PK\x01\x02":
+ break # Bad: Central Dir File Header
if len(buffer) != 46:
- raise EOFError('EOF read where not expected')
+ raise EOFError("EOF read where not expected")
flags = _unpack_uint16(buffer[8:10])
compress = _unpack_uint16(buffer[10:12])
time = _unpack_uint16(buffer[12:14])
@@ -454,7 +450,7 @@ def _read_directory(archive):
file_offset = _unpack_uint32(buffer[42:46])
header_size = name_size + extra_size + comment_size
if file_offset > header_offset:
- raise ZipImportError(f'bad local header offset: {archive!r}', path=archive)
+ raise ZipImportError(f"bad local header offset: {archive!r}", path=archive)
file_offset += arc_offset
try:
@@ -478,18 +474,19 @@ def _read_directory(archive):
else:
# Historical ZIP filename encoding
try:
- name = name.decode('ascii')
+ name = name.decode("ascii")
except UnicodeDecodeError:
- name = name.decode('latin1').translate(cp437_table)
+ name = name.decode("latin1").translate(cp437_table)
- name = name.replace('/', path_sep)
+ name = name.replace("/", path_sep)
path = _bootstrap_external._path_join(archive, name)
t = (path, compress, data_size, file_size, file_offset, time, date, crc)
files[name] = t
count += 1
- _bootstrap._verbose_message('zipimport: found {} names in {!r}', count, archive)
+ _bootstrap._verbose_message("zipimport: found {} names in {!r}", count, archive)
return files
+
# During bootstrap, we may need to load the encodings
# package from a ZIP file. But the cp437 encoding is implemented
# in Python in the encodings package.
@@ -498,31 +495,31 @@ def _read_directory(archive):
# the cp437 encoding.
cp437_table = (
# ASCII part, 8 rows x 16 chars
- '\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f'
- '\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f'
- ' !"#$%&\'()*+,-./'
- '0123456789:;<=>?'
- '@ABCDEFGHIJKLMNO'
- 'PQRSTUVWXYZ[\\]^_'
- '`abcdefghijklmno'
- 'pqrstuvwxyz{|}~\x7f'
+ "\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f"
+ "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f"
+ " !\"#$%&'()*+,-./"
+ "0123456789:;<=>?"
+ "@ABCDEFGHIJKLMNO"
+ "PQRSTUVWXYZ[\\]^_"
+ "`abcdefghijklmno"
+ "pqrstuvwxyz{|}~\x7f"
# non-ASCII part, 16 rows x 8 chars
- '\xc7\xfc\xe9\xe2\xe4\xe0\xe5\xe7'
- '\xea\xeb\xe8\xef\xee\xec\xc4\xc5'
- '\xc9\xe6\xc6\xf4\xf6\xf2\xfb\xf9'
- '\xff\xd6\xdc\xa2\xa3\xa5\u20a7\u0192'
- '\xe1\xed\xf3\xfa\xf1\xd1\xaa\xba'
- '\xbf\u2310\xac\xbd\xbc\xa1\xab\xbb'
- '\u2591\u2592\u2593\u2502\u2524\u2561\u2562\u2556'
- '\u2555\u2563\u2551\u2557\u255d\u255c\u255b\u2510'
- '\u2514\u2534\u252c\u251c\u2500\u253c\u255e\u255f'
- '\u255a\u2554\u2569\u2566\u2560\u2550\u256c\u2567'
- '\u2568\u2564\u2565\u2559\u2558\u2552\u2553\u256b'
- '\u256a\u2518\u250c\u2588\u2584\u258c\u2590\u2580'
- '\u03b1\xdf\u0393\u03c0\u03a3\u03c3\xb5\u03c4'
- '\u03a6\u0398\u03a9\u03b4\u221e\u03c6\u03b5\u2229'
- '\u2261\xb1\u2265\u2264\u2320\u2321\xf7\u2248'
- '\xb0\u2219\xb7\u221a\u207f\xb2\u25a0\xa0'
+ "\xc7\xfc\xe9\xe2\xe4\xe0\xe5\xe7"
+ "\xea\xeb\xe8\xef\xee\xec\xc4\xc5"
+ "\xc9\xe6\xc6\xf4\xf6\xf2\xfb\xf9"
+ "\xff\xd6\xdc\xa2\xa3\xa5\u20a7\u0192"
+ "\xe1\xed\xf3\xfa\xf1\xd1\xaa\xba"
+ "\xbf\u2310\xac\xbd\xbc\xa1\xab\xbb"
+ "\u2591\u2592\u2593\u2502\u2524\u2561\u2562\u2556"
+ "\u2555\u2563\u2551\u2557\u255d\u255c\u255b\u2510"
+ "\u2514\u2534\u252c\u251c\u2500\u253c\u255e\u255f"
+ "\u255a\u2554\u2569\u2566\u2560\u2550\u256c\u2567"
+ "\u2568\u2564\u2565\u2559\u2558\u2552\u2553\u256b"
+ "\u256a\u2518\u250c\u2588\u2584\u258c\u2590\u2580"
+ "\u03b1\xdf\u0393\u03c0\u03a3\u03c3\xb5\u03c4"
+ "\u03a6\u0398\u03a9\u03b4\u221e\u03c6\u03b5\u2229"
+ "\u2261\xb1\u2265\u2264\u2320\u2321\xf7\u2248"
+ "\xb0\u2219\xb7\u221a\u207f\xb2\u25a0\xa0"
)
_importing_zlib = False
@@ -535,28 +532,29 @@ def _get_decompress_func():
if _importing_zlib:
# Someone has a zlib.py[co] in their Zip file
# let's avoid a stack overflow.
- _bootstrap._verbose_message('zipimport: zlib UNAVAILABLE')
+ _bootstrap._verbose_message("zipimport: zlib UNAVAILABLE")
raise ZipImportError("can't decompress data; zlib not available")
_importing_zlib = True
try:
from zlib import decompress
except Exception:
- _bootstrap._verbose_message('zipimport: zlib UNAVAILABLE')
+ _bootstrap._verbose_message("zipimport: zlib UNAVAILABLE")
raise ZipImportError("can't decompress data; zlib not available")
finally:
_importing_zlib = False
- _bootstrap._verbose_message('zipimport: zlib available')
+ _bootstrap._verbose_message("zipimport: zlib available")
return decompress
+
# Given a path to a Zip file and a toc_entry, return the (uncompressed) data.
def _get_data(archive, toc_entry):
datapath, compress, data_size, file_size, file_offset, time, date, crc = toc_entry
if data_size < 0:
- raise ZipImportError('negative data size')
+ raise ZipImportError("negative data size")
- with _io.open(archive, 'rb') as fp:
+ with _io.open(archive, "rb") as fp:
# Check to make sure the local file header is correct
try:
fp.seek(file_offset)
@@ -564,11 +562,11 @@ def _get_data(archive, toc_entry):
raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
buffer = fp.read(30)
if len(buffer) != 30:
- raise EOFError('EOF read where not expected')
+ raise EOFError("EOF read where not expected")
- if buffer[:4] != b'PK\x03\x04':
+ if buffer[:4] != b"PK\x03\x04":
# Bad: Local File Header
- raise ZipImportError(f'bad local file header: {archive!r}', path=archive)
+ raise ZipImportError(f"bad local file header: {archive!r}", path=archive)
name_size = _unpack_uint16(buffer[26:28])
extra_size = _unpack_uint16(buffer[28:30])
@@ -601,16 +599,17 @@ def _eq_mtime(t1, t2):
# dostime only stores even seconds, so be lenient
return abs(t1 - t2) <= 1
+
# Given the contents of a .py[co] file, unmarshal the data
# and return the code object. Return None if it the magic word doesn't
# match (we do this instead of raising an exception as we fall back
# to .py if available and we don't want to mask other errors).
def _unmarshal_code(pathname, data, mtime):
if len(data) < 16:
- raise ZipImportError('bad pyc data')
+ raise ZipImportError("bad pyc data")
if data[:4] != _bootstrap_external.MAGIC_NUMBER:
- _bootstrap._verbose_message('{!r} has bad magic', pathname)
+ _bootstrap._verbose_message("{!r} has bad magic", pathname)
return None # signal caller to try alternative
flags = _unpack_uint32(data[4:8])
@@ -619,47 +618,57 @@ def _unmarshal_code(pathname, data, mtime):
# pycs. We could validate hash-based pycs against the source, but it
# seems likely that most people putting hash-based pycs in a zipfile
# will use unchecked ones.
- if (_imp.check_hash_based_pycs != 'never' and
- (flags != 0x1 or _imp.check_hash_based_pycs == 'always')):
+ if _imp.check_hash_based_pycs != "never" and (
+ flags != 0x1 or _imp.check_hash_based_pycs == "always"
+ ):
return None
elif mtime != 0 and not _eq_mtime(_unpack_uint32(data[8:12]), mtime):
- _bootstrap._verbose_message('{!r} has bad mtime', pathname)
+ _bootstrap._verbose_message("{!r} has bad mtime", pathname)
return None # signal caller to try alternative
# XXX the pyc's size field is ignored; timestamp collisions are probably
# unimportant with zip files.
code = marshal.loads(data[16:])
if not isinstance(code, _code_type):
- raise TypeError(f'compiled module {pathname!r} is not a code object')
+ raise TypeError(f"compiled module {pathname!r} is not a code object")
return code
+
_code_type = type(_unmarshal_code.__code__)
# Replace any occurrences of '\r\n?' in the input string with '\n'.
# This converts DOS and Mac line endings to Unix line endings.
def _normalize_line_endings(source):
- source = source.replace(b'\r\n', b'\n')
- source = source.replace(b'\r', b'\n')
+ source = source.replace(b"\r\n", b"\n")
+ source = source.replace(b"\r", b"\n")
return source
+
# Given a string buffer containing Python source code, compile it
# and return a code object.
def _compile_source(pathname, source):
source = _normalize_line_endings(source)
- return compile(source, pathname, 'exec', dont_inherit=True)
+ return compile(source, pathname, "exec", dont_inherit=True)
+
# Convert the date/time values found in the Zip archive to a value
# that's compatible with the time stamp stored in .pyc files.
def _parse_dostime(d, t):
- return time.mktime((
- (d >> 9) + 1980, # bits 9..15: year
- (d >> 5) & 0xF, # bits 5..8: month
- d & 0x1F, # bits 0..4: day
- t >> 11, # bits 11..15: hours
- (t >> 5) & 0x3F, # bits 8..10: minutes
- (t & 0x1F) * 2, # bits 0..7: seconds / 2
- -1, -1, -1))
+ return time.mktime(
+ (
+ (d >> 9) + 1980, # bits 9..15: year
+ (d >> 5) & 0xF, # bits 5..8: month
+ d & 0x1F, # bits 0..4: day
+ t >> 11, # bits 11..15: hours
+ (t >> 5) & 0x3F, # bits 8..10: minutes
+ (t & 0x1F) * 2, # bits 0..7: seconds / 2
+ -1,
+ -1,
+ -1,
+ )
+ )
+
# Given a path to a .pyc file in the archive, return the
# modification time of the matching .py file, or 0 if no source
@@ -667,7 +676,7 @@ def _parse_dostime(d, t):
def _get_mtime_of_source(self, path):
try:
# strip 'c' or 'o' from *.py[co]
- assert path[-1:] in ('c', 'o')
+ assert path[-1:] in ("c", "o")
path = path[:-1]
toc_entry = self._files[path]
# fetch the time stamp of the .py file for comparison
@@ -678,13 +687,14 @@ def _get_mtime_of_source(self, path):
except (KeyError, IndexError, TypeError):
return 0
+
# Get the code object associated with the module specified by
# 'fullname'.
def _get_module_code(self, fullname):
path = _get_module_path(self, fullname)
for suffix, isbytecode, ispackage in _zip_searchorder:
fullpath = path + suffix
- _bootstrap._verbose_message('trying {}{}{}', self.archive, path_sep, fullpath, verbosity=2)
+ _bootstrap._verbose_message("trying {}{}{}", self.archive, path_sep, fullpath, verbosity=2)
try:
toc_entry = self._files[fullpath]
except KeyError:
@@ -713,6 +723,7 @@ class _ZipImportResourceReader:
This class is allowed to reference all the innards and private parts of
the zipimporter.
"""
+
_registered = False
def __init__(self, zipimporter, fullname):
@@ -720,9 +731,10 @@ class _ZipImportResourceReader:
self.fullname = fullname
def open_resource(self, resource):
- fullname_as_path = self.fullname.replace('.', '/')
- path = f'{fullname_as_path}/{resource}'
+ fullname_as_path = self.fullname.replace(".", "/")
+ path = f"{fullname_as_path}/{resource}"
from io import BytesIO
+
try:
return BytesIO(self.zipimporter.get_data(path))
except OSError:
@@ -737,8 +749,8 @@ class _ZipImportResourceReader:
def is_resource(self, name):
# Maybe we could do better, but if we can get the data, it's a
# resource. Otherwise it isn't.
- fullname_as_path = self.fullname.replace('.', '/')
- path = f'{fullname_as_path}/{name}'
+ fullname_as_path = self.fullname.replace(".", "/")
+ path = f"{fullname_as_path}/{name}"
try:
self.zipimporter.get_data(path)
except OSError:
@@ -754,11 +766,12 @@ class _ZipImportResourceReader:
# top of the archive, and then we iterate through _files looking for
# names inside that "directory".
from pathlib import Path
+
fullname_path = Path(self.zipimporter.get_filename(self.fullname))
relative_path = fullname_path.relative_to(self.zipimporter.archive)
# Don't forget that fullname names a package, so its path will include
# __init__.py, which we want to ignore.
- assert relative_path.name == '__init__.py'
+ assert relative_path.name == "__init__.py"
package_path = relative_path.parent
subdirs_seen = set()
for filename in self.zipimporter._files:
diff --git a/maubot/loader/__init__.py b/maubot/loader/__init__.py
index b783152..c4291ed 100644
--- a/maubot/loader/__init__.py
+++ b/maubot/loader/__init__.py
@@ -1,2 +1,2 @@
-from .abc import BasePluginLoader, PluginLoader, PluginClass, IDConflictError, PluginMeta
-from .zip import ZippedPluginLoader, MaubotZipImportError
+from .abc import BasePluginLoader, IDConflictError, PluginClass, PluginLoader, PluginMeta
+from .zip import MaubotZipImportError, ZippedPluginLoader
diff --git a/maubot/loader/abc.py b/maubot/loader/abc.py
index 81c99ce..f99358c 100644
--- a/maubot/loader/abc.py
+++ b/maubot/loader/abc.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2021 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,14 +13,14 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import TypeVar, Type, Dict, Set, List, TYPE_CHECKING
+from typing import TYPE_CHECKING, Dict, List, Set, Type, TypeVar
from abc import ABC, abstractmethod
import asyncio
from attr import dataclass
-from packaging.version import Version, InvalidVersion
+from packaging.version import InvalidVersion, Version
-from mautrix.types import SerializableAttrs, SerializerError, serializer, deserializer
+from mautrix.types import SerializableAttrs, SerializerError, deserializer, serializer
from ..__meta__ import __version__
from ..plugin_base import Plugin
@@ -89,16 +89,16 @@ class BasePluginLoader(ABC):
class PluginLoader(BasePluginLoader, ABC):
- id_cache: Dict[str, 'PluginLoader'] = {}
+ id_cache: Dict[str, "PluginLoader"] = {}
meta: PluginMeta
- references: Set['PluginInstance']
+ references: Set["PluginInstance"]
def __init__(self):
self.references = set()
@classmethod
- def find(cls, plugin_id: str) -> 'PluginLoader':
+ def find(cls, plugin_id: str) -> "PluginLoader":
return cls.id_cache[plugin_id]
def to_dict(self) -> dict:
@@ -109,12 +109,14 @@ class PluginLoader(BasePluginLoader, ABC):
}
async def stop_instances(self) -> None:
- await asyncio.gather(*[instance.stop() for instance
- in self.references if instance.started])
+ 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])
+ await asyncio.gather(
+ *[instance.start() for instance in self.references if instance.enabled]
+ )
@abstractmethod
async def load(self) -> Type[PluginClass]:
diff --git a/maubot/loader/zip.py b/maubot/loader/zip.py
index 6d8a8ce..62db112 100644
--- a/maubot/loader/zip.py
+++ b/maubot/loader/zip.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2021 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,22 +13,23 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Dict, List, Type, Tuple, Optional
-from zipfile import ZipFile, BadZipFile
-from time import time
-import logging
-import sys
-import os
+from __future__ import annotations
+
+from time import time
+from zipfile import BadZipFile, ZipFile
+import logging
+import os
+import sys
-from ruamel.yaml import YAML, YAMLError
from packaging.version import Version
+from ruamel.yaml import YAML, YAMLError
from mautrix.types import SerializerError
-from ..lib.zipimport import zipimporter, ZipImportError
-from ..plugin_base import Plugin
from ..config import Config
-from .abc import PluginLoader, PluginClass, PluginMeta, IDConflictError
+from ..lib.zipimport import ZipImportError, zipimporter
+from ..plugin_base import Plugin
+from .abc import IDConflictError, PluginClass, PluginLoader, PluginMeta
yaml = YAML()
@@ -50,23 +51,25 @@ class MaubotZipLoadError(MaubotZipImportError):
class ZippedPluginLoader(PluginLoader):
- path_cache: Dict[str, 'ZippedPluginLoader'] = {}
+ path_cache: dict[str, ZippedPluginLoader] = {}
log: logging.Logger = logging.getLogger("maubot.loader.zip")
trash_path: str = "delete"
- directories: List[str] = []
+ directories: list[str] = []
- path: str
- meta: PluginMeta
- main_class: str
- main_module: str
- _loaded: Type[PluginClass]
- _importer: zipimporter
- _file: ZipFile
+ path: str | None
+ meta: PluginMeta | None
+ main_class: str | None
+ main_module: str | None
+ _loaded: type[PluginClass] | None
+ _importer: zipimporter | None
+ _file: ZipFile | None
def __init__(self, path: str) -> None:
super().__init__()
self.path = path
self.meta = None
+ self.main_class = None
+ self.main_module = None
self._loaded = None
self._importer = None
self._file = None
@@ -75,7 +78,8 @@ class ZippedPluginLoader(PluginLoader):
try:
existing = self.id_cache[self.meta.id]
raise IDConflictError(
- f"Plugin with id {self.meta.id} already loaded from {existing.source}")
+ f"Plugin with id {self.meta.id} already loaded from {existing.source}"
+ )
except KeyError:
pass
self.path_cache[self.path] = self
@@ -83,13 +87,10 @@ class ZippedPluginLoader(PluginLoader):
self.log.debug(f"Preloaded plugin {self.meta.id} from {self.path}")
def to_dict(self) -> dict:
- return {
- **super().to_dict(),
- "path": self.path
- }
+ return {**super().to_dict(), "path": self.path}
@classmethod
- def get(cls, path: str) -> 'ZippedPluginLoader':
+ def get(cls, path: str) -> ZippedPluginLoader:
path = os.path.abspath(path)
try:
return cls.path_cache[path]
@@ -101,10 +102,12 @@ class ZippedPluginLoader(PluginLoader):
return self.path
def __repr__(self) -> str:
- return ("")
+ return (
+ ""
+ )
def sync_read_file(self, path: str) -> bytes:
return self._file.read(path)
@@ -112,16 +115,19 @@ class ZippedPluginLoader(PluginLoader):
async def read_file(self, path: str) -> bytes:
return self.sync_read_file(path)
- def sync_list_files(self, directory: str) -> List[str]:
+ def sync_list_files(self, directory: str) -> list[str]:
directory = directory.rstrip("/")
- return [file.filename for file in self._file.filelist
- if os.path.dirname(file.filename) == directory]
+ return [
+ file.filename
+ for file in self._file.filelist
+ if os.path.dirname(file.filename) == directory
+ ]
- async def list_files(self, directory: str) -> List[str]:
+ async def list_files(self, directory: str) -> list[str]:
return self.sync_list_files(directory)
@staticmethod
- def _read_meta(source) -> Tuple[ZipFile, PluginMeta]:
+ def _read_meta(source) -> tuple[ZipFile, PluginMeta]:
try:
file = ZipFile(source)
data = file.read("maubot.yaml")
@@ -142,7 +148,7 @@ class ZippedPluginLoader(PluginLoader):
return file, meta
@classmethod
- def verify_meta(cls, source) -> Tuple[str, Version]:
+ def verify_meta(cls, source) -> tuple[str, Version]:
_, meta = cls._read_meta(source)
return meta.id, meta.version
@@ -173,24 +179,24 @@ class ZippedPluginLoader(PluginLoader):
code = importer.get_code(self.main_module.replace(".", "/"))
if self.main_class not in code.co_names:
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:
- raise MaubotZipPreLoadError(
- f"Main module {self.main_module} not found in file") from e
+ raise MaubotZipPreLoadError(f"Main module {self.main_module} not found in file") from e
for module in self.meta.modules:
try:
importer.find_module(module)
except ZipImportError as e:
raise MaubotZipPreLoadError(f"Module {module} not found in file") from e
- async 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]:
+ def _load(self, reset_cache: bool = False) -> type[PluginClass]:
if self._loaded is not None and not reset_cache:
return self._loaded
self._load_meta()
@@ -219,7 +225,7 @@ class ZippedPluginLoader(PluginLoader):
self.log.debug(f"Loaded and imported plugin {self.meta.id} from {self.path}")
return plugin
- async def reload(self, new_path: Optional[str] = None) -> Type[PluginClass]:
+ async def reload(self, new_path: str | None = None) -> type[PluginClass]:
await self.unload()
if new_path is not None:
self.path = new_path
@@ -251,7 +257,7 @@ class ZippedPluginLoader(PluginLoader):
self.path = None
@classmethod
- def trash(cls, file_path: str, new_name: Optional[str] = None, reason: str = "error") -> None:
+ def trash(cls, file_path: str, new_name: str | None = None, reason: str = "error") -> None:
if cls.trash_path == "delete":
os.remove(file_path)
else:
diff --git a/maubot/management/api/__init__.py b/maubot/management/api/__init__.py
index 5326039..1c4d7d3 100644
--- a/maubot/management/api/__init__.py
+++ b/maubot/management/api/__init__.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,13 +13,14 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from aiohttp import web
from asyncio import AbstractEventLoop
import importlib
+from aiohttp import web
+
from ...config import Config
-from .base import routes, get_config, set_config, set_loop
from .auth import check_token
+from .base import get_config, routes, set_config, set_loop
from .middleware import auth, error
@@ -30,9 +31,11 @@ def features(request: web.Request) -> web.Response:
if err is None:
return web.json_response(data)
else:
- return web.json_response({
- "login": data["login"],
- })
+ return web.json_response(
+ {
+ "login": data["login"],
+ }
+ )
def init(cfg: Config, loop: AbstractEventLoop) -> web.Application:
diff --git a/maubot/management/api/auth.py b/maubot/management/api/auth.py
index 4675301..76ddcf3 100644
--- a/maubot/management/api/auth.py
+++ b/maubot/management/api/auth.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,7 +13,8 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Optional
+from __future__ import annotations
+
from time import time
from aiohttp import web
@@ -21,7 +22,7 @@ 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 .base import get_config, routes
from .responses import resp
@@ -33,10 +34,13 @@ def is_valid_token(token: str) -> bool:
def create_token(user: UserID) -> str:
- return sign_token(get_config()["server.unshared_secret"], {
- "user_id": user,
- "created_at": int(time()),
- })
+ return sign_token(
+ get_config()["server.unshared_secret"],
+ {
+ "user_id": user,
+ "created_at": int(time()),
+ },
+ )
def get_token(request: web.Request) -> str:
@@ -44,11 +48,11 @@ def get_token(request: web.Request) -> str:
if not token or not token.startswith("Bearer "):
token = request.query.get("access_token", None)
else:
- token = token[len("Bearer "):]
+ token = token[len("Bearer ") :]
return token
-def check_token(request: web.Request) -> Optional[web.Response]:
+def check_token(request: web.Request) -> web.Response | None:
token = get_token(request)
if not token:
return resp.no_token
diff --git a/maubot/management/api/base.py b/maubot/management/api/base.py
index b6a5dea..73b2508 100644
--- a/maubot/management/api/base.py
+++ b/maubot/management/api/base.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,15 +13,18 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from aiohttp import web
+from __future__ import annotations
+
import asyncio
+from aiohttp import web
+
from ...__meta__ import __version__
from ...config import Config
routes: web.RouteTableDef = web.RouteTableDef()
-_config: Config = None
-_loop: asyncio.AbstractEventLoop = None
+_config: Config | None = None
+_loop: asyncio.AbstractEventLoop | None = None
def set_config(config: Config) -> None:
@@ -44,6 +47,4 @@ def get_loop() -> asyncio.AbstractEventLoop:
@routes.get("/version")
async def version(_: web.Request) -> web.Response:
- return web.json_response({
- "version": __version__
- })
+ return web.json_response({"version": __version__})
diff --git a/maubot/management/api/client.py b/maubot/management/api/client.py
index c74f9e9..0b3a239 100644
--- a/maubot/management/api/client.py
+++ b/maubot/management/api/client.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2021 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,17 +13,18 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Optional
+from __future__ import annotations
+
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 mautrix.errors import MatrixConnectionError, MatrixInvalidToken, MatrixRequestError
+from mautrix.types import FilterID, SyncToken, UserID
-from ...db import DBClient
from ...client import Client
+from ...db import DBClient
from .base import routes
from .responses import resp
@@ -42,12 +43,17 @@ async def get_client(request: web.Request) -> web.Response:
return resp.found(client.to_dict())
-async def _create_client(user_id: Optional[UserID], data: dict) -> web.Response:
+async def _create_client(user_id: UserID | None, data: dict) -> web.Response:
homeserver = data.get("homeserver", None)
access_token = data.get("access_token", None)
device_id = data.get("device_id", None)
- new_client = MatrixClient(mxid="@not:a.mxid", base_url=homeserver, token=access_token,
- loop=Client.loop, client_session=Client.http_client)
+ new_client = MatrixClient(
+ mxid="@not:a.mxid",
+ base_url=homeserver,
+ token=access_token,
+ loop=Client.loop,
+ client_session=Client.http_client,
+ )
try:
whoami = await new_client.whoami()
except MatrixInvalidToken:
@@ -64,13 +70,20 @@ async def _create_client(user_id: Optional[UserID], data: dict) -> web.Response:
return resp.mxid_mismatch(whoami.user_id)
elif whoami.device_id and device_id and whoami.device_id != device_id:
return resp.device_id_mismatch(whoami.device_id)
- db_instance = DBClient(id=whoami.user_id, 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), online=data.get("online", True),
- displayname=data.get("displayname", "disable"),
- avatar_url=data.get("avatar_url", "disable"),
- device_id=device_id)
+ db_instance = DBClient(
+ id=whoami.user_id,
+ 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),
+ online=data.get("online", True),
+ displayname=data.get("displayname", "disable"),
+ avatar_url=data.get("avatar_url", "disable"),
+ device_id=device_id,
+ )
client = Client(db_instance)
client.db_instance.insert()
await client.start()
@@ -79,9 +92,11 @@ async def _create_client(user_id: Optional[UserID], data: dict) -> web.Response:
async def _update_client(client: Client, data: dict, is_login: bool = False) -> web.Response:
try:
- await client.update_access_details(data.get("access_token", None),
- data.get("homeserver", None),
- data.get("device_id", None))
+ await client.update_access_details(
+ data.get("access_token", None),
+ data.get("homeserver", None),
+ data.get("device_id", None),
+ )
except MatrixInvalidToken:
return resp.bad_client_access_token
except MatrixRequestError:
@@ -91,9 +106,9 @@ async def _update_client(client: Client, data: dict, is_login: bool = False) ->
except ValueError as e:
str_err = str(e)
if str_err.startswith("MXID mismatch"):
- return resp.mxid_mismatch(str(e)[len("MXID mismatch: "):])
+ return resp.mxid_mismatch(str(e)[len("MXID mismatch: ") :])
elif str_err.startswith("Device ID mismatch"):
- return resp.device_id_mismatch(str(e)[len("Device ID mismatch: "):])
+ return resp.device_id_mismatch(str(e)[len("Device ID mismatch: ") :])
with client.db_instance.edit_mode():
await client.update_avatar_url(data.get("avatar_url", None))
await client.update_displayname(data.get("displayname", None))
@@ -105,8 +120,9 @@ async def _update_client(client: Client, data: dict, is_login: bool = False) ->
return resp.updated(client.to_dict(), is_login=is_login)
-async def _create_or_update_client(user_id: UserID, data: dict, is_login: bool = False
- ) -> web.Response:
+async def _create_or_update_client(
+ user_id: UserID, data: dict, is_login: bool = False
+) -> web.Response:
client = Client.get(user_id, None)
if not client:
return await _create_client(user_id, data)
diff --git a/maubot/management/api/client_auth.py b/maubot/management/api/client_auth.py
index 5957f43..754c0d7 100644
--- a/maubot/management/api/client_auth.py
+++ b/maubot/management/api/client_auth.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2021 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,26 +13,26 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Dict, Tuple, NamedTuple, Optional
-from json import JSONDecodeError
+from typing import Dict, NamedTuple, Optional, Tuple
from http import HTTPStatus
-import hashlib
+from json import JSONDecodeError
import asyncio
+import hashlib
+import hmac
import random
import string
-import hmac
from aiohttp import web
from yarl import URL
-from mautrix.api import SynapseAdminPath, Method, Path
-from mautrix.errors import MatrixRequestError
+from mautrix.api import Method, Path, SynapseAdminPath
from mautrix.client import ClientAPI
-from mautrix.types import LoginType, LoginResponse
+from mautrix.errors import MatrixRequestError
+from mautrix.types import LoginResponse, LoginType
-from .base import routes, get_config, get_loop
+from .base import get_config, get_loop, routes
+from .client import _create_client, _create_or_update_client
from .responses import resp
-from .client import _create_or_update_client, _create_client
def known_homeservers() -> Dict[str, Dict[str, str]]:
@@ -59,8 +59,9 @@ class AuthRequestInfo(NamedTuple):
truthy_strings = ("1", "true", "yes")
-async def read_client_auth_request(request: web.Request) -> Tuple[Optional[AuthRequestInfo],
- Optional[web.Response]]:
+async def read_client_auth_request(
+ request: web.Request,
+) -> Tuple[Optional[AuthRequestInfo], Optional[web.Response]]:
server_name = request.match_info.get("server", None)
server = known_homeservers().get(server_name, None)
if not server:
@@ -81,21 +82,30 @@ async def read_client_auth_request(request: web.Request) -> Tuple[Optional[AuthR
base_url = server["url"]
except KeyError:
return None, resp.invalid_server
- return AuthRequestInfo(
- server_name=server_name,
- client=ClientAPI(base_url=base_url, loop=get_loop()),
- secret=server.get("secret"),
- username=username,
- password=password,
- user_type=body.get("user_type", "bot"),
- device_name=body.get("device_name", "Maubot"),
- update_client=request.query.get("update_client", "").lower() in truthy_strings,
- sso=sso,
- ), None
+ return (
+ AuthRequestInfo(
+ server_name=server_name,
+ client=ClientAPI(base_url=base_url, loop=get_loop()),
+ secret=server.get("secret"),
+ username=username,
+ password=password,
+ user_type=body.get("user_type", "bot"),
+ device_name=body.get("device_name", "Maubot"),
+ update_client=request.query.get("update_client", "").lower() in truthy_strings,
+ sso=sso,
+ ),
+ None,
+ )
-def generate_mac(secret: str, nonce: str, username: str, password: str, admin: bool = False,
- user_type: str = None) -> str:
+def generate_mac(
+ secret: str,
+ nonce: str,
+ username: str,
+ password: str,
+ admin: bool = False,
+ user_type: str = None,
+) -> str:
mac = hmac.new(key=secret.encode("utf-8"), digestmod=hashlib.sha1)
mac.update(nonce.encode("utf-8"))
mac.update(b"\x00")
@@ -132,18 +142,24 @@ async def register(request: web.Request) -> web.Response:
try:
raw_res = await req.client.api.request(Method.POST, path, content=content)
except MatrixRequestError as e:
- return web.json_response({
- "errcode": e.errcode,
- "error": e.message,
- "http_status": e.http_status,
- }, status=HTTPStatus.INTERNAL_SERVER_ERROR)
+ return web.json_response(
+ {
+ "errcode": e.errcode,
+ "error": e.message,
+ "http_status": e.http_status,
+ },
+ status=HTTPStatus.INTERNAL_SERVER_ERROR,
+ )
login_res = LoginResponse.deserialize(raw_res)
if req.update_client:
- return await _create_client(login_res.user_id, {
- "homeserver": str(req.client.api.base_url),
- "access_token": login_res.access_token,
- "device_id": login_res.device_id,
- })
+ return await _create_client(
+ login_res.user_id,
+ {
+ "homeserver": str(req.client.api.base_url),
+ "access_token": login_res.access_token,
+ "device_id": login_res.device_id,
+ },
+ )
return web.json_response(login_res.serialize())
@@ -162,13 +178,17 @@ async def _do_sso(req: AuthRequestInfo) -> web.Response:
flows = await req.client.get_login_flows()
if not flows.supports_type(LoginType.SSO):
return resp.sso_not_supported
- waiter_id = ''.join(random.choices(string.ascii_lowercase + string.digits, k=16))
+ waiter_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=16))
cfg = get_config()
- public_url = (URL(cfg["server.public_url"]) / cfg["server.base_path"].lstrip("/")
- / "client/auth_external_sso/complete" / waiter_id)
- sso_url = (req.client.api.base_url
- .with_path(str(Path.login.sso.redirect))
- .with_query({"redirectUrl": str(public_url)}))
+ public_url = (
+ URL(cfg["server.public_url"])
+ / cfg["server.base_path"].lstrip("/")
+ / "client/auth_external_sso/complete"
+ / waiter_id
+ )
+ sso_url = req.client.api.base_url.with_path(str(Path.login.sso.redirect)).with_query(
+ {"redirectUrl": str(public_url)}
+ )
sso_waiters[waiter_id] = req, get_loop().create_future()
return web.json_response({"sso_url": str(sso_url), "id": waiter_id})
@@ -178,25 +198,40 @@ async def _do_login(req: AuthRequestInfo, login_token: Optional[str] = None) ->
device_id = f"maubot_{device_id}"
try:
if req.sso:
- res = await req.client.login(token=login_token, login_type=LoginType.TOKEN,
- device_id=device_id, store_access_token=False,
- initial_device_display_name=req.device_name)
+ res = await req.client.login(
+ token=login_token,
+ login_type=LoginType.TOKEN,
+ device_id=device_id,
+ store_access_token=False,
+ initial_device_display_name=req.device_name,
+ )
else:
- res = await req.client.login(identifier=req.username, login_type=LoginType.PASSWORD,
- password=req.password, device_id=device_id,
- initial_device_display_name=req.device_name,
- store_access_token=False)
+ res = await req.client.login(
+ identifier=req.username,
+ login_type=LoginType.PASSWORD,
+ password=req.password,
+ device_id=device_id,
+ initial_device_display_name=req.device_name,
+ store_access_token=False,
+ )
except MatrixRequestError as e:
- return web.json_response({
- "errcode": e.errcode,
- "error": e.message,
- }, status=e.http_status)
+ return web.json_response(
+ {
+ "errcode": e.errcode,
+ "error": e.message,
+ },
+ status=e.http_status,
+ )
if req.update_client:
- return await _create_or_update_client(res.user_id, {
- "homeserver": str(req.client.api.base_url),
- "access_token": res.access_token,
- "device_id": res.device_id,
- }, is_login=True)
+ return await _create_or_update_client(
+ res.user_id,
+ {
+ "homeserver": str(req.client.api.base_url),
+ "access_token": res.access_token,
+ "device_id": res.device_id,
+ },
+ is_login=True,
+ )
return web.json_response(res.serialize())
@@ -230,6 +265,8 @@ async def complete_sso(request: web.Request) -> web.Response:
return web.Response(status=400, text="Missing loginToken query parameter\n")
except asyncio.InvalidStateError:
return web.Response(status=500, text="Invalid state\n")
- return web.Response(status=200,
- text="Login token received, please return to your Maubot client. "
- "This tab can be closed.\n")
+ return web.Response(
+ status=200,
+ text="Login token received, please return to your Maubot client. "
+ "This tab can be closed.\n",
+ )
diff --git a/maubot/management/api/client_proxy.py b/maubot/management/api/client_proxy.py
index 8c293cd..dca741f 100644
--- a/maubot/management/api/client_proxy.py
+++ b/maubot/management/api/client_proxy.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,7 +13,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from aiohttp import web, client as http
+from aiohttp import client as http, web
from ...client import Client
from .base import routes
@@ -45,8 +45,9 @@ async def proxy(request: web.Request) -> web.StreamResponse:
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:
+ 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):
diff --git a/maubot/management/api/dev_open.py b/maubot/management/api/dev_open.py
index 323c515..2881d46 100644
--- a/maubot/management/api/dev_open.py
+++ b/maubot/management/api/dev_open.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -14,11 +14,11 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
from string import Template
-from subprocess import run
+import asyncio
import re
-from ruamel.yaml import YAML
from aiohttp import web
+from ruamel.yaml import YAML
from .base import routes
@@ -27,9 +27,7 @@ enabled = False
@routes.get("/debug/open")
async def check_enabled(_: web.Request) -> web.Response:
- return web.json_response({
- "enabled": enabled,
- })
+ return web.json_response({"enabled": enabled})
try:
@@ -40,7 +38,6 @@ try:
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()
@@ -51,13 +48,9 @@ try:
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
- })
-
+ res = await asyncio.create_subprocess_shell(cmd)
+ stdout, stderr = await res.communicate()
+ return web.json_response({"return": res.returncode, "stdout": stdout, "stderr": stderr})
enabled = True
except Exception:
diff --git a/maubot/management/api/instance.py b/maubot/management/api/instance.py
index 91861af..c875c6a 100644
--- a/maubot/management/api/instance.py
+++ b/maubot/management/api/instance.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -17,10 +17,10 @@ from json import JSONDecodeError
from aiohttp import web
+from ...client import Client
from ...db import DBPlugin
from ...instance import PluginInstance
from ...loader import PluginLoader
-from ...client import Client
from .base import routes
from .responses import resp
@@ -52,8 +52,13 @@ async def _create_instance(instance_id: str, data: dict) -> web.Response:
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", ""))
+ 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()
instance.db_instance.insert()
diff --git a/maubot/management/api/instance_database.py b/maubot/management/api/instance_database.py
index bc3baf3..ef7da30 100644
--- a/maubot/management/api/instance_database.py
+++ b/maubot/management/api/instance_database.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,13 +13,14 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Union, TYPE_CHECKING
+from __future__ import annotations
+
from datetime import datetime
from aiohttp import web
-from sqlalchemy import Table, Column, asc, desc, exc
-from sqlalchemy.orm import Query
+from sqlalchemy import Column, Table, asc, desc, exc
from sqlalchemy.engine.result import ResultProxy, RowProxy
+from sqlalchemy.orm import Query
from ...instance import PluginInstance
from .base import routes
@@ -34,23 +35,26 @@ async def get_database(request: web.Request) -> web.Response:
return resp.instance_not_found
elif not instance.inst_db:
return resp.plugin_has_no_database
- if TYPE_CHECKING:
- table: Table
- column: Column
- return web.json_response({
- table.name: {
- "columns": {
- column.name: {
- "type": str(column.type),
- "unique": column.unique or False,
- "default": column.default,
- "nullable": column.nullable,
- "primary": column.primary_key,
- "autoincrement": column.autoincrement,
- } for column in table.columns
- },
- } for table in instance.get_db_tables().values()
- })
+ table: Table
+ column: Column
+ return web.json_response(
+ {
+ table.name: {
+ "columns": {
+ column.name: {
+ "type": str(column.type),
+ "unique": column.unique or False,
+ "default": column.default,
+ "nullable": column.nullable,
+ "primary": column.primary_key,
+ "autoincrement": column.autoincrement,
+ }
+ for column in table.columns
+ },
+ }
+ for table in instance.get_db_tables().values()
+ }
+ )
def check_type(val):
@@ -74,9 +78,12 @@ async def get_table(request: web.Request) -> web.Response:
return resp.table_not_found
try:
order = [tuple(order.split(":")) for order in request.query.getall("order")]
- order = [(asc if sort.lower() == "asc" else desc)(table.columns[column])
- if sort else table.columns[column]
- for column, sort in order]
+ order = [
+ (asc if sort.lower() == "asc" else desc)(table.columns[column])
+ if sort
+ else table.columns[column]
+ for column, sort in order
+ ]
except KeyError:
order = []
limit = int(request.query.get("limit", 100))
@@ -96,12 +103,12 @@ async def query(request: web.Request) -> web.Response:
sql_query = data["query"]
except KeyError:
return resp.query_missing
- return execute_query(instance, sql_query,
- rows_as_dict=data.get("rows_as_dict", False))
+ return execute_query(instance, sql_query, rows_as_dict=data.get("rows_as_dict", False))
-def execute_query(instance: PluginInstance, sql_query: Union[str, Query],
- rows_as_dict: bool = False) -> web.Response:
+def execute_query(
+ instance: PluginInstance, sql_query: str | Query, rows_as_dict: bool = False
+) -> web.Response:
try:
res: ResultProxy = instance.inst_db.execute(sql_query)
except exc.IntegrityError as e:
@@ -114,10 +121,14 @@ def execute_query(instance: PluginInstance, sql_query: Union[str, Query],
}
if res.returns_rows:
row: RowProxy
- data["rows"] = [({key: check_type(value) for key, value in row.items()}
- if rows_as_dict
- else [check_type(value) for value in row])
- for row in res]
+ data["rows"] = [
+ (
+ {key: check_type(value) for key, value in row.items()}
+ if rows_as_dict
+ else [check_type(value) for value in row]
+ )
+ for row in res
+ ]
data["columns"] = res.keys()
else:
data["rowcount"] = res.rowcount
diff --git a/maubot/management/api/log.py b/maubot/management/api/log.py
index 3ed5ca1..1c5df93 100644
--- a/maubot/management/api/log.py
+++ b/maubot/management/api/log.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,31 +13,60 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Deque, List
-from datetime import datetime
+from __future__ import annotations
+
from collections import deque
-import logging
+from datetime import datetime
import asyncio
+import logging
-from aiohttp import web
+from aiohttp import web, web_ws
-from .base import routes, get_loop
from .auth import is_valid_token
+from .base import get_loop, routes
-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"}
+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]
+ lines: deque[dict]
formatter: logging.Formatter
- listeners: List[web.WebSocketResponse]
+ listeners: list[web.WebSocketResponse]
loop: asyncio.AbstractEventLoop
def __init__(self, level=logging.NOTSET) -> None:
@@ -56,9 +85,7 @@ class LogCollector(logging.Handler):
# 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
+ name: value for name, value in record.__dict__.items() if name not in EXCLUDE_ATTRS
}
content["id"] = str(record.relativeCreated)
content["msg"] = record.getMessage()
@@ -119,6 +146,7 @@ async def log_websocket(request: web.Request) -> web.WebSocketResponse:
asyncio.ensure_future(close_if_not_authenticated())
try:
+ msg: web_ws.WSMessage
async for msg in ws:
if msg.type != web.WSMsgType.TEXT:
continue
diff --git a/maubot/management/api/login.py b/maubot/management/api/login.py
index 21f9342..bfb2f6a 100644
--- a/maubot/management/api/login.py
+++ b/maubot/management/api/login.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -17,9 +17,10 @@ import json
from aiohttp import web
-from .base import routes, get_config
-from .responses import resp
from .auth import create_token
+from .base import get_config, routes
+from .responses import resp
+
@routes.post("/auth/login")
async def login(request: web.Request) -> web.Response:
diff --git a/maubot/management/api/middleware.py b/maubot/management/api/middleware.py
index fa04edb..0ecb681 100644
--- a/maubot/management/api/middleware.py
+++ b/maubot/management/api/middleware.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -13,15 +13,15 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from typing import Callable, Awaitable
-import logging
+from typing import Awaitable, Callable
import base64
+import logging
from aiohttp import web
-from .responses import resp
from .auth import check_token
from .base import get_config
+from .responses import resp
Handler = Callable[[web.Request], Awaitable[web.Response]]
log = logging.getLogger("maubot.server")
@@ -29,7 +29,7 @@ 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"]):]
+ subpath = request.path[len(get_config()["server.base_path"]) :]
if (
subpath.startswith("/auth/")
or subpath.startswith("/client/auth_external_sso/complete/")
@@ -52,15 +52,18 @@ async def error(request: web.Request, handler: Handler) -> web.Response:
return resp.path_not_found
elif ex.status_code == 405:
return resp.method_not_allowed
- return web.json_response({
- "httpexception": {
- "headers": {key: value for key, value in ex.headers.items()},
- "class": type(ex).__name__,
- "body": ex.text or base64.b64encode(ex.body)
+ return web.json_response(
+ {
+ "httpexception": {
+ "headers": {key: value for key, value in ex.headers.items()},
+ "class": type(ex).__name__,
+ "body": ex.text or base64.b64encode(ex.body),
+ },
+ "error": f"Unhandled HTTP {ex.status}: {ex.text[:128] or 'non-text response'}",
+ "errcode": f"unhandled_http_{ex.status}",
},
- "error": f"Unhandled HTTP {ex.status}: {ex.text[:128] or 'non-text response'}",
- "errcode": f"unhandled_http_{ex.status}",
- }, status=ex.status)
+ status=ex.status,
+ )
except Exception:
log.exception("Error in handler")
return resp.internal_server_error
diff --git a/maubot/management/api/plugin.py b/maubot/management/api/plugin.py
index 4429e11..ecd3c6a 100644
--- a/maubot/management/api/plugin.py
+++ b/maubot/management/api/plugin.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -17,9 +17,9 @@ import traceback
from aiohttp import web
-from ...loader import PluginLoader, MaubotZipImportError
-from .responses import resp
+from ...loader import MaubotZipImportError, PluginLoader
from .base import routes
+from .responses import resp
@routes.get("/plugins")
diff --git a/maubot/management/api/plugin_upload.py b/maubot/management/api/plugin_upload.py
index 7b5b5de..f187c71 100644
--- a/maubot/management/api/plugin_upload.py
+++ b/maubot/management/api/plugin_upload.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -15,16 +15,16 @@
# along with this program. If not, see .
from io import BytesIO
from time import time
-import traceback
import os.path
import re
+import traceback
from aiohttp import web
from packaging.version import Version
-from ...loader import PluginLoader, ZippedPluginLoader, MaubotZipImportError
+from ...loader import MaubotZipImportError, PluginLoader, ZippedPluginLoader
+from .base import get_config, routes
from .responses import resp
-from .base import routes, get_config
@routes.put("/plugin/{id}")
@@ -78,15 +78,20 @@ async def upload_new_plugin(content: bytes, pid: str, version: Version) -> web.R
return resp.created(plugin.to_dict())
-async def upload_replacement_plugin(plugin: ZippedPluginLoader, content: bytes,
- new_version: Version) -> web.Response:
+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 str(plugin.meta.version) in old_filename:
- replacement = (str(new_version) if plugin.meta.version != new_version
- else f"{new_version}-ts{int(time())}")
- filename = re.sub(f"{re.escape(str(plugin.meta.version))}(-ts[0-9]+)?",
- replacement, old_filename)
+ replacement = (
+ str(new_version)
+ if plugin.meta.version != new_version
+ else f"{new_version}-ts{int(time())}"
+ )
+ filename = re.sub(
+ f"{re.escape(str(plugin.meta.version))}(-ts[0-9]+)?", replacement, old_filename
+ )
else:
filename = old_filename.rstrip(".mbp")
filename = f"{filename}-v{new_version}.mbp"
diff --git a/maubot/management/api/responses.py b/maubot/management/api/responses.py
index 5fbedb8..8e07abb 100644
--- a/maubot/management/api/responses.py
+++ b/maubot/management/api/responses.py
@@ -1,5 +1,5 @@
# maubot - A plugin-based Matrix bot system.
-# Copyright (C) 2019 Tulir Asokan
+# Copyright (C) 2022 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
@@ -16,296 +16,416 @@
from http import HTTPStatus
from aiohttp import web
-from sqlalchemy.exc import OperationalError, IntegrityError
+from sqlalchemy.exc import IntegrityError, OperationalError
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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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,
+ )
def device_id_mismatch(self, found: str) -> web.Response:
- return web.json_response({
- "error": "The Matrix device ID of the client and the device ID of the access token "
- f"don't match. Access token is for device {found}",
- "errcode": "mxid_mismatch",
- }, status=HTTPStatus.BAD_REQUEST)
+ return web.json_response(
+ {
+ "error": (
+ "The Matrix device ID of the client and the device ID of the access token "
+ f"don't match. Access token is for device {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)
+ 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)
+ return web.json_response(
+ {
+ "error": "Username or password missing",
+ "errcode": "username_or_password_missing",
+ },
+ status=HTTPStatus.BAD_REQUEST,
+ )
@property
def query_missing(self) -> web.Response:
- return web.json_response({
- "error": "Query missing",
- "errcode": "query_missing",
- }, status=HTTPStatus.BAD_REQUEST)
+ return web.json_response(
+ {
+ "error": "Query missing",
+ "errcode": "query_missing",
+ },
+ status=HTTPStatus.BAD_REQUEST,
+ )
@staticmethod
def sql_operational_error(error: OperationalError, query: str) -> web.Response:
- return web.json_response({
- "ok": False,
- "query": query,
- "error": str(error.orig),
- "full_error": str(error),
- "errcode": "sql_operational_error",
- }, status=HTTPStatus.BAD_REQUEST)
+ return web.json_response(
+ {
+ "ok": False,
+ "query": query,
+ "error": str(error.orig),
+ "full_error": str(error),
+ "errcode": "sql_operational_error",
+ },
+ status=HTTPStatus.BAD_REQUEST,
+ )
@staticmethod
def sql_integrity_error(error: IntegrityError, query: str) -> web.Response:
- return web.json_response({
- "ok": False,
- "query": query,
- "error": str(error.orig),
- "full_error": str(error),
- "errcode": "sql_integrity_error",
- }, status=HTTPStatus.BAD_REQUEST)
+ return web.json_response(
+ {
+ "ok": False,
+ "query": query,
+ "error": str(error.orig),
+ "full_error": str(error),
+ "errcode": "sql_integrity_error",
+ },
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ return web.json_response(
+ {
+ "error": "Registration target server not found",
+ "errcode": "server_not_found",
+ },
+ status=HTTPStatus.NOT_FOUND,
+ )
@property
def registration_secret_not_found(self) -> web.Response:
- return web.json_response({
- "error": "Config does not have a registration secret for that server",
- "errcode": "registration_secret_not_found",
- }, status=HTTPStatus.NOT_FOUND)
+ return web.json_response(
+ {
+ "error": "Config does not have a registration secret for that server",
+ "errcode": "registration_secret_not_found",
+ },
+ status=HTTPStatus.NOT_FOUND,
+ )
@property
def registration_no_sso(self) -> web.Response:
- return web.json_response({
- "error": "The register operation is only for registering with a password",
- "errcode": "registration_no_sso",
- }, status=HTTPStatus.BAD_REQUEST)
+ return web.json_response(
+ {
+ "error": "The register operation is only for registering with a password",
+ "errcode": "registration_no_sso",
+ },
+ status=HTTPStatus.BAD_REQUEST,
+ )
@property
def sso_not_supported(self) -> web.Response:
- return web.json_response({
- "error": "That server does not seem to support single sign-on",
- "errcode": "sso_not_supported",
- }, status=HTTPStatus.FORBIDDEN)
+ return web.json_response(
+ {
+ "error": "That server does not seem to support single sign-on",
+ "errcode": "sso_not_supported",
+ },
+ status=HTTPStatus.FORBIDDEN,
+ )
@property
def plugin_has_no_database(self) -> web.Response:
- return web.json_response({
- "error": "Given plugin does not have a database",
- "errcode": "plugin_has_no_database",
- })
+ return web.json_response(
+ {
+ "error": "Given plugin does not have a database",
+ "errcode": "plugin_has_no_database",
+ }
+ )
@property
def table_not_found(self) -> web.Response:
- return web.json_response({
- "error": "Given table not found in plugin database",
- "errcode": "table_not_found",
- })
+ return web.json_response(
+ {
+ "error": "Given table not found in plugin database",
+ "errcode": "table_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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ return web.json_response(
+ {"success": True},
+ status=HTTPStatus.OK,
+ )
@property
def deleted(self) -> web.Response:
@@ -320,15 +440,10 @@ class _Response:
return web.json_response(data, status=HTTPStatus.ACCEPTED if is_login else HTTPStatus.OK)
def logged_in(self, token: str) -> web.Response:
- return self.found({
- "token": token,
- })
+ return self.found({"token": token})
def pong(self, user: str, features: dict) -> web.Response:
- return self.found({
- "username": user,
- "features": features,
- })
+ return self.found({"username": user, "features": features})
@staticmethod
def created(data: dict) -> web.Response:
diff --git a/maubot/management/frontend/public/index.html b/maubot/management/frontend/public/index.html
index 43255d8..d3679bf 100644
--- a/maubot/management/frontend/public/index.html
+++ b/maubot/management/frontend/public/index.html
@@ -1,6 +1,6 @@