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