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,3 @@
from .abc import BasePluginLoader, IDConflictError, PluginClass, PluginLoader
from .meta import DatabaseType, PluginMeta
from .zip import MaubotZipImportError, ZippedPluginLoader

View File

@@ -0,0 +1,98 @@
# 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 TYPE_CHECKING, TypeVar
from abc import ABC, abstractmethod
import asyncio
from ..plugin_base import Plugin
from .meta import PluginMeta
if TYPE_CHECKING:
from ..instance import PluginInstance
PluginClass = TypeVar("PluginClass", bound=Plugin)
class IDConflictError(Exception):
pass
class BasePluginLoader(ABC):
meta: PluginMeta
@property
@abstractmethod
def source(self) -> str:
pass
def sync_read_file(self, path: str) -> bytes:
raise NotImplementedError("This loader doesn't support synchronous operations")
@abstractmethod
async def read_file(self, path: str) -> bytes:
pass
def sync_list_files(self, directory: str) -> list[str]:
raise NotImplementedError("This loader doesn't support synchronous operations")
@abstractmethod
async def list_files(self, directory: str) -> list[str]:
pass
class PluginLoader(BasePluginLoader, ABC):
id_cache: dict[str, PluginLoader] = {}
meta: PluginMeta
references: set[PluginInstance]
def __init__(self):
self.references = set()
@classmethod
def find(cls, plugin_id: str) -> PluginLoader:
return cls.id_cache[plugin_id]
def to_dict(self) -> dict:
return {
"id": self.meta.id,
"version": str(self.meta.version),
"instances": [instance.to_dict() for instance in self.references],
}
async def stop_instances(self) -> None:
await asyncio.gather(
*[instance.stop() for instance in self.references if instance.started]
)
async def start_instances(self) -> None:
await asyncio.gather(
*[instance.start() for instance in self.references if instance.enabled]
)
@abstractmethod
async def load(self) -> type[PluginClass]:
pass
@abstractmethod
async def reload(self) -> type[PluginClass]:
pass
@abstractmethod
async def delete(self) -> None:
pass

View File

@@ -0,0 +1,69 @@
# 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 List, Optional
from attr import dataclass
from packaging.version import InvalidVersion, Version
from mautrix.types import (
ExtensibleEnum,
SerializableAttrs,
SerializerError,
deserializer,
serializer,
)
from ..__meta__ import __version__
@serializer(Version)
def serialize_version(version: Version) -> str:
return str(version)
@deserializer(Version)
def deserialize_version(version: str) -> Version:
try:
return Version(version)
except InvalidVersion as e:
raise SerializerError("Invalid version") from e
class DatabaseType(ExtensibleEnum):
SQLALCHEMY = "sqlalchemy"
ASYNCPG = "asyncpg"
@dataclass
class PluginMeta(SerializableAttrs):
id: str
version: Version
modules: List[str]
main_class: str
maubot: Version = Version(__version__)
database: bool = False
database_type: DatabaseType = DatabaseType.SQLALCHEMY
config: bool = False
webapp: bool = False
license: str = ""
extra_files: List[str] = []
dependencies: List[str] = []
soft_dependencies: List[str] = []
@property
def database_type_str(self) -> Optional[str]:
return self.database_type.value if self.database else None

View File

@@ -0,0 +1,310 @@
# 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 time import time
from zipfile import BadZipFile, ZipFile
import logging
import os
import sys
from packaging.version import Version
from ruamel.yaml import YAML, YAMLError
from mautrix.types import SerializerError
from ..__meta__ import __version__
from ..config import Config
from ..lib.zipimport import ZipImportError, zipimporter
from ..plugin_base import Plugin
from .abc import IDConflictError, PluginClass, PluginLoader
from .meta import DatabaseType, PluginMeta
current_version = Version(__version__)
yaml = YAML()
class MaubotZipImportError(Exception):
pass
class MaubotZipMetaError(MaubotZipImportError):
pass
class MaubotZipPreLoadError(MaubotZipImportError):
pass
class MaubotZipLoadError(MaubotZipImportError):
pass
class ZippedPluginLoader(PluginLoader):
path_cache: dict[str, ZippedPluginLoader] = {}
log: logging.Logger = logging.getLogger("maubot.loader.zip")
trash_path: str = "delete"
directories: list[str] = []
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
self._load_meta()
self._run_preload_checks(self._get_importer())
try:
existing = self.id_cache[self.meta.id]
raise IDConflictError(
f"Plugin with id {self.meta.id} already loaded from {existing.source}"
)
except KeyError:
pass
self.path_cache[self.path] = self
self.id_cache[self.meta.id] = self
self.log.debug(f"Preloaded plugin {self.meta.id} from {self.path}")
def to_dict(self) -> dict:
return {**super().to_dict(), "path": self.path}
@classmethod
def get(cls, path: str) -> ZippedPluginLoader:
path = os.path.abspath(path)
try:
return cls.path_cache[path]
except KeyError:
return cls(path)
@property
def source(self) -> str:
return self.path
def __repr__(self) -> str:
return (
"<ZippedPlugin "
f"path='{self.path}' "
f"meta={self.meta} "
f"loaded={self._loaded is not None}>"
)
def sync_read_file(self, path: str) -> bytes:
return self._file.read(path)
async def read_file(self, path: str) -> bytes:
return self.sync_read_file(path)
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
]
async def list_files(self, directory: str) -> list[str]:
return self.sync_list_files(directory)
@staticmethod
def _read_meta(source) -> tuple[ZipFile, PluginMeta]:
try:
file = ZipFile(source)
data = file.read("maubot.yaml")
except FileNotFoundError as e:
raise MaubotZipMetaError("Maubot plugin not found") from e
except BadZipFile as e:
raise MaubotZipMetaError("File is not a maubot plugin") from e
except KeyError as e:
raise MaubotZipMetaError("File does not contain a maubot plugin definition") from e
try:
meta_dict = yaml.load(data)
except (YAMLError, KeyError, IndexError, ValueError) as e:
raise MaubotZipMetaError("Maubot plugin definition file is not valid YAML") from e
try:
meta = PluginMeta.deserialize(meta_dict)
except SerializerError as e:
raise MaubotZipMetaError("Maubot plugin definition in file is invalid") from e
if meta.maubot > current_version:
raise MaubotZipMetaError(
f"Plugin requires maubot {meta.maubot}, but this instance is {current_version}"
)
return file, meta
@classmethod
def verify_meta(cls, source) -> tuple[str, Version, DatabaseType | None]:
_, meta = cls._read_meta(source)
return meta.id, meta.version, meta.database_type if meta.database else None
def _load_meta(self) -> None:
file, meta = self._read_meta(self.path)
if self.meta and meta.id != self.meta.id:
raise MaubotZipMetaError("Maubot plugin ID changed during reload")
self.meta = meta
if "/" in meta.main_class:
self.main_module, self.main_class = meta.main_class.split("/")[:2]
else:
self.main_module = meta.modules[-1]
self.main_class = meta.main_class
self._file = file
def _get_importer(self, reset_cache: bool = False) -> zipimporter:
try:
if not self._importer or self._importer.archive != self.path:
self._importer = zipimporter(self.path)
if reset_cache:
self._importer.reset_cache()
return self._importer
except ZipImportError as e:
raise MaubotZipMetaError("File not found or not a maubot plugin") from e
def _run_preload_checks(self, importer: zipimporter) -> None:
try:
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}"
)
except ZipImportError as 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]:
try:
return self._load(reset_cache)
except MaubotZipImportError:
self.log.exception(f"Failed to load {self.meta.id} v{self.meta.version}")
raise
def _load(self, reset_cache: bool = False) -> type[PluginClass]:
if self._loaded is not None and not reset_cache:
return self._loaded
self._load_meta()
importer = self._get_importer(reset_cache=reset_cache)
self._run_preload_checks(importer)
if reset_cache:
self.log.debug(f"Re-preloaded plugin {self.meta.id} from {self.path}")
for module in self.meta.modules:
try:
importer.load_module(module)
except ZipImportError:
raise MaubotZipLoadError(f"Module {module} not found in file")
except Exception:
raise MaubotZipLoadError(f"Failed to load module {module}")
try:
main_mod = sys.modules[self.main_module]
except KeyError as e:
raise MaubotZipLoadError(f"Main module {self.main_module} of plugin not found") from e
try:
plugin = getattr(main_mod, self.main_class)
except AttributeError as e:
raise MaubotZipLoadError(f"Main class {self.main_class} of plugin not found") from e
if not issubclass(plugin, Plugin):
raise MaubotZipLoadError("Main class of plugin does not extend maubot.Plugin")
self._loaded = plugin
self.log.debug(f"Loaded and imported plugin {self.meta.id} from {self.path}")
return plugin
async def reload(self, new_path: str | None = None) -> type[PluginClass]:
self._unload()
if new_path is not None and new_path != self.path:
try:
del self.path_cache[self.path]
except KeyError:
pass
self.path = new_path
self.path_cache[self.path] = self
return await self.load(reset_cache=True)
def _unload(self) -> None:
for name, mod in list(sys.modules.items()):
if (getattr(mod, "__file__", "") or "").startswith(self.path):
del sys.modules[name]
self._loaded = None
self.log.debug(f"Unloaded plugin {self.meta.id} at {self.path}")
async def delete(self) -> None:
self._unload()
try:
del self.path_cache[self.path]
except KeyError:
pass
try:
del self.id_cache[self.meta.id]
except KeyError:
pass
if self._importer:
self._importer.remove_cache()
self._importer = None
self._loaded = None
self.trash(self.path, reason="delete")
self.meta = None
self.path = None
@classmethod
def trash(cls, file_path: str, new_name: str | None = None, reason: str = "error") -> None:
if cls.trash_path == "delete":
try:
os.remove(file_path)
except FileNotFoundError:
pass
else:
new_name = new_name or f"{int(time())}-{reason}-{os.path.basename(file_path)}"
try:
os.rename(file_path, os.path.abspath(os.path.join(cls.trash_path, new_name)))
except OSError as e:
cls.log.warning(f"Failed to rename {file_path}: {e} - trying to delete")
try:
os.remove(file_path)
except FileNotFoundError:
pass
@classmethod
def load_all(cls):
cls.log.debug("Preloading plugins...")
for directory in cls.directories:
for file in os.listdir(directory):
if not file.endswith(".mbp"):
continue
path = os.path.abspath(os.path.join(directory, file))
try:
cls.get(path)
except MaubotZipImportError:
cls.log.exception(f"Failed to load plugin at {path}, trashing...")
cls.trash(path)
except IDConflictError:
cls.log.error(f"Duplicate plugin ID at {path}, trashing...")
cls.trash(path)
def init(config: Config) -> None:
ZippedPluginLoader.trash_path = config["plugin_directories.trash"]
ZippedPluginLoader.directories = config["plugin_directories.load"]
ZippedPluginLoader.load_all()