From 0ec98eb041a664b1326c677b4ed2502816067da2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sven=20M=C3=A4der?= <maeder@phys.ethz.ch>
Date: Tue, 12 Mar 2024 17:12:05 +0100
Subject: [PATCH] Allow to run predefined shell commands

---
 README.md                             | 121 +++++++++++++++++--
 base-config.yaml                      |  26 +++-
 exec/__init__.py                      |   1 -
 exec_cmd/__init__.py                  |   1 +
 {exec => exec_cmd}/bot.py             | 105 +++++++++++-----
 {exec => exec_cmd}/runner/__init__.py |   0
 {exec => exec_cmd}/runner/base.py     |   0
 {exec => exec_cmd}/runner/python.py   |   0
 {exec => exec_cmd}/runner/shell.py    |   0
 maubot.yaml                           |   8 +-
 mbcbuild.sh                           |   1 -
 standalone-example-config.yaml        | 165 ++++++++++++++++++++++++++
 12 files changed, 375 insertions(+), 53 deletions(-)
 delete mode 100644 exec/__init__.py
 create mode 100644 exec_cmd/__init__.py
 rename {exec => exec_cmd}/bot.py (77%)
 rename {exec => exec_cmd}/runner/__init__.py (100%)
 rename {exec => exec_cmd}/runner/base.py (100%)
 rename {exec => exec_cmd}/runner/python.py (100%)
 rename {exec => exec_cmd}/runner/shell.py (100%)
 delete mode 100755 mbcbuild.sh
 create mode 100644 standalone-example-config.yaml

diff --git a/README.md b/README.md
index b7dd7dc..9a3b6d0 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,116 @@
-# exec
+# exec-cmd
+
+## cmd
+
+A [maubot](https://github.com/maubot/maubot) that executes predefined shell commands.
+
+### Setup
+
+Instructions to run the bot in [standalone](https://docs.mau.fi/maubot/usage/standalone.html) mode.
+
+Install dependencies and create a local user:
+
+```bash
+apt install python3-pip python3-setuptools python3-wheel python3-venv
+useradd -r -d /opt/maubot -s /usr/sbin/nologin maubot
+mkdir /opt/maubot
+chown -R maubot:maubot /opt/maubot
+su - maubot -s /bin/bash
+```
+
+Install maubot as maubot system user in a venv:
+
+```bash
+python3 -m venv env
+source env/bin/activate
+pip install --upgrade pip setuptools wheel
+pip install --upgrade maubot[all]
+```
+
+Clone and setup the maubot plugin:
+
+```bash
+git clone https://gitlab.phys.ethz.ch/isgphys/maubot-exec-cmd.git git
+ln -s git/maubot.yaml
+ln -s git/exec_cmd
+cp standalone-example-config.yaml config.yaml
+```
+
+Generate an `access_token` and `device_id` (login) using the
+[client-server api](https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3login)
+with a cli http tool such as [httpie](https://httpie.io/):
+
+```bash
+http POST 'https://example.com/_matrix/client/v3/login' <<<'{"identifier":{"type":"m.id.user","user":"botusername"},"initial_device_display_name":"Standalone Bot","password":"ilovebananas","type":"m.login.password"}'
+```
+
+Invite the bot user to your room and note the `<roomid_or_alias>`.
+
+Manually join the bot user to the room using the
+[client-server api](https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3joinroomidoralias):
+
+```bash
+http POST 'https://example.com/_matrix/client/v3/join/<roomid_or_alias>' Authorization:"Bearer <access_token>"
+```
+
+### Configuration
+
+Configure the required bot user credentials: `id`, `homeserver`, `access_token` and `device_id`.
+
+It is recommended to leave `user.autojoin` on `false` and use a manual join as shown above.
+
+The command prefix can be configured with:
+
+```yaml
+prefix_cmd: 'cmd'
+```
+
+The list of Matrix user IDs who are allowed to execute predefined shell commands:
+
+```yaml
+whitelist_cmd:
+- '@user:example.com'
+```
+
+The `commands` dictionary holds all predefined commands.
+The keys represent the command names that can be sent to the bot.
+For multi word bot commands `_` must be used as delimiter instead of spaces (` `).
+
+```yaml
+commands:
+    ps: ps -ef | grep maubot
+    device_off: echo powering off
+    device_start: |
+        echo device power on
+        sleep 5
+        echo device is starting
+        sleep 5
+        echo device started
+```
+
+### Usage
+
+Start the bot:
+
+```bash
+python -m maubot.standalone
+```
+
+Available bot commands with the above:
+
+```yaml
+!cmd                # lists available commands
+!cmd ps
+!cmd device off
+!cmd device start
+```
+
+
+## exec
 A [maubot](https://github.com/maubot/maubot) that executes code.
 exec is updated to be compatible with python 3.8+.
 
-## Usage
+### Usage
 The bot is triggered by a specific message prefix (defaults to `!exec`) and
 executes the code in the first code block.
 
@@ -35,12 +143,3 @@ If running in userbot mode, the bot will edit your original message instead of
 making a new reply message.
 
 Currently, the bot supports `python` and `shell` as languages.
-
-
-## Contributors
-
-  <a href="https://github.com/YingzhouLi/exec/graphs/contributors">
-  <img src="https://contrib.rocks/image?repo=YingzhouLi/exec" />
-  </a>
-
-  Made with [contrib.rocks](https://contrib.rocks).
diff --git a/base-config.yaml b/base-config.yaml
index e82cab8..061a42b 100644
--- a/base-config.yaml
+++ b/base-config.yaml
@@ -1,13 +1,31 @@
-# The message prefix to treat as exec commands.
-prefix: '!exec'
+# The message prefix to run predefined shell commands (excluding `!`).
+prefix_cmd: 'cmd'
+# The list of user IDs who are allowed to execute predefined shell commands.
+# There is absolutely no sandboxing in maubot or this plugin, keep this list small.
+whitelist_cmd:
+- '@user:example.com'
+# Dict with predefined shell commands
+# Key: command name (use `_` as placeholder for spaces ` ` in multi word commands)
+# Value: command or shell script to execute
+commands:
+    ps: ps -ef | grep maubot
+    device_off: echo powering off
+    device_start: |
+        echo device power on
+        sleep 5
+        echo device is starting
+        sleep 5
+        echo device started
+
+# The message prefix to treat as exec commands (including `!`).
+prefix_exec: '!exec'
 # Whether or not to enable "userbot" mode, where commands that the bot's user
 # sends are handled and responded to with edits instead of replies.
 # This is intended to be used when you run the plugin on your own account.
 userbot: false
 # The list of user IDs who are allowed to execute stuff. There is absolutely no
 # sandboxing in maubot or this plugin, keep this list small.
-whitelist:
-- '@user:example.com'
+whitelist_exec: []
 
 output:
     # Number of seconds to wait between output update edits.
diff --git a/exec/__init__.py b/exec/__init__.py
deleted file mode 100644
index 19d8add..0000000
--- a/exec/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .bot import ExecBot
diff --git a/exec_cmd/__init__.py b/exec_cmd/__init__.py
new file mode 100644
index 0000000..6824790
--- /dev/null
+++ b/exec_cmd/__init__.py
@@ -0,0 +1 @@
+from .bot import ExecCmdBot
diff --git a/exec/bot.py b/exec_cmd/bot.py
similarity index 77%
rename from exec/bot.py
rename to exec_cmd/bot.py
index 3294550..f81a8af 100644
--- a/exec/bot.py
+++ b/exec_cmd/bot.py
@@ -13,7 +13,7 @@
 #
 # 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, Set, Optional, Any
+from typing import Type, Set, Optional, Any, Dict
 from io import StringIO
 from html import escape as escape_orig
 from time import time
@@ -24,7 +24,7 @@ from mautrix.types import EventType, UserID, TextMessageEventContent, MessageTyp
 from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
 from mautrix.util.formatter import MatrixParser, EntityString, SimpleEntity, EntityType
 from maubot import Plugin, MessageEvent
-from maubot.handlers import event
+from maubot.handlers import event, command
 
 from .runner import PythonRunner, ShellRunner, OutputType
 
@@ -33,28 +33,38 @@ def escape(val: Optional[str]) -> Optional[str]:
     return escape_orig(val) if val is not None else None
 
 
+class ConfigValidationError(Exception):
+    pass
+
+
 class EntityParser(MatrixParser[EntityString]):
     fs = EntityString[SimpleEntity, EntityType]
 
 
 class Config(BaseProxyConfig):
     def do_update(self, helper: ConfigUpdateHelper) -> None:
-        helper.copy("prefix")
+        helper.copy("prefix_exec")
+        helper.copy("prefix_cmd")
         helper.copy("userbot")
-        helper.copy("whitelist")
+        helper.copy("whitelist_exec")
+        helper.copy("whitelist_cmd")
         helper.copy("output.interval")
         helper.copy("output.template_args")
         helper.copy("output.plaintext")
         helper.copy("output.html")
+        helper.copy("commands")
 
 
-class ExecBot(Plugin):
-    whitelist: Set[UserID]
+class ExecCmdBot(Plugin):
+    whitelist_exec: Set[UserID]
+    whitelist_cmd: Set[UserID]
     userbot: bool
-    prefix: str
+    prefix_exec: str
+    prefix_cmd: str
     output_interval: int
     plaintext_template: Template
     html_template: Template
+    commands: Dict
 
     @classmethod
     def get_config_class(cls) -> Type[BaseProxyConfig]:
@@ -65,13 +75,18 @@ class ExecBot(Plugin):
 
     def on_external_config_update(self) -> None:
         self.config.load_and_update()
-        self.whitelist = set(self.config["whitelist"])
+        self.whitelist_exec = set(self.config["whitelist_exec"])
+        self.whitelist_cmd = set(self.config["whitelist_cmd"])
         self.userbot = self.config["userbot"]
-        self.prefix = self.config["prefix"]
+        self.prefix_exec = self.config["prefix_exec"]
+        self.prefix_cmd = self.config["prefix_cmd"]
         self.output_interval = self.config["output.interval"]
         template_args = self.config["output.template_args"]
         self.plaintext_template = Template(self.config["output.plaintext"], **template_args)
         self.html_template = Template(self.config["output.html"], **template_args)
+        self.commands = self.config["commands"]
+        if any(" " in cmd for cmd in self.commands):
+            raise ConfigValidationError("commands keys contain spaces")
 
     def format_status(self, code: str, language: str, output: str = "", output_html: str = "",
                       return_value: Any = None, exception_header: Optional[str] = None,
@@ -89,29 +104,7 @@ class ExecBot(Plugin):
                 exception_header=escape(exception_header)))
         return content
 
-    @event.on(EventType.ROOM_MESSAGE)
-    async def exec(self, evt: MessageEvent) -> None:
-        if ((evt.content.msgtype != MessageType.TEXT
-             or evt.sender not in self.whitelist
-             or not evt.content.body.startswith(self.prefix)
-             or not evt.content.formatted_body)):
-            return
-
-        command = await EntityParser().parse(evt.content.formatted_body)
-        entity: SimpleEntity
-        code: Optional[str] = None
-        lang: Optional[str] = None
-        stdin: str = ""
-        for entity in command.entities:
-            if entity.type != EntityType.PREFORMATTED:
-                continue
-            current_lang = entity.extra_info["language"].lower()
-            value = command.text[entity.offset:entity.offset + entity.length]
-            if not code:
-                code = value
-                lang = current_lang
-            elif current_lang == "stdin" or current_lang == "input":
-                stdin += value
+    async def exec_runner(self, evt: MessageEvent, lang: str, code: str, stdin: str = "") -> None:
         if not code or not lang:
             return
 
@@ -170,3 +163,51 @@ class ExecBot(Plugin):
                                      msgtype=msgtype)
         content.set_edit(output_event_id)
         await self.client.send_message(evt.room_id, content)
+
+    @event.on(EventType.ROOM_MESSAGE)
+    async def exec(self, evt: MessageEvent) -> None:
+        if ((evt.content.msgtype != MessageType.TEXT
+             or evt.sender not in self.whitelist_exec
+             or not evt.content.body.startswith(self.prefix_exec)
+             or not evt.content.formatted_body)):
+            return
+
+        command = await EntityParser().parse(evt.content.formatted_body)
+        entity: SimpleEntity
+        code: Optional[str] = None
+        lang: Optional[str] = None
+        stdin: str = ""
+        for entity in command.entities:
+            if entity.type != EntityType.PREFORMATTED:
+                continue
+            current_lang = entity.extra_info["language"].lower()
+            value = command.text[entity.offset:entity.offset + entity.length]
+            if not code:
+                code = value
+                lang = current_lang
+            elif current_lang == "stdin" or current_lang == "input":
+                stdin += value
+        if not code or not lang:
+            return
+
+        await self.exec_runner(evt, lang, code, stdin=stdin)
+
+    @command.new(name=lambda self: self.prefix_cmd)
+    @command.argument("command", pass_raw=True, required=True)
+    async def cmd(self, evt: MessageEvent, command: str) -> None:
+        if evt.sender not in self.whitelist_cmd:
+            return
+
+        if not command:
+            available = ["`" + c.replace("_", " ") + "`" for c in self.commands.keys()]
+            available_list = "- " + "\n- ".join(available)
+            await evt.reply(f"available commands:\n{available_list}")
+            return
+
+        key: str
+        key = command.replace(" ", "_")
+        if key not in self.commands.keys():
+            await evt.reply("unknown command")
+            return
+
+        await self.exec_runner(evt, "sh", self.commands[key])
diff --git a/exec/runner/__init__.py b/exec_cmd/runner/__init__.py
similarity index 100%
rename from exec/runner/__init__.py
rename to exec_cmd/runner/__init__.py
diff --git a/exec/runner/base.py b/exec_cmd/runner/base.py
similarity index 100%
rename from exec/runner/base.py
rename to exec_cmd/runner/base.py
diff --git a/exec/runner/python.py b/exec_cmd/runner/python.py
similarity index 100%
rename from exec/runner/python.py
rename to exec_cmd/runner/python.py
diff --git a/exec/runner/shell.py b/exec_cmd/runner/shell.py
similarity index 100%
rename from exec/runner/shell.py
rename to exec_cmd/runner/shell.py
diff --git a/maubot.yaml b/maubot.yaml
index b316d5d..d85de03 100644
--- a/maubot.yaml
+++ b/maubot.yaml
@@ -1,9 +1,9 @@
 maubot: 0.1.0
-id: xyz.maubot.exec
-version: 0.2.0
+id: ch.ethz.phys.exec-cmd
+version: 0.0.1
 license: AGPL-3.0-or-later
 modules:
-- exec
-main_class: ExecBot
+- exec_cmd
+main_class: ExecCmdBot
 extra_files:
 - base-config.yaml
diff --git a/mbcbuild.sh b/mbcbuild.sh
deleted file mode 100755
index 201a951..0000000
--- a/mbcbuild.sh
+++ /dev/null
@@ -1 +0,0 @@
-zip -9r exec-develop.mbp exec maubot.yaml base-config.yaml
diff --git a/standalone-example-config.yaml b/standalone-example-config.yaml
new file mode 100644
index 0000000..265a808
--- /dev/null
+++ b/standalone-example-config.yaml
@@ -0,0 +1,165 @@
+# Bot account details
+user:
+    credentials:
+        id: "@bot:example.com"
+        homeserver: https://example.com
+        access_token: foo
+        # If you want to enable encryption, set the device ID corresponding to the access token here.
+        # When using an appservice, you should use appservice login manually to generate a device ID and access token.
+        device_id: null
+    # Enable /sync? This is not needed for purely unencrypted webhook-based bots, but is necessary in most other cases.
+    sync: true
+    # Receive appservice transactions? This will add a /_matrix/app/v1/transactions endpoint on
+    # the HTTP server configured below. The base_path will not be applied for the /transactions path.
+    appservice: false
+    # When appservice mode is enabled, the hs_token for the appservice.
+    hs_token: null
+    # Automatically accept invites?
+    autojoin: false
+    # The displayname and avatar URL to set for the bot on startup.
+    # Set to "disable" to not change the the current displayname/avatar.
+    displayname: Standalone Bot
+    avatar_url: mxc://maunium.net/AKwRzQkTbggfVZGEqexbYLIO
+
+    # Should events from the initial sync be ignored? This should usually always be true.
+    ignore_initial_sync: true
+    # Should events from the first sync after starting be ignored? This can be set to false
+    # if you want the bot to handle messages that were sent while the bot was down.
+    ignore_first_sync: true
+
+# Web server settings. These will only take effect if the plugin requests it using `webapp: true` in the meta file,
+# or if user -> appservice is set to true.
+server:
+    # The IP and port to listen to.
+    hostname: 0.0.0.0
+    port: 8080
+    # The base path where the plugin's web resources will be served. Unlike the normal mode,
+    # the webserver is dedicated for a single bot in standalone mode, so the default path
+    # is just /. If you want to emulate normal mode, set this to /_matrix/maubot/plugin/something
+    base_path: /
+    # The public URL where the resources are available. The base path is automatically appended to this.
+    public_url: https://example.com
+
+# The database for the plugin. Used for plugin data, the sync token and e2ee data (if enabled).
+# SQLite and Postgres are supported.
+database: sqlite:bot.db
+
+# Additional arguments for asyncpg.create_pool() or sqlite3.connect()
+# https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.pool.create_pool
+# https://docs.python.org/3/library/sqlite3.html#sqlite3.connect
+# For sqlite, min_size is used as the connection thread pool size and max_size is ignored.
+database_opts:
+    min_size: 1
+    max_size: 10
+
+# Config for the plugin. Refer to the plugin's base-config.yaml to find what (if anything) to put here.
+plugin_config:
+    # The message prefix to run predefined shell commands (excluding `!`).
+    prefix_cmd: 'cmd'
+    # The list of user IDs who are allowed to execute predefined shell commands.
+    # There is absolutely no sandboxing in maubot or this plugin, keep this list small.
+    whitelist_cmd:
+    - '@user:example.com'
+    # Dict with predefined shell commands
+    # Key: command name (use `_` as placeholder for spaces ` ` in multi word commands)
+    # Value: command or shell script to execute
+    commands:
+        ps: ps -ef | grep maubot
+        device_off: echo powering off
+        device_start: |
+            echo device power on
+            sleep 5
+            echo device is starting
+            sleep 5
+            echo device started
+
+    # The message prefix to treat as exec commands (including `!`).
+    prefix_exec: '!exec'
+    # Whether or not to enable "userbot" mode, where commands that the bot's user
+    # sends are handled and responded to with edits instead of replies.
+    # This is intended to be used when you run the plugin on your own account.
+    userbot: false
+    # The list of user IDs who are allowed to execute stuff. There is absolutely no
+    # sandboxing in maubot or this plugin, keep this list small.
+    whitelist_exec: []
+
+    output:
+        # Number of seconds to wait between output update edits.
+        interval: 5
+        # Arguments for the Jinja2 template initialization.
+        template_args:
+            lstrip_blocks: true
+            trim_blocks: yes
+        # Plaintext output template.
+        plaintext: |
+            Input ({{ input }}):
+            {{ code }}
+            {% if output %}
+
+            Output:
+            {{ output }}
+            {% endif %}
+            {% if return_value != None %}
+
+            Return:
+            {{ return_value }}
+            {% endif %}
+            {% if exception != None %}
+
+            {% if exception_header %}{{ exception_header }}:{% endif %}
+            {{ exception }}
+            {% endif %}
+            {% if duration != None %}
+
+            Took {{ duration | round(3) }} seconds
+            {% else %}
+
+            Running...
+            {% endif %}
+        # HTML output template.
+        html: |
+            <h4>Input</h4>
+            <pre><code class="language-{{ language }}">{{ code }}</code></pre>
+            {% if output %}
+            <h4>Output</h4>
+            <pre>{{ output }}</pre>
+            {% endif %}
+            {% if return_value != None %}
+                {% if language in ("bash", "sh", "shell") %}
+                <h4>Return: <code>{{ return_value }}</code></h4>
+                {% else %}
+                <h4>Return</h4>
+                <pre>{{ return_value }}</pre>
+                {% endif %}
+            {% endif %}
+            {% if exception != None %}
+                {% if exception_header %}<h4>{{ exception_header }}</h4>{% endif %}
+                <pre><code class="language-pytb">{{ exception }}</code></pre>
+            {% endif %}
+            {% if duration != None %}
+                <h4>Took {{ duration | round(3) }} seconds</h4>
+            {% else %}
+                <h4>Running...</h4>
+            {% endif %}
+
+# Standard Python logging configuration
+logging:
+    version: 1
+    formatters:
+        colored:
+            (): maubot.lib.color_log.ColorFormatter
+            format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
+    handlers:
+        console:
+            class: logging.StreamHandler
+            formatter: colored
+    loggers:
+        maubot:
+            level: DEBUG
+        mau:
+            level: DEBUG
+        aiohttp:
+            level: INFO
+    root:
+        level: DEBUG
+        handlers: [console]
-- 
GitLab