Add Cloudron packaging for Maubot

This commit is contained in:
Your Name
2025-11-13 21:00:23 -06:00
commit 1af2e64aae
179 changed files with 24749 additions and 0 deletions

View File

@@ -0,0 +1 @@
from . import auth, build, init, login, logs, upload

View File

@@ -0,0 +1,166 @@
# maubot - A plugin-based Matrix bot system.
# 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import json
import webbrowser
from colorama import Fore
from yarl import URL
import aiohttp
import click
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."
),
}
async def list_servers(server: str, sess: aiohttp.ClientSession) -> None:
url = URL(server) / "_matrix/maubot/v1/client/auth/servers"
async with sess.get(url) as resp:
data = await resp.json()
print(f"{Fore.GREEN}Available Matrix servers for registration and login:{Fore.RESET}")
for server in data.keys():
print(f"* {Fore.CYAN}{server}{Fore.RESET}")
@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,
)
@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,
)
@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:
if list:
await list_servers(server, sess)
return
endpoint = "register" if register else "login"
url = URL(server) / "_matrix/maubot/v1/client/auth" / homeserver / endpoint
if update_client:
url = url.update_query({"update_client": "true"})
if sso:
url = url.update_query({"sso": "true"})
req_data = {"device_name": device_name}
else:
req_data = {"username": username, "password": password, "device_name": device_name}
async with sess.post(url, json=req_data) as resp:
if not 200 <= resp.status < 300:
await print_error(resp, is_register=register)
elif sso:
await wait_sso(resp, sess, server, homeserver)
else:
await print_response(resp, is_register=register)
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}")
webbrowser.open(sso_url, autoraise=True)
print(f"{Fore.GREEN}Waiting for login token...{Fore.RESET}")
wait_url = URL(server) / "_matrix/maubot/v1/client/auth" / homeserver / "sso" / reg_id / "wait"
async with sess.post(wait_url, json={}) as resp:
await print_response(resp, is_register=False)
async def print_response(resp: aiohttp.ClientResponse, is_register: bool) -> None:
if resp.status == 200:
data = await resp.json()
action = "registered" if is_register else "logged in as"
print(f"{Fore.GREEN}Successfully {action} {Fore.CYAN}{data['user_id']}{Fore.GREEN}.")
print(f"{Fore.GREEN}Access token: {Fore.CYAN}{data['access_token']}{Fore.RESET}")
print(f"{Fore.GREEN}Device ID: {Fore.CYAN}{data['device_id']}{Fore.RESET}")
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}"
)
else:
await print_error(resp, is_register)
async def print_error(resp: aiohttp.ClientResponse, is_register: bool) -> None:
try:
err_data = await resp.json()
error = friendly_errors.get(err_data["errcode"], err_data["error"])
except (aiohttp.ContentTypeError, json.JSONDecodeError, KeyError):
error = await resp.text()
action = "register" if is_register else "log in"
print(f"{Fore.RED}Failed to {action}: {error}{Fore.RESET}")

View File

@@ -0,0 +1,155 @@
# maubot - A plugin-based Matrix bot system.
# 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import IO
from io import BytesIO
import asyncio
import glob
import os
import zipfile
from aiohttp import ClientSession
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 ..base import app
from ..cliq import cliq
from ..cliq.validators import PathValidator
from ..config import get_token
from .upload import upload_file
yaml = YAML()
def zipdir(zip, dir):
for root, dirs, files in os.walk(dir):
for file in files:
zip.write(os.path.join(root, file))
def read_meta(path: str) -> PluginMeta | None:
try:
with open(os.path.join(path, "maubot.yaml")) as meta_file:
try:
meta_dict = yaml.load(meta_file)
except YAMLError as e:
print(Fore.RED + "Failed to build plugin: Metadata file is not YAML")
print(Fore.RED + str(e) + Fore.RESET)
return None
except FileNotFoundError:
print(Fore.RED + "Failed to build plugin: Metadata file not found" + Fore.RESET)
return None
try:
meta = PluginMeta.deserialize(meta_dict)
except SerializerError as e:
print(Fore.RED + "Failed to build plugin: Metadata file is not valid")
print(Fore.RED + str(e) + Fore.RESET)
return None
return meta
def read_output_path(output: str, meta: PluginMeta) -> str | None:
directory = os.getcwd()
filename = f"{meta.id}-v{meta.version}.mbp"
if not output:
output = os.path.join(directory, filename)
elif os.path.isdir(output):
output = os.path.join(output, filename)
elif os.path.exists(output):
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: str | IO) -> None:
with zipfile.ZipFile(output, "w") as zip:
meta_dump = BytesIO()
yaml.dump(meta.serialize(), meta_dump)
zip.writestr("maubot.yaml", meta_dump.getvalue())
for module in meta.modules:
if os.path.isfile(f"{module}.py"):
zip.write(f"{module}.py")
elif module is not None and os.path.isdir(module):
if os.path.isfile(f"{module}/__init__.py"):
zipdir(zip, module)
else:
print(
Fore.YELLOW
+ f"Module {module} is missing __init__.py, skipping"
+ Fore.RESET
)
else:
print(Fore.YELLOW + f"Module {module} not found, skipping" + Fore.RESET)
for pattern in meta.extra_files:
for file in glob.iglob(pattern):
zip.write(file)
@cliq.with_authenticated_http
async def upload_plugin(output: str | IO, *, server: str, sess: ClientSession) -> None:
server, token = get_token(server)
if not token:
return
if isinstance(output, str):
with open(output, "rb") as file:
await upload_file(sess, file, server)
else:
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."
),
)
@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("-s", "--server", help="Server to upload built plugin to")
def build(path: str, output: str, upload: bool, server: str) -> None:
meta = read_meta(path)
if not meta:
return
if output or not upload:
output = read_output_path(output, meta)
if not output:
return
else:
output = BytesIO()
os.chdir(path)
write_plugin(meta, output)
if isinstance(output, str):
print(f"{Fore.GREEN}Plugin built to {Fore.CYAN}{output}{Fore.GREEN}.{Fore.RESET}")
else:
output.seek(0)
if upload:
asyncio.run(upload_plugin(output, server=server))

View File

@@ -0,0 +1,99 @@
# maubot - A plugin-based Matrix bot system.
# 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import os
from jinja2 import Template
from packaging.version import Version
from pkg_resources import resource_string
from .. import cliq
from ..cliq import SPDXValidator, VersionValidator
from ..util import spdx
loaded: bool = False
meta_template: Template
mod_template: Template
base_config: str
def load_templates():
global mod_template, meta_template, base_config, loaded
if loaded:
return
meta_template = Template(resource_string("maubot.cli", "res/maubot.yaml.j2").decode("utf-8"))
mod_template = Template(resource_string("maubot.cli", "res/plugin.py.j2").decode("utf-8"))
base_config = resource_string("maubot.cli", "res/config.yaml").decode("utf-8")
loaded = True
@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,
)
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
)
with open("maubot.yaml", "w") as file:
file.write(meta)
if license:
with open("LICENSE", "w") as file:
file.write(spdx.get(license)["licenseText"])
if not os.path.isdir(name):
os.mkdir(name)
mod = mod_template.render(config=config, name=main_class)
with open(f"{name}/__init__.py", "w") as file:
file.write(mod)
if config:
with open("base-config.yaml", "w") as file:
file.write(base_config)

View File

@@ -0,0 +1,77 @@
# maubot - A plugin-based Matrix bot system.
# 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import json
import os
from colorama import Fore
from yarl import URL
import aiohttp
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.with_http
async def login(
server: str, username: str, password: str, alias: str, sess: aiohttp.ClientSession
) -> None:
data = {
"username": username,
"password": password,
}
url = URL(server) / "_matrix/maubot/v1/auth/login"
async with sess.post(url, json=data) as resp:
if resp.status == 200:
data = await resp.json()
config["servers"][server] = data["token"]
if not config["default_server"]:
print(Fore.CYAN, "Setting", server, "as the default server")
config["default_server"] = server
if alias:
config["aliases"][alias] = server
save_config()
print(Fore.GREEN + "Logged in successfully")
else:
try:
err = (await resp.json())["error"]
except (json.JSONDecodeError, KeyError):
err = await resp.text()
print(Fore.RED + err + Fore.RESET)

View File

@@ -0,0 +1,107 @@
# maubot - A plugin-based Matrix bot system.
# 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from datetime import datetime
import asyncio
from aiohttp import ClientSession, WSMessage, WSMsgType
from colorama import Fore
import click
from mautrix.types import Obj
from ..base import app
from ..config import get_token
history_count: int = 10
@app.command(help="View the logs of a server")
@click.argument("server", required=False)
@click.option("-t", "--tail", default=10, help="Maximum number of old log lines to display")
def logs(server: str, tail: int) -> None:
server, token = get_token(server)
if not token:
return
global history_count
history_count = tail
loop = asyncio.get_event_loop()
loop.run_until_complete(view_logs(server, token))
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 = datetime.strptime(entry.time, "%Y-%m-%dT%H:%M:%S.%f%z")
levelcolors = {
"DEBUG": "",
"INFO": Fore.CYAN,
"WARNING": Fore.YELLOW,
"ERROR": Fore.RED,
"FATAL": Fore.MAGENTA,
}
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,
)
)
if entry.exc_info:
print(entry.exc_info)
def handle_msg(data: dict) -> bool:
if "auth_success" in data:
if data["auth_success"]:
print(Fore.GREEN + "Connected to log websocket" + Fore.RESET)
else:
print(Fore.RED + "Failed to authenticate to log websocket" + Fore.RESET)
return False
elif "history" in data:
for entry in data["history"][-history_count:]:
print_entry(entry)
else:
print_entry(data)
return True
async def view_logs(server: str, token: str) -> None:
async with ClientSession() as session:
async with session.ws_connect(f"{server}/_matrix/maubot/v1/logs") as ws:
await ws.send_str(token)
try:
msg: WSMessage
async for msg in ws:
if msg.type == WSMsgType.TEXT:
if not handle_msg(msg.json()):
break
elif msg.type == WSMsgType.ERROR:
print(Fore.YELLOW + "Connection error: " + msg.data + Fore.RESET)
elif msg.type == WSMsgType.CLOSE:
print(Fore.YELLOW + "Server closed connection" + Fore.RESET)
except asyncio.CancelledError:
pass

View File

@@ -0,0 +1,58 @@
# maubot - A plugin-based Matrix bot system.
# 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
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import IO
import json
from colorama import Fore
from yarl import URL
import aiohttp
import click
from ..cliq import cliq
class UploadError(Exception):
pass
@cliq.command(help="Upload a maubot plugin")
@click.argument("path")
@click.option("-s", "--server", help="The maubot instance to upload the plugin to")
@cliq.with_authenticated_http
async def upload(path: str, server: str, sess: aiohttp.ClientSession) -> None:
with open(path, "rb") as file:
await upload_file(sess, file, server)
async def upload_file(sess: aiohttp.ClientSession, file: IO, server: str) -> None:
url = (URL(server) / "_matrix/maubot/v1/plugins/upload").with_query({"allow_override": "true"})
headers = {"Content-Type": "application/zip"}
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}"
)
else:
try:
err = await resp.json()
if "stacktrace" in err:
print(err["stacktrace"])
err = err["error"]
except (aiohttp.ContentTypeError, json.JSONDecodeError, KeyError):
err = await resp.text()
print(f"{Fore.RED}Failed to upload plugin: {err}{Fore.RESET}")