diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3afa3d4..a6dd0ae 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -36,3 +36,32 @@ push tag: - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME + +build standalone: + stage: build + script: + - docker pull $CI_REGISTRY_IMAGE:standalone || true + - docker build --pull --cache-from $CI_REGISTRY_IMAGE:standalone --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone maubot/standalone + - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone + +push latest standalone: + stage: push + only: + - master + variables: + GIT_STRATEGY: none + script: + - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone + - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone $CI_REGISTRY_IMAGE:standalone + - docker push $CI_REGISTRY_IMAGE:standalone + +push tag standalone: + stage: push + variables: + GIT_STRATEGY: none + except: + - master + script: + - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone + - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME-standalone + - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME-standalone diff --git a/maubot/standalone/Dockerfile b/maubot/standalone/Dockerfile new file mode 100644 index 0000000..0dd0043 --- /dev/null +++ b/maubot/standalone/Dockerfile @@ -0,0 +1,20 @@ +FROM docker.io/alpine:3.10 + +RUN apk add --no-cache --virtual .build-deps \ + python3-dev \ + libffi-dev \ + build-base \ + && apk add --no-cache \ + py3-aiohttp \ + py3-sqlalchemy \ + py3-attrs \ + py3-bcrypt \ + ca-certificates \ + su-exec \ + py3-psycopg2 \ + py3-ruamel.yaml \ + py3-jinja2 \ + py3-packaging \ + py3-markdown \ + && pip3 install maubot \ + && apk del .build-deps diff --git a/maubot/standalone/__init__.py b/maubot/standalone/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/maubot/standalone/__main__.py b/maubot/standalone/__main__.py new file mode 100644 index 0000000..b250a57 --- /dev/null +++ b/maubot/standalone/__main__.py @@ -0,0 +1,212 @@ +# supportportal - A maubot plugin to manage customer support on Matrix. +# Copyright (C) 2019 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 Optional +from aiohttp import ClientSession +import logging.config +import importlib +import argparse +import asyncio +import signal +import copy +import sys + +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap +import sqlalchemy as sql + +from mautrix.util.config import RecursiveDict +from mautrix.util.db import Base +from mautrix.types import (UserID, Filter, RoomFilter, RoomEventFilter, StrippedStateEvent, + EventType, Membership) + +from .config import Config +from ..plugin_base import Plugin +from ..loader import PluginMeta +from ..matrix import MaubotMatrixClient +from ..lib.store_proxy import ClientStoreProxy +from ..__meta__ import __version__ + +from supportportal import SupportPortalBot + +parser = argparse.ArgumentParser( + description="A plugin-based Matrix bot system -- standalone mode.", + prog="python -m maubot.standalone") +parser.add_argument("-c", "--config", type=str, default="config.yaml", + metavar="", help="the path to your config file") +parser.add_argument("-b", "--base-config", type=str, default="example-config.yaml", + metavar="", help="the path to the example config " + "(for automatic config updates)") +parser.add_argument("-m", "--meta", type=str, default="maubot.yaml", + metavar="", help="the path to your plugin metadata file") +args = parser.parse_args() + +config = Config(args.config, args.base_config) +config.load() +try: + config.update() +except Exception as e: + print("Failed to update config:", e) + +logging.config.dictConfig(copy.deepcopy(config["logging"])) + +log = logging.getLogger("maubot.init") + +log.debug(f"Loading plugin metadata from {args.meta}") +yaml = YAML() +with open(args.meta, "r") as meta_file: + meta: PluginMeta = PluginMeta.deserialize(yaml.load(meta_file.read())) + +if "/" in meta.main_class: + module, main_class = meta.main_class.split("/", 1) +else: + module = meta.modules[0] + main_class = meta.main_class +bot_module = importlib.import_module(module) +plugin = getattr(bot_module, main_class) + +log.info(f"Initializing standalone {meta.id} v{meta.version} on maubot {__version__}") + + +class NextBatch(Base): + __tablename__ = "standalone_next_batch" + + user_id: str = sql.Column(sql.String(255), primary_key=True) + next_batch: str = sql.Column(sql.String(255)) + filter_id: str = sql.Column(sql.String(255)) + + @classmethod + def get(cls, user_id: UserID) -> Optional['NextBatch']: + return cls._select_one_or_none(cls.c.user_id == user_id) + + +log.debug("Opening database") +db = sql.create_engine(config["database"]) +Base.metadata.bind = db +Base.metadata.create_all() +NextBatch.bind(db) + +user_id = config["user.credentials.id"] +homeserver = config["user.credentials.homeserver"] +access_token = config["user.credentials.access_token"] + +nb = NextBatch.get(user_id) +if not nb: + nb = NextBatch(user_id=user_id, next_batch="", filter_id="") + nb.insert() + +bot_config = None +if meta.config: + log.debug("Loading config") + config_class = SupportPortalBot.get_config_class() + + + def load() -> CommentedMap: + return config["plugin_config"] + + + def load_base() -> RecursiveDict[CommentedMap]: + return RecursiveDict(config.load_base()["plugin_config"], CommentedMap) + + + def save(data: RecursiveDict[CommentedMap]) -> None: + config["plugin_config"] = data + config.save() + + + try: + bot_config = config_class(load=load, load_base=load_base, save=save) + bot_config.load_and_update() + except Exception: + log.fatal("Failed to load plugin config", exc_info=True) + sys.exit(1) + +loop = asyncio.get_event_loop() + +client: MaubotMatrixClient = None +bot: Plugin = None + + +async def main(): + http_client = ClientSession(loop=loop) + + global client, bot + + client = MaubotMatrixClient(mxid=user_id, base_url=homeserver, token=access_token, + client_session=http_client, loop=loop, store=ClientStoreProxy(nb), + log=logging.getLogger("maubot.client").getChild(user_id)) + + while True: + try: + whoami_user_id = await client.whoami() + except Exception: + log.exception("Failed to connect to homeserver, retrying in 10 seconds...") + await asyncio.sleep(10) + continue + if whoami_user_id != user_id: + log.fatal(f"User ID mismatch: configured {user_id}, but server said {whoami_user_id}") + sys.exit(1) + break + + if config["user.sync"]: + if not nb.filter_id: + nb.edit(filter_id=await client.create_filter(Filter( + room=RoomFilter(timeline=RoomEventFilter(limit=50)), + ))) + client.start(nb.filter_id) + + if config["autojoin"]: + log.debug("Autojoin is enabled") + + @client.on(EventType.ROOM_MEMBER) + async def _handle_invite(evt: StrippedStateEvent) -> None: + if evt.state_key == client.mxid and evt.content.membership == Membership.INVITE: + await client.join_room(evt.room_id) + + displayname, avatar_url = config["user.displayname"], config["user.avatar_url"] + if avatar_url != "disable": + await client.set_avatar_url(avatar_url) + if displayname != "disable": + await client.set_displayname(displayname) + + bot = plugin(client=client, loop=loop, http=http_client, instance_id="__main__", + log=logging.getLogger("maubot.instance.__main__"), config=bot_config, + database=db if meta.database else None, webapp=None, webapp_url=None) + + await bot.internal_start() + + +try: + log.info("Starting plugin") + loop.run_until_complete(main()) +except Exception: + log.fatal("Failed to start plugin", exc_info=True) + sys.exit(1) + +signal.signal(signal.SIGINT, signal.default_int_handler) +signal.signal(signal.SIGTERM, signal.default_int_handler) + +try: + log.info("Startup completed, running forever") + loop.run_forever() +except KeyboardInterrupt: + log.info("Interrupt received, stopping") + client.stop() + loop.run_until_complete(bot.internal_stop()) + loop.close() + sys.exit(0) +except Exception: + log.fatal("Fatal error in bot", exc_info=True) + sys.exit(1) diff --git a/maubot/standalone/config.py b/maubot/standalone/config.py new file mode 100644 index 0000000..2b0fdfe --- /dev/null +++ b/maubot/standalone/config.py @@ -0,0 +1,37 @@ +# maubot - A plugin-based Matrix bot system. +# Copyright (C) 2019 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 mautrix.util.config import BaseFileConfig, ConfigUpdateHelper + + +class Config(BaseFileConfig): + def do_update(self, helper: ConfigUpdateHelper) -> None: + copy, _, base = helper + copy("user.credentials.id") + copy("user.credentials.homeserver") + copy("user.credentials.access_token") + copy("user.sync") + copy("user.autojoin") + copy("user.displayname") + copy("user.avatar_url") + if "database" in base: + copy("database") + if "plugin_config" in base: + copy("plugin_config") + if "server" in base: + copy("server.hostname") + copy("server.port") + copy("server.public_url") + copy("logging")