Add a bunch of stuff

This commit is contained in:
Tulir Asokan 2018-10-14 22:08:11 +03:00
parent 60c3e8a4d3
commit 7924b70549
12 changed files with 373 additions and 10 deletions

View file

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

View file

@ -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

View file

@ -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")

1
maubot/__meta__.py Normal file
View file

@ -0,0 +1 @@
__version__ = "0.1.0+dev"

View file

@ -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)

52
maubot/command_spec.py Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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)

View file

@ -47,7 +47,7 @@ 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),
primary_user: UserID = Column(String(255),
ForeignKey("client.id", onupdate="CASCADE", ondelete="RESTRICT"),
nullable=False)

64
maubot/event.py Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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")

View file

@ -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:

190
maubot/plugin.py Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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 ("<maubot.plugin.ZippedModule "
f"path='{self.path}' "
f"id='{self.id}' "
f"loaded={self.loaded}>")
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

31
maubot/plugin_base.py Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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

View file

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