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",