diff --git a/example-config.yaml b/example-config.yaml index 7d8e61b..a951ae9 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -1,3 +1,8 @@ +# The full URI to the database. SQLite and Postgres are fully supported. +# Other DBMSes supported by SQLAlchemy may or may not work. +# Format examples: +# SQLite: sqlite:///filename.db +# Postgres: postgres://username:password@hostname/dbname database: sqlite:///maubot.db # If multiple directories have a plugin with the same name, the first directory is used. @@ -5,8 +10,15 @@ plugin_directories: - ./plugins server: + # The IP:port to listen to. listen: 0.0.0.0:29316 + # The base management API path. base_path: /_matrix/maubot + # The base appservice API path. Use / for legacy appservice API and /_matrix/app/v1 for v1. + appservice_base_path: /_matrix/app/v1 + # The shared secret to authorize users of the API. + # Set to "generate" to generate and save a new token at startup. + shared_secret: generate admins: - "@admin:example.com" diff --git a/maubot/__init__.py b/maubot/__init__.py index 038c1c1..9c3cbb8 100644 --- a/maubot/__init__.py +++ b/maubot/__init__.py @@ -1 +1,3 @@ -__version__ = "0.1.0+dev" +from .plugin_base import Plugin +from .command_spec import CommandSpec, Command, PassiveCommand, Argument +from .event import FakeEvent as Event diff --git a/maubot/__main__.py b/maubot/__main__.py index 67112bc..2b97589 100644 --- a/maubot/__main__.py +++ b/maubot/__main__.py @@ -20,7 +20,7 @@ import argparse import copy from .config import Config -from . import __version__ +from .__meta__ import __version__ parser = argparse.ArgumentParser(description="A plugin-based Matrix bot system.", prog="python -m maubot") diff --git a/maubot/__meta__.py b/maubot/__meta__.py new file mode 100644 index 0000000..038c1c1 --- /dev/null +++ b/maubot/__meta__.py @@ -0,0 +1 @@ +__version__ = "0.1.0+dev" diff --git a/maubot/client.py b/maubot/client.py index 3d73b3f..539ca54 100644 --- a/maubot/client.py +++ b/maubot/client.py @@ -42,11 +42,11 @@ class Client: self.client.add_event_handler(self.handle_invite, EventType.ROOM_MEMBER) @classmethod - def get(cls, id: UserID) -> Optional['Client']: + def get(cls, user_id: UserID) -> Optional['Client']: try: - return cls.cache[id] + return cls.cache[user_id] except KeyError: - db_instance = DBClient.query.get(id) + db_instance = DBClient.query.get(user_id) if not db_instance: return None return Client(db_instance) @@ -126,6 +126,6 @@ class Client: # endregion - async def handle_invite(self, evt: StateEvent): + async def handle_invite(self, evt: StateEvent) -> None: if evt.state_key == self.id and evt.content.membership == Membership.INVITE: await self.client.join_room_by_id(evt.room_id) diff --git a/maubot/command_spec.py b/maubot/command_spec.py new file mode 100644 index 0000000..8ac6e23 --- /dev/null +++ b/maubot/command_spec.py @@ -0,0 +1,52 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import List, Dict +from attr import dataclass + +from mautrix.types import Event +from mautrix.client.api.types.util import SerializableAttrs + + +@dataclass +class Argument(SerializableAttrs['Argument']): + matches: str + required: bool = False + description: str = None + + +@dataclass +class Command(SerializableAttrs['Command']): + syntax: str + arguments: Dict[str, Argument] + description: str = None + + +@dataclass +class PassiveCommand(SerializableAttrs['PassiveCommand']): + name: str + matches: str + match_against: str + match_event: Event = None + + +@dataclass +class CommandSpec(SerializableAttrs['CommandSpec']): + commands: List[Command] = [] + passive_commands: List[PassiveCommand] = [] + + def __add__(self, other: 'CommandSpec') -> 'CommandSpec': + return CommandSpec(commands=self.commands + other.commands, + passive_commands=self.passive_commands + other.passive_commands) diff --git a/maubot/db.py b/maubot/db.py index 812cc3e..841dbe8 100644 --- a/maubot/db.py +++ b/maubot/db.py @@ -47,9 +47,9 @@ 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: str = Column(String(255), - ForeignKey("client.id", onupdate="CASCADE", ondelete="RESTRICT"), - nullable=False) + primary_user: UserID = Column(String(255), + ForeignKey("client.id", onupdate="CASCADE", ondelete="RESTRICT"), + nullable=False) class DBClient(Base): diff --git a/maubot/event.py b/maubot/event.py new file mode 100644 index 0000000..b779529 --- /dev/null +++ b/maubot/event.py @@ -0,0 +1,64 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import Awaitable, Union + +from mautrix.types import Event as MatrixEvent, EventType, MessageEventContent, MessageType, EventID +from mautrix.client.api.types.event.base import BaseRoomEvent +from mautrix.client import ClientAPI + + +class FakeEvent(BaseRoomEvent): + def __new__(cls, *args, **kwargs): + raise RuntimeError("Can't create instance of type hint header class") + + def respond(self, content: Union[str, MessageEventContent], + event_type: EventType = EventType.ROOM_MESSAGE) -> Awaitable[EventID]: + raise RuntimeError("Can't call methods of type hint header class") + + def reply(self, content: Union[str, MessageEventContent], + event_type: EventType = EventType.ROOM_MESSAGE) -> Awaitable[EventID]: + raise RuntimeError("Can't call methods of type hint header class") + + def mark_read(self) -> Awaitable[None]: + raise RuntimeError("Can't call methods of type hint header class") + + +class Event: + def __init__(self, client: ClientAPI, target: MatrixEvent): + self.client: ClientAPI = client + self.target: MatrixEvent = target + + def __getattr__(self, item): + return getattr(self.target, item) + + def __setattr__(self, key, value): + return setattr(self.target, key, value) + + def respond(self, content: Union[str, MessageEventContent], + event_type: EventType = EventType.ROOM_MESSAGE) -> Awaitable[EventID]: + if isinstance(content, str): + content = MessageEventContent(msgtype=MessageType.TEXT, body=content) + return self.client.send_message_event(self.target.room_id, event_type, content) + + def reply(self, content: Union[str, MessageEventContent], + event_type: EventType = EventType.ROOM_MESSAGE) -> Awaitable[EventID]: + if isinstance(content, str): + content = MessageEventContent(msgtype=MessageType.TEXT, body=content) + content.set_reply(self.target) + return self.client.send_message_event(self.target.room_id, event_type, content) + + def mark_read(self) -> Awaitable[None]: + return self.client.send_receipt(self.target.room_id, self.target.event_id, "m.read") diff --git a/tmp/fzipimport.py b/maubot/lib/zipimport.py similarity index 98% rename from tmp/fzipimport.py rename to maubot/lib/zipimport.py index f829309..0ef16cf 100644 --- a/tmp/fzipimport.py +++ b/maubot/lib/zipimport.py @@ -1,3 +1,9 @@ +# The pure Python implementation of zipimport in Python 3.8+. Slightly modified to allow clearing +# the zip directory cache to bypass https://bugs.python.org/issue19081 +# +# https://github.com/python/cpython/blob/5a5ce064b3baadcb79605c5a42ee3d0aee57cdfc/Lib/zipimport.py +# See license at https://github.com/python/cpython/blob/master/LICENSE + """zipimport provides support for importing Python modules from Zip archives. This module exports three objects: diff --git a/maubot/plugin.py b/maubot/plugin.py new file mode 100644 index 0000000..b952af0 --- /dev/null +++ b/maubot/plugin.py @@ -0,0 +1,190 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import Dict, List, TypeVar, Type +from zipfile import ZipFile, BadZipFile +import logging +import sys +import os +import io +import configparser + +from mautrix.types import UserID + +from .lib.zipimport import zipimporter, ZipImportError +from .db import DBPlugin +from .plugin_base import Plugin + +log = logging.getLogger("maubot.plugin") +PluginClass = TypeVar("PluginClass", bound=Plugin) + +class MaubotImportError(Exception): + pass + + +class ZippedModule: + path_cache: Dict[str, 'ZippedModule'] = {} + id_cache: Dict[str, 'ZippedModule'] = {} + + path: str + id: str + version: str + modules: List[str] + main_class: str + main_module: str + loaded: bool + _importer: zipimporter + + def __init__(self, path: str) -> None: + self.path = path + self.id = None + self.loaded = False + self._load_meta() + self._run_preload_checks(self._get_importer()) + self.path_cache[self.path] = self + self.id_cache[self.id] = self + + def __repr__(self) -> str: + return ("") + + def _load_meta(self) -> None: + try: + file = ZipFile(self.path) + data = file.read("maubot.ini") + except FileNotFoundError as e: + raise MaubotImportError(f"Maubot plugin not found at {self.path}") from e + except BadZipFile as e: + raise MaubotImportError(f"File at {self.path} is not a maubot plugin") from e + except KeyError as e: + raise MaubotImportError( + "File at {path} does not contain a maubot plugin definition") from e + config = configparser.ConfigParser() + try: + config.read_string(data.decode("utf-8"), source=f"{self.path}/maubot.ini") + meta = config["maubot"] + meta_id = meta["ID"] + version = meta["Version"] + modules = [mod.strip() for mod in meta["Modules"].split(",")] + main_class = meta["MainClass"] + main_module = modules[-1] + if "/" in main_class: + main_module, main_class = main_class.split("/")[:2] + except (configparser.Error, KeyError, IndexError, ValueError) as e: + raise MaubotImportError( + f"Maubot plugin definition in file at {self.path} is invalid") from e + if self.id and meta_id != self.id: + raise MaubotImportError("Maubot plugin ID changed during reload") + self.id, self.version, self.modules = meta_id, version, modules + self.main_class, self.main_module = main_class, main_module + + def _get_importer(self, reset_cache: bool = False) -> zipimporter: + try: + importer = zipimporter(self.path) + if reset_cache: + importer.reset_cache() + return importer + except ZipImportError as e: + raise MaubotImportError(f"File at {self.path} 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 MaubotImportError(f"Main class {self.main_class} not in {self.main_module}") + except ZipImportError as e: + raise MaubotImportError( + f"Main module {self.main_module} not found in {self.path}") from e + for module in self.modules: + try: + importer.find_module(module) + except ZipImportError as e: + raise MaubotImportError(f"Module {module} not found in {self.path}") from e + + def load(self) -> Type[PluginClass]: + importer = self._get_importer(reset_cache=self.loaded) + self._run_preload_checks(importer) + for module in self.modules: + importer.load_module(module) + self.loaded = True + main_mod = sys.modules[self.main_module] + plugin = getattr(main_mod, self.main_class) + if not issubclass(plugin, Plugin): + raise MaubotImportError( + f"Main class of plugin at {self.path} does not extend maubot.Plugin") + return plugin + + def reload(self) -> Type[PluginClass]: + self.unload() + return self.load() + + def unload(self) -> None: + for name, mod in list(sys.modules.items()): + if getattr(mod, "__file__", "").startswith(self.path): + del sys.modules[name] + + def destroy(self) -> None: + self.unload() + try: + del self.path_cache[self.path] + except KeyError: + pass + try: + del self.id_cache[self.id] + except KeyError: + pass + + +class PluginInstance: + cache: Dict[str, 'PluginInstance'] = {} + plugin_directories: List[str] = [] + + def __init__(self, db_instance: DBPlugin): + self.db_instance = db_instance + self.cache[self.id] = self + + @property + def id(self) -> str: + return self.db_instance.id + + @id.setter + def id(self, value: str) -> None: + self.db_instance.id = value + + @property + def type(self) -> str: + return self.db_instance.type + + @type.setter + def type(self, value: str) -> None: + self.db_instance.type = value + + @property + def enabled(self) -> bool: + return self.db_instance.enabled + + @enabled.setter + def enabled(self, value: bool) -> None: + self.db_instance.enabled = value + + @property + def primary_user(self) -> UserID: + return self.db_instance.primary_user + + @primary_user.setter + def primary_user(self, value: UserID) -> None: + self.db_instance.primary_user = value diff --git a/maubot/plugin_base.py b/maubot/plugin_base.py new file mode 100644 index 0000000..489352a --- /dev/null +++ b/maubot/plugin_base.py @@ -0,0 +1,31 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import TYPE_CHECKING +from abc import ABC + +if TYPE_CHECKING: + from mautrix import Client as MatrixClient + + +class Plugin(ABC): + def __init__(self, client: 'MatrixClient') -> None: + self.client = client + + async def start(self) -> None: + pass + + async def stop(self) -> None: + pass diff --git a/setup.py b/setup.py index 6f467be..ca44f69 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,10 @@ import setuptools -from maubot import __version__ +import os + +path = os.path.join(os.path.abspath(os.path.dirname(__file__)), "maubot", "__meta__.py") +__version__ = "UNKNOWN" +with open(path) as f: + exec(f.read()) setuptools.setup( name="maubot",