Add Cloudron packaging for Maubot
This commit is contained in:
5
maubot-src/.dockerignore
Normal file
5
maubot-src/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.editorconfig
|
||||
.codeclimate.yml
|
||||
*.png
|
||||
.venv
|
||||
maubot/management/frontend/node_modules
|
||||
19
maubot-src/.editorconfig
Normal file
19
maubot-src/.editorconfig
Normal file
@@ -0,0 +1,19 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 99
|
||||
|
||||
[*.json]
|
||||
indent_size = 2
|
||||
|
||||
[spec.yaml]
|
||||
indent_size = 2
|
||||
|
||||
[CHANGELOG.md]
|
||||
max_line_length = 80
|
||||
26
maubot-src/.github/workflows/python-lint.yml
vendored
Normal file
26
maubot-src/.github/workflows/python-lint.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Python lint
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- uses: isort/isort-action@master
|
||||
with:
|
||||
sortPaths: "./maubot"
|
||||
- uses: psf/black@stable
|
||||
with:
|
||||
src: "./maubot"
|
||||
version: "24.10.0"
|
||||
- name: pre-commit
|
||||
run: |
|
||||
pip install pre-commit
|
||||
pre-commit run -av trailing-whitespace
|
||||
pre-commit run -av end-of-file-fixer
|
||||
pre-commit run -av check-yaml
|
||||
pre-commit run -av check-added-large-files
|
||||
19
maubot-src/.gitignore
vendored
Normal file
19
maubot-src/.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
build/
|
||||
dist/
|
||||
*.egg-info
|
||||
|
||||
.venv
|
||||
pip-selfcheck.json
|
||||
*.pyc
|
||||
__pycache__
|
||||
|
||||
*.db*
|
||||
*.log
|
||||
/*.yaml
|
||||
!example-config.yaml
|
||||
!.pre-commit-config.yaml
|
||||
|
||||
/start
|
||||
logs/
|
||||
plugins/
|
||||
trash/
|
||||
29
maubot-src/.gitlab-ci-plugin.yml
Normal file
29
maubot-src/.gitlab-ci-plugin.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
image: dock.mau.dev/maubot/maubot
|
||||
|
||||
stages:
|
||||
- build
|
||||
|
||||
variables:
|
||||
PYTHONPATH: /opt/maubot
|
||||
|
||||
build:
|
||||
stage: build
|
||||
except:
|
||||
- tags
|
||||
script:
|
||||
- python3 -m maubot.cli build -o xyz.maubot.$CI_PROJECT_NAME-$CI_COMMIT_REF_NAME-$CI_COMMIT_SHORT_SHA.mbp
|
||||
artifacts:
|
||||
paths:
|
||||
- "*.mbp"
|
||||
expire_in: 365 days
|
||||
|
||||
build tags:
|
||||
stage: build
|
||||
only:
|
||||
- tags
|
||||
script:
|
||||
- python3 -m maubot.cli build -o xyz.maubot.$CI_PROJECT_NAME-$CI_COMMIT_TAG.mbp
|
||||
artifacts:
|
||||
paths:
|
||||
- "*.mbp"
|
||||
expire_in: never
|
||||
86
maubot-src/.gitlab-ci.yml
Normal file
86
maubot-src/.gitlab-ci.yml
Normal file
@@ -0,0 +1,86 @@
|
||||
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/docker:latest
|
||||
|
||||
stages:
|
||||
- build frontend
|
||||
- build
|
||||
- manifest
|
||||
|
||||
default:
|
||||
before_script:
|
||||
- docker login -u "$CI_DEPENDENCY_PROXY_USER" -p "$CI_DEPENDENCY_PROXY_PASSWORD" "$CI_DEPENDENCY_PROXY_SERVER"
|
||||
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
|
||||
|
||||
build frontend:
|
||||
image: node:24-alpine
|
||||
stage: build frontend
|
||||
before_script: []
|
||||
variables:
|
||||
NODE_ENV: "production"
|
||||
cache:
|
||||
paths:
|
||||
- maubot/management/frontend/node_modules
|
||||
script:
|
||||
- cd maubot/management/frontend
|
||||
- yarn --prod
|
||||
- yarn build
|
||||
- mv build ../../../frontend
|
||||
artifacts:
|
||||
paths:
|
||||
- frontend
|
||||
expire_in: 1 hour
|
||||
|
||||
build amd64:
|
||||
stage: build
|
||||
tags:
|
||||
- amd64
|
||||
script:
|
||||
- echo maubot/management/frontend >> .dockerignore
|
||||
- docker pull $CI_REGISTRY_IMAGE:latest || true
|
||||
- |
|
||||
docker build \
|
||||
--pull --cache-from $CI_REGISTRY_IMAGE:latest \
|
||||
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 \
|
||||
--build-arg DOCKER_HUB=$CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX \
|
||||
. -f Dockerfile.ci
|
||||
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
|
||||
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
|
||||
|
||||
build arm64:
|
||||
stage: build
|
||||
tags:
|
||||
- arm64
|
||||
script:
|
||||
- echo maubot/management/frontend >> .dockerignore
|
||||
- docker pull $CI_REGISTRY_IMAGE:latest || true
|
||||
- |
|
||||
docker build \
|
||||
--pull --cache-from $CI_REGISTRY_IMAGE:latest \
|
||||
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 \
|
||||
--build-arg DOCKER_HUB=$CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX \
|
||||
. -f Dockerfile.ci
|
||||
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
|
||||
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
|
||||
|
||||
manifest:
|
||||
stage: manifest
|
||||
before_script:
|
||||
- "mkdir -p $HOME/.docker && echo '{\"experimental\": \"enabled\"}' > $HOME/.docker/config.json"
|
||||
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
|
||||
script:
|
||||
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
|
||||
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
|
||||
- if [ "$CI_COMMIT_BRANCH" = "master" ]; then docker manifest create $CI_REGISTRY_IMAGE:latest $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 && docker manifest push $CI_REGISTRY_IMAGE:latest; fi
|
||||
- if [ "$CI_COMMIT_BRANCH" != "master" ]; then docker manifest create $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 && docker manifest push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME; fi
|
||||
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
|
||||
|
||||
|
||||
build standalone amd64:
|
||||
stage: build
|
||||
tags:
|
||||
- amd64
|
||||
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 . -f maubot/standalone/Dockerfile
|
||||
- if [ "$CI_COMMIT_BRANCH" = "master" ]; then docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone $CI_REGISTRY_IMAGE:standalone && docker push $CI_REGISTRY_IMAGE:standalone; fi
|
||||
- if [ "$CI_COMMIT_BRANCH" != "master" ]; then 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; fi
|
||||
- docker rmi $CI_REGISTRY_IMAGE:standalone $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME-standalone $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-standalone || true
|
||||
20
maubot-src/.pre-commit-config.yaml
Normal file
20
maubot-src/.pre-commit-config.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude_types: [markdown]
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.10.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3
|
||||
files: ^maubot/.*\.pyi?$
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.13.2
|
||||
hooks:
|
||||
- id: isort
|
||||
files: ^maubot/.*\.pyi?$
|
||||
171
maubot-src/CHANGELOG.md
Normal file
171
maubot-src/CHANGELOG.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# v0.6.0 (unreleased)
|
||||
|
||||
* Added support for room v12 creator power when checking power levels after
|
||||
following a tombstone.
|
||||
* Added support for verifying the maubot device using a recovery key.
|
||||
* Improved cutting off long plaintext body when parsing markdown.
|
||||
|
||||
# v0.5.2 (2025-05-05)
|
||||
|
||||
* Improved tombstone handling to ensure that the tombstone sender has
|
||||
permissions to invite users to the target room.
|
||||
* Fixed autojoin and online flags not being applied if set during client
|
||||
creation (thanks to [@bnsh] in [#258]).
|
||||
* Fixed plugin web apps not being cleared properly when unloading plugins.
|
||||
|
||||
[@bnsh]: https://github.com/bnsh
|
||||
[#258]: https://github.com/maubot/maubot/pull/258
|
||||
|
||||
# v0.5.1 (2025-01-03)
|
||||
|
||||
* Updated Docker image to Alpine 3.21.
|
||||
* Updated media upload/download endpoints in management frontend
|
||||
(thanks to [@domrim] in [#253]).
|
||||
* Fixed plugin web app base path not including a trailing slash
|
||||
(thanks to [@jkhsjdhjs] in [#240]).
|
||||
* Changed markdown parsing to cut off plaintext body if necessary to allow
|
||||
longer formatted messages.
|
||||
* Updated dependencies to fix Python 3.13 compatibility.
|
||||
|
||||
[@domrim]: https://github.com/domrim
|
||||
[@jkhsjdhjs]: https://github.com/jkhsjdhjs
|
||||
[#253]: https://github.com/maubot/maubot/pull/253
|
||||
[#240]: https://github.com/maubot/maubot/pull/240
|
||||
|
||||
# v0.5.0 (2024-08-24)
|
||||
|
||||
* Dropped Python 3.9 support.
|
||||
* Updated Docker image to Alpine 3.20.
|
||||
* Updated mautrix-python to 0.20.6 to support authenticated media.
|
||||
* Removed hard dependency on SQLAlchemy.
|
||||
* Fixed `main_class` to default to being loaded from the last module instead of
|
||||
the first if a module name is not explicitly specified.
|
||||
* This was already the [documented behavior](https://docs.mau.fi/maubot/dev/reference/plugin-metadata.html),
|
||||
and loading from the first module doesn't make sense due to import order.
|
||||
* Added simple scheduler utility for running background tasks periodically or
|
||||
after a certain delay.
|
||||
* Added testing framework for plugins (thanks to [@abompard] in [#225]).
|
||||
* Changed `mbc build` to ignore directories declared in `modules` that are
|
||||
missing an `__init__.py` file.
|
||||
* Importing the modules at runtime would fail and break the plugin.
|
||||
To include non-code resources outside modules in the mbp archive,
|
||||
use `extra_files` instead.
|
||||
|
||||
[#225]: https://github.com/maubot/maubot/issues/225
|
||||
[@abompard]: https://github.com/abompard
|
||||
|
||||
# v0.4.2 (2023-09-20)
|
||||
|
||||
* Updated Pillow to 10.0.1.
|
||||
* Updated Docker image to Alpine 3.18.
|
||||
* Added logging for errors for /whoami errors when adding new bot accounts.
|
||||
* Added support for using appservice tokens (including appservice encryption)
|
||||
in standalone mode.
|
||||
|
||||
# v0.4.1 (2023-03-15)
|
||||
|
||||
* Added `in_thread` parameter to `evt.reply()` and `evt.respond()`.
|
||||
* By default, responses will go to the thread if the command is in a thread.
|
||||
* By setting the flag to `True` or `False`, the plugin can force the response
|
||||
to either be or not be in a thread.
|
||||
* Fixed static files like the frontend app manifest not being served correctly.
|
||||
* Fixed `self.loader.meta` not being available to plugins in standalone mode.
|
||||
* Updated to mautrix-python v0.19.6.
|
||||
|
||||
# v0.4.0 (2023-01-29)
|
||||
|
||||
* Dropped support for using a custom maubot API base path.
|
||||
* The public URL can still have a path prefix, e.g. when using a reverse
|
||||
proxy. Both the web interface and `mbc` CLI tool should work fine with
|
||||
custom prefixes.
|
||||
* Added `evt.redact()` as a shortcut for `self.client.redact(evt.room_id, evt.event_id)`.
|
||||
* Fixed `mbc logs` command not working on Python 3.8+.
|
||||
* Fixed saving plugin configs (broke in v0.3.0).
|
||||
* Fixed SSO login using the wrong API path (probably broke in v0.3.0).
|
||||
* Stopped using `cd` in the docker image's `mbc` wrapper to enable using
|
||||
path-dependent commands like `mbc build` by mounting a directory.
|
||||
* Updated Docker image to Alpine 3.17.
|
||||
|
||||
# v0.3.1 (2022-03-29)
|
||||
|
||||
* Added encryption dependencies to standalone dockerfile.
|
||||
* Fixed running without encryption dependencies installed.
|
||||
* Removed unnecessary imports that broke on SQLAlchemy 1.4+.
|
||||
* Removed unused alembic dependency.
|
||||
|
||||
# v0.3.0 (2022-03-28)
|
||||
|
||||
* Dropped Python 3.7 support.
|
||||
* Switched main maubot database to asyncpg/aiosqlite.
|
||||
* Using the same SQLite database for crypto is now safe again.
|
||||
* Added support for asyncpg/aiosqlite for plugin databases.
|
||||
* There are some [basic docs](https://docs.mau.fi/maubot/dev/database/index.html)
|
||||
and [a simple example](./examples/database) for the new system.
|
||||
* The old SQLAlchemy system is now deprecated, but will be preserved for
|
||||
backwards-compatibility until most plugins have updated.
|
||||
* Started enforcing minimum maubot version in plugins.
|
||||
* Trying to upload a plugin where the specified version is higher than the
|
||||
running maubot version will fail.
|
||||
* Fixed bug where uploading a plugin twice, deleting it and trying to upload
|
||||
again would fail.
|
||||
* Updated Docker image to Alpine 3.15.
|
||||
* Formatted all code using [black](https://github.com/psf/black)
|
||||
and [isort](https://github.com/PyCQA/isort).
|
||||
|
||||
# v0.2.1 (2021-11-22)
|
||||
|
||||
Docker-only release: added automatic moving of plugin databases from
|
||||
`/data/plugins/*.db` to `/data/dbs`
|
||||
|
||||
# v0.2.0 (2021-11-20)
|
||||
|
||||
* Moved plugin databases from `/data/plugins` to `/data/dbs` in the docker image.
|
||||
* v0.2.0 was missing the automatic migration of databases, it was added in v0.2.1.
|
||||
* If you were using a custom path, you'll have to mount it at `/data/dbs` or
|
||||
move the databases yourself.
|
||||
* Removed support for pickle crypto store and added support for SQLite crypto store.
|
||||
* **If you were previously using the dangerous pickle store for e2ee, you'll
|
||||
have to re-login with the bots (which can now be done conveniently with
|
||||
`mbc auth --update-client`).**
|
||||
* Added SSO support to `mbc auth`.
|
||||
* Added support for setting device ID for e2ee using the web interface.
|
||||
* Added e2ee fingerprint field to the web interface.
|
||||
* Added `--update-client` flag to store access token inside maubot instead of
|
||||
returning it in `mbc auth`.
|
||||
* This will also automatically store the device ID now.
|
||||
* Updated standalone mode.
|
||||
* Added e2ee and web server support.
|
||||
* It's now officially supported and [somewhat documented](https://docs.mau.fi/maubot/usage/standalone.html).
|
||||
* Replaced `_` with `-` when generating command name from function name.
|
||||
* Replaced unmaintained PyInquirer dependency with questionary
|
||||
(thanks to [@TinfoilSubmarine] in [#139]).
|
||||
* Updated Docker image to Alpine 3.14.
|
||||
* Fixed avatar URLs without the `mxc://` prefix appearing like they work in the
|
||||
frontend, but not actually working when saved.
|
||||
|
||||
[@TinfoilSubmarine]: https://github.com/TinfoilSubmarine
|
||||
[#139]: https://github.com/maubot/maubot/pull/139
|
||||
|
||||
# v0.1.2 (2021-06-12)
|
||||
|
||||
* Added `loader` instance property for plugins to allow reading files within
|
||||
the plugin archive.
|
||||
* Added support for reloading `webapp` and `database` meta flags in plugins.
|
||||
Previously you had to restart maubot instead of just reloading the plugin
|
||||
when enabling the webapp or database for the first time.
|
||||
* Added warning log if a plugin uses `@web` decorators without enabling the
|
||||
`webapp` meta flag.
|
||||
* Updated frontend to latest React and dependency versions.
|
||||
* Updated Docker image to Alpine 3.13.
|
||||
* Fixed registering accounts with Synapse shared secret registration.
|
||||
* Fixed plugins using `get_event` in encrypted rooms.
|
||||
* Fixed using the `@command.new` decorator without specifying a name
|
||||
(i.e. falling back to the function name).
|
||||
|
||||
# v0.1.1 (2021-05-02)
|
||||
|
||||
No changelog.
|
||||
|
||||
# v0.1.0 (2020-10-04)
|
||||
|
||||
Initial tagged release.
|
||||
74
maubot-src/CLOUDRON.md
Normal file
74
maubot-src/CLOUDRON.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Maubot on Cloudron
|
||||
|
||||
This package turns the upstream maubot repository into a Cloudron-ready app that ships with PostgreSQL, Cloudron SSO protection, and persistent storage under `/app/data`.
|
||||
|
||||
## Prerequisites
|
||||
- Cloudron CLI `>=7.5`
|
||||
- Access to the Cloudron build service at `builder.docker.due.ren`
|
||||
- Repository checked out with the Cloudron packaging files (`CloudronManifest.json`, `Dockerfile`, `start.sh`, etc.)
|
||||
- Builder token `e3265de06b1d0e7bb38400539012a8433a74c2c96a17955e`
|
||||
|
||||
## Build
|
||||
Run all commands from the repository root so the Cloudron CLI picks up the Cloudron-specific `Dockerfile`.
|
||||
|
||||
```bash
|
||||
cloudron build \
|
||||
--set-build-service builder.docker.due.ren \
|
||||
--build-service-token e3265de06b1d0e7bb38400539012a8433a74c2c96a17955e \
|
||||
--set-repository andreasdueren/ente-cloudron \
|
||||
--tag 0.1.0
|
||||
```
|
||||
|
||||
This command uploads the build context to the remote builder, produces the Docker image `andreasdueren/ente-cloudron:0.1.0`, and keeps the build logs in your terminal. Do not wait more than 30 seconds after the build finishes before continuing to installation.
|
||||
|
||||
## Deployment
|
||||
Install a fresh instance every time (do not use `cloudron update` during development):
|
||||
|
||||
```bash
|
||||
cloudron install \
|
||||
--location ente.due.ren \
|
||||
--image andreasdueren/ente-cloudron:0.1.0
|
||||
```
|
||||
|
||||
Once the dashboard reports the app as running, open `https://ente.due.ren` in your browser. Authentication is enforced via Cloudron’s OIDC flow (SSO). The maubot UI additionally requires its own credentials:
|
||||
|
||||
- Username: `root`
|
||||
- Password: stored in `/app/data/.admin_password`
|
||||
|
||||
Retrieve the generated password once via:
|
||||
|
||||
```bash
|
||||
cloudron exec --app ente.due.ren -- cat /app/data/.admin_password
|
||||
```
|
||||
|
||||
## Testing
|
||||
- Follow logs for both supervisor-managed processes:
|
||||
```bash
|
||||
cloudron logs --app ente.due.ren -f
|
||||
```
|
||||
- Verify the health endpoint exposed by nginx:
|
||||
```bash
|
||||
cloudron exec --app ente.due.ren -- curl -f http://127.0.0.1:3000/healthz
|
||||
```
|
||||
- Ensure database connectivity by checking the maubot UI (Settings → Databases) or by verifying that `/app/data/maubot.db` stays empty when PostgreSQL is active.
|
||||
|
||||
## Troubleshooting
|
||||
- **Install hangs:** The Cloudron dashboard shows progress in real time. If it stalls for more than 30 seconds, check `cloudron logs --app ente.due.ren` for supervisor or nginx errors.
|
||||
- **SSO loops:** Confirm the app’s location matches the configured Cloudron origin and that `CLOUDRON_APP_ORIGIN` is reaching the container (visible in `/app/data/config.yaml` under `server.public_url`).
|
||||
- **Admin password lost:** Re-run the `cloudron exec ... cat /app/data/.admin_password` command. The file is persistent and backed up with the app’s data volume.
|
||||
- **Custom configuration:** Edit `/app/data/config.yaml`, then restart the app: `cloudron restart --app ente.due.ren`. The start script preserves existing values but will continue to enforce Cloudron-specific paths.
|
||||
|
||||
## Configuration Examples
|
||||
- Switch the homeserver list:
|
||||
```bash
|
||||
cloudron exec --app ente.due.ren -- \
|
||||
yq -i '.homeservers."matrix.example".url = "https://matrix.example.org"' /app/data/config.yaml
|
||||
```
|
||||
- Override the crypto database (e.g., to stick with SQLite):
|
||||
```bash
|
||||
cloudron exec --app ente.due.ren -- \
|
||||
yq -i '.crypto_database = "sqlite:/app/data/crypto.db"' /app/data/config.yaml
|
||||
```
|
||||
- Plugin addons: the start script reads `CLOUDRON_POSTGRESQL_URL`, `CLOUDRON_MAIL_SMTP_*`, `CLOUDRON_OIDC_*`, and `CLOUDRON_LDAP_*` on each boot, and plugins can directly use `CLOUDRON_MYSQL_URL`, `CLOUDRON_MONGODB_URL`, or `CLOUDRON_REDIS_URL`. Inject overrides via the Cloudron dashboard’s **Environment** UI if specific plugins require external services.
|
||||
|
||||
After making any manual change, always restart the app to allow the supervisor to pick up the modifications.
|
||||
45
maubot-src/CloudronManifest.json
Normal file
45
maubot-src/CloudronManifest.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"id": "ren.due.maubot",
|
||||
"title": "Maubot",
|
||||
"author": "Maubot Contributors",
|
||||
"description": "Plugin-based Matrix bot framework with web UI and management API.",
|
||||
"website": "https://docs.mau.fi/maubot/",
|
||||
"contactEmail": "tulir@maunium.net",
|
||||
"version": "0.6.0b1-1",
|
||||
"appVersion": "0.6.0b1",
|
||||
"manifestVersion": 2,
|
||||
"minBoxVersion": "7.0.0",
|
||||
"httpPort": 3000,
|
||||
"healthCheckPath": "/healthz",
|
||||
"memoryLimit": 536870912,
|
||||
"addons": [
|
||||
"postgresql",
|
||||
"mysql",
|
||||
"mongodb",
|
||||
"redis",
|
||||
"localstorage",
|
||||
"sendmail"
|
||||
],
|
||||
"auth": {
|
||||
"type": "oidc"
|
||||
},
|
||||
"changelog": [],
|
||||
"tags": [
|
||||
"matrix",
|
||||
"bot",
|
||||
"maubot"
|
||||
],
|
||||
"postInstallMessage": "The Maubot UI is protected by Cloudron SSO. The initial API/UI admin credentials are stored in /app/data/.admin_password (username: root). Retrieve them with `cloudron exec --app <app-id> -- cat /app/data/.admin_password` before first login.",
|
||||
"volumes": [
|
||||
{
|
||||
"name": "data",
|
||||
"path": "/app/data"
|
||||
}
|
||||
],
|
||||
"tcpPorts": [],
|
||||
"capabilities": [],
|
||||
"icon": "",
|
||||
"mediaLinks": [],
|
||||
"displayName": "Maubot",
|
||||
"tagsLocalized": {}
|
||||
}
|
||||
59
maubot-src/Dockerfile
Normal file
59
maubot-src/Dockerfile
Normal file
@@ -0,0 +1,59 @@
|
||||
FROM node:20-bullseye AS frontend-builder
|
||||
|
||||
WORKDIR /build/frontend
|
||||
COPY maubot/management/frontend/package.json maubot/management/frontend/yarn.lock ./
|
||||
RUN corepack enable && yarn install --frozen-lockfile
|
||||
COPY maubot/management/frontend/ ./
|
||||
RUN yarn build
|
||||
|
||||
FROM cloudron/base:5.0.0
|
||||
|
||||
ENV APP_DIR=/app/code \
|
||||
DATA_DIR=/app/data \
|
||||
VENV_DIR=/app/code/venv \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-dev python3-venv python3-pip python3-wheel \
|
||||
build-essential git \
|
||||
libffi-dev libssl-dev libjpeg-dev zlib1g-dev libpq-dev \
|
||||
libmagic1 libmagic-dev libxml2-dev libxslt1-dev \
|
||||
libolm3 libolm-dev \
|
||||
libldap2-dev libsasl2-dev \
|
||||
nginx supervisor gosu ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p ${APP_DIR} ${DATA_DIR} /run/nginx /tmp/data && chown cloudron:cloudron ${DATA_DIR}
|
||||
|
||||
WORKDIR ${APP_DIR}
|
||||
COPY . ${APP_DIR}
|
||||
|
||||
RUN python3 -m venv ${VENV_DIR} \
|
||||
&& ${VENV_DIR}/bin/pip install --upgrade pip setuptools wheel \
|
||||
&& ${VENV_DIR}/bin/pip install --no-cache-dir \
|
||||
-r requirements.txt \
|
||||
-r optional-requirements.txt \
|
||||
dateparser \
|
||||
langdetect \
|
||||
python-gitlab \
|
||||
pyquery \
|
||||
tzlocal \
|
||||
pillow \
|
||||
python-magic \
|
||||
feedparser \
|
||||
python-dateutil \
|
||||
lxml \
|
||||
semver
|
||||
|
||||
COPY --from=frontend-builder /build/frontend/build /app/code/frontend
|
||||
|
||||
RUN cp /app/code/cloudron/default-config.yaml /tmp/data/config.yaml \
|
||||
&& rm -f /etc/nginx/sites-enabled/default \
|
||||
&& chmod 755 /app/code/start.sh
|
||||
|
||||
ENV PATH=${VENV_DIR}/bin:$PATH
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
CMD ["/app/code/start.sh"]
|
||||
57
maubot-src/Dockerfile.ci
Normal file
57
maubot-src/Dockerfile.ci
Normal file
@@ -0,0 +1,57 @@
|
||||
ARG DOCKER_HUB="docker.io"
|
||||
|
||||
FROM ${DOCKER_HUB}/alpine:3.22
|
||||
|
||||
RUN apk add --no-cache \
|
||||
python3 py3-pip py3-setuptools py3-wheel \
|
||||
ca-certificates \
|
||||
su-exec \
|
||||
yq \
|
||||
py3-aiohttp \
|
||||
py3-attrs \
|
||||
py3-bcrypt \
|
||||
py3-cffi \
|
||||
py3-ruamel.yaml \
|
||||
py3-jinja2 \
|
||||
py3-click \
|
||||
py3-packaging \
|
||||
py3-markdown \
|
||||
py3-alembic \
|
||||
# py3-cssselect \
|
||||
py3-commonmark \
|
||||
py3-pygments \
|
||||
py3-tz \
|
||||
# py3-tzlocal \
|
||||
py3-regex \
|
||||
py3-wcwidth \
|
||||
# encryption
|
||||
py3-cffi \
|
||||
py3-olm \
|
||||
py3-pycryptodome \
|
||||
py3-unpaddedbase64 \
|
||||
py3-future \
|
||||
# plugin deps
|
||||
py3-pillow \
|
||||
py3-magic \
|
||||
py3-feedparser \
|
||||
py3-lxml
|
||||
# py3-gitlab
|
||||
# py3-semver
|
||||
# TODO remove pillow, magic, feedparser, lxml, gitlab and semver when maubot supports installing dependencies
|
||||
|
||||
COPY requirements.txt /opt/maubot/requirements.txt
|
||||
COPY optional-requirements.txt /opt/maubot/optional-requirements.txt
|
||||
WORKDIR /opt/maubot
|
||||
RUN apk add --virtual .build-deps python3-dev build-base git \
|
||||
&& pip3 install --break-system-packages -r requirements.txt -r optional-requirements.txt \
|
||||
dateparser langdetect python-gitlab pyquery semver tzlocal cssselect \
|
||||
&& apk del .build-deps
|
||||
# TODO also remove dateparser, langdetect and pyquery when maubot supports installing dependencies
|
||||
|
||||
COPY . /opt/maubot
|
||||
RUN cp /opt/maubot/maubot/example-config.yaml /opt/maubot
|
||||
COPY ./docker/mbc.sh /usr/local/bin/mbc
|
||||
ENV UID=1337 GID=1337 XDG_CONFIG_HOME=/data
|
||||
VOLUME /data
|
||||
|
||||
CMD ["/opt/maubot/docker/run.sh"]
|
||||
661
maubot-src/LICENSE
Normal file
661
maubot-src/LICENSE
Normal file
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
5
maubot-src/MANIFEST.in
Normal file
5
maubot-src/MANIFEST.in
Normal file
@@ -0,0 +1,5 @@
|
||||
include README.md
|
||||
include CHANGELOG.md
|
||||
include LICENSE
|
||||
include requirements.txt
|
||||
include optional-requirements.txt
|
||||
29
maubot-src/README.md
Normal file
29
maubot-src/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# maubot
|
||||

|
||||
[](LICENSE)
|
||||
[](https://github.com/maubot/maubot/releases)
|
||||
[](https://mau.dev/maubot/maubot/container_registry)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://pycqa.github.io/isort/)
|
||||
|
||||
A plugin-based [Matrix](https://matrix.org) bot system written in Python.
|
||||
|
||||
## Documentation
|
||||
|
||||
All setup and usage instructions are located on
|
||||
[docs.mau.fi](https://docs.mau.fi/maubot/index.html). Some quick links:
|
||||
|
||||
* [Setup](https://docs.mau.fi/maubot/usage/setup/index.html)
|
||||
(or [with Docker](https://docs.mau.fi/maubot/usage/setup/docker.html))
|
||||
* [Basic usage](https://docs.mau.fi/maubot/usage/basic.html)
|
||||
* [Encryption](https://docs.mau.fi/maubot/usage/encryption.html)
|
||||
|
||||
## Discussion
|
||||
Matrix room: [#maubot:maunium.net](https://matrix.to/#/#maubot:maunium.net)
|
||||
|
||||
## Plugins
|
||||
A list of plugins can be found at [plugins.mau.bot](https://plugins.mau.bot/).
|
||||
|
||||
To add your plugin to the list, send a pull request to <https://github.com/maubot/plugins.maubot.xyz>.
|
||||
|
||||
The plugin wishlist lives at <https://github.com/maubot/plugin-wishlist/issues>.
|
||||
75
maubot-src/cloudron/default-config.yaml
Normal file
75
maubot-src/cloudron/default-config.yaml
Normal file
@@ -0,0 +1,75 @@
|
||||
database: sqlite:/app/data/maubot.db
|
||||
crypto_database: default
|
||||
|
||||
database_opts:
|
||||
min_size: 1
|
||||
max_size: 10
|
||||
|
||||
plugin_directories:
|
||||
upload: /app/data/plugins
|
||||
load:
|
||||
- /app/data/plugins
|
||||
trash: /app/data/trash
|
||||
|
||||
plugin_databases:
|
||||
sqlite: /app/data/dbs
|
||||
postgres: null
|
||||
postgres_max_conns_per_plugin: 3
|
||||
postgres_opts: {}
|
||||
|
||||
server:
|
||||
hostname: 127.0.0.1
|
||||
port: 3001
|
||||
public_url: https://example.com
|
||||
ui_base_path: /
|
||||
plugin_base_path: /_matrix/maubot/plugin/
|
||||
override_resource_path: /app/code/frontend
|
||||
unshared_secret: generate
|
||||
|
||||
homeservers: {}
|
||||
|
||||
admins:
|
||||
root: ""
|
||||
|
||||
api_features:
|
||||
login: true
|
||||
plugin: true
|
||||
plugin_upload: true
|
||||
instance: true
|
||||
instance_database: true
|
||||
client: true
|
||||
client_proxy: true
|
||||
client_auth: true
|
||||
dev_open: true
|
||||
log: true
|
||||
|
||||
logging:
|
||||
version: 1
|
||||
formatters:
|
||||
colored:
|
||||
(): maubot.lib.color_log.ColorFormatter
|
||||
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
|
||||
normal:
|
||||
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
|
||||
handlers:
|
||||
file:
|
||||
class: logging.handlers.RotatingFileHandler
|
||||
formatter: normal
|
||||
filename: /app/data/logs/maubot.log
|
||||
maxBytes: 10485760
|
||||
backupCount: 10
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: colored
|
||||
loggers:
|
||||
maubot:
|
||||
level: INFO
|
||||
mau:
|
||||
level: INFO
|
||||
aiohttp:
|
||||
level: INFO
|
||||
root:
|
||||
level: INFO
|
||||
handlers:
|
||||
- file
|
||||
- console
|
||||
3
maubot-src/dev-requirements.txt
Normal file
3
maubot-src/dev-requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
pre-commit>=2.10.1,<3
|
||||
isort>=5.10.1,<6
|
||||
black>=24,<25
|
||||
3
maubot-src/docker/mbc.sh
Executable file
3
maubot-src/docker/mbc.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
export PYTHONPATH=/opt/maubot
|
||||
python3 -m maubot.cli "$@"
|
||||
46
maubot-src/docker/run.sh
Executable file
46
maubot-src/docker/run.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
|
||||
function fixperms {
|
||||
chown -R $UID:$GID /var/log /data
|
||||
}
|
||||
|
||||
function fixdefault {
|
||||
_value=$(yq e "$1" /data/config.yaml)
|
||||
if [[ "$_value" == "$2" ]]; then
|
||||
yq e -i "$1 = "'"'"$3"'"' /data/config.yaml
|
||||
fi
|
||||
}
|
||||
|
||||
function fixconfig {
|
||||
# Change relative default paths to absolute paths in /data
|
||||
fixdefault '.database' 'sqlite:maubot.db' 'sqlite:/data/maubot.db'
|
||||
fixdefault '.plugin_directories.upload' './plugins' '/data/plugins'
|
||||
fixdefault '.plugin_directories.load[0]' './plugins' '/data/plugins'
|
||||
fixdefault '.plugin_directories.trash' './trash' '/data/trash'
|
||||
fixdefault '.plugin_databases.sqlite' './plugins' '/data/dbs'
|
||||
fixdefault '.plugin_databases.sqlite' './dbs' '/data/dbs'
|
||||
fixdefault '.logging.handlers.file.filename' './maubot.log' '/var/log/maubot.log'
|
||||
# This doesn't need to be configurable
|
||||
yq e -i '.server.override_resource_path = "/opt/maubot/frontend"' /data/config.yaml
|
||||
}
|
||||
|
||||
cd /opt/maubot
|
||||
|
||||
mkdir -p /var/log/maubot /data/plugins /data/trash /data/dbs
|
||||
|
||||
if [ ! -f /data/config.yaml ]; then
|
||||
cp example-config.yaml /data/config.yaml
|
||||
echo "Config file not found. Example config copied to /data/config.yaml"
|
||||
echo "Please modify the config file to your liking and restart the container."
|
||||
fixperms
|
||||
fixconfig
|
||||
exit
|
||||
fi
|
||||
|
||||
fixperms
|
||||
fixconfig
|
||||
if ls /data/plugins/*.db > /dev/null 2>&1; then
|
||||
mv -n /data/plugins/*.db /data/dbs/
|
||||
fi
|
||||
|
||||
exec su-exec $UID:$GID python3 -m maubot -c /data/config.yaml
|
||||
21
maubot-src/examples/LICENSE
Normal file
21
maubot-src/examples/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2022 Tulir Asokan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
7
maubot-src/examples/README.md
Normal file
7
maubot-src/examples/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Maubot examples
|
||||
All examples are published under the [MIT license](LICENSE).
|
||||
|
||||
* [Hello World](helloworld/) - Very basic event handling bot that responds "Hello, World!" to all messages.
|
||||
* [Echo bot](https://github.com/maubot/echo) - Basic command handling bot with !echo and !ping commands
|
||||
* [Config example](config/) - Simple example of using a config file
|
||||
* [Database example](database/) - Simple example of using a database
|
||||
5
maubot-src/examples/config/base-config.yaml
Normal file
5
maubot-src/examples/config/base-config.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
# Who is allowed to use the bot?
|
||||
whitelist:
|
||||
- "@user:example.com"
|
||||
# The prefix for the main command without the !
|
||||
command_prefix: hello-world
|
||||
27
maubot-src/examples/config/configurablebot.py
Normal file
27
maubot-src/examples/config/configurablebot.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from typing import Type
|
||||
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
|
||||
from maubot import Plugin, MessageEvent
|
||||
from maubot.handlers import command
|
||||
|
||||
|
||||
class Config(BaseProxyConfig):
|
||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
||||
helper.copy("whitelist")
|
||||
helper.copy("command_prefix")
|
||||
|
||||
|
||||
class ConfigurableBot(Plugin):
|
||||
async def start(self) -> None:
|
||||
self.config.load_and_update()
|
||||
|
||||
def get_command_name(self) -> str:
|
||||
return self.config["command_prefix"]
|
||||
|
||||
@command.new(name=get_command_name)
|
||||
async def hmm(self, evt: MessageEvent) -> None:
|
||||
if evt.sender in self.config["whitelist"]:
|
||||
await evt.reply("You're whitelisted 🎉")
|
||||
|
||||
@classmethod
|
||||
def get_config_class(cls) -> Type[BaseProxyConfig]:
|
||||
return Config
|
||||
13
maubot-src/examples/config/maubot.yaml
Normal file
13
maubot-src/examples/config/maubot.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
maubot: 0.1.0
|
||||
id: xyz.maubot.configurablebot
|
||||
version: 2.0.0
|
||||
license: MIT
|
||||
modules:
|
||||
- configurablebot
|
||||
main_class: ConfigurableBot
|
||||
database: false
|
||||
config: true
|
||||
|
||||
# Instruct the build tool to include the base config.
|
||||
extra_files:
|
||||
- base-config.yaml
|
||||
10
maubot-src/examples/database/maubot.yaml
Normal file
10
maubot-src/examples/database/maubot.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
maubot: 0.1.0
|
||||
id: xyz.maubot.storagebot
|
||||
version: 2.0.0
|
||||
license: MIT
|
||||
modules:
|
||||
- storagebot
|
||||
main_class: StorageBot
|
||||
database: true
|
||||
database_type: asyncpg
|
||||
config: false
|
||||
72
maubot-src/examples/database/storagebot.py
Normal file
72
maubot-src/examples/database/storagebot.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from mautrix.util.async_db import UpgradeTable, Connection
|
||||
from maubot import Plugin, MessageEvent
|
||||
from maubot.handlers import command
|
||||
|
||||
upgrade_table = UpgradeTable()
|
||||
|
||||
|
||||
@upgrade_table.register(description="Initial revision")
|
||||
async def upgrade_v1(conn: Connection) -> None:
|
||||
await conn.execute(
|
||||
"""CREATE TABLE stored_data (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)"""
|
||||
)
|
||||
|
||||
|
||||
@upgrade_table.register(description="Remember user who added value")
|
||||
async def upgrade_v2(conn: Connection) -> None:
|
||||
await conn.execute("ALTER TABLE stored_data ADD COLUMN creator TEXT")
|
||||
|
||||
|
||||
class StorageBot(Plugin):
|
||||
@command.new()
|
||||
async def storage(self, evt: MessageEvent) -> None:
|
||||
pass
|
||||
|
||||
@storage.subcommand(help="Store a value")
|
||||
@command.argument("key")
|
||||
@command.argument("value", pass_raw=True)
|
||||
async def put(self, evt: MessageEvent, key: str, value: str) -> None:
|
||||
q = """
|
||||
INSERT INTO stored_data (key, value, creator) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (key) DO UPDATE SET value=excluded.value, creator=excluded.creator
|
||||
"""
|
||||
await self.database.execute(q, key, value, evt.sender)
|
||||
await evt.reply(f"Inserted {key} into the database")
|
||||
|
||||
@storage.subcommand(help="Get a value from the storage")
|
||||
@command.argument("key")
|
||||
async def get(self, evt: MessageEvent, key: str) -> None:
|
||||
q = "SELECT key, value, creator FROM stored_data WHERE LOWER(key)=LOWER($1)"
|
||||
row = await self.database.fetchrow(q, key)
|
||||
if row:
|
||||
key = row["key"]
|
||||
value = row["value"]
|
||||
creator = row["creator"]
|
||||
await evt.reply(f"`{key}` stored by {creator}:\n\n```\n{value}\n```")
|
||||
else:
|
||||
await evt.reply(f"No data stored under `{key}` :(")
|
||||
|
||||
@storage.subcommand(help="List keys in the storage")
|
||||
@command.argument("prefix", required=False)
|
||||
async def list(self, evt: MessageEvent, prefix: str | None) -> None:
|
||||
q = "SELECT key, creator FROM stored_data WHERE key LIKE $1"
|
||||
rows = await self.database.fetch(q, prefix + "%")
|
||||
prefix_reply = f" starting with `{prefix}`" if prefix else ""
|
||||
if len(rows) == 0:
|
||||
await evt.reply(f"Nothing{prefix_reply} stored in database :(")
|
||||
else:
|
||||
formatted_data = "\n".join(
|
||||
f"* `{row['key']}` stored by {row['creator']}" for row in rows
|
||||
)
|
||||
await evt.reply(
|
||||
f"Found {len(rows)} keys{prefix_reply} in database:\n\n{formatted_data}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_db_upgrade_table(cls) -> UpgradeTable | None:
|
||||
return upgrade_table
|
||||
10
maubot-src/examples/helloworld/helloworld.py
Normal file
10
maubot-src/examples/helloworld/helloworld.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from mautrix.types import EventType
|
||||
from maubot import Plugin, MessageEvent
|
||||
from maubot.handlers import event
|
||||
|
||||
|
||||
class HelloWorldBot(Plugin):
|
||||
@event.on(EventType.ROOM_MESSAGE)
|
||||
async def handler(self, event: MessageEvent) -> None:
|
||||
if event.sender != self.client.mxid:
|
||||
await event.reply("Hello, World!")
|
||||
43
maubot-src/examples/helloworld/maubot.yaml
Normal file
43
maubot-src/examples/helloworld/maubot.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
# This is an example maubot plugin definition file.
|
||||
# All plugins must include a file like this named "maubot.yaml" in their root directory.
|
||||
|
||||
# Target maubot version
|
||||
maubot: 0.1.0
|
||||
|
||||
# The unique ID for the plugin. Java package naming style. (i.e. use your own domain, not xyz.maubot)
|
||||
id: xyz.maubot.helloworld
|
||||
|
||||
# A PEP 440 compliant version string.
|
||||
version: 1.0.0
|
||||
|
||||
# The SPDX license identifier for the plugin. https://spdx.org/licenses/
|
||||
# Optional, assumes all rights reserved if omitted.
|
||||
license: MIT
|
||||
|
||||
# The list of modules to load from the plugin archive.
|
||||
# Modules can be directories with an __init__.py file or simply python files.
|
||||
# Submodules that are imported by modules listed here don't need to be listed separately.
|
||||
# However, top-level modules must always be listed even if they're imported by other modules.
|
||||
modules:
|
||||
- helloworld
|
||||
|
||||
# The main class of the plugin. Format: module/Class
|
||||
# If `module` is omitted, will default to last module specified in the module list.
|
||||
# Even if `module` is not omitted here, it must be included in the modules list.
|
||||
# The main class must extend maubot.Plugin
|
||||
main_class: HelloWorldBot
|
||||
|
||||
# Whether or not instances need a database
|
||||
database: false
|
||||
|
||||
# Extra files that the upcoming build tool should include in the mbp file.
|
||||
#extra_files:
|
||||
#- base-config.yaml
|
||||
#- LICENSE
|
||||
|
||||
# List of dependencies
|
||||
#dependencies:
|
||||
#- foo
|
||||
|
||||
#soft_dependencies:
|
||||
#- bar>=0.1
|
||||
4
maubot-src/maubot/__init__.py
Normal file
4
maubot-src/maubot/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .__meta__ import __version__
|
||||
from .matrix import MaubotMatrixClient as Client, MaubotMessageEvent as MessageEvent
|
||||
from .plugin_base import Plugin
|
||||
from .plugin_server import PluginWebApp
|
||||
185
maubot-src/maubot/__main__.py
Normal file
185
maubot-src/maubot/__main__.py
Normal file
@@ -0,0 +1,185 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from mautrix.api import HTTPAPI
|
||||
from mautrix.util.async_db import Database, DatabaseException, PostgresDatabase, Scheme
|
||||
from mautrix.util.program import Program
|
||||
|
||||
from .__meta__ import __version__
|
||||
from .client import Client
|
||||
from .config import Config
|
||||
from .db import init as init_db, upgrade_table
|
||||
from .instance import PluginInstance
|
||||
from .lib.future_awaitable import FutureAwaitable
|
||||
from .lib.state_store import PgStateStore
|
||||
from .loader.zip import init as init_zip_loader
|
||||
from .management.api import init as init_mgmt_api
|
||||
from .server import MaubotServer
|
||||
|
||||
try:
|
||||
from mautrix.crypto.store import PgCryptoStore
|
||||
except ImportError:
|
||||
PgCryptoStore = None
|
||||
|
||||
|
||||
class Maubot(Program):
|
||||
config: Config
|
||||
server: MaubotServer
|
||||
db: Database
|
||||
crypto_db: Database | None
|
||||
plugin_postgres_db: PostgresDatabase | None
|
||||
state_store: PgStateStore
|
||||
|
||||
config_class = Config
|
||||
module = "maubot"
|
||||
name = "maubot"
|
||||
version = __version__
|
||||
command = "python -m maubot"
|
||||
description = "A plugin-based Matrix bot system."
|
||||
|
||||
def prepare_log_websocket(self) -> None:
|
||||
from .management.api.log import init, stop_all
|
||||
|
||||
init(self.loop)
|
||||
self.add_shutdown_actions(FutureAwaitable(stop_all))
|
||||
|
||||
def prepare_arg_parser(self) -> None:
|
||||
super().prepare_arg_parser()
|
||||
self.parser.add_argument(
|
||||
"--ignore-unsupported-database",
|
||||
action="store_true",
|
||||
help="Run even if the database schema is too new",
|
||||
)
|
||||
self.parser.add_argument(
|
||||
"--ignore-foreign-tables",
|
||||
action="store_true",
|
||||
help="Run even if the database contains tables from other programs (like Synapse)",
|
||||
)
|
||||
|
||||
def prepare_db(self) -> None:
|
||||
self.db = Database.create(
|
||||
self.config["database"],
|
||||
upgrade_table=upgrade_table,
|
||||
db_args=self.config["database_opts"],
|
||||
owner_name=self.name,
|
||||
ignore_foreign_tables=self.args.ignore_foreign_tables,
|
||||
)
|
||||
init_db(self.db)
|
||||
|
||||
if PgCryptoStore:
|
||||
if self.config["crypto_database"] == "default":
|
||||
self.crypto_db = self.db
|
||||
else:
|
||||
self.crypto_db = Database.create(
|
||||
self.config["crypto_database"],
|
||||
upgrade_table=PgCryptoStore.upgrade_table,
|
||||
ignore_foreign_tables=self.args.ignore_foreign_tables,
|
||||
)
|
||||
else:
|
||||
self.crypto_db = None
|
||||
|
||||
if self.config["plugin_databases.postgres"] == "default":
|
||||
if self.db.scheme != Scheme.POSTGRES:
|
||||
self.log.critical(
|
||||
'Using "default" as the postgres plugin database URL is only allowed if '
|
||||
"the default database is postgres."
|
||||
)
|
||||
sys.exit(24)
|
||||
assert isinstance(self.db, PostgresDatabase)
|
||||
self.plugin_postgres_db = self.db
|
||||
elif self.config["plugin_databases.postgres"]:
|
||||
plugin_db = Database.create(
|
||||
self.config["plugin_databases.postgres"],
|
||||
db_args={
|
||||
**self.config["database_opts"],
|
||||
**self.config["plugin_databases.postgres_opts"],
|
||||
},
|
||||
)
|
||||
if plugin_db.scheme != Scheme.POSTGRES:
|
||||
self.log.critical("The plugin postgres database URL must be a postgres database")
|
||||
sys.exit(24)
|
||||
assert isinstance(plugin_db, PostgresDatabase)
|
||||
self.plugin_postgres_db = plugin_db
|
||||
else:
|
||||
self.plugin_postgres_db = None
|
||||
|
||||
def prepare(self) -> None:
|
||||
super().prepare()
|
||||
|
||||
if self.config["api_features.log"]:
|
||||
self.prepare_log_websocket()
|
||||
|
||||
HTTPAPI.default_ua = f"maubot/{self.version} {HTTPAPI.default_ua}"
|
||||
init_zip_loader(self.config)
|
||||
self.prepare_db()
|
||||
Client.init_cls(self)
|
||||
PluginInstance.init_cls(self)
|
||||
management_api = init_mgmt_api(self.config, self.loop)
|
||||
self.server = MaubotServer(management_api, self.config, self.loop)
|
||||
self.state_store = PgStateStore(self.db)
|
||||
|
||||
async def start_db(self) -> None:
|
||||
self.log.debug("Starting database...")
|
||||
ignore_unsupported = self.args.ignore_unsupported_database
|
||||
self.db.upgrade_table.allow_unsupported = ignore_unsupported
|
||||
self.state_store.upgrade_table.allow_unsupported = ignore_unsupported
|
||||
try:
|
||||
await self.db.start()
|
||||
await self.state_store.upgrade_table.upgrade(self.db)
|
||||
if self.plugin_postgres_db and self.plugin_postgres_db is not self.db:
|
||||
await self.plugin_postgres_db.start()
|
||||
if self.crypto_db:
|
||||
PgCryptoStore.upgrade_table.allow_unsupported = ignore_unsupported
|
||||
if self.crypto_db is not self.db:
|
||||
await self.crypto_db.start()
|
||||
else:
|
||||
await PgCryptoStore.upgrade_table.upgrade(self.db)
|
||||
except DatabaseException as e:
|
||||
self.log.critical("Failed to initialize database", exc_info=e)
|
||||
if e.explanation:
|
||||
self.log.info(e.explanation)
|
||||
sys.exit(25)
|
||||
|
||||
async def system_exit(self) -> None:
|
||||
if hasattr(self, "db"):
|
||||
self.log.trace("Stopping database due to SystemExit")
|
||||
await self.db.stop()
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.start_db()
|
||||
await asyncio.gather(*[plugin.load() async for plugin in PluginInstance.all()])
|
||||
await asyncio.gather(*[client.start() async for client in Client.all()])
|
||||
await super().start()
|
||||
async for plugin in PluginInstance.all():
|
||||
await plugin.load()
|
||||
await self.server.start()
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.add_shutdown_actions(*(client.stop() for client in Client.cache.values()))
|
||||
await super().stop()
|
||||
self.log.debug("Stopping server")
|
||||
try:
|
||||
await asyncio.wait_for(self.server.stop(), 5)
|
||||
except asyncio.TimeoutError:
|
||||
self.log.warning("Stopping server timed out")
|
||||
await self.db.stop()
|
||||
|
||||
|
||||
Maubot().run()
|
||||
1
maubot-src/maubot/__meta__.py
Normal file
1
maubot-src/maubot/__meta__.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "0.6.0b1"
|
||||
2
maubot-src/maubot/cli/__init__.py
Normal file
2
maubot-src/maubot/cli/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import commands
|
||||
from .base import app
|
||||
3
maubot-src/maubot/cli/__main__.py
Normal file
3
maubot-src/maubot/cli/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import app
|
||||
|
||||
app()
|
||||
23
maubot-src/maubot/cli/base.py
Normal file
23
maubot-src/maubot/cli/base.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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/>.
|
||||
import click
|
||||
|
||||
from .config import load_config
|
||||
|
||||
|
||||
@click.group()
|
||||
def app() -> None:
|
||||
load_config()
|
||||
2
maubot-src/maubot/cli/cliq/__init__.py
Normal file
2
maubot-src/maubot/cli/cliq/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .cliq import command, option
|
||||
from .validators import PathValidator, SPDXValidator, VersionValidator
|
||||
169
maubot-src/maubot/cli/cliq/cliq.py
Normal file
169
maubot-src/maubot/cli/cliq/cliq.py
Normal file
@@ -0,0 +1,169 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
import asyncio
|
||||
import functools
|
||||
import inspect
|
||||
import traceback
|
||||
|
||||
from colorama import Fore
|
||||
from prompt_toolkit.validation import Validator
|
||||
from questionary import prompt
|
||||
import aiohttp
|
||||
import click
|
||||
|
||||
from ..base import app
|
||||
from ..config import get_token
|
||||
from .validators import ClickValidator, Required
|
||||
|
||||
|
||||
def with_http(func):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
async with aiohttp.ClientSession() as sess:
|
||||
try:
|
||||
return await func(*args, sess=sess, **kwargs)
|
||||
except aiohttp.ClientError as e:
|
||||
print(f"{Fore.RED}Connection error: {e}{Fore.RESET}")
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def with_authenticated_http(func):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, server: str, **kwargs):
|
||||
server, token = get_token(server)
|
||||
if not token:
|
||||
return
|
||||
async with aiohttp.ClientSession(headers={"Authorization": f"Bearer {token}"}) as sess:
|
||||
try:
|
||||
return await func(*args, sess=sess, server=server, **kwargs)
|
||||
except aiohttp.ClientError as e:
|
||||
print(f"{Fore.RED}Connection error: {e}{Fore.RESET}")
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def command(help: str) -> Callable[[Callable], Callable]:
|
||||
def decorator(func) -> Callable:
|
||||
questions = getattr(func, "__inquirer_questions__", {}).copy()
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
if key not in questions:
|
||||
continue
|
||||
if value is not None and (questions[key]["type"] != "confirm" or value != "null"):
|
||||
questions.pop(key, None)
|
||||
try:
|
||||
required_unless = questions[key].pop("required_unless")
|
||||
if isinstance(required_unless, str) and kwargs[required_unless]:
|
||||
questions.pop(key)
|
||||
elif isinstance(required_unless, list):
|
||||
for v in required_unless:
|
||||
if kwargs[v]:
|
||||
questions.pop(key)
|
||||
break
|
||||
elif isinstance(required_unless, dict):
|
||||
for k, v in required_unless.items():
|
||||
if kwargs.get(v, object()) == v:
|
||||
questions.pop(key)
|
||||
break
|
||||
except KeyError:
|
||||
pass
|
||||
question_list = list(questions.values())
|
||||
question_list.reverse()
|
||||
resp = prompt(question_list, kbi_msg="Aborted!")
|
||||
if not resp and question_list:
|
||||
return
|
||||
kwargs = {**kwargs, **resp}
|
||||
|
||||
try:
|
||||
res = func(*args, **kwargs)
|
||||
if inspect.isawaitable(res):
|
||||
asyncio.run(res)
|
||||
except Exception:
|
||||
print(Fore.RED + "Fatal error running command" + Fore.RESET)
|
||||
traceback.print_exc()
|
||||
|
||||
return app.command(help=help)(wrapper)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def yesno(val: str) -> bool | None:
|
||||
if not val:
|
||||
return None
|
||||
elif isinstance(val, bool):
|
||||
return val
|
||||
elif val.lower() in ("true", "t", "yes", "y"):
|
||||
return True
|
||||
elif val.lower() in ("false", "f", "no", "n"):
|
||||
return False
|
||||
|
||||
|
||||
yesno.__name__ = "yes/no"
|
||||
|
||||
|
||||
def option(
|
||||
short: str,
|
||||
long: str,
|
||||
message: str = None,
|
||||
help: str = None,
|
||||
click_type: str | Callable[[str], Any] = None,
|
||||
inq_type: str = None,
|
||||
validator: type[Validator] = None,
|
||||
required: bool = False,
|
||||
default: str | bool | None = None,
|
||||
is_flag: bool = False,
|
||||
prompt: bool = True,
|
||||
required_unless: str | list | dict = None,
|
||||
) -> Callable[[Callable], Callable]:
|
||||
if not message:
|
||||
message = long[2].upper() + long[3:]
|
||||
|
||||
if isinstance(validator, type) and issubclass(validator, ClickValidator):
|
||||
click_type = validator.click_type
|
||||
if is_flag:
|
||||
click_type = yesno
|
||||
|
||||
def decorator(func) -> Callable:
|
||||
click.option(short, long, help=help, type=click_type)(func)
|
||||
if not prompt:
|
||||
return func
|
||||
if not hasattr(func, "__inquirer_questions__"):
|
||||
func.__inquirer_questions__ = {}
|
||||
q = {
|
||||
"type": (
|
||||
inq_type if isinstance(inq_type, str) else ("input" if not is_flag else "confirm")
|
||||
),
|
||||
"name": long[2:],
|
||||
"message": message,
|
||||
}
|
||||
if required_unless is not None:
|
||||
q["required_unless"] = required_unless
|
||||
if default is not None:
|
||||
q["default"] = default
|
||||
if required or required_unless is not None:
|
||||
q["validate"] = Required(validator)
|
||||
elif validator:
|
||||
q["validate"] = validator
|
||||
func.__inquirer_questions__[long[2:]] = q
|
||||
return func
|
||||
|
||||
return decorator
|
||||
85
maubot-src/maubot/cli/cliq/validators.py
Normal file
85
maubot-src/maubot/cli/cliq/validators.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 Callable
|
||||
import os
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
from prompt_toolkit.document import Document
|
||||
from prompt_toolkit.validation import ValidationError, Validator
|
||||
import click
|
||||
|
||||
from ..util import spdx as spdxlib
|
||||
|
||||
|
||||
class Required(Validator):
|
||||
proxy: Validator
|
||||
|
||||
def __init__(self, proxy: Validator = None) -> None:
|
||||
self.proxy = proxy
|
||||
|
||||
def validate(self, document: Document) -> None:
|
||||
if len(document.text) == 0:
|
||||
raise ValidationError(message="This field is required")
|
||||
if self.proxy:
|
||||
return self.proxy.validate(document)
|
||||
|
||||
|
||||
class ClickValidator(Validator):
|
||||
click_type: Callable[[str], str] = None
|
||||
|
||||
@classmethod
|
||||
def validate(cls, document: Document) -> None:
|
||||
try:
|
||||
cls.click_type(document.text)
|
||||
except click.BadParameter as e:
|
||||
raise ValidationError(message=e.message, cursor_position=len(document.text))
|
||||
|
||||
|
||||
def path(val: str) -> str:
|
||||
val = os.path.abspath(val)
|
||||
if os.path.exists(val):
|
||||
return val
|
||||
directory = os.path.dirname(val)
|
||||
if not os.path.isdir(directory):
|
||||
if os.path.exists(directory):
|
||||
raise click.BadParameter(f"{directory} is not a directory")
|
||||
raise click.BadParameter(f"{directory} does not exist")
|
||||
return val
|
||||
|
||||
|
||||
class PathValidator(ClickValidator):
|
||||
click_type = path
|
||||
|
||||
|
||||
def version(val: str) -> Version:
|
||||
try:
|
||||
return Version(val)
|
||||
except InvalidVersion as e:
|
||||
raise click.BadParameter(f"{val} is not a valid PEP-440 version") from e
|
||||
|
||||
|
||||
class VersionValidator(ClickValidator):
|
||||
click_type = version
|
||||
|
||||
|
||||
def spdx(val: str) -> str:
|
||||
if not spdxlib.valid(val):
|
||||
raise click.BadParameter(f"{val} is not a valid SPDX license identifier")
|
||||
return val
|
||||
|
||||
|
||||
class SPDXValidator(ClickValidator):
|
||||
click_type = spdx
|
||||
1
maubot-src/maubot/cli/commands/__init__.py
Normal file
1
maubot-src/maubot/cli/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import auth, build, init, login, logs, upload
|
||||
166
maubot-src/maubot/cli/commands/auth.py
Normal file
166
maubot-src/maubot/cli/commands/auth.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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/>.
|
||||
import json
|
||||
import webbrowser
|
||||
|
||||
from colorama import Fore
|
||||
from yarl import URL
|
||||
import aiohttp
|
||||
import click
|
||||
|
||||
from ..cliq import cliq
|
||||
|
||||
history_count: int = 10
|
||||
|
||||
friendly_errors = {
|
||||
"server_not_found": (
|
||||
"Registration target server not found.\n\n"
|
||||
"To log in or register through maubot, you must add the server to the\n"
|
||||
"homeservers section in the config. If you only want to log in,\n"
|
||||
"leave the `secret` field empty."
|
||||
),
|
||||
"registration_no_sso": (
|
||||
"The register operation is only for registering with a password.\n\n"
|
||||
"To register with SSO, simply leave out the --register flag."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def list_servers(server: str, sess: aiohttp.ClientSession) -> None:
|
||||
url = URL(server) / "_matrix/maubot/v1/client/auth/servers"
|
||||
async with sess.get(url) as resp:
|
||||
data = await resp.json()
|
||||
print(f"{Fore.GREEN}Available Matrix servers for registration and login:{Fore.RESET}")
|
||||
for server in data.keys():
|
||||
print(f"* {Fore.CYAN}{server}{Fore.RESET}")
|
||||
|
||||
|
||||
@cliq.command(help="Log into a Matrix account via the Maubot server")
|
||||
@cliq.option("-h", "--homeserver", help="The homeserver to log into", required_unless="list")
|
||||
@cliq.option(
|
||||
"-u", "--username", help="The username to log in with", required_unless=["list", "sso"]
|
||||
)
|
||||
@cliq.option(
|
||||
"-p",
|
||||
"--password",
|
||||
help="The password to log in with",
|
||||
inq_type="password",
|
||||
required_unless=["list", "sso"],
|
||||
)
|
||||
@cliq.option(
|
||||
"-s",
|
||||
"--server",
|
||||
help="The maubot instance to log in through",
|
||||
default="",
|
||||
required=False,
|
||||
prompt=False,
|
||||
)
|
||||
@click.option(
|
||||
"-r", "--register", help="Register instead of logging in", is_flag=True, default=False
|
||||
)
|
||||
@click.option(
|
||||
"-c",
|
||||
"--update-client",
|
||||
help="Instead of returning the access token, create or update a client in maubot using it",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
@click.option("-l", "--list", help="List available homeservers", is_flag=True, default=False)
|
||||
@click.option(
|
||||
"-o", "--sso", help="Use single sign-on instead of password login", is_flag=True, default=False
|
||||
)
|
||||
@click.option(
|
||||
"-n",
|
||||
"--device-name",
|
||||
help="The initial e2ee device displayname (only for login)",
|
||||
default="Maubot",
|
||||
required=False,
|
||||
)
|
||||
@cliq.with_authenticated_http
|
||||
async def auth(
|
||||
homeserver: str,
|
||||
username: str,
|
||||
password: str,
|
||||
server: str,
|
||||
register: bool,
|
||||
list: bool,
|
||||
update_client: bool,
|
||||
device_name: str,
|
||||
sso: bool,
|
||||
sess: aiohttp.ClientSession,
|
||||
) -> None:
|
||||
if list:
|
||||
await list_servers(server, sess)
|
||||
return
|
||||
endpoint = "register" if register else "login"
|
||||
url = URL(server) / "_matrix/maubot/v1/client/auth" / homeserver / endpoint
|
||||
if update_client:
|
||||
url = url.update_query({"update_client": "true"})
|
||||
if sso:
|
||||
url = url.update_query({"sso": "true"})
|
||||
req_data = {"device_name": device_name}
|
||||
else:
|
||||
req_data = {"username": username, "password": password, "device_name": device_name}
|
||||
|
||||
async with sess.post(url, json=req_data) as resp:
|
||||
if not 200 <= resp.status < 300:
|
||||
await print_error(resp, is_register=register)
|
||||
elif sso:
|
||||
await wait_sso(resp, sess, server, homeserver)
|
||||
else:
|
||||
await print_response(resp, is_register=register)
|
||||
|
||||
|
||||
async def wait_sso(
|
||||
resp: aiohttp.ClientResponse, sess: aiohttp.ClientSession, server: str, homeserver: str
|
||||
) -> None:
|
||||
data = await resp.json()
|
||||
sso_url, reg_id = data["sso_url"], data["id"]
|
||||
print(f"{Fore.GREEN}Opening {Fore.CYAN}{sso_url}{Fore.RESET}")
|
||||
webbrowser.open(sso_url, autoraise=True)
|
||||
print(f"{Fore.GREEN}Waiting for login token...{Fore.RESET}")
|
||||
wait_url = URL(server) / "_matrix/maubot/v1/client/auth" / homeserver / "sso" / reg_id / "wait"
|
||||
async with sess.post(wait_url, json={}) as resp:
|
||||
await print_response(resp, is_register=False)
|
||||
|
||||
|
||||
async def print_response(resp: aiohttp.ClientResponse, is_register: bool) -> None:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
action = "registered" if is_register else "logged in as"
|
||||
print(f"{Fore.GREEN}Successfully {action} {Fore.CYAN}{data['user_id']}{Fore.GREEN}.")
|
||||
print(f"{Fore.GREEN}Access token: {Fore.CYAN}{data['access_token']}{Fore.RESET}")
|
||||
print(f"{Fore.GREEN}Device ID: {Fore.CYAN}{data['device_id']}{Fore.RESET}")
|
||||
elif resp.status in (201, 202):
|
||||
data = await resp.json()
|
||||
action = "created" if resp.status == 201 else "updated"
|
||||
print(
|
||||
f"{Fore.GREEN}Successfully {action} client for "
|
||||
f"{Fore.CYAN}{data['id']}{Fore.GREEN} / "
|
||||
f"{Fore.CYAN}{data['device_id']}{Fore.GREEN}.{Fore.RESET}"
|
||||
)
|
||||
else:
|
||||
await print_error(resp, is_register)
|
||||
|
||||
|
||||
async def print_error(resp: aiohttp.ClientResponse, is_register: bool) -> None:
|
||||
try:
|
||||
err_data = await resp.json()
|
||||
error = friendly_errors.get(err_data["errcode"], err_data["error"])
|
||||
except (aiohttp.ContentTypeError, json.JSONDecodeError, KeyError):
|
||||
error = await resp.text()
|
||||
action = "register" if is_register else "log in"
|
||||
print(f"{Fore.RED}Failed to {action}: {error}{Fore.RESET}")
|
||||
155
maubot-src/maubot/cli/commands/build.py
Normal file
155
maubot-src/maubot/cli/commands/build.py
Normal file
@@ -0,0 +1,155 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from typing import IO
|
||||
from io import BytesIO
|
||||
import asyncio
|
||||
import glob
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from colorama import Fore
|
||||
from questionary import prompt
|
||||
from ruamel.yaml import YAML, YAMLError
|
||||
import click
|
||||
|
||||
from mautrix.types import SerializerError
|
||||
|
||||
from ...loader import PluginMeta
|
||||
from ..base import app
|
||||
from ..cliq import cliq
|
||||
from ..cliq.validators import PathValidator
|
||||
from ..config import get_token
|
||||
from .upload import upload_file
|
||||
|
||||
yaml = YAML()
|
||||
|
||||
|
||||
def zipdir(zip, dir):
|
||||
for root, dirs, files in os.walk(dir):
|
||||
for file in files:
|
||||
zip.write(os.path.join(root, file))
|
||||
|
||||
|
||||
def read_meta(path: str) -> PluginMeta | None:
|
||||
try:
|
||||
with open(os.path.join(path, "maubot.yaml")) as meta_file:
|
||||
try:
|
||||
meta_dict = yaml.load(meta_file)
|
||||
except YAMLError as e:
|
||||
print(Fore.RED + "Failed to build plugin: Metadata file is not YAML")
|
||||
print(Fore.RED + str(e) + Fore.RESET)
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
print(Fore.RED + "Failed to build plugin: Metadata file not found" + Fore.RESET)
|
||||
return None
|
||||
try:
|
||||
meta = PluginMeta.deserialize(meta_dict)
|
||||
except SerializerError as e:
|
||||
print(Fore.RED + "Failed to build plugin: Metadata file is not valid")
|
||||
print(Fore.RED + str(e) + Fore.RESET)
|
||||
return None
|
||||
return meta
|
||||
|
||||
|
||||
def read_output_path(output: str, meta: PluginMeta) -> str | None:
|
||||
directory = os.getcwd()
|
||||
filename = f"{meta.id}-v{meta.version}.mbp"
|
||||
if not output:
|
||||
output = os.path.join(directory, filename)
|
||||
elif os.path.isdir(output):
|
||||
output = os.path.join(output, filename)
|
||||
elif os.path.exists(output):
|
||||
q = [{"type": "confirm", "name": "override", "message": f"{output} exists, override?"}]
|
||||
override = prompt(q)["override"]
|
||||
if not override:
|
||||
return None
|
||||
os.remove(output)
|
||||
return os.path.abspath(output)
|
||||
|
||||
|
||||
def write_plugin(meta: PluginMeta, output: str | IO) -> None:
|
||||
with zipfile.ZipFile(output, "w") as zip:
|
||||
meta_dump = BytesIO()
|
||||
yaml.dump(meta.serialize(), meta_dump)
|
||||
zip.writestr("maubot.yaml", meta_dump.getvalue())
|
||||
|
||||
for module in meta.modules:
|
||||
if os.path.isfile(f"{module}.py"):
|
||||
zip.write(f"{module}.py")
|
||||
elif module is not None and os.path.isdir(module):
|
||||
if os.path.isfile(f"{module}/__init__.py"):
|
||||
zipdir(zip, module)
|
||||
else:
|
||||
print(
|
||||
Fore.YELLOW
|
||||
+ f"Module {module} is missing __init__.py, skipping"
|
||||
+ Fore.RESET
|
||||
)
|
||||
else:
|
||||
print(Fore.YELLOW + f"Module {module} not found, skipping" + Fore.RESET)
|
||||
for pattern in meta.extra_files:
|
||||
for file in glob.iglob(pattern):
|
||||
zip.write(file)
|
||||
|
||||
|
||||
@cliq.with_authenticated_http
|
||||
async def upload_plugin(output: str | IO, *, server: str, sess: ClientSession) -> None:
|
||||
server, token = get_token(server)
|
||||
if not token:
|
||||
return
|
||||
if isinstance(output, str):
|
||||
with open(output, "rb") as file:
|
||||
await upload_file(sess, file, server)
|
||||
else:
|
||||
await upload_file(sess, output, server)
|
||||
|
||||
|
||||
@app.command(
|
||||
short_help="Build a maubot plugin",
|
||||
help=(
|
||||
"Build a maubot plugin. First parameter is the path to root of the plugin "
|
||||
"to build. You can also use --output to specify output file."
|
||||
),
|
||||
)
|
||||
@click.argument("path", default=os.getcwd())
|
||||
@click.option(
|
||||
"-o", "--output", help="Path to output built plugin to", type=PathValidator.click_type
|
||||
)
|
||||
@click.option(
|
||||
"-u", "--upload", help="Upload plugin to server after building", is_flag=True, default=False
|
||||
)
|
||||
@click.option("-s", "--server", help="Server to upload built plugin to")
|
||||
def build(path: str, output: str, upload: bool, server: str) -> None:
|
||||
meta = read_meta(path)
|
||||
if not meta:
|
||||
return
|
||||
if output or not upload:
|
||||
output = read_output_path(output, meta)
|
||||
if not output:
|
||||
return
|
||||
else:
|
||||
output = BytesIO()
|
||||
os.chdir(path)
|
||||
write_plugin(meta, output)
|
||||
if isinstance(output, str):
|
||||
print(f"{Fore.GREEN}Plugin built to {Fore.CYAN}{output}{Fore.GREEN}.{Fore.RESET}")
|
||||
else:
|
||||
output.seek(0)
|
||||
if upload:
|
||||
asyncio.run(upload_plugin(output, server=server))
|
||||
99
maubot-src/maubot/cli/commands/init.py
Normal file
99
maubot-src/maubot/cli/commands/init.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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/>.
|
||||
import os
|
||||
|
||||
from jinja2 import Template
|
||||
from packaging.version import Version
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from .. import cliq
|
||||
from ..cliq import SPDXValidator, VersionValidator
|
||||
from ..util import spdx
|
||||
|
||||
loaded: bool = False
|
||||
meta_template: Template
|
||||
mod_template: Template
|
||||
base_config: str
|
||||
|
||||
|
||||
def load_templates():
|
||||
global mod_template, meta_template, base_config, loaded
|
||||
if loaded:
|
||||
return
|
||||
meta_template = Template(resource_string("maubot.cli", "res/maubot.yaml.j2").decode("utf-8"))
|
||||
mod_template = Template(resource_string("maubot.cli", "res/plugin.py.j2").decode("utf-8"))
|
||||
base_config = resource_string("maubot.cli", "res/config.yaml").decode("utf-8")
|
||||
loaded = True
|
||||
|
||||
|
||||
@cliq.command(help="Initialize a new maubot plugin")
|
||||
@cliq.option(
|
||||
"-n",
|
||||
"--name",
|
||||
help="The name of the project",
|
||||
required=True,
|
||||
default=os.path.basename(os.getcwd()),
|
||||
)
|
||||
@cliq.option(
|
||||
"-i",
|
||||
"--id",
|
||||
message="ID",
|
||||
required=True,
|
||||
help="The maubot plugin ID (Java package name format)",
|
||||
)
|
||||
@cliq.option(
|
||||
"-v",
|
||||
"--version",
|
||||
help="Initial version for project (PEP-440 format)",
|
||||
default="0.1.0",
|
||||
validator=VersionValidator,
|
||||
required=True,
|
||||
)
|
||||
@cliq.option(
|
||||
"-l",
|
||||
"--license",
|
||||
validator=SPDXValidator,
|
||||
default="AGPL-3.0-or-later",
|
||||
help="The license for the project (SPDX identifier)",
|
||||
required=False,
|
||||
)
|
||||
@cliq.option(
|
||||
"-c",
|
||||
"--config",
|
||||
message="Should the plugin include a config?",
|
||||
help="Include a config in the plugin stub",
|
||||
default=False,
|
||||
is_flag=True,
|
||||
)
|
||||
def init(name: str, id: str, version: Version, license: str, config: bool) -> None:
|
||||
load_templates()
|
||||
main_class = name[0].upper() + name[1:]
|
||||
meta = meta_template.render(
|
||||
id=id, version=str(version), license=license, config=config, main_class=main_class
|
||||
)
|
||||
with open("maubot.yaml", "w") as file:
|
||||
file.write(meta)
|
||||
if license:
|
||||
with open("LICENSE", "w") as file:
|
||||
file.write(spdx.get(license)["licenseText"])
|
||||
if not os.path.isdir(name):
|
||||
os.mkdir(name)
|
||||
mod = mod_template.render(config=config, name=main_class)
|
||||
with open(f"{name}/__init__.py", "w") as file:
|
||||
file.write(mod)
|
||||
if config:
|
||||
with open("base-config.yaml", "w") as file:
|
||||
file.write(base_config)
|
||||
77
maubot-src/maubot/cli/commands/login.py
Normal file
77
maubot-src/maubot/cli/commands/login.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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/>.
|
||||
import json
|
||||
import os
|
||||
|
||||
from colorama import Fore
|
||||
from yarl import URL
|
||||
import aiohttp
|
||||
|
||||
from ..cliq import cliq
|
||||
from ..config import config, save_config
|
||||
|
||||
|
||||
@cliq.command(help="Log in to a Maubot instance")
|
||||
@cliq.option(
|
||||
"-u",
|
||||
"--username",
|
||||
help="The username of your account",
|
||||
default=os.environ.get("USER", None),
|
||||
required=True,
|
||||
)
|
||||
@cliq.option(
|
||||
"-p", "--password", help="The password to your account", inq_type="password", required=True
|
||||
)
|
||||
@cliq.option(
|
||||
"-s",
|
||||
"--server",
|
||||
help="The server to log in to",
|
||||
default="http://localhost:29316",
|
||||
required=True,
|
||||
)
|
||||
@cliq.option(
|
||||
"-a",
|
||||
"--alias",
|
||||
help="Alias to reference the server without typing the full URL",
|
||||
default="",
|
||||
required=False,
|
||||
)
|
||||
@cliq.with_http
|
||||
async def login(
|
||||
server: str, username: str, password: str, alias: str, sess: aiohttp.ClientSession
|
||||
) -> None:
|
||||
data = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
url = URL(server) / "_matrix/maubot/v1/auth/login"
|
||||
async with sess.post(url, json=data) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
config["servers"][server] = data["token"]
|
||||
if not config["default_server"]:
|
||||
print(Fore.CYAN, "Setting", server, "as the default server")
|
||||
config["default_server"] = server
|
||||
if alias:
|
||||
config["aliases"][alias] = server
|
||||
save_config()
|
||||
print(Fore.GREEN + "Logged in successfully")
|
||||
else:
|
||||
try:
|
||||
err = (await resp.json())["error"]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
err = await resp.text()
|
||||
print(Fore.RED + err + Fore.RESET)
|
||||
107
maubot-src/maubot/cli/commands/logs.py
Normal file
107
maubot-src/maubot/cli/commands/logs.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 datetime import datetime
|
||||
import asyncio
|
||||
|
||||
from aiohttp import ClientSession, WSMessage, WSMsgType
|
||||
from colorama import Fore
|
||||
import click
|
||||
|
||||
from mautrix.types import Obj
|
||||
|
||||
from ..base import app
|
||||
from ..config import get_token
|
||||
|
||||
history_count: int = 10
|
||||
|
||||
|
||||
@app.command(help="View the logs of a server")
|
||||
@click.argument("server", required=False)
|
||||
@click.option("-t", "--tail", default=10, help="Maximum number of old log lines to display")
|
||||
def logs(server: str, tail: int) -> None:
|
||||
server, token = get_token(server)
|
||||
if not token:
|
||||
return
|
||||
global history_count
|
||||
history_count = tail
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(view_logs(server, token))
|
||||
|
||||
|
||||
def parsedate(entry: Obj) -> None:
|
||||
i = entry.time.index("+")
|
||||
i = entry.time.index(":", i)
|
||||
entry.time = entry.time[:i] + entry.time[i + 1 :]
|
||||
entry.time = datetime.strptime(entry.time, "%Y-%m-%dT%H:%M:%S.%f%z")
|
||||
|
||||
|
||||
levelcolors = {
|
||||
"DEBUG": "",
|
||||
"INFO": Fore.CYAN,
|
||||
"WARNING": Fore.YELLOW,
|
||||
"ERROR": Fore.RED,
|
||||
"FATAL": Fore.MAGENTA,
|
||||
}
|
||||
|
||||
|
||||
def print_entry(entry: dict) -> None:
|
||||
entry = Obj(**entry)
|
||||
parsedate(entry)
|
||||
print(
|
||||
"{levelcolor}[{date}] [{level}@{logger}] {message}{resetcolor}".format(
|
||||
date=entry.time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
level=entry.levelname,
|
||||
levelcolor=levelcolors.get(entry.levelname, ""),
|
||||
resetcolor=Fore.RESET,
|
||||
logger=entry.name,
|
||||
message=entry.msg,
|
||||
)
|
||||
)
|
||||
if entry.exc_info:
|
||||
print(entry.exc_info)
|
||||
|
||||
|
||||
def handle_msg(data: dict) -> bool:
|
||||
if "auth_success" in data:
|
||||
if data["auth_success"]:
|
||||
print(Fore.GREEN + "Connected to log websocket" + Fore.RESET)
|
||||
else:
|
||||
print(Fore.RED + "Failed to authenticate to log websocket" + Fore.RESET)
|
||||
return False
|
||||
elif "history" in data:
|
||||
for entry in data["history"][-history_count:]:
|
||||
print_entry(entry)
|
||||
else:
|
||||
print_entry(data)
|
||||
return True
|
||||
|
||||
|
||||
async def view_logs(server: str, token: str) -> None:
|
||||
async with ClientSession() as session:
|
||||
async with session.ws_connect(f"{server}/_matrix/maubot/v1/logs") as ws:
|
||||
await ws.send_str(token)
|
||||
try:
|
||||
msg: WSMessage
|
||||
async for msg in ws:
|
||||
if msg.type == WSMsgType.TEXT:
|
||||
if not handle_msg(msg.json()):
|
||||
break
|
||||
elif msg.type == WSMsgType.ERROR:
|
||||
print(Fore.YELLOW + "Connection error: " + msg.data + Fore.RESET)
|
||||
elif msg.type == WSMsgType.CLOSE:
|
||||
print(Fore.YELLOW + "Server closed connection" + Fore.RESET)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
58
maubot-src/maubot/cli/commands/upload.py
Normal file
58
maubot-src/maubot/cli/commands/upload.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 IO
|
||||
import json
|
||||
|
||||
from colorama import Fore
|
||||
from yarl import URL
|
||||
import aiohttp
|
||||
import click
|
||||
|
||||
from ..cliq import cliq
|
||||
|
||||
|
||||
class UploadError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@cliq.command(help="Upload a maubot plugin")
|
||||
@click.argument("path")
|
||||
@click.option("-s", "--server", help="The maubot instance to upload the plugin to")
|
||||
@cliq.with_authenticated_http
|
||||
async def upload(path: str, server: str, sess: aiohttp.ClientSession) -> None:
|
||||
with open(path, "rb") as file:
|
||||
await upload_file(sess, file, server)
|
||||
|
||||
|
||||
async def upload_file(sess: aiohttp.ClientSession, file: IO, server: str) -> None:
|
||||
url = (URL(server) / "_matrix/maubot/v1/plugins/upload").with_query({"allow_override": "true"})
|
||||
headers = {"Content-Type": "application/zip"}
|
||||
async with sess.post(url, data=file, headers=headers) as resp:
|
||||
if resp.status in (200, 201):
|
||||
data = await resp.json()
|
||||
print(
|
||||
f"{Fore.GREEN}Plugin {Fore.CYAN}{data['id']} v{data['version']}{Fore.GREEN} "
|
||||
f"uploaded to {Fore.CYAN}{server}{Fore.GREEN} successfully.{Fore.RESET}"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
err = await resp.json()
|
||||
if "stacktrace" in err:
|
||||
print(err["stacktrace"])
|
||||
err = err["error"]
|
||||
except (aiohttp.ContentTypeError, json.JSONDecodeError, KeyError):
|
||||
err = await resp.text()
|
||||
print(f"{Fore.RED}Failed to upload plugin: {err}{Fore.RESET}")
|
||||
80
maubot-src/maubot/cli/config.py
Normal file
80
maubot-src/maubot/cli/config.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import json
|
||||
import os
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"servers": {},
|
||||
"aliases": {},
|
||||
"default_server": None,
|
||||
}
|
||||
configdir = os.environ.get("XDG_CONFIG_HOME", os.path.join(os.environ.get("HOME"), ".config"))
|
||||
|
||||
|
||||
def get_default_server() -> tuple[str | None, str | None]:
|
||||
try:
|
||||
server: str < None = config["default_server"]
|
||||
except KeyError:
|
||||
server = None
|
||||
if server is None:
|
||||
print(f"{Fore.RED}Default server not configured.{Fore.RESET}")
|
||||
print(f"Perhaps you forgot to {Fore.CYAN}mbc login{Fore.RESET}?")
|
||||
return None, None
|
||||
return server, _get_token(server)
|
||||
|
||||
|
||||
def get_token(server: str) -> tuple[str | None, str | None]:
|
||||
if not server:
|
||||
return get_default_server()
|
||||
if server in config["aliases"]:
|
||||
server = config["aliases"][server]
|
||||
return server, _get_token(server)
|
||||
|
||||
|
||||
def _resolve_alias(alias: str) -> str | None:
|
||||
try:
|
||||
return config["aliases"][alias]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def _get_token(server: str) -> str | None:
|
||||
try:
|
||||
return config["servers"][server]
|
||||
except KeyError:
|
||||
print(f"{Fore.RED}No access token saved for {server}.{Fore.RESET}")
|
||||
return None
|
||||
|
||||
|
||||
def save_config() -> None:
|
||||
with open(f"{configdir}/maubot-cli.json", "w") as file:
|
||||
json.dump(config, file)
|
||||
|
||||
|
||||
def load_config() -> None:
|
||||
try:
|
||||
with open(f"{configdir}/maubot-cli.json") as file:
|
||||
loaded = json.load(file)
|
||||
config["servers"] = loaded.get("servers", {})
|
||||
config["aliases"] = loaded.get("aliases", {})
|
||||
config["default_server"] = loaded.get("default_server", None)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
6
maubot-src/maubot/cli/res/config.yaml
Normal file
6
maubot-src/maubot/cli/res/config.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
example_1: Example value 1
|
||||
example_2:
|
||||
list:
|
||||
- foo
|
||||
- bar
|
||||
value: asd
|
||||
42
maubot-src/maubot/cli/res/maubot.yaml.j2
Normal file
42
maubot-src/maubot/cli/res/maubot.yaml.j2
Normal file
@@ -0,0 +1,42 @@
|
||||
# The unique ID for the plugin. Java package naming style. (i.e. use your own domain, not xyz.maubot)
|
||||
id: {{ id }}
|
||||
|
||||
# A PEP 440 compliant version string.
|
||||
version: {{ version }}
|
||||
|
||||
# The SPDX license identifier for the plugin. https://spdx.org/licenses/
|
||||
# Optional, assumes all rights reserved if omitted.
|
||||
{% if license %}
|
||||
license: {{ license }}
|
||||
{% else %}
|
||||
#license: null
|
||||
{% endif %}
|
||||
|
||||
# The list of modules to load from the plugin archive.
|
||||
# Modules can be directories with an __init__.py file or simply python files.
|
||||
# Submodules that are imported by modules listed here don't need to be listed separately.
|
||||
# However, top-level modules must always be listed even if they're imported by other modules.
|
||||
modules:
|
||||
- {{ name }}
|
||||
|
||||
# The main class of the plugin. Format: module/Class
|
||||
# If `module` is omitted, will default to last module specified in the module list.
|
||||
# Even if `module` is not omitted here, it must be included in the modules list.
|
||||
# The main class must extend maubot.Plugin
|
||||
main_class: {{ main_class }}
|
||||
|
||||
# Extra files that the upcoming build tool should include in the mbp file.
|
||||
{% if config %}
|
||||
extra_files:
|
||||
- base-config.yaml
|
||||
{% else %}
|
||||
#extra_files:
|
||||
#- base-config.yaml
|
||||
{% endif %}
|
||||
|
||||
# List of dependencies
|
||||
#dependencies:
|
||||
#- foo
|
||||
|
||||
#soft_dependencies:
|
||||
#- bar>=0.1
|
||||
27
maubot-src/maubot/cli/res/plugin.py.j2
Normal file
27
maubot-src/maubot/cli/res/plugin.py.j2
Normal file
@@ -0,0 +1,27 @@
|
||||
from typing import Type
|
||||
{% if config %}
|
||||
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
|
||||
{% endif %}
|
||||
from maubot import Plugin
|
||||
{% if config %}
|
||||
|
||||
class Config(BaseProxyConfig):
|
||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
||||
helper.copy("example_1")
|
||||
helper.copy("example_2.list")
|
||||
helper.copy("example_2.value")
|
||||
{% endif %}
|
||||
|
||||
class {{ name }}(Plugin):
|
||||
async def start(self) -> None:{% if config %}
|
||||
self.config.load_and_update()
|
||||
self.log.debug("Loaded %s from config example 2", self.config["example_2.value"]){% else %}
|
||||
pass{% endif %}
|
||||
|
||||
async def stop(self) -> None:
|
||||
pass
|
||||
{% if config %}
|
||||
@classmethod
|
||||
def get_config_class(cls) -> Type[BaseProxyConfig]:
|
||||
return Config
|
||||
{% endif %}
|
||||
BIN
maubot-src/maubot/cli/res/spdx.json.zip
Normal file
BIN
maubot-src/maubot/cli/res/spdx.json.zip
Normal file
Binary file not shown.
0
maubot-src/maubot/cli/util/__init__.py
Normal file
0
maubot-src/maubot/cli/util/__init__.py
Normal file
45
maubot-src/maubot/cli/util/spdx.py
Normal file
45
maubot-src/maubot/cli/util/spdx.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
import pkg_resources
|
||||
|
||||
spdx_list: dict[str, dict[str, str]] | None = None
|
||||
|
||||
|
||||
def load() -> None:
|
||||
global spdx_list
|
||||
if spdx_list is not None:
|
||||
return
|
||||
with pkg_resources.resource_stream("maubot.cli", "res/spdx.json.zip") as disk_file:
|
||||
with zipfile.ZipFile(disk_file) as zip_file:
|
||||
with zip_file.open("spdx.json") as file:
|
||||
spdx_list = json.load(file)
|
||||
|
||||
|
||||
def get(id: str) -> dict[str, str]:
|
||||
if not spdx_list:
|
||||
load()
|
||||
return spdx_list[id]
|
||||
|
||||
|
||||
def valid(id: str) -> bool:
|
||||
if not spdx_list:
|
||||
load()
|
||||
return id in spdx_list
|
||||
581
maubot-src/maubot/client.py
Normal file
581
maubot-src/maubot/client.py
Normal file
@@ -0,0 +1,581 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, cast
|
||||
from collections import defaultdict
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from mautrix.api import HTTPAPI
|
||||
from mautrix.client import InternalEventType
|
||||
from mautrix.errors import MatrixInvalidToken
|
||||
from mautrix.types import (
|
||||
ContentURI,
|
||||
DeviceID,
|
||||
EventFilter,
|
||||
EventType,
|
||||
Filter,
|
||||
FilterID,
|
||||
Membership,
|
||||
PresenceState,
|
||||
RoomEventFilter,
|
||||
RoomFilter,
|
||||
StateEvent,
|
||||
StateFilter,
|
||||
StrippedStateEvent,
|
||||
SyncToken,
|
||||
TrustState,
|
||||
UserID,
|
||||
)
|
||||
from mautrix.util import background_task
|
||||
from mautrix.util.async_getter_lock import async_getter_lock
|
||||
from mautrix.util.logging import TraceLogger
|
||||
|
||||
from .db import Client as DBClient
|
||||
from .matrix import MaubotMatrixClient
|
||||
|
||||
try:
|
||||
from mautrix.crypto import OlmMachine, PgCryptoStore
|
||||
|
||||
crypto_import_error = None
|
||||
except ImportError as e:
|
||||
OlmMachine = PgCryptoStore = None
|
||||
crypto_import_error = e
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .__main__ import Maubot
|
||||
from .instance import PluginInstance
|
||||
|
||||
|
||||
class Client(DBClient):
|
||||
maubot: "Maubot" = None
|
||||
cache: dict[UserID, Client] = {}
|
||||
_async_get_locks: dict[Any, asyncio.Lock] = defaultdict(lambda: asyncio.Lock())
|
||||
log: TraceLogger = logging.getLogger("maubot.client")
|
||||
|
||||
http_client: ClientSession = None
|
||||
|
||||
references: set[PluginInstance]
|
||||
client: MaubotMatrixClient
|
||||
crypto: OlmMachine | None
|
||||
crypto_store: PgCryptoStore | None
|
||||
started: bool
|
||||
sync_ok: bool
|
||||
|
||||
remote_displayname: str | None
|
||||
remote_avatar_url: ContentURI | None
|
||||
trust_state: TrustState | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: UserID,
|
||||
homeserver: str,
|
||||
access_token: str,
|
||||
device_id: DeviceID,
|
||||
enabled: bool = False,
|
||||
next_batch: SyncToken = "",
|
||||
filter_id: FilterID = "",
|
||||
sync: bool = True,
|
||||
autojoin: bool = True,
|
||||
online: bool = True,
|
||||
displayname: str = "disable",
|
||||
avatar_url: str = "disable",
|
||||
) -> None:
|
||||
super().__init__(
|
||||
id=id,
|
||||
homeserver=homeserver,
|
||||
access_token=access_token,
|
||||
device_id=device_id,
|
||||
enabled=bool(enabled),
|
||||
next_batch=next_batch,
|
||||
filter_id=filter_id,
|
||||
sync=bool(sync),
|
||||
autojoin=bool(autojoin),
|
||||
online=bool(online),
|
||||
displayname=displayname,
|
||||
avatar_url=avatar_url,
|
||||
)
|
||||
self._postinited = False
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.id)
|
||||
|
||||
@classmethod
|
||||
def init_cls(cls, maubot: "Maubot") -> None:
|
||||
cls.maubot = maubot
|
||||
|
||||
def _make_client(
|
||||
self, homeserver: str | None = None, token: str | None = None, device_id: str | None = None
|
||||
) -> MaubotMatrixClient:
|
||||
return MaubotMatrixClient(
|
||||
mxid=self.id,
|
||||
base_url=homeserver or self.homeserver,
|
||||
token=token or self.access_token,
|
||||
client_session=self.http_client,
|
||||
log=self.log,
|
||||
crypto_log=self.log.getChild("crypto"),
|
||||
loop=self.maubot.loop,
|
||||
device_id=device_id or self.device_id,
|
||||
sync_store=self,
|
||||
state_store=self.maubot.state_store,
|
||||
)
|
||||
|
||||
def postinit(self) -> None:
|
||||
if self._postinited:
|
||||
raise RuntimeError("postinit() called twice")
|
||||
self._postinited = True
|
||||
self.cache[self.id] = self
|
||||
self.log = self.log.getChild(self.id)
|
||||
self.http_client = ClientSession(
|
||||
loop=self.maubot.loop,
|
||||
headers={
|
||||
"User-Agent": HTTPAPI.default_ua,
|
||||
},
|
||||
)
|
||||
self.references = set()
|
||||
self.started = False
|
||||
self.sync_ok = True
|
||||
self.trust_state = None
|
||||
self.remote_displayname = None
|
||||
self.remote_avatar_url = None
|
||||
self.client = self._make_client()
|
||||
if self.enable_crypto:
|
||||
self._prepare_crypto()
|
||||
else:
|
||||
self.crypto_store = None
|
||||
self.crypto = None
|
||||
self.client.ignore_initial_sync = True
|
||||
self.client.ignore_first_sync = True
|
||||
self.client.presence = PresenceState.ONLINE if self.online else PresenceState.OFFLINE
|
||||
if self.autojoin:
|
||||
self.client.add_event_handler(EventType.ROOM_MEMBER, self._handle_invite)
|
||||
self.client.add_event_handler(EventType.ROOM_TOMBSTONE, self._handle_tombstone)
|
||||
self.client.add_event_handler(InternalEventType.SYNC_ERRORED, self._set_sync_ok(False))
|
||||
self.client.add_event_handler(InternalEventType.SYNC_SUCCESSFUL, self._set_sync_ok(True))
|
||||
|
||||
def _set_sync_ok(self, ok: bool) -> Callable[[dict[str, Any]], Awaitable[None]]:
|
||||
async def handler(data: dict[str, Any]) -> None:
|
||||
self.sync_ok = ok
|
||||
|
||||
return handler
|
||||
|
||||
@property
|
||||
def enable_crypto(self) -> bool:
|
||||
if not self.device_id:
|
||||
return False
|
||||
elif not OlmMachine:
|
||||
global crypto_import_error
|
||||
self.log.warning(
|
||||
"Client has device ID, but encryption dependencies not installed",
|
||||
exc_info=crypto_import_error,
|
||||
)
|
||||
# Clear the stack trace after it's logged once to avoid spamming logs
|
||||
crypto_import_error = None
|
||||
return False
|
||||
elif not self.maubot.crypto_db:
|
||||
self.log.warning("Client has device ID, but crypto database is not prepared")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _prepare_crypto(self) -> None:
|
||||
self.crypto_store = PgCryptoStore(
|
||||
account_id=self.id, pickle_key="mau.crypto", db=self.maubot.crypto_db
|
||||
)
|
||||
self.crypto = OlmMachine(
|
||||
self.client,
|
||||
self.crypto_store,
|
||||
self.maubot.state_store,
|
||||
log=self.client.crypto_log,
|
||||
)
|
||||
self.client.crypto = self.crypto
|
||||
|
||||
def _remove_crypto_event_handlers(self) -> None:
|
||||
if not self.crypto:
|
||||
return
|
||||
handlers = [
|
||||
(InternalEventType.DEVICE_OTK_COUNT, self.crypto.handle_otk_count),
|
||||
(InternalEventType.DEVICE_LISTS, self.crypto.handle_device_lists),
|
||||
(EventType.TO_DEVICE_ENCRYPTED, self.crypto.handle_to_device_event),
|
||||
(EventType.ROOM_KEY_REQUEST, self.crypto.handle_room_key_request),
|
||||
(EventType.ROOM_MEMBER, self.crypto.handle_member_event),
|
||||
]
|
||||
for event_type, func in handlers:
|
||||
self.client.remove_event_handler(event_type, func)
|
||||
|
||||
async def start(self, try_n: int | None = 0) -> None:
|
||||
try:
|
||||
if try_n > 0:
|
||||
await asyncio.sleep(try_n * 10)
|
||||
await self._start(try_n)
|
||||
except Exception:
|
||||
self.log.exception("Failed to start")
|
||||
|
||||
async def _start_crypto(self) -> None:
|
||||
self.log.debug("Enabling end-to-end encryption support")
|
||||
await self.crypto_store.open()
|
||||
crypto_device_id = await self.crypto_store.get_device_id()
|
||||
if crypto_device_id and crypto_device_id != self.device_id:
|
||||
self.log.warning(
|
||||
"Mismatching device ID in crypto store and main database, resetting encryption"
|
||||
)
|
||||
await self.crypto_store.delete()
|
||||
crypto_device_id = None
|
||||
await self.crypto.load()
|
||||
if not crypto_device_id:
|
||||
await self.crypto_store.put_device_id(self.device_id)
|
||||
if not self.crypto.account.shared:
|
||||
await self.crypto.share_keys()
|
||||
self.trust_state = await self.crypto.resolve_trust(
|
||||
self.crypto.own_identity,
|
||||
allow_fetch=False,
|
||||
)
|
||||
|
||||
async def _start(self, try_n: int | None = 0) -> None:
|
||||
if not self.enabled:
|
||||
self.log.debug("Not starting disabled client")
|
||||
return
|
||||
elif self.started:
|
||||
self.log.warning("Ignoring start() call to started client")
|
||||
return
|
||||
try:
|
||||
await self.client.versions()
|
||||
whoami = await self.client.whoami()
|
||||
except MatrixInvalidToken as e:
|
||||
self.log.error(f"Invalid token: {e}. Disabling client")
|
||||
self.enabled = False
|
||||
await self.update()
|
||||
return
|
||||
except Exception as e:
|
||||
if try_n >= 8:
|
||||
self.log.exception("Failed to get /account/whoami, disabling client")
|
||||
self.enabled = False
|
||||
await self.update()
|
||||
else:
|
||||
self.log.warning(
|
||||
f"Failed to get /account/whoami, retrying in {(try_n + 1) * 10}s: {e}"
|
||||
)
|
||||
background_task.create(self.start(try_n + 1))
|
||||
return
|
||||
if whoami.user_id != self.id:
|
||||
self.log.error(f"User ID mismatch: expected {self.id}, but got {whoami.user_id}")
|
||||
self.enabled = False
|
||||
await self.update()
|
||||
return
|
||||
elif whoami.device_id and self.device_id and whoami.device_id != self.device_id:
|
||||
self.log.error(
|
||||
f"Device ID mismatch: expected {self.device_id}, but got {whoami.device_id}"
|
||||
)
|
||||
self.enabled = False
|
||||
await self.update()
|
||||
return
|
||||
if not self.filter_id:
|
||||
self.filter_id = await self.client.create_filter(
|
||||
Filter(
|
||||
room=RoomFilter(
|
||||
timeline=RoomEventFilter(
|
||||
limit=50,
|
||||
lazy_load_members=True,
|
||||
),
|
||||
state=StateFilter(
|
||||
lazy_load_members=True,
|
||||
),
|
||||
),
|
||||
presence=EventFilter(
|
||||
not_types=[EventType.PRESENCE],
|
||||
),
|
||||
)
|
||||
)
|
||||
await self.update()
|
||||
if self.displayname != "disable":
|
||||
await self.client.set_displayname(self.displayname)
|
||||
if self.avatar_url != "disable":
|
||||
await self.client.set_avatar_url(self.avatar_url)
|
||||
if self.crypto:
|
||||
await self._start_crypto()
|
||||
self.start_sync()
|
||||
await self._update_remote_profile()
|
||||
self.started = True
|
||||
self.log.info("Client started, starting plugin instances...")
|
||||
await self.start_plugins()
|
||||
|
||||
async def start_plugins(self) -> None:
|
||||
await asyncio.gather(*[plugin.start() for plugin in self.references])
|
||||
|
||||
async def stop_plugins(self) -> None:
|
||||
await asyncio.gather(*[plugin.stop() for plugin in self.references if plugin.started])
|
||||
|
||||
def start_sync(self) -> None:
|
||||
if self.sync:
|
||||
self.client.start(self.filter_id)
|
||||
|
||||
def stop_sync(self) -> None:
|
||||
self.client.stop()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self.started:
|
||||
self.started = False
|
||||
await self.stop_plugins()
|
||||
self.stop_sync()
|
||||
if self.crypto:
|
||||
await self.crypto_store.close()
|
||||
|
||||
async def clear_cache(self) -> None:
|
||||
self.stop_sync()
|
||||
self.filter_id = FilterID("")
|
||||
self.next_batch = SyncToken("")
|
||||
await self.update()
|
||||
self.start_sync()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"homeserver": self.homeserver,
|
||||
"access_token": self.access_token,
|
||||
"device_id": self.device_id,
|
||||
"fingerprint": (
|
||||
self.crypto.account.fingerprint if self.crypto and self.crypto.account else None
|
||||
),
|
||||
"enabled": self.enabled,
|
||||
"started": self.started,
|
||||
"sync": self.sync,
|
||||
"sync_ok": self.sync_ok,
|
||||
"autojoin": self.autojoin,
|
||||
"online": self.online,
|
||||
"displayname": self.displayname,
|
||||
"avatar_url": self.avatar_url,
|
||||
"remote_displayname": self.remote_displayname,
|
||||
"remote_avatar_url": self.remote_avatar_url,
|
||||
"instances": [instance.to_dict() for instance in self.references],
|
||||
"trust_state": str(self.trust_state) if self.trust_state else None,
|
||||
}
|
||||
|
||||
async def _handle_tombstone(self, evt: StateEvent) -> None:
|
||||
if evt.state_key != "":
|
||||
return
|
||||
if not evt.content.replacement_room:
|
||||
self.log.info(f"{evt.room_id} tombstoned with no replacement, ignoring")
|
||||
return
|
||||
is_joined = await self.client.state_store.is_joined(
|
||||
evt.content.replacement_room,
|
||||
self.client.mxid,
|
||||
)
|
||||
if is_joined:
|
||||
self.log.debug(
|
||||
f"Ignoring tombstone from {evt.room_id} to {evt.content.replacement_room} "
|
||||
f"sent by {evt.sender}: already joined to replacement room"
|
||||
)
|
||||
return
|
||||
self.log.debug(
|
||||
f"Following tombstone from {evt.room_id} to {evt.content.replacement_room} "
|
||||
f"sent by {evt.sender}"
|
||||
)
|
||||
_, server = self.client.parse_user_id(evt.sender)
|
||||
room_id = await self.client.join_room(evt.content.replacement_room, servers=[server])
|
||||
power_levels = await self.client.get_state_event(room_id, EventType.ROOM_POWER_LEVELS)
|
||||
create_event = await self.client.get_state_event(
|
||||
room_id, EventType.ROOM_CREATE, format="event"
|
||||
)
|
||||
if power_levels.get_user_level(evt.sender, create_event) < power_levels.invite:
|
||||
self.log.warning(
|
||||
f"{evt.room_id} was tombstoned into {room_id} by {evt.sender},"
|
||||
" but the sender doesn't have invite power levels, leaving..."
|
||||
)
|
||||
await self.client.leave_room(
|
||||
room_id,
|
||||
f"Followed tombstone from {evt.room_id} by {evt.sender},"
|
||||
" but sender doesn't have sufficient power level for invites",
|
||||
)
|
||||
|
||||
async def _handle_invite(self, evt: StrippedStateEvent) -> None:
|
||||
if evt.state_key == self.id and evt.content.membership == Membership.INVITE:
|
||||
await self.client.join_room(evt.room_id)
|
||||
|
||||
async def update_started(self, started: bool | None) -> None:
|
||||
if started is None or started == self.started:
|
||||
return
|
||||
if started:
|
||||
await self.start()
|
||||
else:
|
||||
await self.stop()
|
||||
|
||||
async def update_enabled(self, enabled: bool | None, save: bool = True) -> None:
|
||||
if enabled is None or enabled == self.enabled:
|
||||
return
|
||||
self.enabled = enabled
|
||||
if save:
|
||||
await self.update()
|
||||
|
||||
async def update_displayname(self, displayname: str | None, save: bool = True) -> None:
|
||||
if displayname is None or displayname == self.displayname:
|
||||
return
|
||||
self.displayname = displayname
|
||||
if self.displayname != "disable":
|
||||
await self.client.set_displayname(self.displayname)
|
||||
else:
|
||||
await self._update_remote_profile()
|
||||
if save:
|
||||
await self.update()
|
||||
|
||||
async def update_avatar_url(self, avatar_url: ContentURI, save: bool = True) -> None:
|
||||
if avatar_url is None or avatar_url == self.avatar_url:
|
||||
return
|
||||
self.avatar_url = avatar_url
|
||||
if self.avatar_url != "disable":
|
||||
await self.client.set_avatar_url(self.avatar_url)
|
||||
else:
|
||||
await self._update_remote_profile()
|
||||
if save:
|
||||
await self.update()
|
||||
|
||||
async def update_sync(self, sync: bool | None, save: bool = True) -> None:
|
||||
if sync is None or self.sync == sync:
|
||||
return
|
||||
self.sync = sync
|
||||
if self.started:
|
||||
if sync:
|
||||
self.start_sync()
|
||||
else:
|
||||
self.stop_sync()
|
||||
if save:
|
||||
await self.update()
|
||||
|
||||
async def update_autojoin(self, autojoin: bool | None, save: bool = True) -> None:
|
||||
if autojoin is None or autojoin == self.autojoin:
|
||||
return
|
||||
if autojoin:
|
||||
self.client.add_event_handler(EventType.ROOM_MEMBER, self._handle_invite)
|
||||
else:
|
||||
self.client.remove_event_handler(EventType.ROOM_MEMBER, self._handle_invite)
|
||||
self.autojoin = autojoin
|
||||
if save:
|
||||
await self.update()
|
||||
|
||||
async def update_online(self, online: bool | None, save: bool = True) -> None:
|
||||
if online is None or online == self.online:
|
||||
return
|
||||
self.client.presence = PresenceState.ONLINE if online else PresenceState.OFFLINE
|
||||
self.online = online
|
||||
if save:
|
||||
await self.update()
|
||||
|
||||
async def update_access_details(
|
||||
self,
|
||||
access_token: str | None,
|
||||
homeserver: str | None,
|
||||
device_id: str | None = None,
|
||||
) -> None:
|
||||
if not access_token and not homeserver:
|
||||
return
|
||||
if device_id is None:
|
||||
device_id = self.device_id
|
||||
elif not device_id:
|
||||
device_id = None
|
||||
if (
|
||||
access_token == self.access_token
|
||||
and homeserver == self.homeserver
|
||||
and device_id == self.device_id
|
||||
):
|
||||
return
|
||||
new_client = self._make_client(homeserver, access_token, device_id)
|
||||
whoami = await new_client.whoami()
|
||||
if whoami.user_id != self.id:
|
||||
raise ValueError(f"MXID mismatch: {whoami.user_id}")
|
||||
elif whoami.device_id and device_id and whoami.device_id != device_id:
|
||||
raise ValueError(f"Device ID mismatch: {whoami.device_id}")
|
||||
new_client.sync_store = self
|
||||
self.stop_sync()
|
||||
|
||||
# TODO this event handler transfer is pretty hacky
|
||||
self._remove_crypto_event_handlers()
|
||||
self.client.crypto = None
|
||||
new_client.event_handlers = self.client.event_handlers
|
||||
new_client.global_event_handlers = self.client.global_event_handlers
|
||||
|
||||
self.client = new_client
|
||||
self.homeserver = homeserver
|
||||
self.access_token = access_token
|
||||
self.device_id = device_id
|
||||
if self.enable_crypto:
|
||||
self._prepare_crypto()
|
||||
await self._start_crypto()
|
||||
else:
|
||||
self.crypto_store = None
|
||||
self.crypto = None
|
||||
self.start_sync()
|
||||
|
||||
async def _update_remote_profile(self) -> None:
|
||||
try:
|
||||
profile = await self.client.get_profile(self.id)
|
||||
self.remote_displayname, self.remote_avatar_url = (
|
||||
profile.displayname,
|
||||
profile.avatar_url,
|
||||
)
|
||||
except Exception:
|
||||
self.log.warning("Failed to update own profile from server", exc_info=True)
|
||||
|
||||
async def delete(self) -> None:
|
||||
try:
|
||||
del self.cache[self.id]
|
||||
except KeyError:
|
||||
pass
|
||||
await super().delete()
|
||||
|
||||
@classmethod
|
||||
@async_getter_lock
|
||||
async def get(
|
||||
cls,
|
||||
user_id: UserID,
|
||||
*,
|
||||
homeserver: str | None = None,
|
||||
access_token: str | None = None,
|
||||
device_id: DeviceID | None = None,
|
||||
) -> Client | None:
|
||||
try:
|
||||
return cls.cache[user_id]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
user = cast(cls, await super().get(user_id))
|
||||
if user is not None:
|
||||
user.postinit()
|
||||
return user
|
||||
|
||||
if homeserver and access_token:
|
||||
user = cls(
|
||||
user_id,
|
||||
homeserver=homeserver,
|
||||
access_token=access_token,
|
||||
device_id=device_id or "",
|
||||
)
|
||||
await user.insert()
|
||||
user.postinit()
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def all(cls) -> AsyncGenerator[Client, None]:
|
||||
users = await super().all()
|
||||
user: cls
|
||||
for user in users:
|
||||
try:
|
||||
yield cls.cache[user.id]
|
||||
except KeyError:
|
||||
user.postinit()
|
||||
yield user
|
||||
100
maubot-src/maubot/config.py
Normal file
100
maubot-src/maubot/config.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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/>.
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
|
||||
import bcrypt
|
||||
|
||||
from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper
|
||||
|
||||
bcrypt_regex = re.compile(r"^\$2[ayb]\$.{56}$")
|
||||
|
||||
|
||||
class Config(BaseFileConfig):
|
||||
@staticmethod
|
||||
def _new_token() -> str:
|
||||
return "".join(random.choices(string.ascii_lowercase + string.digits, k=64))
|
||||
|
||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
||||
base = helper.base
|
||||
copy = helper.copy
|
||||
|
||||
if "database" in self and self["database"].startswith("sqlite:///"):
|
||||
helper.base["database"] = self["database"].replace("sqlite:///", "sqlite:")
|
||||
else:
|
||||
copy("database")
|
||||
copy("database_opts")
|
||||
if isinstance(self["crypto_database"], dict):
|
||||
if self["crypto_database.type"] == "postgres":
|
||||
base["crypto_database"] = self["crypto_database.postgres_uri"]
|
||||
else:
|
||||
copy("crypto_database")
|
||||
copy("plugin_directories.upload")
|
||||
copy("plugin_directories.load")
|
||||
copy("plugin_directories.trash")
|
||||
if "plugin_directories.db" in self:
|
||||
base["plugin_databases.sqlite"] = self["plugin_directories.db"]
|
||||
else:
|
||||
copy("plugin_databases.sqlite")
|
||||
copy("plugin_databases.postgres")
|
||||
copy("plugin_databases.postgres_opts")
|
||||
copy("server.hostname")
|
||||
copy("server.port")
|
||||
copy("server.public_url")
|
||||
copy("server.listen")
|
||||
copy("server.ui_base_path")
|
||||
copy("server.plugin_base_path")
|
||||
copy("server.override_resource_path")
|
||||
shared_secret = self["server.unshared_secret"]
|
||||
if shared_secret is None or shared_secret == "generate":
|
||||
base["server.unshared_secret"] = self._new_token()
|
||||
else:
|
||||
base["server.unshared_secret"] = shared_secret
|
||||
if "registration_secrets" in self:
|
||||
base["homeservers"] = self["registration_secrets"]
|
||||
else:
|
||||
copy("homeservers")
|
||||
copy("admins")
|
||||
for username, password in base["admins"].items():
|
||||
if password and not bcrypt_regex.match(password):
|
||||
if password == "password":
|
||||
password = self._new_token()
|
||||
base["admins"][username] = bcrypt.hashpw(
|
||||
password.encode("utf-8"), bcrypt.gensalt()
|
||||
).decode("utf-8")
|
||||
copy("api_features.login")
|
||||
copy("api_features.plugin")
|
||||
copy("api_features.plugin_upload")
|
||||
copy("api_features.instance")
|
||||
copy("api_features.instance_database")
|
||||
copy("api_features.client")
|
||||
copy("api_features.client_proxy")
|
||||
copy("api_features.client_auth")
|
||||
copy("api_features.dev_open")
|
||||
copy("api_features.log")
|
||||
copy("logging")
|
||||
|
||||
def is_admin(self, user: str) -> bool:
|
||||
return user == "root" or user in self["admins"]
|
||||
|
||||
def check_password(self, user: str, passwd: str) -> bool:
|
||||
if user == "root":
|
||||
return False
|
||||
passwd_hash = self["admins"].get(user, None)
|
||||
if not passwd_hash:
|
||||
return False
|
||||
return bcrypt.checkpw(passwd.encode("utf-8"), passwd_hash.encode("utf-8"))
|
||||
13
maubot-src/maubot/db/__init__.py
Normal file
13
maubot-src/maubot/db/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
from .client import Client
|
||||
from .instance import DatabaseEngine, Instance
|
||||
from .upgrade import upgrade_table
|
||||
|
||||
|
||||
def init(db: Database) -> None:
|
||||
for table in (Client, Instance):
|
||||
table.db = db
|
||||
|
||||
|
||||
__all__ = ["upgrade_table", "init", "Client", "Instance", "DatabaseEngine"]
|
||||
114
maubot-src/maubot/db/client.py
Normal file
114
maubot-src/maubot/db/client.py
Normal file
@@ -0,0 +1,114 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from asyncpg import Record
|
||||
from attr import dataclass
|
||||
|
||||
from mautrix.client import SyncStore
|
||||
from mautrix.types import ContentURI, DeviceID, FilterID, SyncToken, UserID
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Client(SyncStore):
|
||||
db: ClassVar[Database] = fake_db
|
||||
|
||||
id: UserID
|
||||
homeserver: str
|
||||
access_token: str
|
||||
device_id: DeviceID
|
||||
enabled: bool
|
||||
|
||||
next_batch: SyncToken
|
||||
filter_id: FilterID
|
||||
|
||||
sync: bool
|
||||
autojoin: bool
|
||||
online: bool
|
||||
|
||||
displayname: str
|
||||
avatar_url: ContentURI
|
||||
|
||||
@classmethod
|
||||
def _from_row(cls, row: Record | None) -> Client | None:
|
||||
if row is None:
|
||||
return None
|
||||
return cls(**row)
|
||||
|
||||
_columns = (
|
||||
"id, homeserver, access_token, device_id, enabled, next_batch, filter_id, "
|
||||
"sync, autojoin, online, displayname, avatar_url"
|
||||
)
|
||||
|
||||
@property
|
||||
def _values(self):
|
||||
return (
|
||||
self.id,
|
||||
self.homeserver,
|
||||
self.access_token,
|
||||
self.device_id,
|
||||
self.enabled,
|
||||
self.next_batch,
|
||||
self.filter_id,
|
||||
self.sync,
|
||||
self.autojoin,
|
||||
self.online,
|
||||
self.displayname,
|
||||
self.avatar_url,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def all(cls) -> list[Client]:
|
||||
rows = await cls.db.fetch(f"SELECT {cls._columns} FROM client")
|
||||
return [cls._from_row(row) for row in rows]
|
||||
|
||||
@classmethod
|
||||
async def get(cls, id: str) -> Client | None:
|
||||
q = f"SELECT {cls._columns} FROM client WHERE id=$1"
|
||||
return cls._from_row(await cls.db.fetchrow(q, id))
|
||||
|
||||
async def insert(self) -> None:
|
||||
q = """
|
||||
INSERT INTO client (
|
||||
id, homeserver, access_token, device_id, enabled, next_batch, filter_id,
|
||||
sync, autojoin, online, displayname, avatar_url
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
"""
|
||||
await self.db.execute(q, *self._values)
|
||||
|
||||
async def put_next_batch(self, next_batch: SyncToken) -> None:
|
||||
await self.db.execute("UPDATE client SET next_batch=$1 WHERE id=$2", next_batch, self.id)
|
||||
self.next_batch = next_batch
|
||||
|
||||
async def get_next_batch(self) -> SyncToken:
|
||||
return self.next_batch
|
||||
|
||||
async def update(self) -> None:
|
||||
q = """
|
||||
UPDATE client SET homeserver=$2, access_token=$3, device_id=$4, enabled=$5,
|
||||
next_batch=$6, filter_id=$7, sync=$8, autojoin=$9, online=$10,
|
||||
displayname=$11, avatar_url=$12
|
||||
WHERE id=$1
|
||||
"""
|
||||
await self.db.execute(q, *self._values)
|
||||
|
||||
async def delete(self) -> None:
|
||||
await self.db.execute("DELETE FROM client WHERE id=$1", self.id)
|
||||
101
maubot-src/maubot/db/instance.py
Normal file
101
maubot-src/maubot/db/instance.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
from enum import Enum
|
||||
|
||||
from asyncpg import Record
|
||||
from attr import dataclass
|
||||
|
||||
from mautrix.types import UserID
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
||||
|
||||
|
||||
class DatabaseEngine(Enum):
|
||||
SQLITE = "sqlite"
|
||||
POSTGRES = "postgres"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Instance:
|
||||
db: ClassVar[Database] = fake_db
|
||||
|
||||
id: str
|
||||
type: str
|
||||
enabled: bool
|
||||
primary_user: UserID
|
||||
config_str: str
|
||||
database_engine: DatabaseEngine | None
|
||||
|
||||
@property
|
||||
def database_engine_str(self) -> str | None:
|
||||
return self.database_engine.value if self.database_engine else None
|
||||
|
||||
@classmethod
|
||||
def _from_row(cls, row: Record | None) -> Instance | None:
|
||||
if row is None:
|
||||
return None
|
||||
data = {**row}
|
||||
db_engine = data.pop("database_engine", None)
|
||||
return cls(**data, database_engine=DatabaseEngine(db_engine) if db_engine else None)
|
||||
|
||||
_columns = "id, type, enabled, primary_user, config, database_engine"
|
||||
|
||||
@classmethod
|
||||
async def all(cls) -> list[Instance]:
|
||||
q = f"SELECT {cls._columns} FROM instance"
|
||||
rows = await cls.db.fetch(q)
|
||||
return [cls._from_row(row) for row in rows]
|
||||
|
||||
@classmethod
|
||||
async def get(cls, id: str) -> Instance | None:
|
||||
q = f"SELECT {cls._columns} FROM instance WHERE id=$1"
|
||||
return cls._from_row(await cls.db.fetchrow(q, id))
|
||||
|
||||
async def update_id(self, new_id: str) -> None:
|
||||
await self.db.execute("UPDATE instance SET id=$1 WHERE id=$2", new_id, self.id)
|
||||
self.id = new_id
|
||||
|
||||
@property
|
||||
def _values(self):
|
||||
return (
|
||||
self.id,
|
||||
self.type,
|
||||
self.enabled,
|
||||
self.primary_user,
|
||||
self.config_str,
|
||||
self.database_engine_str,
|
||||
)
|
||||
|
||||
async def insert(self) -> None:
|
||||
q = (
|
||||
"INSERT INTO instance (id, type, enabled, primary_user, config, database_engine) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6)"
|
||||
)
|
||||
await self.db.execute(q, *self._values)
|
||||
|
||||
async def update(self) -> None:
|
||||
q = """
|
||||
UPDATE instance SET type=$2, enabled=$3, primary_user=$4, config=$5, database_engine=$6
|
||||
WHERE id=$1
|
||||
"""
|
||||
await self.db.execute(q, *self._values)
|
||||
|
||||
async def delete(self) -> None:
|
||||
await self.db.execute("DELETE FROM instance WHERE id=$1", self.id)
|
||||
5
maubot-src/maubot/db/upgrade/__init__.py
Normal file
5
maubot-src/maubot/db/upgrade/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from mautrix.util.async_db import UpgradeTable
|
||||
|
||||
upgrade_table = UpgradeTable()
|
||||
|
||||
from . import v01_initial_revision, v02_instance_database_engine
|
||||
136
maubot-src/maubot/db/upgrade/v01_initial_revision.py
Normal file
136
maubot-src/maubot/db/upgrade/v01_initial_revision.py
Normal file
@@ -0,0 +1,136 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from mautrix.util.async_db import Connection, Scheme
|
||||
|
||||
from . import upgrade_table
|
||||
|
||||
legacy_version_query = "SELECT version_num FROM alembic_version"
|
||||
last_legacy_version = "90aa88820eab"
|
||||
|
||||
|
||||
@upgrade_table.register(description="Initial asyncpg revision")
|
||||
async def upgrade_v1(conn: Connection, scheme: Scheme) -> None:
|
||||
if await conn.table_exists("alembic_version"):
|
||||
await migrate_legacy_to_v1(conn, scheme)
|
||||
else:
|
||||
return await create_v1_tables(conn)
|
||||
|
||||
|
||||
async def create_v1_tables(conn: Connection) -> None:
|
||||
await conn.execute(
|
||||
"""CREATE TABLE client (
|
||||
id TEXT PRIMARY KEY,
|
||||
homeserver TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
|
||||
next_batch TEXT NOT NULL,
|
||||
filter_id TEXT NOT NULL,
|
||||
|
||||
sync BOOLEAN NOT NULL,
|
||||
autojoin BOOLEAN NOT NULL,
|
||||
online BOOLEAN NOT NULL,
|
||||
|
||||
displayname TEXT NOT NULL,
|
||||
avatar_url TEXT NOT NULL
|
||||
)"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""CREATE TABLE instance (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
primary_user TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
FOREIGN KEY (primary_user) REFERENCES client(id) ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
)"""
|
||||
)
|
||||
|
||||
|
||||
async def migrate_legacy_to_v1(conn: Connection, scheme: Scheme) -> None:
|
||||
legacy_version = await conn.fetchval(legacy_version_query)
|
||||
if legacy_version != last_legacy_version:
|
||||
raise RuntimeError(
|
||||
"Legacy database is not on last version. "
|
||||
"Please upgrade the old database with alembic or drop it completely first."
|
||||
)
|
||||
await conn.execute("ALTER TABLE plugin RENAME TO instance")
|
||||
await update_state_store(conn, scheme)
|
||||
if scheme != Scheme.SQLITE:
|
||||
await varchar_to_text(conn)
|
||||
await conn.execute("DROP TABLE alembic_version")
|
||||
|
||||
|
||||
async def update_state_store(conn: Connection, scheme: Scheme) -> None:
|
||||
# The Matrix state store already has more or less the correct schema, so set the version
|
||||
await conn.execute("CREATE TABLE mx_version (version INTEGER PRIMARY KEY)")
|
||||
await conn.execute("INSERT INTO mx_version (version) VALUES (2)")
|
||||
if scheme != Scheme.SQLITE:
|
||||
# Remove old uppercase membership type and recreate it as lowercase
|
||||
await conn.execute("ALTER TABLE mx_user_profile ALTER COLUMN membership TYPE TEXT")
|
||||
await conn.execute("DROP TYPE IF EXISTS membership")
|
||||
await conn.execute(
|
||||
"CREATE TYPE membership AS ENUM ('join', 'leave', 'invite', 'ban', 'knock')"
|
||||
)
|
||||
await conn.execute(
|
||||
"ALTER TABLE mx_user_profile ALTER COLUMN membership TYPE membership "
|
||||
"USING LOWER(membership)::membership"
|
||||
)
|
||||
else:
|
||||
# Recreate table to remove CHECK constraint and lowercase everything
|
||||
await conn.execute(
|
||||
"""CREATE TABLE new_mx_user_profile (
|
||||
room_id TEXT,
|
||||
user_id TEXT,
|
||||
membership TEXT NOT NULL
|
||||
CHECK (membership IN ('join', 'leave', 'invite', 'ban', 'knock')),
|
||||
displayname TEXT,
|
||||
avatar_url TEXT,
|
||||
PRIMARY KEY (room_id, user_id)
|
||||
)"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO new_mx_user_profile (room_id, user_id, membership, displayname, avatar_url)
|
||||
SELECT room_id, user_id, LOWER(membership), displayname, avatar_url
|
||||
FROM mx_user_profile
|
||||
"""
|
||||
)
|
||||
await conn.execute("DROP TABLE mx_user_profile")
|
||||
await conn.execute("ALTER TABLE new_mx_user_profile RENAME TO mx_user_profile")
|
||||
|
||||
|
||||
async def varchar_to_text(conn: Connection) -> None:
|
||||
columns_to_adjust = {
|
||||
"client": (
|
||||
"id",
|
||||
"homeserver",
|
||||
"device_id",
|
||||
"next_batch",
|
||||
"filter_id",
|
||||
"displayname",
|
||||
"avatar_url",
|
||||
),
|
||||
"instance": ("id", "type", "primary_user"),
|
||||
"mx_room_state": ("room_id",),
|
||||
"mx_user_profile": ("room_id", "user_id", "displayname", "avatar_url"),
|
||||
}
|
||||
for table, columns in columns_to_adjust.items():
|
||||
for column in columns:
|
||||
await conn.execute(f'ALTER TABLE "{table}" ALTER COLUMN {column} TYPE TEXT')
|
||||
25
maubot-src/maubot/db/upgrade/v02_instance_database_engine.py
Normal file
25
maubot-src/maubot/db/upgrade/v02_instance_database_engine.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from mautrix.util.async_db import Connection
|
||||
|
||||
from . import upgrade_table
|
||||
|
||||
|
||||
@upgrade_table.register(description="Store instance database engine")
|
||||
async def upgrade_v2(conn: Connection) -> None:
|
||||
await conn.execute("ALTER TABLE instance ADD COLUMN database_engine TEXT")
|
||||
132
maubot-src/maubot/example-config.yaml
Normal file
132
maubot-src/maubot/example-config.yaml
Normal file
@@ -0,0 +1,132 @@
|
||||
# The full URI to the database. SQLite and Postgres are fully supported.
|
||||
# Format examples:
|
||||
# SQLite: sqlite:filename.db
|
||||
# Postgres: postgresql://username:password@hostname/dbname
|
||||
database: sqlite:maubot.db
|
||||
|
||||
# Separate database URL for the crypto database. "default" means use the same database as above.
|
||||
crypto_database: default
|
||||
|
||||
# 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
|
||||
|
||||
# Configuration for storing plugin .mbp files
|
||||
plugin_directories:
|
||||
# The directory where uploaded new plugins should be stored.
|
||||
upload: ./plugins
|
||||
# The directories from which plugins should be loaded.
|
||||
# Duplicate plugin IDs will be moved to the trash.
|
||||
load:
|
||||
- ./plugins
|
||||
# The directory where old plugin versions and conflicting plugins should be moved.
|
||||
# Set to "delete" to delete files immediately.
|
||||
trash: ./trash
|
||||
|
||||
# Configuration for storing plugin databases
|
||||
plugin_databases:
|
||||
# The directory where SQLite plugin databases should be stored.
|
||||
sqlite: ./plugins
|
||||
# The connection URL for plugin databases. If null, all plugins will get SQLite databases.
|
||||
# If set, plugins using the new asyncpg interface will get a Postgres connection instead.
|
||||
# Plugins using the legacy SQLAlchemy interface will always get a SQLite connection.
|
||||
#
|
||||
# To use the same connection pool as the default database, set to "default"
|
||||
# (the default database above must be postgres to do this).
|
||||
#
|
||||
# When enabled, maubot will create separate Postgres schemas in the database for each plugin.
|
||||
# To view schemas in psql, use `\dn`. To view enter and interact with a specific schema,
|
||||
# use `SET search_path = name` (where `name` is the name found with `\dn`) and then use normal
|
||||
# SQL queries/psql commands.
|
||||
postgres: null
|
||||
# Maximum number of connections per plugin instance.
|
||||
postgres_max_conns_per_plugin: 3
|
||||
# Overrides for the default database_opts when using a non-"default" postgres connection string.
|
||||
postgres_opts: {}
|
||||
|
||||
server:
|
||||
# The IP and port to listen to.
|
||||
hostname: 0.0.0.0
|
||||
port: 29316
|
||||
# Public base URL where the server is visible.
|
||||
public_url: https://example.com
|
||||
# The base path for the UI. Note that this does not change the API path.
|
||||
# Add a path prefix to public_url if you want everything on a subpath.
|
||||
ui_base_path: /_matrix/maubot
|
||||
# The base path for plugin endpoints. The instance ID will be appended directly.
|
||||
plugin_base_path: /_matrix/maubot/plugin/
|
||||
# Override path from where to load UI resources.
|
||||
# Set to false to using pkg_resources to find the path.
|
||||
override_resource_path: false
|
||||
# The shared secret to sign API access tokens.
|
||||
# Set to "generate" to generate and save a new token at startup.
|
||||
unshared_secret: generate
|
||||
|
||||
# Known homeservers. This is required for the `mbc auth` command and also allows
|
||||
# more convenient access from the management UI. This is not required to create
|
||||
# clients in the management UI, since you can also just type the homeserver URL
|
||||
# into the box there.
|
||||
homeservers:
|
||||
matrix.org:
|
||||
# Client-server API URL
|
||||
url: https://matrix-client.matrix.org
|
||||
# registration_shared_secret from synapse config
|
||||
# You can leave this empty if you don't have access to the homeserver.
|
||||
# When this is empty, `mbc auth --register` won't work, but `mbc auth` (login) will.
|
||||
secret: null
|
||||
|
||||
# List of administrator users. Each key is a username and the value is the password.
|
||||
# Plaintext passwords will be bcrypted on startup. Set empty password to prevent normal login.
|
||||
# Root is a special user that can't have a password and will always exist.
|
||||
admins:
|
||||
root: ""
|
||||
|
||||
# API feature switches.
|
||||
api_features:
|
||||
login: true
|
||||
plugin: true
|
||||
plugin_upload: true
|
||||
instance: true
|
||||
instance_database: true
|
||||
client: true
|
||||
client_proxy: true
|
||||
client_auth: true
|
||||
dev_open: true
|
||||
log: true
|
||||
|
||||
# Python logging configuration.
|
||||
#
|
||||
# See section 16.7.2 of the Python documentation for more info:
|
||||
# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema
|
||||
logging:
|
||||
version: 1
|
||||
formatters:
|
||||
colored:
|
||||
(): maubot.lib.color_log.ColorFormatter
|
||||
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
|
||||
normal:
|
||||
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
|
||||
handlers:
|
||||
file:
|
||||
class: logging.handlers.RotatingFileHandler
|
||||
formatter: normal
|
||||
filename: ./maubot.log
|
||||
maxBytes: 10485760
|
||||
backupCount: 10
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: colored
|
||||
loggers:
|
||||
maubot:
|
||||
level: DEBUG
|
||||
mau:
|
||||
level: DEBUG
|
||||
aiohttp:
|
||||
level: INFO
|
||||
root:
|
||||
level: DEBUG
|
||||
handlers: [file, console]
|
||||
1
maubot-src/maubot/handlers/__init__.py
Normal file
1
maubot-src/maubot/handlers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import command, event, web
|
||||
496
maubot-src/maubot/handlers/command.py
Normal file
496
maubot-src/maubot/handlers/command.py
Normal file
@@ -0,0 +1,496 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 (
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
NewType,
|
||||
Optional,
|
||||
Pattern,
|
||||
Sequence,
|
||||
Set,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import functools
|
||||
import inspect
|
||||
import re
|
||||
|
||||
from mautrix.types import EventType, MessageType
|
||||
|
||||
from ..matrix import MaubotMessageEvent
|
||||
from . import event
|
||||
|
||||
PrefixType = Optional[Union[str, Callable[[], str], Callable[[Any], str]]]
|
||||
AliasesType = Union[
|
||||
List[str], Tuple[str, ...], Set[str], Callable[[str], bool], Callable[[Any, str], bool]
|
||||
]
|
||||
CommandHandlerFunc = NewType(
|
||||
"CommandHandlerFunc", Callable[[MaubotMessageEvent, Any], Awaitable[Any]]
|
||||
)
|
||||
CommandHandlerDecorator = NewType(
|
||||
"CommandHandlerDecorator",
|
||||
Callable[[Union["CommandHandler", CommandHandlerFunc]], "CommandHandler"],
|
||||
)
|
||||
PassiveCommandHandlerDecorator = NewType(
|
||||
"PassiveCommandHandlerDecorator", Callable[[CommandHandlerFunc], CommandHandlerFunc]
|
||||
)
|
||||
|
||||
|
||||
def _split_in_two(val: str, split_by: str) -> List[str]:
|
||||
return val.split(split_by, 1) if split_by in val else [val, ""]
|
||||
|
||||
|
||||
class CommandHandler:
|
||||
def __init__(self, func: CommandHandlerFunc) -> None:
|
||||
self.__mb_func__: CommandHandlerFunc = func
|
||||
self.__mb_parent__: Optional[CommandHandler] = None
|
||||
self.__mb_subcommands__: List[CommandHandler] = []
|
||||
self.__mb_arguments__: List[Argument] = []
|
||||
self.__mb_help__: Optional[str] = None
|
||||
self.__mb_get_name__: Callable[[Any], str] = lambda s: "noname"
|
||||
self.__mb_is_command_match__: Callable[[Any, str], bool] = self.__command_match_unset
|
||||
self.__mb_require_subcommand__: bool = True
|
||||
self.__mb_must_consume_args__: bool = True
|
||||
self.__mb_arg_fallthrough__: bool = True
|
||||
self.__mb_event_handler__: bool = True
|
||||
self.__mb_event_types__: set[EventType] = {EventType.ROOM_MESSAGE}
|
||||
self.__mb_msgtypes__: Iterable[MessageType] = (MessageType.TEXT,)
|
||||
self.__bound_copies__: Dict[Any, CommandHandler] = {}
|
||||
self.__bound_instance__: Any = None
|
||||
|
||||
def __get__(self, instance, instancetype):
|
||||
if not instance or self.__bound_instance__:
|
||||
return self
|
||||
try:
|
||||
return self.__bound_copies__[instance]
|
||||
except KeyError:
|
||||
new_ch = type(self)(self.__mb_func__)
|
||||
keys = [
|
||||
"parent",
|
||||
"subcommands",
|
||||
"arguments",
|
||||
"help",
|
||||
"get_name",
|
||||
"is_command_match",
|
||||
"require_subcommand",
|
||||
"must_consume_args",
|
||||
"arg_fallthrough",
|
||||
"event_handler",
|
||||
"event_types",
|
||||
"msgtypes",
|
||||
]
|
||||
for key in keys:
|
||||
key = f"__mb_{key}__"
|
||||
setattr(new_ch, key, getattr(self, key))
|
||||
new_ch.__bound_instance__ = instance
|
||||
new_ch.__mb_subcommands__ = [
|
||||
subcmd.__get__(instance, instancetype) for subcmd in self.__mb_subcommands__
|
||||
]
|
||||
self.__bound_copies__[instance] = new_ch
|
||||
return new_ch
|
||||
|
||||
@staticmethod
|
||||
def __command_match_unset(self, val: str) -> bool:
|
||||
raise NotImplementedError("Hmm")
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
evt: MaubotMessageEvent,
|
||||
*,
|
||||
_existing_args: Dict[str, Any] = None,
|
||||
remaining_val: str = None,
|
||||
) -> Any:
|
||||
if evt.sender == evt.client.mxid or evt.content.msgtype not in self.__mb_msgtypes__:
|
||||
return
|
||||
if remaining_val is None:
|
||||
if not evt.content.body or evt.content.body[0] != "!":
|
||||
return
|
||||
command, remaining_val = _split_in_two(evt.content.body[1:], " ")
|
||||
command = command.lower()
|
||||
if not self.__mb_is_command_match__(self.__bound_instance__, command):
|
||||
return
|
||||
call_args: Dict[str, Any] = {**_existing_args} if _existing_args else {}
|
||||
|
||||
if not self.__mb_arg_fallthrough__ and len(self.__mb_subcommands__) > 0:
|
||||
ok, res = await self.__call_subcommand__(evt, call_args, remaining_val)
|
||||
if ok:
|
||||
return res
|
||||
|
||||
ok, remaining_val = await self.__parse_args__(evt, call_args, remaining_val)
|
||||
if not ok:
|
||||
return
|
||||
elif self.__mb_arg_fallthrough__ and len(self.__mb_subcommands__) > 0:
|
||||
ok, res = await self.__call_subcommand__(evt, call_args, remaining_val)
|
||||
if ok:
|
||||
return res
|
||||
elif self.__mb_require_subcommand__:
|
||||
await evt.reply(self.__mb_full_help__)
|
||||
return
|
||||
|
||||
if self.__mb_must_consume_args__ and remaining_val.strip():
|
||||
await evt.reply(self.__mb_full_help__)
|
||||
return
|
||||
|
||||
if self.__bound_instance__:
|
||||
return await self.__mb_func__(self.__bound_instance__, evt, **call_args)
|
||||
return await self.__mb_func__(evt, **call_args)
|
||||
|
||||
async def __call_subcommand__(
|
||||
self, evt: MaubotMessageEvent, call_args: Dict[str, Any], remaining_val: str
|
||||
) -> Tuple[bool, Any]:
|
||||
command, remaining_val = _split_in_two(remaining_val.strip(), " ")
|
||||
for subcommand in self.__mb_subcommands__:
|
||||
if subcommand.__mb_is_command_match__(subcommand.__bound_instance__, command):
|
||||
return True, await subcommand(
|
||||
evt, _existing_args=call_args, remaining_val=remaining_val
|
||||
)
|
||||
return False, None
|
||||
|
||||
async def __parse_args__(
|
||||
self, evt: MaubotMessageEvent, call_args: Dict[str, Any], remaining_val: str
|
||||
) -> Tuple[bool, str]:
|
||||
for arg in self.__mb_arguments__:
|
||||
try:
|
||||
remaining_val, call_args[arg.name] = arg.match(
|
||||
remaining_val.strip(), evt=evt, instance=self.__bound_instance__
|
||||
)
|
||||
if arg.required and call_args[arg.name] is None:
|
||||
raise ValueError("Argument required")
|
||||
except ArgumentSyntaxError as e:
|
||||
await evt.reply(e.message + (f"\n{self.__mb_usage__}" if e.show_usage else ""))
|
||||
return False, remaining_val
|
||||
except ValueError:
|
||||
await evt.reply(self.__mb_usage__)
|
||||
return False, remaining_val
|
||||
return True, remaining_val
|
||||
|
||||
@property
|
||||
def __mb_full_help__(self) -> str:
|
||||
usage = self.__mb_usage_without_subcommands__ + "\n\n"
|
||||
if not self.__mb_require_subcommand__:
|
||||
usage += f"* {self.__mb_prefix__} {self.__mb_usage_args__} - {self.__mb_help__}\n"
|
||||
usage += "\n".join(cmd.__mb_usage_inline__ for cmd in self.__mb_subcommands__)
|
||||
return usage
|
||||
|
||||
@property
|
||||
def __mb_usage_args__(self) -> str:
|
||||
arg_usage = " ".join(
|
||||
f"<{arg.label}>" if arg.required else f"[{arg.label}]" for arg in self.__mb_arguments__
|
||||
)
|
||||
if self.__mb_subcommands__ and self.__mb_arg_fallthrough__:
|
||||
arg_usage += " " + self.__mb_usage_subcommand__
|
||||
return arg_usage
|
||||
|
||||
@property
|
||||
def __mb_usage_subcommand__(self) -> str:
|
||||
return f"<subcommand> [...]"
|
||||
|
||||
@property
|
||||
def __mb_name__(self) -> str:
|
||||
return self.__mb_get_name__(self.__bound_instance__)
|
||||
|
||||
@property
|
||||
def __mb_prefix__(self) -> str:
|
||||
if self.__mb_parent__:
|
||||
return (
|
||||
f"!{self.__mb_parent__.__mb_get_name__(self.__bound_instance__)} "
|
||||
f"{self.__mb_name__}"
|
||||
)
|
||||
return f"!{self.__mb_name__}"
|
||||
|
||||
@property
|
||||
def __mb_usage_inline__(self) -> str:
|
||||
if not self.__mb_arg_fallthrough__:
|
||||
return (
|
||||
f"* {self.__mb_name__} {self.__mb_usage_args__} - {self.__mb_help__}\n"
|
||||
f"* {self.__mb_name__} {self.__mb_usage_subcommand__}"
|
||||
)
|
||||
return f"* {self.__mb_name__} {self.__mb_usage_args__} - {self.__mb_help__}"
|
||||
|
||||
@property
|
||||
def __mb_subcommands_list__(self) -> str:
|
||||
return f"**Subcommands:** {', '.join(sc.__mb_name__ for sc in self.__mb_subcommands__)}"
|
||||
|
||||
@property
|
||||
def __mb_usage_without_subcommands__(self) -> str:
|
||||
if not self.__mb_arg_fallthrough__:
|
||||
if not self.__mb_arguments__:
|
||||
return f"**Usage:** {self.__mb_prefix__} [subcommand] [...]"
|
||||
return (
|
||||
f"**Usage:** {self.__mb_prefix__} {self.__mb_usage_args__}"
|
||||
f" _OR_ {self.__mb_prefix__} {self.__mb_usage_subcommand__}"
|
||||
)
|
||||
return f"**Usage:** {self.__mb_prefix__} {self.__mb_usage_args__}"
|
||||
|
||||
@property
|
||||
def __mb_usage__(self) -> str:
|
||||
if len(self.__mb_subcommands__) > 0:
|
||||
return f"{self.__mb_usage_without_subcommands__} \n{self.__mb_subcommands_list__}"
|
||||
return self.__mb_usage_without_subcommands__
|
||||
|
||||
def subcommand(
|
||||
self,
|
||||
name: PrefixType = None,
|
||||
*,
|
||||
help: str = None,
|
||||
aliases: AliasesType = None,
|
||||
required_subcommand: bool = True,
|
||||
arg_fallthrough: bool = True,
|
||||
) -> CommandHandlerDecorator:
|
||||
def decorator(func: Union[CommandHandler, CommandHandlerFunc]) -> CommandHandler:
|
||||
if not isinstance(func, CommandHandler):
|
||||
func = CommandHandler(func)
|
||||
new(
|
||||
name,
|
||||
help=help,
|
||||
aliases=aliases,
|
||||
require_subcommand=required_subcommand,
|
||||
arg_fallthrough=arg_fallthrough,
|
||||
)(func)
|
||||
func.__mb_parent__ = self
|
||||
func.__mb_event_handler__ = False
|
||||
self.__mb_subcommands__.append(func)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def new(
|
||||
name: PrefixType = None,
|
||||
*,
|
||||
help: str = None,
|
||||
aliases: AliasesType = None,
|
||||
event_type: EventType = EventType.ROOM_MESSAGE,
|
||||
msgtypes: Iterable[MessageType] = None,
|
||||
require_subcommand: bool = True,
|
||||
arg_fallthrough: bool = True,
|
||||
must_consume_args: bool = True,
|
||||
) -> CommandHandlerDecorator:
|
||||
def decorator(func: Union[CommandHandler, CommandHandlerFunc]) -> CommandHandler:
|
||||
if not isinstance(func, CommandHandler):
|
||||
func = CommandHandler(func)
|
||||
func.__mb_help__ = help
|
||||
if name:
|
||||
if callable(name):
|
||||
if len(inspect.getfullargspec(name).args) == 0:
|
||||
func.__mb_get_name__ = lambda self: name()
|
||||
else:
|
||||
func.__mb_get_name__ = name
|
||||
else:
|
||||
func.__mb_get_name__ = lambda self: name
|
||||
else:
|
||||
func.__mb_get_name__ = lambda self: func.__mb_func__.__name__.replace("_", "-")
|
||||
if callable(aliases):
|
||||
if len(inspect.getfullargspec(aliases).args) == 1:
|
||||
func.__mb_is_command_match__ = lambda self, val: aliases(val)
|
||||
else:
|
||||
func.__mb_is_command_match__ = aliases
|
||||
elif isinstance(aliases, (list, set, tuple)):
|
||||
func.__mb_is_command_match__ = lambda self, val: (
|
||||
val == func.__mb_get_name__(self) or val in aliases
|
||||
)
|
||||
else:
|
||||
func.__mb_is_command_match__ = lambda self, val: val == func.__mb_get_name__(self)
|
||||
# Decorators are executed last to first, so we reverse the argument list.
|
||||
func.__mb_arguments__.reverse()
|
||||
func.__mb_require_subcommand__ = require_subcommand
|
||||
func.__mb_arg_fallthrough__ = arg_fallthrough
|
||||
func.__mb_must_consume_args__ = must_consume_args
|
||||
func.__mb_event_types__ = {event_type}
|
||||
if msgtypes:
|
||||
func.__mb_msgtypes__ = msgtypes
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class ArgumentSyntaxError(ValueError):
|
||||
def __init__(self, message: str, show_usage: bool = True) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.show_usage = show_usage
|
||||
|
||||
|
||||
class Argument(ABC):
|
||||
def __init__(
|
||||
self, name: str, label: str = None, *, required: bool = False, pass_raw: bool = False
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.label = label or name
|
||||
self.required = required
|
||||
self.pass_raw = pass_raw
|
||||
|
||||
@abstractmethod
|
||||
def match(self, val: str, **kwargs) -> Tuple[str, Any]:
|
||||
pass
|
||||
|
||||
def __call__(self, func: Union[CommandHandler, CommandHandlerFunc]) -> CommandHandler:
|
||||
if not isinstance(func, CommandHandler):
|
||||
func = CommandHandler(func)
|
||||
func.__mb_arguments__.append(self)
|
||||
return func
|
||||
|
||||
|
||||
class RegexArgument(Argument):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
label: str = None,
|
||||
*,
|
||||
required: bool = False,
|
||||
pass_raw: bool = False,
|
||||
matches: str = None,
|
||||
) -> None:
|
||||
super().__init__(name, label, required=required, pass_raw=pass_raw)
|
||||
matches = f"^{matches}" if self.pass_raw else f"^{matches}$"
|
||||
self.regex = re.compile(matches)
|
||||
|
||||
def match(self, val: str, **kwargs) -> Tuple[str, Any]:
|
||||
orig_val = val
|
||||
if not self.pass_raw:
|
||||
val = re.split(r"\s", val, 1)[0]
|
||||
match = self.regex.match(val)
|
||||
if match:
|
||||
return (
|
||||
orig_val[: match.start()] + orig_val[match.end() :],
|
||||
match.groups() or val[match.start() : match.end()],
|
||||
)
|
||||
return orig_val, None
|
||||
|
||||
|
||||
class CustomArgument(Argument):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
label: str = None,
|
||||
*,
|
||||
required: bool = False,
|
||||
pass_raw: bool = False,
|
||||
matcher: Callable[[str], Any],
|
||||
) -> None:
|
||||
super().__init__(name, label, required=required, pass_raw=pass_raw)
|
||||
self.matcher = matcher
|
||||
|
||||
def match(self, val: str, **kwargs) -> Tuple[str, Any]:
|
||||
if self.pass_raw:
|
||||
return self.matcher(val)
|
||||
orig_val = val
|
||||
val = re.split(r"\s", val, 1)[0]
|
||||
res = self.matcher(val)
|
||||
if res is not None:
|
||||
return orig_val[len(val) :], res
|
||||
return orig_val, None
|
||||
|
||||
|
||||
class SimpleArgument(Argument):
|
||||
def match(self, val: str, **kwargs) -> Tuple[str, Any]:
|
||||
if self.pass_raw:
|
||||
return "", val
|
||||
res = re.split(r"\s", val, 1)[0]
|
||||
return val[len(res) :], res
|
||||
|
||||
|
||||
def argument(
|
||||
name: str,
|
||||
label: str = None,
|
||||
*,
|
||||
required: bool = True,
|
||||
matches: Optional[str] = None,
|
||||
parser: Optional[Callable[[str], Any]] = None,
|
||||
pass_raw: bool = False,
|
||||
) -> CommandHandlerDecorator:
|
||||
if matches:
|
||||
return RegexArgument(name, label, required=required, matches=matches, pass_raw=pass_raw)
|
||||
elif parser:
|
||||
return CustomArgument(name, label, required=required, matcher=parser, pass_raw=pass_raw)
|
||||
else:
|
||||
return SimpleArgument(name, label, required=required, pass_raw=pass_raw)
|
||||
|
||||
|
||||
def passive(
|
||||
regex: Union[str, Pattern],
|
||||
*,
|
||||
msgtypes: Sequence[MessageType] = (MessageType.TEXT,),
|
||||
field: Callable[[MaubotMessageEvent], str] = lambda evt: evt.content.body,
|
||||
event_type: EventType = EventType.ROOM_MESSAGE,
|
||||
multiple: bool = False,
|
||||
case_insensitive: bool = False,
|
||||
multiline: bool = False,
|
||||
dot_all: bool = False,
|
||||
) -> PassiveCommandHandlerDecorator:
|
||||
if not isinstance(regex, Pattern):
|
||||
flags = re.RegexFlag.UNICODE
|
||||
if case_insensitive:
|
||||
flags |= re.IGNORECASE
|
||||
if multiline:
|
||||
flags |= re.MULTILINE
|
||||
if dot_all:
|
||||
flags |= re.DOTALL
|
||||
regex = re.compile(regex, flags=flags)
|
||||
|
||||
def decorator(func: CommandHandlerFunc) -> CommandHandlerFunc:
|
||||
combine = None
|
||||
if hasattr(func, "__mb_passive_orig__"):
|
||||
combine = func
|
||||
func = func.__mb_passive_orig__
|
||||
|
||||
@event.on(event_type)
|
||||
@functools.wraps(func)
|
||||
async def replacement(self, evt: MaubotMessageEvent = None) -> None:
|
||||
if not evt and isinstance(self, MaubotMessageEvent):
|
||||
evt = self
|
||||
self = None
|
||||
if evt.sender == evt.client.mxid:
|
||||
return
|
||||
elif msgtypes and evt.content.msgtype not in msgtypes:
|
||||
return
|
||||
data = field(evt)
|
||||
if multiple:
|
||||
val = [
|
||||
(data[match.pos : match.endpos], *match.groups())
|
||||
for match in regex.finditer(data)
|
||||
]
|
||||
else:
|
||||
match = regex.search(data)
|
||||
if match:
|
||||
val = (data[match.pos : match.endpos], *match.groups())
|
||||
else:
|
||||
val = None
|
||||
if val:
|
||||
if self:
|
||||
await func(self, evt, val)
|
||||
else:
|
||||
await func(evt, val)
|
||||
|
||||
if combine:
|
||||
orig_replacement = replacement
|
||||
|
||||
@event.on(event_type)
|
||||
@functools.wraps(func)
|
||||
async def replacement(self, evt: MaubotMessageEvent = None) -> None:
|
||||
await asyncio.gather(combine(self, evt), orig_replacement(self, evt))
|
||||
|
||||
replacement.__mb_passive_orig__ = func
|
||||
|
||||
return replacement
|
||||
|
||||
return decorator
|
||||
44
maubot-src/maubot/handlers/event.py
Normal file
44
maubot-src/maubot/handlers/event.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from typing import Callable, NewType
|
||||
|
||||
from mautrix.client import EventHandler, InternalEventType
|
||||
from mautrix.types import EventType
|
||||
|
||||
EventHandlerDecorator = NewType("EventHandlerDecorator", Callable[[EventHandler], EventHandler])
|
||||
|
||||
|
||||
def on(var: EventType | InternalEventType | EventHandler) -> EventHandlerDecorator | EventHandler:
|
||||
def decorator(func: EventHandler) -> EventHandler:
|
||||
func.__mb_event_handler__ = True
|
||||
if isinstance(var, (EventType, InternalEventType)):
|
||||
if hasattr(func, "__mb_event_types__"):
|
||||
func.__mb_event_types__.add(var)
|
||||
else:
|
||||
func.__mb_event_types__ = {var}
|
||||
else:
|
||||
func.__mb_event_types__ = {EventType.ALL}
|
||||
|
||||
return func
|
||||
|
||||
return decorator if isinstance(var, (EventType, InternalEventType)) else decorator(var)
|
||||
|
||||
|
||||
def off(func: EventHandler) -> EventHandler:
|
||||
func.__mb_event_handler__ = False
|
||||
return func
|
||||
66
maubot-src/maubot/handlers/web.py
Normal file
66
maubot-src/maubot/handlers/web.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 Any, Awaitable, Callable
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
|
||||
WebHandler = Callable[[web.Request], Awaitable[web.StreamResponse]]
|
||||
WebHandlerDecorator = Callable[[WebHandler], WebHandler]
|
||||
|
||||
|
||||
def head(path: str, **kwargs: Any) -> WebHandlerDecorator:
|
||||
return handle(hdrs.METH_HEAD, path, **kwargs)
|
||||
|
||||
|
||||
def options(path: str, **kwargs: Any) -> WebHandlerDecorator:
|
||||
return handle(hdrs.METH_OPTIONS, path, **kwargs)
|
||||
|
||||
|
||||
def get(path: str, **kwargs: Any) -> WebHandlerDecorator:
|
||||
return handle(hdrs.METH_GET, path, **kwargs)
|
||||
|
||||
|
||||
def post(path: str, **kwargs: Any) -> WebHandlerDecorator:
|
||||
return handle(hdrs.METH_POST, path, **kwargs)
|
||||
|
||||
|
||||
def put(path: str, **kwargs: Any) -> WebHandlerDecorator:
|
||||
return handle(hdrs.METH_PUT, path, **kwargs)
|
||||
|
||||
|
||||
def patch(path: str, **kwargs: Any) -> WebHandlerDecorator:
|
||||
return handle(hdrs.METH_PATCH, path, **kwargs)
|
||||
|
||||
|
||||
def delete(path: str, **kwargs: Any) -> WebHandlerDecorator:
|
||||
return handle(hdrs.METH_DELETE, path, **kwargs)
|
||||
|
||||
|
||||
def view(path: str, **kwargs: Any) -> WebHandlerDecorator:
|
||||
return handle(hdrs.METH_ANY, path, **kwargs)
|
||||
|
||||
|
||||
def handle(method: str, path: str, **kwargs) -> WebHandlerDecorator:
|
||||
def decorator(handler: WebHandler) -> WebHandler:
|
||||
try:
|
||||
handlers = getattr(handler, "__mb_web_handler__")
|
||||
except AttributeError:
|
||||
handlers = []
|
||||
setattr(handler, "__mb_web_handler__", handlers)
|
||||
handlers.append((method, path, kwargs))
|
||||
return handler
|
||||
|
||||
return decorator
|
||||
533
maubot-src/maubot/instance.py
Normal file
533
maubot-src/maubot/instance.py
Normal file
@@ -0,0 +1,533 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, AsyncGenerator, cast
|
||||
from collections import defaultdict
|
||||
import asyncio
|
||||
import inspect
|
||||
import io
|
||||
import logging
|
||||
import os.path
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.comments import CommentedMap
|
||||
|
||||
from mautrix.types import UserID
|
||||
from mautrix.util import background_task
|
||||
from mautrix.util.async_db import Database, Scheme, UpgradeTable
|
||||
from mautrix.util.async_getter_lock import async_getter_lock
|
||||
from mautrix.util.config import BaseProxyConfig, RecursiveDict
|
||||
from mautrix.util.logging import TraceLogger
|
||||
|
||||
from .client import Client
|
||||
from .db import DatabaseEngine, Instance as DBInstance
|
||||
from .lib.optionalalchemy import Engine, MetaData, create_engine
|
||||
from .lib.plugin_db import ProxyPostgresDatabase
|
||||
from .loader import DatabaseType, PluginLoader, ZippedPluginLoader
|
||||
from .plugin_base import Plugin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .__main__ import Maubot
|
||||
from .server import PluginWebApp
|
||||
|
||||
log: TraceLogger = cast(TraceLogger, logging.getLogger("maubot.instance"))
|
||||
db_log: TraceLogger = cast(TraceLogger, logging.getLogger("maubot.instance_db"))
|
||||
|
||||
yaml = YAML()
|
||||
yaml.indent(4)
|
||||
yaml.width = 200
|
||||
|
||||
|
||||
class PluginInstance(DBInstance):
|
||||
maubot: "Maubot" = None
|
||||
cache: dict[str, PluginInstance] = {}
|
||||
plugin_directories: list[str] = []
|
||||
_async_get_locks: dict[Any, asyncio.Lock] = defaultdict(lambda: asyncio.Lock())
|
||||
|
||||
log: logging.Logger
|
||||
loader: PluginLoader | None
|
||||
client: Client | None
|
||||
plugin: Plugin | None
|
||||
config: BaseProxyConfig | None
|
||||
base_cfg: RecursiveDict[CommentedMap] | None
|
||||
base_cfg_str: str | None
|
||||
inst_db: sql.engine.Engine | Database | None
|
||||
inst_db_tables: dict | None
|
||||
inst_webapp: PluginWebApp | None
|
||||
inst_webapp_url: str | None
|
||||
started: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
type: str,
|
||||
enabled: bool,
|
||||
primary_user: UserID,
|
||||
config: str = "",
|
||||
database_engine: DatabaseEngine | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
id=id,
|
||||
type=type,
|
||||
enabled=bool(enabled),
|
||||
primary_user=primary_user,
|
||||
config_str=config,
|
||||
database_engine=database_engine,
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.id)
|
||||
|
||||
@classmethod
|
||||
def init_cls(cls, maubot: "Maubot") -> None:
|
||||
cls.maubot = maubot
|
||||
|
||||
def postinit(self) -> None:
|
||||
self.log = log.getChild(self.id)
|
||||
self.cache[self.id] = self
|
||||
self.config = None
|
||||
self.started = False
|
||||
self.loader = None
|
||||
self.client = None
|
||||
self.plugin = None
|
||||
self.inst_db = None
|
||||
self.inst_db_tables = None
|
||||
self.inst_webapp = None
|
||||
self.inst_webapp_url = None
|
||||
self.base_cfg = None
|
||||
self.base_cfg_str = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"type": self.type,
|
||||
"enabled": self.enabled,
|
||||
"started": self.started,
|
||||
"primary_user": self.primary_user,
|
||||
"config": self.config_str,
|
||||
"base_config": self.base_cfg_str,
|
||||
"database": (
|
||||
self.inst_db is not None and self.maubot.config["api_features.instance_database"]
|
||||
),
|
||||
"database_interface": self.loader.meta.database_type_str if self.loader else "unknown",
|
||||
"database_engine": self.database_engine_str,
|
||||
}
|
||||
|
||||
def _introspect_sqlalchemy(self) -> dict:
|
||||
metadata = MetaData()
|
||||
metadata.reflect(self.inst_db)
|
||||
return {
|
||||
table.name: {
|
||||
"columns": {
|
||||
column.name: {
|
||||
"type": str(column.type),
|
||||
"unique": column.unique or False,
|
||||
"default": column.default,
|
||||
"nullable": column.nullable,
|
||||
"primary": column.primary_key,
|
||||
}
|
||||
for column in table.columns
|
||||
},
|
||||
}
|
||||
for table in metadata.tables.values()
|
||||
}
|
||||
|
||||
async def _introspect_sqlite(self) -> dict:
|
||||
q = """
|
||||
SELECT
|
||||
m.name AS table_name,
|
||||
p.cid AS col_id,
|
||||
p.name AS column_name,
|
||||
p.type AS data_type,
|
||||
p.pk AS is_primary,
|
||||
p.dflt_value AS column_default,
|
||||
p.[notnull] AS is_nullable
|
||||
FROM sqlite_master m
|
||||
LEFT JOIN pragma_table_info((m.name)) p
|
||||
WHERE m.type = 'table'
|
||||
ORDER BY table_name, col_id
|
||||
"""
|
||||
data = await self.inst_db.fetch(q)
|
||||
tables = defaultdict(lambda: {"columns": {}})
|
||||
for column in data:
|
||||
table_name = column["table_name"]
|
||||
col_name = column["column_name"]
|
||||
tables[table_name]["columns"][col_name] = {
|
||||
"type": column["data_type"],
|
||||
"nullable": bool(column["is_nullable"]),
|
||||
"default": column["column_default"],
|
||||
"primary": bool(column["is_primary"]),
|
||||
# TODO uniqueness?
|
||||
}
|
||||
return tables
|
||||
|
||||
async def _introspect_postgres(self) -> dict:
|
||||
assert isinstance(self.inst_db, ProxyPostgresDatabase)
|
||||
q = """
|
||||
SELECT col.table_name, col.column_name, col.data_type, col.is_nullable, col.column_default,
|
||||
tc.constraint_type
|
||||
FROM information_schema.columns col
|
||||
LEFT JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.column_name=col.column_name
|
||||
LEFT JOIN information_schema.table_constraints tc
|
||||
ON col.table_name=tc.table_name
|
||||
AND col.table_schema=tc.table_schema
|
||||
AND ccu.constraint_name=tc.constraint_name
|
||||
AND ccu.constraint_schema=tc.constraint_schema
|
||||
AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE')
|
||||
WHERE col.table_schema=$1
|
||||
"""
|
||||
data = await self.inst_db.fetch(q, self.inst_db.schema_name)
|
||||
tables = defaultdict(lambda: {"columns": {}})
|
||||
for column in data:
|
||||
table_name = column["table_name"]
|
||||
col_name = column["column_name"]
|
||||
tables[table_name]["columns"].setdefault(
|
||||
col_name,
|
||||
{
|
||||
"type": column["data_type"],
|
||||
"nullable": column["is_nullable"],
|
||||
"default": column["column_default"],
|
||||
"primary": False,
|
||||
"unique": False,
|
||||
},
|
||||
)
|
||||
if column["constraint_type"] == "PRIMARY KEY":
|
||||
tables[table_name]["columns"][col_name]["primary"] = True
|
||||
elif column["constraint_type"] == "UNIQUE":
|
||||
tables[table_name]["columns"][col_name]["unique"] = True
|
||||
return tables
|
||||
|
||||
async def get_db_tables(self) -> dict:
|
||||
if self.inst_db_tables is None:
|
||||
if isinstance(self.inst_db, Engine):
|
||||
self.inst_db_tables = self._introspect_sqlalchemy()
|
||||
elif self.inst_db.scheme == Scheme.SQLITE:
|
||||
self.inst_db_tables = await self._introspect_sqlite()
|
||||
else:
|
||||
self.inst_db_tables = await self._introspect_postgres()
|
||||
return self.inst_db_tables
|
||||
|
||||
async def load(self) -> bool:
|
||||
if not self.loader:
|
||||
try:
|
||||
self.loader = PluginLoader.find(self.type)
|
||||
except KeyError:
|
||||
self.log.error(f"Failed to find loader for type {self.type}")
|
||||
await self.update_enabled(False)
|
||||
return False
|
||||
if not self.client:
|
||||
self.client = await Client.get(self.primary_user)
|
||||
if not self.client:
|
||||
self.log.error(f"Failed to get client for user {self.primary_user}")
|
||||
await self.update_enabled(False)
|
||||
return False
|
||||
if self.loader.meta.webapp:
|
||||
self.enable_webapp()
|
||||
self.log.debug("Plugin instance dependencies loaded")
|
||||
self.loader.references.add(self)
|
||||
self.client.references.add(self)
|
||||
return True
|
||||
|
||||
def enable_webapp(self) -> None:
|
||||
self.inst_webapp, self.inst_webapp_url = self.maubot.server.get_instance_subapp(self.id)
|
||||
|
||||
def disable_webapp(self) -> None:
|
||||
self.maubot.server.remove_instance_webapp(self.id)
|
||||
self.inst_webapp = None
|
||||
self.inst_webapp_url = None
|
||||
|
||||
@property
|
||||
def _sqlite_db_path(self) -> str:
|
||||
return os.path.join(self.maubot.config["plugin_databases.sqlite"], f"{self.id}.db")
|
||||
|
||||
async def delete(self) -> None:
|
||||
if self.loader is not None:
|
||||
self.loader.references.remove(self)
|
||||
if self.client is not None:
|
||||
self.client.references.remove(self)
|
||||
try:
|
||||
del self.cache[self.id]
|
||||
except KeyError:
|
||||
pass
|
||||
await super().delete()
|
||||
if self.inst_db:
|
||||
await self.stop_database()
|
||||
await self.delete_database()
|
||||
if self.inst_webapp:
|
||||
self.disable_webapp()
|
||||
|
||||
def load_config(self) -> CommentedMap:
|
||||
return yaml.load(self.config_str)
|
||||
|
||||
def save_config(self, data: RecursiveDict[CommentedMap]) -> None:
|
||||
buf = io.StringIO()
|
||||
yaml.dump(data, buf)
|
||||
val = buf.getvalue()
|
||||
if val != self.config_str:
|
||||
self.config_str = val
|
||||
self.log.debug("Creating background task to save updated config")
|
||||
background_task.create(self.update())
|
||||
|
||||
async def start_database(
|
||||
self, upgrade_table: UpgradeTable | None = None, actually_start: bool = True
|
||||
) -> None:
|
||||
if self.loader.meta.database_type == DatabaseType.SQLALCHEMY:
|
||||
if self.database_engine is None:
|
||||
await self.update_db_engine(DatabaseEngine.SQLITE)
|
||||
elif self.database_engine == DatabaseEngine.POSTGRES:
|
||||
raise RuntimeError(
|
||||
"Instance database engine is marked as Postgres, but plugin uses legacy "
|
||||
"database interface, which doesn't support postgres."
|
||||
)
|
||||
self.inst_db = create_engine(f"sqlite:///{self._sqlite_db_path}")
|
||||
elif self.loader.meta.database_type == DatabaseType.ASYNCPG:
|
||||
if self.database_engine is None:
|
||||
if os.path.exists(self._sqlite_db_path) or not self.maubot.plugin_postgres_db:
|
||||
await self.update_db_engine(DatabaseEngine.SQLITE)
|
||||
else:
|
||||
await self.update_db_engine(DatabaseEngine.POSTGRES)
|
||||
instance_db_log = db_log.getChild(self.id)
|
||||
if self.database_engine == DatabaseEngine.POSTGRES:
|
||||
if not self.maubot.plugin_postgres_db:
|
||||
raise RuntimeError(
|
||||
"Instance database engine is marked as Postgres, but this maubot isn't "
|
||||
"configured to support Postgres for plugin databases"
|
||||
)
|
||||
self.inst_db = ProxyPostgresDatabase(
|
||||
pool=self.maubot.plugin_postgres_db,
|
||||
instance_id=self.id,
|
||||
max_conns=self.maubot.config["plugin_databases.postgres_max_conns_per_plugin"],
|
||||
upgrade_table=upgrade_table,
|
||||
log=instance_db_log,
|
||||
)
|
||||
else:
|
||||
self.inst_db = Database.create(
|
||||
f"sqlite:{self._sqlite_db_path}",
|
||||
upgrade_table=upgrade_table,
|
||||
log=instance_db_log,
|
||||
)
|
||||
if actually_start:
|
||||
await self.inst_db.start()
|
||||
else:
|
||||
raise RuntimeError(f"Unrecognized database type {self.loader.meta.database_type}")
|
||||
|
||||
async def stop_database(self) -> None:
|
||||
if isinstance(self.inst_db, Database):
|
||||
await self.inst_db.stop()
|
||||
elif isinstance(self.inst_db, Engine):
|
||||
self.inst_db.dispose()
|
||||
else:
|
||||
raise RuntimeError(f"Unknown database type {type(self.inst_db).__name__}")
|
||||
|
||||
async def delete_database(self) -> None:
|
||||
if self.loader.meta.database_type == DatabaseType.SQLALCHEMY:
|
||||
ZippedPluginLoader.trash(self._sqlite_db_path, reason="deleted")
|
||||
elif self.loader.meta.database_type == DatabaseType.ASYNCPG:
|
||||
if self.inst_db is None:
|
||||
await self.start_database(None, actually_start=False)
|
||||
if isinstance(self.inst_db, ProxyPostgresDatabase):
|
||||
await self.inst_db.delete()
|
||||
else:
|
||||
ZippedPluginLoader.trash(self._sqlite_db_path, reason="deleted")
|
||||
else:
|
||||
raise RuntimeError(f"Unrecognized database type {self.loader.meta.database_type}")
|
||||
self.inst_db = None
|
||||
|
||||
async def start(self) -> None:
|
||||
if self.started:
|
||||
self.log.warning("Ignoring start() call to already started plugin")
|
||||
return
|
||||
elif not self.enabled:
|
||||
self.log.warning("Plugin disabled, not starting.")
|
||||
return
|
||||
if not self.client or not self.loader:
|
||||
self.log.warning("Missing plugin instance dependencies, attempting to load...")
|
||||
if not await self.load():
|
||||
return
|
||||
cls = await self.loader.load()
|
||||
if self.loader.meta.webapp and self.inst_webapp is None:
|
||||
self.log.debug("Enabling webapp after plugin meta reload")
|
||||
self.enable_webapp()
|
||||
elif not self.loader.meta.webapp and self.inst_webapp is not None:
|
||||
self.log.debug("Disabling webapp after plugin meta reload")
|
||||
self.disable_webapp()
|
||||
if self.loader.meta.database:
|
||||
try:
|
||||
await self.start_database(cls.get_db_upgrade_table())
|
||||
except Exception:
|
||||
self.log.exception("Failed to start instance database")
|
||||
await self.update_enabled(False)
|
||||
return
|
||||
config_class = cls.get_config_class()
|
||||
if config_class:
|
||||
try:
|
||||
base = await self.loader.read_file("base-config.yaml")
|
||||
self.base_cfg = RecursiveDict(yaml.load(base.decode("utf-8")), CommentedMap)
|
||||
buf = io.StringIO()
|
||||
yaml.dump(self.base_cfg._data, buf)
|
||||
self.base_cfg_str = buf.getvalue()
|
||||
except (FileNotFoundError, KeyError):
|
||||
self.base_cfg = None
|
||||
self.base_cfg_str = None
|
||||
if self.base_cfg:
|
||||
base_cfg_func = self.base_cfg.clone
|
||||
else:
|
||||
|
||||
def base_cfg_func() -> None:
|
||||
return None
|
||||
|
||||
self.config = config_class(self.load_config, base_cfg_func, self.save_config)
|
||||
self.plugin = cls(
|
||||
client=self.client.client,
|
||||
loop=self.maubot.loop,
|
||||
http=self.client.http_client,
|
||||
instance_id=self.id,
|
||||
log=self.log,
|
||||
config=self.config,
|
||||
database=self.inst_db,
|
||||
loader=self.loader,
|
||||
webapp=self.inst_webapp,
|
||||
webapp_url=self.inst_webapp_url,
|
||||
)
|
||||
try:
|
||||
await self.plugin.internal_start()
|
||||
except Exception:
|
||||
self.log.exception("Failed to start instance")
|
||||
await self.update_enabled(False)
|
||||
return
|
||||
self.started = True
|
||||
self.inst_db_tables = None
|
||||
self.log.info(
|
||||
f"Started instance of {self.loader.meta.id} v{self.loader.meta.version} "
|
||||
f"with user {self.client.id}"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self.started:
|
||||
self.log.warning("Ignoring stop() call to non-running plugin")
|
||||
return
|
||||
self.log.debug("Stopping plugin instance...")
|
||||
self.started = False
|
||||
try:
|
||||
await self.plugin.internal_stop()
|
||||
except Exception:
|
||||
self.log.exception("Failed to stop instance")
|
||||
self.plugin = None
|
||||
if self.inst_db:
|
||||
try:
|
||||
await self.stop_database()
|
||||
except Exception:
|
||||
self.log.exception("Failed to stop instance database")
|
||||
self.inst_db_tables = None
|
||||
|
||||
async def update_id(self, new_id: str | None) -> None:
|
||||
if new_id is not None and new_id.lower() != self.id:
|
||||
await super().update_id(new_id.lower())
|
||||
|
||||
async def update_config(self, config: str | None) -> None:
|
||||
if config is None or self.config_str == config:
|
||||
return
|
||||
self.config_str = config
|
||||
if self.started and self.plugin is not None:
|
||||
res = self.plugin.on_external_config_update()
|
||||
if inspect.isawaitable(res):
|
||||
await res
|
||||
await self.update()
|
||||
|
||||
async def update_primary_user(self, primary_user: UserID | None) -> bool:
|
||||
if primary_user is None or primary_user == self.primary_user:
|
||||
return True
|
||||
client = await Client.get(primary_user)
|
||||
if not client:
|
||||
return False
|
||||
await self.stop()
|
||||
self.primary_user = client.id
|
||||
if self.client:
|
||||
self.client.references.remove(self)
|
||||
self.client = client
|
||||
self.client.references.add(self)
|
||||
await self.update()
|
||||
await self.start()
|
||||
self.log.debug(f"Primary user switched to {self.client.id}")
|
||||
return True
|
||||
|
||||
async def update_type(self, type: str | None) -> bool:
|
||||
if type is None or type == self.type:
|
||||
return True
|
||||
try:
|
||||
loader = PluginLoader.find(type)
|
||||
except KeyError:
|
||||
return False
|
||||
await self.stop()
|
||||
self.type = loader.meta.id
|
||||
if self.loader:
|
||||
self.loader.references.remove(self)
|
||||
self.loader = loader
|
||||
self.loader.references.add(self)
|
||||
await self.update()
|
||||
await self.start()
|
||||
self.log.debug(f"Type switched to {self.loader.meta.id}")
|
||||
return True
|
||||
|
||||
async def update_started(self, started: bool) -> None:
|
||||
if started is not None and started != self.started:
|
||||
await (self.start() if started else self.stop())
|
||||
|
||||
async def update_enabled(self, enabled: bool) -> None:
|
||||
if enabled is not None and enabled != self.enabled:
|
||||
self.enabled = enabled
|
||||
await self.update()
|
||||
|
||||
async def update_db_engine(self, db_engine: DatabaseEngine | None) -> None:
|
||||
if db_engine is not None and db_engine != self.database_engine:
|
||||
self.database_engine = db_engine
|
||||
await self.update()
|
||||
|
||||
@classmethod
|
||||
@async_getter_lock
|
||||
async def get(
|
||||
cls, instance_id: str, *, type: str | None = None, primary_user: UserID | None = None
|
||||
) -> PluginInstance | None:
|
||||
try:
|
||||
return cls.cache[instance_id]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
instance = cast(cls, await super().get(instance_id))
|
||||
if instance is not None:
|
||||
instance.postinit()
|
||||
return instance
|
||||
|
||||
if type and primary_user:
|
||||
instance = cls(instance_id, type=type, enabled=True, primary_user=primary_user)
|
||||
await instance.insert()
|
||||
instance.postinit()
|
||||
return instance
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def all(cls) -> AsyncGenerator[PluginInstance, None]:
|
||||
instances = await super().all()
|
||||
instance: PluginInstance
|
||||
for instance in instances:
|
||||
try:
|
||||
yield cls.cache[instance.id]
|
||||
except KeyError:
|
||||
instance.postinit()
|
||||
yield instance
|
||||
0
maubot-src/maubot/lib/__init__.py
Normal file
0
maubot-src/maubot/lib/__init__.py
Normal file
49
maubot-src/maubot/lib/color_log.py
Normal file
49
maubot-src/maubot/lib/color_log.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 mautrix.util.logging.color import (
|
||||
MAU_COLOR,
|
||||
MXID_COLOR,
|
||||
PREFIX,
|
||||
RESET,
|
||||
ColorFormatter as BaseColorFormatter,
|
||||
)
|
||||
|
||||
INST_COLOR = PREFIX + "35m" # magenta
|
||||
LOADER_COLOR = PREFIX + "36m" # blue
|
||||
|
||||
|
||||
class ColorFormatter(BaseColorFormatter):
|
||||
def _color_name(self, module: str) -> str:
|
||||
client = "maubot.client"
|
||||
if module.startswith(client + "."):
|
||||
suffix = ""
|
||||
if module.endswith(".crypto"):
|
||||
suffix = f".{MAU_COLOR}crypto{RESET}"
|
||||
module = module[: -len(".crypto")]
|
||||
module = module[len(client) + 1 :]
|
||||
return f"{MAU_COLOR}{client}{RESET}.{MXID_COLOR}{module}{RESET}{suffix}"
|
||||
instance = "maubot.instance"
|
||||
if module.startswith(instance + "."):
|
||||
return f"{MAU_COLOR}{instance}{RESET}.{INST_COLOR}{module[len(instance) + 1:]}{RESET}"
|
||||
instance_db = "maubot.instance_db"
|
||||
if module.startswith(instance_db + "."):
|
||||
return f"{MAU_COLOR}{instance_db}{RESET}.{INST_COLOR}{module[len(instance_db) + 1:]}{RESET}"
|
||||
loader = "maubot.loader"
|
||||
if module.startswith(loader + "."):
|
||||
return f"{MAU_COLOR}{instance}{RESET}.{LOADER_COLOR}{module[len(loader) + 1:]}{RESET}"
|
||||
if module.startswith("maubot."):
|
||||
return f"{MAU_COLOR}{module}{RESET}"
|
||||
return super()._color_name(module)
|
||||
9
maubot-src/maubot/lib/future_awaitable.py
Normal file
9
maubot-src/maubot/lib/future_awaitable.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from typing import Any, Awaitable, Callable, Generator
|
||||
|
||||
|
||||
class FutureAwaitable:
|
||||
def __init__(self, func: Callable[[], Awaitable[None]]) -> None:
|
||||
self._func = func
|
||||
|
||||
def __await__(self) -> Generator[Any, None, None]:
|
||||
return self._func().__await__()
|
||||
19
maubot-src/maubot/lib/optionalalchemy.py
Normal file
19
maubot-src/maubot/lib/optionalalchemy.py
Normal file
@@ -0,0 +1,19 @@
|
||||
try:
|
||||
from sqlalchemy import MetaData, asc, create_engine, desc
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
except ImportError:
|
||||
|
||||
class FakeError(Exception):
|
||||
pass
|
||||
|
||||
class FakeType:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise Exception("SQLAlchemy is not installed")
|
||||
|
||||
def create_engine(*args, **kwargs):
|
||||
raise Exception("SQLAlchemy is not installed")
|
||||
|
||||
MetaData = Engine = FakeType
|
||||
IntegrityError = OperationalError = FakeError
|
||||
asc = desc = lambda a: a
|
||||
100
maubot-src/maubot/lib/plugin_db.py
Normal file
100
maubot-src/maubot/lib/plugin_db.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
import asyncio
|
||||
|
||||
from mautrix.util.async_db import Database, PostgresDatabase, Scheme, UpgradeTable
|
||||
from mautrix.util.async_db.connection import LoggingConnection
|
||||
from mautrix.util.logging import TraceLogger
|
||||
|
||||
remove_double_quotes = str.maketrans({'"': "_"})
|
||||
|
||||
|
||||
class ProxyPostgresDatabase(Database):
|
||||
scheme = Scheme.POSTGRES
|
||||
_underlying_pool: PostgresDatabase
|
||||
schema_name: str
|
||||
_quoted_schema: str
|
||||
_default_search_path: str
|
||||
_conn_sema: asyncio.Semaphore
|
||||
_max_conns: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pool: PostgresDatabase,
|
||||
instance_id: str,
|
||||
max_conns: int,
|
||||
upgrade_table: UpgradeTable | None,
|
||||
log: TraceLogger | None = None,
|
||||
) -> None:
|
||||
super().__init__(pool.url, upgrade_table=upgrade_table, log=log)
|
||||
self._underlying_pool = pool
|
||||
# Simple accidental SQL injection prevention.
|
||||
# Doesn't have to be perfect, since plugin instance IDs can only be set by admins anyway.
|
||||
self.schema_name = f"mbp_{instance_id.translate(remove_double_quotes)}"
|
||||
self._quoted_schema = f'"{self.schema_name}"'
|
||||
self._default_search_path = '"$user", public'
|
||||
self._conn_sema = asyncio.BoundedSemaphore(max_conns)
|
||||
self._max_conns = max_conns
|
||||
|
||||
async def start(self) -> None:
|
||||
async with self._underlying_pool.acquire_direct() as conn:
|
||||
self._default_search_path = await conn.fetchval("SHOW search_path")
|
||||
self.log.trace(f"Found default search path: {self._default_search_path}")
|
||||
await conn.execute(f"CREATE SCHEMA IF NOT EXISTS {self._quoted_schema}")
|
||||
await super().start()
|
||||
|
||||
async def stop(self) -> None:
|
||||
for _ in range(self._max_conns):
|
||||
try:
|
||||
await asyncio.wait_for(self._conn_sema.acquire(), timeout=3)
|
||||
except asyncio.TimeoutError:
|
||||
self.log.warning(
|
||||
"Failed to drain plugin database connection pool, "
|
||||
"the plugin may be leaking database connections"
|
||||
)
|
||||
break
|
||||
|
||||
async def delete(self) -> None:
|
||||
self.log.info(f"Deleting schema {self.schema_name} and all data in it")
|
||||
try:
|
||||
await self._underlying_pool.execute(
|
||||
f"DROP SCHEMA IF EXISTS {self._quoted_schema} CASCADE"
|
||||
)
|
||||
except Exception:
|
||||
self.log.warning("Failed to delete schema", exc_info=True)
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire_direct(self) -> LoggingConnection:
|
||||
conn: LoggingConnection
|
||||
async with self._conn_sema, self._underlying_pool.acquire_direct() as conn:
|
||||
await conn.execute(f"SET search_path = {self._quoted_schema}")
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
if not conn.wrapped.is_closed():
|
||||
try:
|
||||
await conn.execute(f"SET search_path = {self._default_search_path}")
|
||||
except Exception:
|
||||
self.log.exception("Error resetting search_path after use")
|
||||
await conn.wrapped.close()
|
||||
else:
|
||||
self.log.debug("Connection was closed after use, not resetting search_path")
|
||||
|
||||
|
||||
__all__ = ["ProxyPostgresDatabase"]
|
||||
27
maubot-src/maubot/lib/state_store.py
Normal file
27
maubot-src/maubot/lib/state_store.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 mautrix.client.state_store.asyncpg import PgStateStore as BasePgStateStore
|
||||
|
||||
try:
|
||||
from mautrix.crypto import StateStore as CryptoStateStore
|
||||
|
||||
class PgStateStore(BasePgStateStore, CryptoStateStore):
|
||||
pass
|
||||
|
||||
except ImportError as e:
|
||||
PgStateStore = BasePgStateStore
|
||||
|
||||
__all__ = ["PgStateStore"]
|
||||
794
maubot-src/maubot/lib/zipimport.py
Normal file
794
maubot-src/maubot/lib/zipimport.py
Normal file
@@ -0,0 +1,794 @@
|
||||
# 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:
|
||||
- zipimporter: a class; its constructor takes a path to a Zip archive.
|
||||
- ZipImportError: exception raised by zipimporter objects. It's a
|
||||
subclass of ImportError, so it can be caught as ImportError, too.
|
||||
- _zip_directory_cache: a dict, mapping archive paths to zip directory
|
||||
info dicts, as used in zipimporter._files.
|
||||
|
||||
It is usually not needed to use the zipimport module explicitly; it is
|
||||
used by the builtin import mechanism for sys.path items that are paths
|
||||
to Zip archives.
|
||||
"""
|
||||
|
||||
from importlib import _bootstrap # for _verbose_message
|
||||
from importlib import _bootstrap_external
|
||||
import marshal # for loads
|
||||
import sys # for modules
|
||||
import time # for mktime
|
||||
|
||||
import _imp # for check_hash_based_pycs
|
||||
import _io # for open
|
||||
|
||||
__all__ = ["ZipImportError", "zipimporter"]
|
||||
|
||||
|
||||
def _unpack_uint32(data):
|
||||
"""Convert 4 bytes in little-endian to an integer."""
|
||||
assert len(data) == 4
|
||||
return int.from_bytes(data, "little")
|
||||
|
||||
|
||||
def _unpack_uint16(data):
|
||||
"""Convert 2 bytes in little-endian to an integer."""
|
||||
assert len(data) == 2
|
||||
return int.from_bytes(data, "little")
|
||||
|
||||
|
||||
path_sep = _bootstrap_external.path_sep
|
||||
alt_path_sep = _bootstrap_external.path_separators[1:]
|
||||
|
||||
|
||||
class ZipImportError(ImportError):
|
||||
pass
|
||||
|
||||
|
||||
# _read_directory() cache
|
||||
_zip_directory_cache = {}
|
||||
|
||||
_module_type = type(sys)
|
||||
|
||||
END_CENTRAL_DIR_SIZE = 22
|
||||
STRING_END_ARCHIVE = b"PK\x05\x06"
|
||||
MAX_COMMENT_LEN = (1 << 16) - 1
|
||||
|
||||
|
||||
class zipimporter:
|
||||
"""zipimporter(archivepath) -> zipimporter object
|
||||
|
||||
Create a new zipimporter instance. 'archivepath' must be a path to
|
||||
a zipfile, or to a specific path inside a zipfile. For example, it can be
|
||||
'/tmp/myimport.zip', or '/tmp/myimport.zip/mydirectory', if mydirectory is a
|
||||
valid directory inside the archive.
|
||||
|
||||
'ZipImportError is raised if 'archivepath' doesn't point to a valid Zip
|
||||
archive.
|
||||
|
||||
The 'archive' attribute of zipimporter objects contains the name of the
|
||||
zipfile targeted.
|
||||
"""
|
||||
|
||||
# Split the "subdirectory" from the Zip archive path, lookup a matching
|
||||
# entry in sys.path_importer_cache, fetch the file directory from there
|
||||
# if found, or else read it from the archive.
|
||||
def __init__(self, path):
|
||||
if not isinstance(path, str):
|
||||
import os
|
||||
|
||||
path = os.fsdecode(path)
|
||||
if not path:
|
||||
raise ZipImportError("archive path is empty", path=path)
|
||||
if alt_path_sep:
|
||||
path = path.replace(alt_path_sep, path_sep)
|
||||
|
||||
prefix = []
|
||||
while True:
|
||||
try:
|
||||
st = _bootstrap_external._path_stat(path)
|
||||
except (OSError, ValueError):
|
||||
# On Windows a ValueError is raised for too long paths.
|
||||
# Back up one path element.
|
||||
dirname, basename = _bootstrap_external._path_split(path)
|
||||
if dirname == path:
|
||||
raise ZipImportError("not a Zip file", path=path)
|
||||
path = dirname
|
||||
prefix.append(basename)
|
||||
else:
|
||||
# it exists
|
||||
if (st.st_mode & 0o170000) != 0o100000: # stat.S_ISREG
|
||||
# it's a not file
|
||||
raise ZipImportError("not a Zip file", path=path)
|
||||
break
|
||||
|
||||
try:
|
||||
files = _zip_directory_cache[path]
|
||||
except KeyError:
|
||||
files = _read_directory(path)
|
||||
_zip_directory_cache[path] = files
|
||||
self._files = files
|
||||
self.archive = path
|
||||
# a prefix directory following the ZIP file path.
|
||||
self.prefix = _bootstrap_external._path_join(*prefix[::-1])
|
||||
if self.prefix:
|
||||
self.prefix += path_sep
|
||||
|
||||
def reset_cache(self):
|
||||
self._files = _read_directory(self.archive)
|
||||
_zip_directory_cache[self.archive] = self._files
|
||||
|
||||
def remove_cache(self):
|
||||
try:
|
||||
del _zip_directory_cache[self.archive]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Check whether we can satisfy the import of the module named by
|
||||
# 'fullname', or whether it could be a portion of a namespace
|
||||
# package. Return self if we can load it, a string containing the
|
||||
# full path if it's a possible namespace portion, None if we
|
||||
# can't load it.
|
||||
def find_loader(self, fullname, path=None):
|
||||
"""find_loader(fullname, path=None) -> self, str or None.
|
||||
|
||||
Search for a module specified by 'fullname'. 'fullname' must be the
|
||||
fully qualified (dotted) module name. It returns the zipimporter
|
||||
instance itself if the module was found, a string containing the
|
||||
full path name if it's possibly a portion of a namespace package,
|
||||
or None otherwise. The optional 'path' argument is ignored -- it's
|
||||
there for compatibility with the importer protocol.
|
||||
"""
|
||||
mi = _get_module_info(self, fullname)
|
||||
if mi is not None:
|
||||
# This is a module or package.
|
||||
return self, []
|
||||
|
||||
# Not a module or regular package. See if this is a directory, and
|
||||
# therefore possibly a portion of a namespace package.
|
||||
|
||||
# We're only interested in the last path component of fullname
|
||||
# earlier components are recorded in self.prefix.
|
||||
modpath = _get_module_path(self, fullname)
|
||||
if _is_dir(self, modpath):
|
||||
# This is possibly a portion of a namespace
|
||||
# package. Return the string representing its path,
|
||||
# without a trailing separator.
|
||||
return None, [f"{self.archive}{path_sep}{modpath}"]
|
||||
|
||||
return None, []
|
||||
|
||||
# Check whether we can satisfy the import of the module named by
|
||||
# 'fullname'. Return self if we can, None if we can't.
|
||||
def find_module(self, fullname, path=None):
|
||||
"""find_module(fullname, path=None) -> self or None.
|
||||
|
||||
Search for a module specified by 'fullname'. 'fullname' must be the
|
||||
fully qualified (dotted) module name. It returns the zipimporter
|
||||
instance itself if the module was found, or None if it wasn't.
|
||||
The optional 'path' argument is ignored -- it's there for compatibility
|
||||
with the importer protocol.
|
||||
"""
|
||||
return self.find_loader(fullname, path)[0]
|
||||
|
||||
def get_code(self, fullname):
|
||||
"""get_code(fullname) -> code object.
|
||||
|
||||
Return the code object for the specified module. Raise ZipImportError
|
||||
if the module couldn't be found.
|
||||
"""
|
||||
code, ispackage, modpath = _get_module_code(self, fullname)
|
||||
return code
|
||||
|
||||
def get_data(self, pathname):
|
||||
"""get_data(pathname) -> string with file data.
|
||||
|
||||
Return the data associated with 'pathname'. Raise OSError if
|
||||
the file wasn't found.
|
||||
"""
|
||||
if alt_path_sep:
|
||||
pathname = pathname.replace(alt_path_sep, path_sep)
|
||||
|
||||
key = pathname
|
||||
if pathname.startswith(self.archive + path_sep):
|
||||
key = pathname[len(self.archive + path_sep) :]
|
||||
|
||||
try:
|
||||
toc_entry = self._files[key]
|
||||
except KeyError:
|
||||
raise OSError(0, "", key)
|
||||
return _get_data(self.archive, toc_entry)
|
||||
|
||||
# Return a string matching __file__ for the named module
|
||||
def get_filename(self, fullname):
|
||||
"""get_filename(fullname) -> filename string.
|
||||
|
||||
Return the filename for the specified module.
|
||||
"""
|
||||
# Deciding the filename requires working out where the code
|
||||
# would come from if the module was actually loaded
|
||||
code, ispackage, modpath = _get_module_code(self, fullname)
|
||||
return modpath
|
||||
|
||||
def get_source(self, fullname):
|
||||
"""get_source(fullname) -> source string.
|
||||
|
||||
Return the source code for the specified module. Raise ZipImportError
|
||||
if the module couldn't be found, return None if the archive does
|
||||
contain the module, but has no source for it.
|
||||
"""
|
||||
mi = _get_module_info(self, fullname)
|
||||
if mi is None:
|
||||
raise ZipImportError(f"can't find module {fullname!r}", name=fullname)
|
||||
|
||||
path = _get_module_path(self, fullname)
|
||||
if mi:
|
||||
fullpath = _bootstrap_external._path_join(path, "__init__.py")
|
||||
else:
|
||||
fullpath = f"{path}.py"
|
||||
|
||||
try:
|
||||
toc_entry = self._files[fullpath]
|
||||
except KeyError:
|
||||
# we have the module, but no source
|
||||
return None
|
||||
return _get_data(self.archive, toc_entry).decode()
|
||||
|
||||
# Return a bool signifying whether the module is a package or not.
|
||||
def is_package(self, fullname):
|
||||
"""is_package(fullname) -> bool.
|
||||
|
||||
Return True if the module specified by fullname is a package.
|
||||
Raise ZipImportError if the module couldn't be found.
|
||||
"""
|
||||
mi = _get_module_info(self, fullname)
|
||||
if mi is None:
|
||||
raise ZipImportError(f"can't find module {fullname!r}", name=fullname)
|
||||
return mi
|
||||
|
||||
# Load and return the module named by 'fullname'.
|
||||
def load_module(self, fullname):
|
||||
"""load_module(fullname) -> module.
|
||||
|
||||
Load the module specified by 'fullname'. 'fullname' must be the
|
||||
fully qualified (dotted) module name. It returns the imported
|
||||
module, or raises ZipImportError if it wasn't found.
|
||||
"""
|
||||
code, ispackage, modpath = _get_module_code(self, fullname)
|
||||
mod = sys.modules.get(fullname)
|
||||
if mod is None or not isinstance(mod, _module_type):
|
||||
mod = _module_type(fullname)
|
||||
sys.modules[fullname] = mod
|
||||
mod.__loader__ = self
|
||||
|
||||
try:
|
||||
if ispackage:
|
||||
# add __path__ to the module *before* the code gets
|
||||
# executed
|
||||
path = _get_module_path(self, fullname)
|
||||
fullpath = _bootstrap_external._path_join(self.archive, path)
|
||||
mod.__path__ = [fullpath]
|
||||
|
||||
if not hasattr(mod, "__builtins__"):
|
||||
mod.__builtins__ = __builtins__
|
||||
_bootstrap_external._fix_up_module(mod.__dict__, fullname, modpath)
|
||||
exec(code, mod.__dict__)
|
||||
except:
|
||||
del sys.modules[fullname]
|
||||
raise
|
||||
|
||||
try:
|
||||
mod = sys.modules[fullname]
|
||||
except KeyError:
|
||||
raise ImportError(f"Loaded module {fullname!r} not found in sys.modules")
|
||||
_bootstrap._verbose_message("import {} # loaded from Zip {}", fullname, modpath)
|
||||
return mod
|
||||
|
||||
def get_resource_reader(self, fullname):
|
||||
"""Return the ResourceReader for a package in a zip file.
|
||||
|
||||
If 'fullname' is a package within the zip file, return the
|
||||
'ResourceReader' object for the package. Otherwise return None.
|
||||
"""
|
||||
try:
|
||||
if not self.is_package(fullname):
|
||||
return None
|
||||
except ZipImportError:
|
||||
return None
|
||||
if not _ZipImportResourceReader._registered:
|
||||
from importlib.abc import ResourceReader
|
||||
|
||||
ResourceReader.register(_ZipImportResourceReader)
|
||||
_ZipImportResourceReader._registered = True
|
||||
return _ZipImportResourceReader(self, fullname)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<zipimporter object "{self.archive}{path_sep}{self.prefix}">'
|
||||
|
||||
|
||||
# _zip_searchorder defines how we search for a module in the Zip
|
||||
# archive: we first search for a package __init__, then for
|
||||
# non-package .pyc, and .py entries. The .pyc entries
|
||||
# are swapped by initzipimport() if we run in optimized mode. Also,
|
||||
# '/' is replaced by path_sep there.
|
||||
_zip_searchorder = (
|
||||
(path_sep + "__init__.pyc", True, True),
|
||||
(path_sep + "__init__.py", False, True),
|
||||
(".pyc", True, False),
|
||||
(".py", False, False),
|
||||
)
|
||||
|
||||
|
||||
# Given a module name, return the potential file path in the
|
||||
# archive (without extension).
|
||||
def _get_module_path(self, fullname):
|
||||
return self.prefix + fullname.rpartition(".")[2]
|
||||
|
||||
|
||||
# Does this path represent a directory?
|
||||
def _is_dir(self, path):
|
||||
# See if this is a "directory". If so, it's eligible to be part
|
||||
# of a namespace package. We test by seeing if the name, with an
|
||||
# appended path separator, exists.
|
||||
dirpath = path + path_sep
|
||||
# If dirpath is present in self._files, we have a directory.
|
||||
return dirpath in self._files
|
||||
|
||||
|
||||
# Return some information about a module.
|
||||
def _get_module_info(self, fullname):
|
||||
path = _get_module_path(self, fullname)
|
||||
for suffix, isbytecode, ispackage in _zip_searchorder:
|
||||
fullpath = path + suffix
|
||||
if fullpath in self._files:
|
||||
return ispackage
|
||||
return None
|
||||
|
||||
|
||||
# implementation
|
||||
|
||||
|
||||
# _read_directory(archive) -> files dict (new reference)
|
||||
#
|
||||
# Given a path to a Zip archive, build a dict, mapping file names
|
||||
# (local to the archive, using SEP as a separator) to toc entries.
|
||||
#
|
||||
# A toc_entry is a tuple:
|
||||
#
|
||||
# (__file__, # value to use for __file__, available for all files,
|
||||
# # encoded to the filesystem encoding
|
||||
# compress, # compression kind; 0 for uncompressed
|
||||
# data_size, # size of compressed data on disk
|
||||
# file_size, # size of decompressed data
|
||||
# file_offset, # offset of file header from start of archive
|
||||
# time, # mod time of file (in dos format)
|
||||
# date, # mod data of file (in dos format)
|
||||
# crc, # crc checksum of the data
|
||||
# )
|
||||
#
|
||||
# Directories can be recognized by the trailing path_sep in the name,
|
||||
# data_size and file_offset are 0.
|
||||
def _read_directory(archive):
|
||||
try:
|
||||
fp = _io.open(archive, "rb")
|
||||
except OSError:
|
||||
raise ZipImportError(f"can't open Zip file: {archive!r}", path=archive)
|
||||
|
||||
with fp:
|
||||
try:
|
||||
fp.seek(-END_CENTRAL_DIR_SIZE, 2)
|
||||
header_position = fp.tell()
|
||||
buffer = fp.read(END_CENTRAL_DIR_SIZE)
|
||||
except OSError:
|
||||
raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
|
||||
if len(buffer) != END_CENTRAL_DIR_SIZE:
|
||||
raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
|
||||
if buffer[:4] != STRING_END_ARCHIVE:
|
||||
# Bad: End of Central Dir signature
|
||||
# Check if there's a comment.
|
||||
try:
|
||||
fp.seek(0, 2)
|
||||
file_size = fp.tell()
|
||||
except OSError:
|
||||
raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
|
||||
max_comment_start = max(file_size - MAX_COMMENT_LEN - END_CENTRAL_DIR_SIZE, 0)
|
||||
try:
|
||||
fp.seek(max_comment_start)
|
||||
data = fp.read()
|
||||
except OSError:
|
||||
raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
|
||||
pos = data.rfind(STRING_END_ARCHIVE)
|
||||
if pos < 0:
|
||||
raise ZipImportError(f"not a Zip file: {archive!r}", path=archive)
|
||||
buffer = data[pos : pos + END_CENTRAL_DIR_SIZE]
|
||||
if len(buffer) != END_CENTRAL_DIR_SIZE:
|
||||
raise ZipImportError(f"corrupt Zip file: {archive!r}", path=archive)
|
||||
header_position = file_size - len(data) + pos
|
||||
|
||||
header_size = _unpack_uint32(buffer[12:16])
|
||||
header_offset = _unpack_uint32(buffer[16:20])
|
||||
if header_position < header_size:
|
||||
raise ZipImportError(f"bad central directory size: {archive!r}", path=archive)
|
||||
if header_position < header_offset:
|
||||
raise ZipImportError(f"bad central directory offset: {archive!r}", path=archive)
|
||||
header_position -= header_size
|
||||
arc_offset = header_position - header_offset
|
||||
if arc_offset < 0:
|
||||
raise ZipImportError(
|
||||
f"bad central directory size or offset: {archive!r}", path=archive
|
||||
)
|
||||
|
||||
files = {}
|
||||
# Start of Central Directory
|
||||
count = 0
|
||||
try:
|
||||
fp.seek(header_position)
|
||||
except OSError:
|
||||
raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
|
||||
while True:
|
||||
buffer = fp.read(46)
|
||||
if len(buffer) < 4:
|
||||
raise EOFError("EOF read where not expected")
|
||||
# Start of file header
|
||||
if buffer[:4] != b"PK\x01\x02":
|
||||
break # Bad: Central Dir File Header
|
||||
if len(buffer) != 46:
|
||||
raise EOFError("EOF read where not expected")
|
||||
flags = _unpack_uint16(buffer[8:10])
|
||||
compress = _unpack_uint16(buffer[10:12])
|
||||
time = _unpack_uint16(buffer[12:14])
|
||||
date = _unpack_uint16(buffer[14:16])
|
||||
crc = _unpack_uint32(buffer[16:20])
|
||||
data_size = _unpack_uint32(buffer[20:24])
|
||||
file_size = _unpack_uint32(buffer[24:28])
|
||||
name_size = _unpack_uint16(buffer[28:30])
|
||||
extra_size = _unpack_uint16(buffer[30:32])
|
||||
comment_size = _unpack_uint16(buffer[32:34])
|
||||
file_offset = _unpack_uint32(buffer[42:46])
|
||||
header_size = name_size + extra_size + comment_size
|
||||
if file_offset > header_offset:
|
||||
raise ZipImportError(f"bad local header offset: {archive!r}", path=archive)
|
||||
file_offset += arc_offset
|
||||
|
||||
try:
|
||||
name = fp.read(name_size)
|
||||
except OSError:
|
||||
raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
|
||||
if len(name) != name_size:
|
||||
raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
|
||||
# On Windows, calling fseek to skip over the fields we don't use is
|
||||
# slower than reading the data because fseek flushes stdio's
|
||||
# internal buffers. See issue #8745.
|
||||
try:
|
||||
if len(fp.read(header_size - name_size)) != header_size - name_size:
|
||||
raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
|
||||
except OSError:
|
||||
raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
|
||||
|
||||
if flags & 0x800:
|
||||
# UTF-8 file names extension
|
||||
name = name.decode()
|
||||
else:
|
||||
# Historical ZIP filename encoding
|
||||
try:
|
||||
name = name.decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
name = name.decode("latin1").translate(cp437_table)
|
||||
|
||||
name = name.replace("/", path_sep)
|
||||
path = _bootstrap_external._path_join(archive, name)
|
||||
t = (path, compress, data_size, file_size, file_offset, time, date, crc)
|
||||
files[name] = t
|
||||
count += 1
|
||||
_bootstrap._verbose_message("zipimport: found {} names in {!r}", count, archive)
|
||||
return files
|
||||
|
||||
|
||||
# During bootstrap, we may need to load the encodings
|
||||
# package from a ZIP file. But the cp437 encoding is implemented
|
||||
# in Python in the encodings package.
|
||||
#
|
||||
# Break out of this dependency by using the translation table for
|
||||
# the cp437 encoding.
|
||||
cp437_table = (
|
||||
# ASCII part, 8 rows x 16 chars
|
||||
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f"
|
||||
"\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f"
|
||||
" !\"#$%&'()*+,-./"
|
||||
"0123456789:;<=>?"
|
||||
"@ABCDEFGHIJKLMNO"
|
||||
"PQRSTUVWXYZ[\\]^_"
|
||||
"`abcdefghijklmno"
|
||||
"pqrstuvwxyz{|}~\x7f"
|
||||
# non-ASCII part, 16 rows x 8 chars
|
||||
"\xc7\xfc\xe9\xe2\xe4\xe0\xe5\xe7"
|
||||
"\xea\xeb\xe8\xef\xee\xec\xc4\xc5"
|
||||
"\xc9\xe6\xc6\xf4\xf6\xf2\xfb\xf9"
|
||||
"\xff\xd6\xdc\xa2\xa3\xa5\u20a7\u0192"
|
||||
"\xe1\xed\xf3\xfa\xf1\xd1\xaa\xba"
|
||||
"\xbf\u2310\xac\xbd\xbc\xa1\xab\xbb"
|
||||
"\u2591\u2592\u2593\u2502\u2524\u2561\u2562\u2556"
|
||||
"\u2555\u2563\u2551\u2557\u255d\u255c\u255b\u2510"
|
||||
"\u2514\u2534\u252c\u251c\u2500\u253c\u255e\u255f"
|
||||
"\u255a\u2554\u2569\u2566\u2560\u2550\u256c\u2567"
|
||||
"\u2568\u2564\u2565\u2559\u2558\u2552\u2553\u256b"
|
||||
"\u256a\u2518\u250c\u2588\u2584\u258c\u2590\u2580"
|
||||
"\u03b1\xdf\u0393\u03c0\u03a3\u03c3\xb5\u03c4"
|
||||
"\u03a6\u0398\u03a9\u03b4\u221e\u03c6\u03b5\u2229"
|
||||
"\u2261\xb1\u2265\u2264\u2320\u2321\xf7\u2248"
|
||||
"\xb0\u2219\xb7\u221a\u207f\xb2\u25a0\xa0"
|
||||
)
|
||||
|
||||
_importing_zlib = False
|
||||
|
||||
|
||||
# Return the zlib.decompress function object, or NULL if zlib couldn't
|
||||
# be imported. The function is cached when found, so subsequent calls
|
||||
# don't import zlib again.
|
||||
def _get_decompress_func():
|
||||
global _importing_zlib
|
||||
if _importing_zlib:
|
||||
# Someone has a zlib.py[co] in their Zip file
|
||||
# let's avoid a stack overflow.
|
||||
_bootstrap._verbose_message("zipimport: zlib UNAVAILABLE")
|
||||
raise ZipImportError("can't decompress data; zlib not available")
|
||||
|
||||
_importing_zlib = True
|
||||
try:
|
||||
from zlib import decompress
|
||||
except Exception:
|
||||
_bootstrap._verbose_message("zipimport: zlib UNAVAILABLE")
|
||||
raise ZipImportError("can't decompress data; zlib not available")
|
||||
finally:
|
||||
_importing_zlib = False
|
||||
|
||||
_bootstrap._verbose_message("zipimport: zlib available")
|
||||
return decompress
|
||||
|
||||
|
||||
# Given a path to a Zip file and a toc_entry, return the (uncompressed) data.
|
||||
def _get_data(archive, toc_entry):
|
||||
datapath, compress, data_size, file_size, file_offset, time, date, crc = toc_entry
|
||||
if data_size < 0:
|
||||
raise ZipImportError("negative data size")
|
||||
|
||||
with _io.open(archive, "rb") as fp:
|
||||
# Check to make sure the local file header is correct
|
||||
try:
|
||||
fp.seek(file_offset)
|
||||
except OSError:
|
||||
raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
|
||||
buffer = fp.read(30)
|
||||
if len(buffer) != 30:
|
||||
raise EOFError("EOF read where not expected")
|
||||
|
||||
if buffer[:4] != b"PK\x03\x04":
|
||||
# Bad: Local File Header
|
||||
raise ZipImportError(f"bad local file header: {archive!r}", path=archive)
|
||||
|
||||
name_size = _unpack_uint16(buffer[26:28])
|
||||
extra_size = _unpack_uint16(buffer[28:30])
|
||||
header_size = 30 + name_size + extra_size
|
||||
file_offset += header_size # Start of file data
|
||||
try:
|
||||
fp.seek(file_offset)
|
||||
except OSError:
|
||||
raise ZipImportError(f"can't read Zip file: {archive!r}", path=archive)
|
||||
raw_data = fp.read(data_size)
|
||||
if len(raw_data) != data_size:
|
||||
raise OSError("zipimport: can't read data")
|
||||
|
||||
if compress == 0:
|
||||
# data is not compressed
|
||||
return raw_data
|
||||
|
||||
# Decompress with zlib
|
||||
try:
|
||||
decompress = _get_decompress_func()
|
||||
except Exception:
|
||||
raise ZipImportError("can't decompress data; zlib not available")
|
||||
return decompress(raw_data, -15)
|
||||
|
||||
|
||||
# Lenient date/time comparison function. The precision of the mtime
|
||||
# in the archive is lower than the mtime stored in a .pyc: we
|
||||
# must allow a difference of at most one second.
|
||||
def _eq_mtime(t1, t2):
|
||||
# dostime only stores even seconds, so be lenient
|
||||
return abs(t1 - t2) <= 1
|
||||
|
||||
|
||||
# Given the contents of a .py[co] file, unmarshal the data
|
||||
# and return the code object. Return None if it the magic word doesn't
|
||||
# match (we do this instead of raising an exception as we fall back
|
||||
# to .py if available and we don't want to mask other errors).
|
||||
def _unmarshal_code(pathname, data, mtime):
|
||||
if len(data) < 16:
|
||||
raise ZipImportError("bad pyc data")
|
||||
|
||||
if data[:4] != _bootstrap_external.MAGIC_NUMBER:
|
||||
_bootstrap._verbose_message("{!r} has bad magic", pathname)
|
||||
return None # signal caller to try alternative
|
||||
|
||||
flags = _unpack_uint32(data[4:8])
|
||||
if flags != 0:
|
||||
# Hash-based pyc. We currently refuse to handle checked hash-based
|
||||
# pycs. We could validate hash-based pycs against the source, but it
|
||||
# seems likely that most people putting hash-based pycs in a zipfile
|
||||
# will use unchecked ones.
|
||||
if _imp.check_hash_based_pycs != "never" and (
|
||||
flags != 0x1 or _imp.check_hash_based_pycs == "always"
|
||||
):
|
||||
return None
|
||||
elif mtime != 0 and not _eq_mtime(_unpack_uint32(data[8:12]), mtime):
|
||||
_bootstrap._verbose_message("{!r} has bad mtime", pathname)
|
||||
return None # signal caller to try alternative
|
||||
|
||||
# XXX the pyc's size field is ignored; timestamp collisions are probably
|
||||
# unimportant with zip files.
|
||||
code = marshal.loads(data[16:])
|
||||
if not isinstance(code, _code_type):
|
||||
raise TypeError(f"compiled module {pathname!r} is not a code object")
|
||||
return code
|
||||
|
||||
|
||||
_code_type = type(_unmarshal_code.__code__)
|
||||
|
||||
|
||||
# Replace any occurrences of '\r\n?' in the input string with '\n'.
|
||||
# This converts DOS and Mac line endings to Unix line endings.
|
||||
def _normalize_line_endings(source):
|
||||
source = source.replace(b"\r\n", b"\n")
|
||||
source = source.replace(b"\r", b"\n")
|
||||
return source
|
||||
|
||||
|
||||
# Given a string buffer containing Python source code, compile it
|
||||
# and return a code object.
|
||||
def _compile_source(pathname, source):
|
||||
source = _normalize_line_endings(source)
|
||||
return compile(source, pathname, "exec", dont_inherit=True)
|
||||
|
||||
|
||||
# Convert the date/time values found in the Zip archive to a value
|
||||
# that's compatible with the time stamp stored in .pyc files.
|
||||
def _parse_dostime(d, t):
|
||||
return time.mktime(
|
||||
(
|
||||
(d >> 9) + 1980, # bits 9..15: year
|
||||
(d >> 5) & 0xF, # bits 5..8: month
|
||||
d & 0x1F, # bits 0..4: day
|
||||
t >> 11, # bits 11..15: hours
|
||||
(t >> 5) & 0x3F, # bits 8..10: minutes
|
||||
(t & 0x1F) * 2, # bits 0..7: seconds / 2
|
||||
-1,
|
||||
-1,
|
||||
-1,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Given a path to a .pyc file in the archive, return the
|
||||
# modification time of the matching .py file, or 0 if no source
|
||||
# is available.
|
||||
def _get_mtime_of_source(self, path):
|
||||
try:
|
||||
# strip 'c' or 'o' from *.py[co]
|
||||
assert path[-1:] in ("c", "o")
|
||||
path = path[:-1]
|
||||
toc_entry = self._files[path]
|
||||
# fetch the time stamp of the .py file for comparison
|
||||
# with an embedded pyc time stamp
|
||||
time = toc_entry[5]
|
||||
date = toc_entry[6]
|
||||
return _parse_dostime(date, time)
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
# Get the code object associated with the module specified by
|
||||
# 'fullname'.
|
||||
def _get_module_code(self, fullname):
|
||||
path = _get_module_path(self, fullname)
|
||||
for suffix, isbytecode, ispackage in _zip_searchorder:
|
||||
fullpath = path + suffix
|
||||
_bootstrap._verbose_message("trying {}{}{}", self.archive, path_sep, fullpath, verbosity=2)
|
||||
try:
|
||||
toc_entry = self._files[fullpath]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
modpath = toc_entry[0]
|
||||
data = _get_data(self.archive, toc_entry)
|
||||
if isbytecode:
|
||||
mtime = _get_mtime_of_source(self, fullpath)
|
||||
code = _unmarshal_code(modpath, data, mtime)
|
||||
else:
|
||||
code = _compile_source(modpath, data)
|
||||
if code is None:
|
||||
# bad magic number or non-matching mtime
|
||||
# in byte code, try next
|
||||
continue
|
||||
modpath = toc_entry[0]
|
||||
return code, ispackage, modpath
|
||||
else:
|
||||
raise ZipImportError(f"can't find module {fullname!r}", name=fullname)
|
||||
|
||||
|
||||
class _ZipImportResourceReader:
|
||||
"""Private class used to support ZipImport.get_resource_reader().
|
||||
|
||||
This class is allowed to reference all the innards and private parts of
|
||||
the zipimporter.
|
||||
"""
|
||||
|
||||
_registered = False
|
||||
|
||||
def __init__(self, zipimporter, fullname):
|
||||
self.zipimporter = zipimporter
|
||||
self.fullname = fullname
|
||||
|
||||
def open_resource(self, resource):
|
||||
fullname_as_path = self.fullname.replace(".", "/")
|
||||
path = f"{fullname_as_path}/{resource}"
|
||||
from io import BytesIO
|
||||
|
||||
try:
|
||||
return BytesIO(self.zipimporter.get_data(path))
|
||||
except OSError:
|
||||
raise FileNotFoundError(path)
|
||||
|
||||
def resource_path(self, resource):
|
||||
# All resources are in the zip file, so there is no path to the file.
|
||||
# Raising FileNotFoundError tells the higher level API to extract the
|
||||
# binary data and create a temporary file.
|
||||
raise FileNotFoundError
|
||||
|
||||
def is_resource(self, name):
|
||||
# Maybe we could do better, but if we can get the data, it's a
|
||||
# resource. Otherwise it isn't.
|
||||
fullname_as_path = self.fullname.replace(".", "/")
|
||||
path = f"{fullname_as_path}/{name}"
|
||||
try:
|
||||
self.zipimporter.get_data(path)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def contents(self):
|
||||
# This is a bit convoluted, because fullname will be a module path,
|
||||
# but _files is a list of file names relative to the top of the
|
||||
# archive's namespace. We want to compare file paths to find all the
|
||||
# names of things inside the module represented by fullname. So we
|
||||
# turn the module path of fullname into a file path relative to the
|
||||
# top of the archive, and then we iterate through _files looking for
|
||||
# names inside that "directory".
|
||||
from pathlib import Path
|
||||
|
||||
fullname_path = Path(self.zipimporter.get_filename(self.fullname))
|
||||
relative_path = fullname_path.relative_to(self.zipimporter.archive)
|
||||
# Don't forget that fullname names a package, so its path will include
|
||||
# __init__.py, which we want to ignore.
|
||||
assert relative_path.name == "__init__.py"
|
||||
package_path = relative_path.parent
|
||||
subdirs_seen = set()
|
||||
for filename in self.zipimporter._files:
|
||||
try:
|
||||
relative = Path(filename).relative_to(package_path)
|
||||
except ValueError:
|
||||
continue
|
||||
# If the path of the file (which is relative to the top of the zip
|
||||
# namespace), relative to the package given when the resource
|
||||
# reader was created, has a parent, then it's a name in a
|
||||
# subdirectory and thus we skip it.
|
||||
parent_name = relative.parent.name
|
||||
if len(parent_name) == 0:
|
||||
yield relative.name
|
||||
elif parent_name not in subdirs_seen:
|
||||
subdirs_seen.add(parent_name)
|
||||
yield parent_name
|
||||
3
maubot-src/maubot/loader/__init__.py
Normal file
3
maubot-src/maubot/loader/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .abc import BasePluginLoader, IDConflictError, PluginClass, PluginLoader
|
||||
from .meta import DatabaseType, PluginMeta
|
||||
from .zip import MaubotZipImportError, ZippedPluginLoader
|
||||
98
maubot-src/maubot/loader/abc.py
Normal file
98
maubot-src/maubot/loader/abc.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
|
||||
from ..plugin_base import Plugin
|
||||
from .meta import PluginMeta
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..instance import PluginInstance
|
||||
|
||||
PluginClass = TypeVar("PluginClass", bound=Plugin)
|
||||
|
||||
|
||||
class IDConflictError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BasePluginLoader(ABC):
|
||||
meta: PluginMeta
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def source(self) -> str:
|
||||
pass
|
||||
|
||||
def sync_read_file(self, path: str) -> bytes:
|
||||
raise NotImplementedError("This loader doesn't support synchronous operations")
|
||||
|
||||
@abstractmethod
|
||||
async def read_file(self, path: str) -> bytes:
|
||||
pass
|
||||
|
||||
def sync_list_files(self, directory: str) -> list[str]:
|
||||
raise NotImplementedError("This loader doesn't support synchronous operations")
|
||||
|
||||
@abstractmethod
|
||||
async def list_files(self, directory: str) -> list[str]:
|
||||
pass
|
||||
|
||||
|
||||
class PluginLoader(BasePluginLoader, ABC):
|
||||
id_cache: dict[str, PluginLoader] = {}
|
||||
|
||||
meta: PluginMeta
|
||||
references: set[PluginInstance]
|
||||
|
||||
def __init__(self):
|
||||
self.references = set()
|
||||
|
||||
@classmethod
|
||||
def find(cls, plugin_id: str) -> PluginLoader:
|
||||
return cls.id_cache[plugin_id]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.meta.id,
|
||||
"version": str(self.meta.version),
|
||||
"instances": [instance.to_dict() for instance in self.references],
|
||||
}
|
||||
|
||||
async def stop_instances(self) -> None:
|
||||
await asyncio.gather(
|
||||
*[instance.stop() for instance in self.references if instance.started]
|
||||
)
|
||||
|
||||
async def start_instances(self) -> None:
|
||||
await asyncio.gather(
|
||||
*[instance.start() for instance in self.references if instance.enabled]
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
async def load(self) -> type[PluginClass]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def reload(self) -> type[PluginClass]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self) -> None:
|
||||
pass
|
||||
69
maubot-src/maubot/loader/meta.py
Normal file
69
maubot-src/maubot/loader/meta.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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, Optional
|
||||
|
||||
from attr import dataclass
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from mautrix.types import (
|
||||
ExtensibleEnum,
|
||||
SerializableAttrs,
|
||||
SerializerError,
|
||||
deserializer,
|
||||
serializer,
|
||||
)
|
||||
|
||||
from ..__meta__ import __version__
|
||||
|
||||
|
||||
@serializer(Version)
|
||||
def serialize_version(version: Version) -> str:
|
||||
return str(version)
|
||||
|
||||
|
||||
@deserializer(Version)
|
||||
def deserialize_version(version: str) -> Version:
|
||||
try:
|
||||
return Version(version)
|
||||
except InvalidVersion as e:
|
||||
raise SerializerError("Invalid version") from e
|
||||
|
||||
|
||||
class DatabaseType(ExtensibleEnum):
|
||||
SQLALCHEMY = "sqlalchemy"
|
||||
ASYNCPG = "asyncpg"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginMeta(SerializableAttrs):
|
||||
id: str
|
||||
version: Version
|
||||
modules: List[str]
|
||||
main_class: str
|
||||
|
||||
maubot: Version = Version(__version__)
|
||||
database: bool = False
|
||||
database_type: DatabaseType = DatabaseType.SQLALCHEMY
|
||||
config: bool = False
|
||||
webapp: bool = False
|
||||
license: str = ""
|
||||
extra_files: List[str] = []
|
||||
dependencies: List[str] = []
|
||||
soft_dependencies: List[str] = []
|
||||
|
||||
@property
|
||||
def database_type_str(self) -> Optional[str]:
|
||||
return self.database_type.value if self.database else None
|
||||
310
maubot-src/maubot/loader/zip.py
Normal file
310
maubot-src/maubot/loader/zip.py
Normal file
@@ -0,0 +1,310 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from time import time
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from packaging.version import Version
|
||||
from ruamel.yaml import YAML, YAMLError
|
||||
|
||||
from mautrix.types import SerializerError
|
||||
|
||||
from ..__meta__ import __version__
|
||||
from ..config import Config
|
||||
from ..lib.zipimport import ZipImportError, zipimporter
|
||||
from ..plugin_base import Plugin
|
||||
from .abc import IDConflictError, PluginClass, PluginLoader
|
||||
from .meta import DatabaseType, PluginMeta
|
||||
|
||||
current_version = Version(__version__)
|
||||
yaml = YAML()
|
||||
|
||||
|
||||
class MaubotZipImportError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MaubotZipMetaError(MaubotZipImportError):
|
||||
pass
|
||||
|
||||
|
||||
class MaubotZipPreLoadError(MaubotZipImportError):
|
||||
pass
|
||||
|
||||
|
||||
class MaubotZipLoadError(MaubotZipImportError):
|
||||
pass
|
||||
|
||||
|
||||
class ZippedPluginLoader(PluginLoader):
|
||||
path_cache: dict[str, ZippedPluginLoader] = {}
|
||||
log: logging.Logger = logging.getLogger("maubot.loader.zip")
|
||||
trash_path: str = "delete"
|
||||
directories: list[str] = []
|
||||
|
||||
path: str | None
|
||||
meta: PluginMeta | None
|
||||
main_class: str | None
|
||||
main_module: str | None
|
||||
_loaded: type[PluginClass] | None
|
||||
_importer: zipimporter | None
|
||||
_file: ZipFile | None
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self.meta = None
|
||||
self.main_class = None
|
||||
self.main_module = None
|
||||
self._loaded = None
|
||||
self._importer = None
|
||||
self._file = None
|
||||
self._load_meta()
|
||||
self._run_preload_checks(self._get_importer())
|
||||
try:
|
||||
existing = self.id_cache[self.meta.id]
|
||||
raise IDConflictError(
|
||||
f"Plugin with id {self.meta.id} already loaded from {existing.source}"
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
self.path_cache[self.path] = self
|
||||
self.id_cache[self.meta.id] = self
|
||||
self.log.debug(f"Preloaded plugin {self.meta.id} from {self.path}")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {**super().to_dict(), "path": self.path}
|
||||
|
||||
@classmethod
|
||||
def get(cls, path: str) -> ZippedPluginLoader:
|
||||
path = os.path.abspath(path)
|
||||
try:
|
||||
return cls.path_cache[path]
|
||||
except KeyError:
|
||||
return cls(path)
|
||||
|
||||
@property
|
||||
def source(self) -> str:
|
||||
return self.path
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
"<ZippedPlugin "
|
||||
f"path='{self.path}' "
|
||||
f"meta={self.meta} "
|
||||
f"loaded={self._loaded is not None}>"
|
||||
)
|
||||
|
||||
def sync_read_file(self, path: str) -> bytes:
|
||||
return self._file.read(path)
|
||||
|
||||
async def read_file(self, path: str) -> bytes:
|
||||
return self.sync_read_file(path)
|
||||
|
||||
def sync_list_files(self, directory: str) -> list[str]:
|
||||
directory = directory.rstrip("/")
|
||||
return [
|
||||
file.filename
|
||||
for file in self._file.filelist
|
||||
if os.path.dirname(file.filename) == directory
|
||||
]
|
||||
|
||||
async def list_files(self, directory: str) -> list[str]:
|
||||
return self.sync_list_files(directory)
|
||||
|
||||
@staticmethod
|
||||
def _read_meta(source) -> tuple[ZipFile, PluginMeta]:
|
||||
try:
|
||||
file = ZipFile(source)
|
||||
data = file.read("maubot.yaml")
|
||||
except FileNotFoundError as e:
|
||||
raise MaubotZipMetaError("Maubot plugin not found") from e
|
||||
except BadZipFile as e:
|
||||
raise MaubotZipMetaError("File is not a maubot plugin") from e
|
||||
except KeyError as e:
|
||||
raise MaubotZipMetaError("File does not contain a maubot plugin definition") from e
|
||||
try:
|
||||
meta_dict = yaml.load(data)
|
||||
except (YAMLError, KeyError, IndexError, ValueError) as e:
|
||||
raise MaubotZipMetaError("Maubot plugin definition file is not valid YAML") from e
|
||||
try:
|
||||
meta = PluginMeta.deserialize(meta_dict)
|
||||
except SerializerError as e:
|
||||
raise MaubotZipMetaError("Maubot plugin definition in file is invalid") from e
|
||||
if meta.maubot > current_version:
|
||||
raise MaubotZipMetaError(
|
||||
f"Plugin requires maubot {meta.maubot}, but this instance is {current_version}"
|
||||
)
|
||||
return file, meta
|
||||
|
||||
@classmethod
|
||||
def verify_meta(cls, source) -> tuple[str, Version, DatabaseType | None]:
|
||||
_, meta = cls._read_meta(source)
|
||||
return meta.id, meta.version, meta.database_type if meta.database else None
|
||||
|
||||
def _load_meta(self) -> None:
|
||||
file, meta = self._read_meta(self.path)
|
||||
if self.meta and meta.id != self.meta.id:
|
||||
raise MaubotZipMetaError("Maubot plugin ID changed during reload")
|
||||
self.meta = meta
|
||||
if "/" in meta.main_class:
|
||||
self.main_module, self.main_class = meta.main_class.split("/")[:2]
|
||||
else:
|
||||
self.main_module = meta.modules[-1]
|
||||
self.main_class = meta.main_class
|
||||
self._file = file
|
||||
|
||||
def _get_importer(self, reset_cache: bool = False) -> zipimporter:
|
||||
try:
|
||||
if not self._importer or self._importer.archive != self.path:
|
||||
self._importer = zipimporter(self.path)
|
||||
if reset_cache:
|
||||
self._importer.reset_cache()
|
||||
return self._importer
|
||||
except ZipImportError as e:
|
||||
raise MaubotZipMetaError("File 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 MaubotZipPreLoadError(
|
||||
f"Main class {self.main_class} not in {self.main_module}"
|
||||
)
|
||||
except ZipImportError as e:
|
||||
raise MaubotZipPreLoadError(f"Main module {self.main_module} not found in file") from e
|
||||
for module in self.meta.modules:
|
||||
try:
|
||||
importer.find_module(module)
|
||||
except ZipImportError as e:
|
||||
raise MaubotZipPreLoadError(f"Module {module} not found in file") from e
|
||||
|
||||
async def load(self, reset_cache: bool = False) -> type[PluginClass]:
|
||||
try:
|
||||
return self._load(reset_cache)
|
||||
except MaubotZipImportError:
|
||||
self.log.exception(f"Failed to load {self.meta.id} v{self.meta.version}")
|
||||
raise
|
||||
|
||||
def _load(self, reset_cache: bool = False) -> type[PluginClass]:
|
||||
if self._loaded is not None and not reset_cache:
|
||||
return self._loaded
|
||||
self._load_meta()
|
||||
importer = self._get_importer(reset_cache=reset_cache)
|
||||
self._run_preload_checks(importer)
|
||||
if reset_cache:
|
||||
self.log.debug(f"Re-preloaded plugin {self.meta.id} from {self.path}")
|
||||
for module in self.meta.modules:
|
||||
try:
|
||||
importer.load_module(module)
|
||||
except ZipImportError:
|
||||
raise MaubotZipLoadError(f"Module {module} not found in file")
|
||||
except Exception:
|
||||
raise MaubotZipLoadError(f"Failed to load module {module}")
|
||||
try:
|
||||
main_mod = sys.modules[self.main_module]
|
||||
except KeyError as e:
|
||||
raise MaubotZipLoadError(f"Main module {self.main_module} of plugin not found") from e
|
||||
try:
|
||||
plugin = getattr(main_mod, self.main_class)
|
||||
except AttributeError as e:
|
||||
raise MaubotZipLoadError(f"Main class {self.main_class} of plugin not found") from e
|
||||
if not issubclass(plugin, Plugin):
|
||||
raise MaubotZipLoadError("Main class of plugin does not extend maubot.Plugin")
|
||||
self._loaded = plugin
|
||||
self.log.debug(f"Loaded and imported plugin {self.meta.id} from {self.path}")
|
||||
return plugin
|
||||
|
||||
async def reload(self, new_path: str | None = None) -> type[PluginClass]:
|
||||
self._unload()
|
||||
if new_path is not None and new_path != self.path:
|
||||
try:
|
||||
del self.path_cache[self.path]
|
||||
except KeyError:
|
||||
pass
|
||||
self.path = new_path
|
||||
self.path_cache[self.path] = self
|
||||
return await self.load(reset_cache=True)
|
||||
|
||||
def _unload(self) -> None:
|
||||
for name, mod in list(sys.modules.items()):
|
||||
if (getattr(mod, "__file__", "") or "").startswith(self.path):
|
||||
del sys.modules[name]
|
||||
self._loaded = None
|
||||
self.log.debug(f"Unloaded plugin {self.meta.id} at {self.path}")
|
||||
|
||||
async def delete(self) -> None:
|
||||
self._unload()
|
||||
try:
|
||||
del self.path_cache[self.path]
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
del self.id_cache[self.meta.id]
|
||||
except KeyError:
|
||||
pass
|
||||
if self._importer:
|
||||
self._importer.remove_cache()
|
||||
self._importer = None
|
||||
self._loaded = None
|
||||
self.trash(self.path, reason="delete")
|
||||
self.meta = None
|
||||
self.path = None
|
||||
|
||||
@classmethod
|
||||
def trash(cls, file_path: str, new_name: str | None = None, reason: str = "error") -> None:
|
||||
if cls.trash_path == "delete":
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
new_name = new_name or f"{int(time())}-{reason}-{os.path.basename(file_path)}"
|
||||
try:
|
||||
os.rename(file_path, os.path.abspath(os.path.join(cls.trash_path, new_name)))
|
||||
except OSError as e:
|
||||
cls.log.warning(f"Failed to rename {file_path}: {e} - trying to delete")
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def load_all(cls):
|
||||
cls.log.debug("Preloading plugins...")
|
||||
for directory in cls.directories:
|
||||
for file in os.listdir(directory):
|
||||
if not file.endswith(".mbp"):
|
||||
continue
|
||||
path = os.path.abspath(os.path.join(directory, file))
|
||||
try:
|
||||
cls.get(path)
|
||||
except MaubotZipImportError:
|
||||
cls.log.exception(f"Failed to load plugin at {path}, trashing...")
|
||||
cls.trash(path)
|
||||
except IDConflictError:
|
||||
cls.log.error(f"Duplicate plugin ID at {path}, trashing...")
|
||||
cls.trash(path)
|
||||
|
||||
|
||||
def init(config: Config) -> None:
|
||||
ZippedPluginLoader.trash_path = config["plugin_directories.trash"]
|
||||
ZippedPluginLoader.directories = config["plugin_directories.load"]
|
||||
ZippedPluginLoader.load_all()
|
||||
0
maubot-src/maubot/management/__init__.py
Normal file
0
maubot-src/maubot/management/__init__.py
Normal file
48
maubot-src/maubot/management/api/__init__.py
Normal file
48
maubot-src/maubot/management/api/__init__.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 asyncio import AbstractEventLoop
|
||||
import importlib
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...config import Config
|
||||
from .auth import check_token
|
||||
from .base import get_config, routes, set_config
|
||||
from .middleware import auth, error
|
||||
|
||||
|
||||
@routes.get("/features")
|
||||
def features(request: web.Request) -> web.Response:
|
||||
data = get_config()["api_features"]
|
||||
err = check_token(request)
|
||||
if err is None:
|
||||
return web.json_response(data)
|
||||
else:
|
||||
return web.json_response(
|
||||
{
|
||||
"login": data["login"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def init(cfg: Config, loop: AbstractEventLoop) -> web.Application:
|
||||
set_config(cfg)
|
||||
for pkg, enabled in cfg["api_features"].items():
|
||||
if enabled:
|
||||
importlib.import_module(f"maubot.management.api.{pkg}")
|
||||
app = web.Application(loop=loop, middlewares=[auth, error], client_max_size=100 * 1024 * 1024)
|
||||
app.add_routes(routes)
|
||||
return app
|
||||
76
maubot-src/maubot/management/api/auth.py
Normal file
76
maubot-src/maubot/management/api/auth.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from time import time
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from mautrix.types import UserID
|
||||
from mautrix.util.signed_token import sign_token, verify_token
|
||||
|
||||
from .base import get_config, routes
|
||||
from .responses import resp
|
||||
|
||||
|
||||
def is_valid_token(token: str) -> bool:
|
||||
data = verify_token(get_config()["server.unshared_secret"], token)
|
||||
if not data:
|
||||
return False
|
||||
return get_config().is_admin(data.get("user_id", None))
|
||||
|
||||
|
||||
def create_token(user: UserID) -> str:
|
||||
return sign_token(
|
||||
get_config()["server.unshared_secret"],
|
||||
{
|
||||
"user_id": user,
|
||||
"created_at": int(time()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_token(request: web.Request) -> str:
|
||||
token = request.headers.get("Authorization", "")
|
||||
if not token or not token.startswith("Bearer "):
|
||||
token = request.query.get("access_token", "")
|
||||
else:
|
||||
token = token[len("Bearer ") :]
|
||||
return token
|
||||
|
||||
|
||||
def check_token(request: web.Request) -> web.Response | None:
|
||||
token = get_token(request)
|
||||
if not token:
|
||||
return resp.no_token
|
||||
elif not is_valid_token(token):
|
||||
return resp.invalid_token
|
||||
return None
|
||||
|
||||
|
||||
@routes.post("/auth/ping")
|
||||
async def ping(request: web.Request) -> web.Response:
|
||||
token = get_token(request)
|
||||
if not token:
|
||||
return resp.no_token
|
||||
|
||||
data = verify_token(get_config()["server.unshared_secret"], token)
|
||||
if not data:
|
||||
return resp.invalid_token
|
||||
user = data.get("user_id", None)
|
||||
if not get_config().is_admin(user):
|
||||
return resp.invalid_token
|
||||
return resp.pong(user, get_config()["api_features"])
|
||||
40
maubot-src/maubot/management/api/base.py
Normal file
40
maubot-src/maubot/management/api/base.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...__meta__ import __version__
|
||||
from ...config import Config
|
||||
|
||||
routes: web.RouteTableDef = web.RouteTableDef()
|
||||
_config: Config | None = None
|
||||
|
||||
|
||||
def set_config(config: Config) -> None:
|
||||
global _config
|
||||
_config = config
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
return _config
|
||||
|
||||
|
||||
@routes.get("/version")
|
||||
async def version(_: web.Request) -> web.Response:
|
||||
return web.json_response({"version": __version__})
|
||||
217
maubot-src/maubot/management/api/client.py
Normal file
217
maubot-src/maubot/management/api/client.py
Normal file
@@ -0,0 +1,217 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from json import JSONDecodeError
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from mautrix.client import Client as MatrixClient
|
||||
from mautrix.errors import MatrixConnectionError, MatrixInvalidToken, MatrixRequestError
|
||||
from mautrix.types import FilterID, SyncToken, UserID
|
||||
|
||||
from ...client import Client
|
||||
from .base import routes
|
||||
from .responses import resp
|
||||
|
||||
log = logging.getLogger("maubot.server.client")
|
||||
|
||||
|
||||
@routes.get("/clients")
|
||||
async def get_clients(_: web.Request) -> web.Response:
|
||||
return resp.found([client.to_dict() for client in Client.cache.values()])
|
||||
|
||||
|
||||
@routes.get("/client/{id}")
|
||||
async def get_client(request: web.Request) -> web.Response:
|
||||
user_id = request.match_info.get("id", None)
|
||||
client = await Client.get(user_id)
|
||||
if not client:
|
||||
return resp.client_not_found
|
||||
return resp.found(client.to_dict())
|
||||
|
||||
|
||||
async def _create_client(user_id: UserID | None, data: dict) -> web.Response:
|
||||
homeserver = data.get("homeserver", None)
|
||||
access_token = data.get("access_token", None)
|
||||
device_id = data.get("device_id", None)
|
||||
new_client = MatrixClient(
|
||||
mxid="@not:a.mxid",
|
||||
base_url=homeserver,
|
||||
token=access_token,
|
||||
client_session=Client.http_client,
|
||||
)
|
||||
try:
|
||||
whoami = await new_client.whoami()
|
||||
except MatrixInvalidToken as e:
|
||||
return resp.bad_client_access_token
|
||||
except MatrixRequestError:
|
||||
log.warning(f"Failed to get whoami from {homeserver} for new client", exc_info=True)
|
||||
return resp.bad_client_access_details
|
||||
except MatrixConnectionError:
|
||||
log.warning(f"Failed to connect to {homeserver} for new client", exc_info=True)
|
||||
return resp.bad_client_connection_details
|
||||
if user_id is None:
|
||||
existing_client = await Client.get(whoami.user_id)
|
||||
if existing_client is not None:
|
||||
return resp.user_exists
|
||||
elif whoami.user_id != user_id:
|
||||
return resp.mxid_mismatch(whoami.user_id)
|
||||
elif whoami.device_id and device_id and whoami.device_id != device_id:
|
||||
return resp.device_id_mismatch(whoami.device_id)
|
||||
client = await Client.get(
|
||||
whoami.user_id, homeserver=homeserver, access_token=access_token, device_id=device_id
|
||||
)
|
||||
client.enabled = data.get("enabled", True)
|
||||
client.sync = data.get("sync", True)
|
||||
await client.update_autojoin(data.get("autojoin", True), save=False)
|
||||
await client.update_online(data.get("online", True), save=False)
|
||||
client.displayname = data.get("displayname", "disable")
|
||||
client.avatar_url = data.get("avatar_url", "disable")
|
||||
await client.update()
|
||||
await client.start()
|
||||
return resp.created(client.to_dict())
|
||||
|
||||
|
||||
async def _update_client(client: Client, data: dict, is_login: bool = False) -> web.Response:
|
||||
try:
|
||||
await client.update_access_details(
|
||||
data.get("access_token"), data.get("homeserver"), data.get("device_id")
|
||||
)
|
||||
except MatrixInvalidToken:
|
||||
return resp.bad_client_access_token
|
||||
except MatrixRequestError:
|
||||
log.warning(
|
||||
f"Failed to get whoami from homeserver to update client details", exc_info=True
|
||||
)
|
||||
return resp.bad_client_access_details
|
||||
except MatrixConnectionError:
|
||||
log.warning(f"Failed to connect to homeserver to update client details", exc_info=True)
|
||||
return resp.bad_client_connection_details
|
||||
except ValueError as e:
|
||||
str_err = str(e)
|
||||
if str_err.startswith("MXID mismatch"):
|
||||
return resp.mxid_mismatch(str(e)[len("MXID mismatch: ") :])
|
||||
elif str_err.startswith("Device ID mismatch"):
|
||||
return resp.device_id_mismatch(str(e)[len("Device ID mismatch: ") :])
|
||||
await client.update_avatar_url(data.get("avatar_url"), save=False)
|
||||
await client.update_displayname(data.get("displayname"), save=False)
|
||||
await client.update_started(data.get("started"))
|
||||
await client.update_enabled(data.get("enabled"), save=False)
|
||||
await client.update_autojoin(data.get("autojoin"), save=False)
|
||||
await client.update_online(data.get("online"), save=False)
|
||||
await client.update_sync(data.get("sync"), save=False)
|
||||
await client.update()
|
||||
return resp.updated(client.to_dict(), is_login=is_login)
|
||||
|
||||
|
||||
async def _create_or_update_client(
|
||||
user_id: UserID, data: dict, is_login: bool = False
|
||||
) -> web.Response:
|
||||
client = await Client.get(user_id)
|
||||
if not client:
|
||||
return await _create_client(user_id, data)
|
||||
else:
|
||||
return await _update_client(client, data, is_login=is_login)
|
||||
|
||||
|
||||
@routes.post("/client/new")
|
||||
async def create_client(request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
except JSONDecodeError:
|
||||
return resp.body_not_json
|
||||
return await _create_client(None, data)
|
||||
|
||||
|
||||
@routes.put("/client/{id}")
|
||||
async def update_client(request: web.Request) -> web.Response:
|
||||
user_id = request.match_info["id"]
|
||||
try:
|
||||
data = await request.json()
|
||||
except JSONDecodeError:
|
||||
return resp.body_not_json
|
||||
return await _create_or_update_client(user_id, data)
|
||||
|
||||
|
||||
@routes.delete("/client/{id}")
|
||||
async def delete_client(request: web.Request) -> web.Response:
|
||||
user_id = request.match_info["id"]
|
||||
client = await Client.get(user_id)
|
||||
if not client:
|
||||
return resp.client_not_found
|
||||
if len(client.references) > 0:
|
||||
return resp.client_in_use
|
||||
if client.started:
|
||||
await client.stop()
|
||||
await client.delete()
|
||||
return resp.deleted
|
||||
|
||||
|
||||
@routes.post("/client/{id}/clearcache")
|
||||
async def clear_client_cache(request: web.Request) -> web.Response:
|
||||
user_id = request.match_info["id"]
|
||||
client = await Client.get(user_id)
|
||||
if not client:
|
||||
return resp.client_not_found
|
||||
await client.clear_cache()
|
||||
return resp.ok
|
||||
|
||||
|
||||
@routes.post("/client/{id}/verify")
|
||||
async def verify_client(request: web.Request) -> web.Response:
|
||||
user_id = request.match_info["id"]
|
||||
client = await Client.get(user_id)
|
||||
if not client:
|
||||
return resp.client_not_found
|
||||
try:
|
||||
req = await request.json()
|
||||
except Exception:
|
||||
return resp.body_not_json
|
||||
try:
|
||||
await client.crypto.verify_with_recovery_key(req["recovery_key"])
|
||||
client.trust_state = await client.crypto.resolve_trust(
|
||||
client.crypto.own_identity,
|
||||
allow_fetch=False,
|
||||
)
|
||||
log.debug(f"Trust state after verifying {client.id}: {client.trust_state}")
|
||||
except Exception as e:
|
||||
log.exception("Failed to verify client with recovery key")
|
||||
return resp.internal_crypto_error(str(e))
|
||||
return resp.ok
|
||||
|
||||
|
||||
@routes.post("/client/{id}/generate_recovery_key")
|
||||
async def generate_recovery_key(request: web.Request) -> web.Response:
|
||||
user_id = request.match_info["id"]
|
||||
client = await Client.get(user_id)
|
||||
if not client:
|
||||
return resp.client_not_found
|
||||
try:
|
||||
keys = await client.crypto.get_own_cross_signing_public_keys()
|
||||
if keys:
|
||||
return resp.client_has_keys
|
||||
key = await client.crypto.generate_recovery_key()
|
||||
client.trust_state = await client.crypto.resolve_trust(
|
||||
client.crypto.own_identity,
|
||||
allow_fetch=False,
|
||||
)
|
||||
log.debug(f"Trust state generating recovery key for {client.id}: {client.trust_state}")
|
||||
except Exception as e:
|
||||
log.exception("Failed to generate recovery key for client")
|
||||
return resp.internal_crypto_error(str(e))
|
||||
return resp.created({"success": True, "recovery_key": key})
|
||||
273
maubot-src/maubot/management/api/client_auth.py
Normal file
273
maubot-src/maubot/management/api/client_auth.py
Normal file
@@ -0,0 +1,273 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from typing import NamedTuple
|
||||
from http import HTTPStatus
|
||||
from json import JSONDecodeError
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import random
|
||||
import string
|
||||
|
||||
from aiohttp import web
|
||||
from yarl import URL
|
||||
|
||||
from mautrix.api import Method, Path, SynapseAdminPath
|
||||
from mautrix.client import ClientAPI
|
||||
from mautrix.errors import MatrixRequestError
|
||||
from mautrix.types import LoginResponse, LoginType
|
||||
|
||||
from .base import get_config, routes
|
||||
from .client import _create_client, _create_or_update_client
|
||||
from .responses import resp
|
||||
|
||||
|
||||
def known_homeservers() -> dict[str, dict[str, str]]:
|
||||
return get_config()["homeservers"]
|
||||
|
||||
|
||||
@routes.get("/client/auth/servers")
|
||||
async def get_known_servers(_: web.Request) -> web.Response:
|
||||
return web.json_response({key: value["url"] for key, value in known_homeservers().items()})
|
||||
|
||||
|
||||
class AuthRequestInfo(NamedTuple):
|
||||
server_name: str
|
||||
client: ClientAPI
|
||||
secret: str
|
||||
username: str
|
||||
password: str
|
||||
user_type: str
|
||||
device_name: str
|
||||
update_client: bool
|
||||
sso: bool
|
||||
|
||||
|
||||
truthy_strings = ("1", "true", "yes")
|
||||
|
||||
|
||||
async def read_client_auth_request(
|
||||
request: web.Request,
|
||||
) -> tuple[AuthRequestInfo | None, web.Response | None]:
|
||||
server_name = request.match_info.get("server", None)
|
||||
server = known_homeservers().get(server_name, None)
|
||||
if not server:
|
||||
return None, resp.server_not_found
|
||||
try:
|
||||
body = await request.json()
|
||||
except JSONDecodeError:
|
||||
return None, resp.body_not_json
|
||||
sso = request.query.get("sso", "").lower() in truthy_strings
|
||||
try:
|
||||
username = body["username"]
|
||||
password = body["password"]
|
||||
except KeyError:
|
||||
if not sso:
|
||||
return None, resp.username_or_password_missing
|
||||
username = password = None
|
||||
try:
|
||||
base_url = server["url"]
|
||||
except KeyError:
|
||||
return None, resp.invalid_server
|
||||
return (
|
||||
AuthRequestInfo(
|
||||
server_name=server_name,
|
||||
client=ClientAPI(base_url=base_url),
|
||||
secret=server.get("secret"),
|
||||
username=username,
|
||||
password=password,
|
||||
user_type=body.get("user_type", "bot"),
|
||||
device_name=body.get("device_name", "Maubot"),
|
||||
update_client=request.query.get("update_client", "").lower() in truthy_strings,
|
||||
sso=sso,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def generate_mac(
|
||||
secret: str,
|
||||
nonce: str,
|
||||
username: str,
|
||||
password: str,
|
||||
admin: bool = False,
|
||||
user_type: str = None,
|
||||
) -> str:
|
||||
mac = hmac.new(key=secret.encode("utf-8"), digestmod=hashlib.sha1)
|
||||
mac.update(nonce.encode("utf-8"))
|
||||
mac.update(b"\x00")
|
||||
mac.update(username.encode("utf-8"))
|
||||
mac.update(b"\x00")
|
||||
mac.update(password.encode("utf-8"))
|
||||
mac.update(b"\x00")
|
||||
mac.update(b"admin" if admin else b"notadmin")
|
||||
if user_type is not None:
|
||||
mac.update(b"\x00")
|
||||
mac.update(user_type.encode("utf8"))
|
||||
return mac.hexdigest()
|
||||
|
||||
|
||||
@routes.post("/client/auth/{server}/register")
|
||||
async def register(request: web.Request) -> web.Response:
|
||||
req, err = await read_client_auth_request(request)
|
||||
if err is not None:
|
||||
return err
|
||||
if req.sso:
|
||||
return resp.registration_no_sso
|
||||
elif not req.secret:
|
||||
return resp.registration_secret_not_found
|
||||
path = SynapseAdminPath.v1.register
|
||||
res = await req.client.api.request(Method.GET, path)
|
||||
content = {
|
||||
"nonce": res["nonce"],
|
||||
"username": req.username,
|
||||
"password": req.password,
|
||||
"admin": False,
|
||||
"user_type": req.user_type,
|
||||
}
|
||||
content["mac"] = generate_mac(**content, secret=req.secret)
|
||||
try:
|
||||
raw_res = await req.client.api.request(Method.POST, path, content=content)
|
||||
except MatrixRequestError as e:
|
||||
return web.json_response(
|
||||
{
|
||||
"errcode": e.errcode,
|
||||
"error": e.message,
|
||||
"http_status": e.http_status,
|
||||
},
|
||||
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
login_res = LoginResponse.deserialize(raw_res)
|
||||
if req.update_client:
|
||||
return await _create_client(
|
||||
login_res.user_id,
|
||||
{
|
||||
"homeserver": str(req.client.api.base_url),
|
||||
"access_token": login_res.access_token,
|
||||
"device_id": login_res.device_id,
|
||||
},
|
||||
)
|
||||
return web.json_response(login_res.serialize())
|
||||
|
||||
|
||||
@routes.post("/client/auth/{server}/login")
|
||||
async def login(request: web.Request) -> web.Response:
|
||||
req, err = await read_client_auth_request(request)
|
||||
if err is not None:
|
||||
return err
|
||||
if req.sso:
|
||||
return await _do_sso(req)
|
||||
else:
|
||||
return await _do_login(req)
|
||||
|
||||
|
||||
async def _do_sso(req: AuthRequestInfo) -> web.Response:
|
||||
flows = await req.client.get_login_flows()
|
||||
if not flows.supports_type(LoginType.SSO):
|
||||
return resp.sso_not_supported
|
||||
waiter_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=16))
|
||||
cfg = get_config()
|
||||
public_url = (
|
||||
URL(cfg["server.public_url"])
|
||||
/ "_matrix/maubot/v1/client/auth_external_sso/complete"
|
||||
/ waiter_id
|
||||
)
|
||||
sso_url = req.client.api.base_url.with_path(str(Path.v3.login.sso.redirect)).with_query(
|
||||
{"redirectUrl": str(public_url)}
|
||||
)
|
||||
sso_waiters[waiter_id] = req, asyncio.get_running_loop().create_future()
|
||||
return web.json_response({"sso_url": str(sso_url), "id": waiter_id})
|
||||
|
||||
|
||||
async def _do_login(req: AuthRequestInfo, login_token: str | None = None) -> web.Response:
|
||||
device_id = "".join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
||||
device_id = f"maubot_{device_id}"
|
||||
try:
|
||||
if req.sso:
|
||||
res = await req.client.login(
|
||||
token=login_token,
|
||||
login_type=LoginType.TOKEN,
|
||||
device_id=device_id,
|
||||
store_access_token=False,
|
||||
initial_device_display_name=req.device_name,
|
||||
)
|
||||
else:
|
||||
res = await req.client.login(
|
||||
identifier=req.username,
|
||||
login_type=LoginType.PASSWORD,
|
||||
password=req.password,
|
||||
device_id=device_id,
|
||||
initial_device_display_name=req.device_name,
|
||||
store_access_token=False,
|
||||
)
|
||||
except MatrixRequestError as e:
|
||||
return web.json_response(
|
||||
{
|
||||
"errcode": e.errcode,
|
||||
"error": e.message,
|
||||
},
|
||||
status=e.http_status,
|
||||
)
|
||||
if req.update_client:
|
||||
return await _create_or_update_client(
|
||||
res.user_id,
|
||||
{
|
||||
"homeserver": str(req.client.api.base_url),
|
||||
"access_token": res.access_token,
|
||||
"device_id": res.device_id,
|
||||
},
|
||||
is_login=True,
|
||||
)
|
||||
return web.json_response(res.serialize())
|
||||
|
||||
|
||||
sso_waiters: dict[str, tuple[AuthRequestInfo, asyncio.Future]] = {}
|
||||
|
||||
|
||||
@routes.post("/client/auth/{server}/sso/{id}/wait")
|
||||
async def wait_sso(request: web.Request) -> web.Response:
|
||||
waiter_id = request.match_info["id"]
|
||||
req, fut = sso_waiters[waiter_id]
|
||||
try:
|
||||
login_token = await fut
|
||||
finally:
|
||||
sso_waiters.pop(waiter_id, None)
|
||||
return await _do_login(req, login_token)
|
||||
|
||||
|
||||
@routes.get("/client/auth_external_sso/complete/{id}")
|
||||
async def complete_sso(request: web.Request) -> web.Response:
|
||||
try:
|
||||
_, fut = sso_waiters[request.match_info["id"]]
|
||||
except KeyError:
|
||||
return web.Response(status=404, text="Invalid session ID\n")
|
||||
if fut.cancelled():
|
||||
return web.Response(status=200, text="The login was cancelled from the Maubot client\n")
|
||||
elif fut.done():
|
||||
return web.Response(status=200, text="The login token was already received\n")
|
||||
try:
|
||||
fut.set_result(request.query["loginToken"])
|
||||
except KeyError:
|
||||
return web.Response(status=400, text="Missing loginToken query parameter\n")
|
||||
except asyncio.InvalidStateError:
|
||||
return web.Response(status=500, text="Invalid state\n")
|
||||
return web.Response(
|
||||
status=200,
|
||||
text="Login token received, please return to your Maubot client. "
|
||||
"This tab can be closed.\n",
|
||||
)
|
||||
56
maubot-src/maubot/management/api/client_proxy.py
Normal file
56
maubot-src/maubot/management/api/client_proxy.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 aiohttp import client as http, web
|
||||
|
||||
from ...client import Client
|
||||
from .base import routes
|
||||
from .responses import resp
|
||||
|
||||
PROXY_CHUNK_SIZE = 32 * 1024
|
||||
|
||||
|
||||
@routes.view("/proxy/{id}/{path:_matrix/.+}")
|
||||
async def proxy(request: web.Request) -> web.StreamResponse:
|
||||
user_id = request.match_info.get("id", None)
|
||||
client = await Client.get(user_id)
|
||||
if not client:
|
||||
return resp.client_not_found
|
||||
|
||||
path = request.match_info.get("path", None)
|
||||
query = request.query.copy()
|
||||
try:
|
||||
del query["access_token"]
|
||||
except KeyError:
|
||||
pass
|
||||
headers = request.headers.copy()
|
||||
del headers["Host"]
|
||||
headers["Authorization"] = f"Bearer {client.access_token}"
|
||||
if "X-Forwarded-For" not in headers:
|
||||
peer = request.transport.get_extra_info("peername")
|
||||
if peer is not None:
|
||||
host, port = peer
|
||||
headers["X-Forwarded-For"] = f"{host}:{port}"
|
||||
|
||||
data = await request.read()
|
||||
async with http.request(
|
||||
request.method, f"{client.homeserver}/{path}", headers=headers, params=query, data=data
|
||||
) as proxy_resp:
|
||||
response = web.StreamResponse(status=proxy_resp.status, headers=proxy_resp.headers)
|
||||
await response.prepare(request)
|
||||
async for chunk in proxy_resp.content.iter_chunked(PROXY_CHUNK_SIZE):
|
||||
await response.write(chunk)
|
||||
await response.write_eof()
|
||||
return response
|
||||
57
maubot-src/maubot/management/api/dev_open.py
Normal file
57
maubot-src/maubot/management/api/dev_open.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 string import Template
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from aiohttp import web
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from .base import routes
|
||||
|
||||
enabled = False
|
||||
|
||||
|
||||
@routes.get("/debug/open")
|
||||
async def check_enabled(_: web.Request) -> web.Response:
|
||||
return web.json_response({"enabled": enabled})
|
||||
|
||||
|
||||
try:
|
||||
yaml = YAML()
|
||||
|
||||
with open(".dev-open-cfg.yaml", "r") as file:
|
||||
cfg = yaml.load(file)
|
||||
editor_command = Template(cfg["editor"])
|
||||
pathmap = [(re.compile(item["find"]), item["replace"]) for item in cfg["pathmap"]]
|
||||
|
||||
@routes.post("/debug/open")
|
||||
async def open_file(request: web.Request) -> web.Response:
|
||||
data = await request.json()
|
||||
try:
|
||||
path = data["path"]
|
||||
for find, replace in pathmap:
|
||||
path = find.sub(replace, path)
|
||||
cmd = editor_command.substitute(path=path, line=data["line"])
|
||||
except (KeyError, ValueError):
|
||||
return web.Response(status=400)
|
||||
res = await asyncio.create_subprocess_shell(cmd)
|
||||
stdout, stderr = await res.communicate()
|
||||
return web.json_response({"return": res.returncode, "stdout": stdout, "stderr": stderr})
|
||||
|
||||
enabled = True
|
||||
except Exception:
|
||||
pass
|
||||
97
maubot-src/maubot/management/api/instance.py
Normal file
97
maubot-src/maubot/management/api/instance.py
Normal file
@@ -0,0 +1,97 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 json import JSONDecodeError
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...client import Client
|
||||
from ...instance import PluginInstance
|
||||
from ...loader import PluginLoader
|
||||
from .base import routes
|
||||
from .responses import resp
|
||||
|
||||
|
||||
@routes.get("/instances")
|
||||
async def get_instances(_: web.Request) -> web.Response:
|
||||
return resp.found([instance.to_dict() for instance in PluginInstance.cache.values()])
|
||||
|
||||
|
||||
@routes.get("/instance/{id}")
|
||||
async def get_instance(request: web.Request) -> web.Response:
|
||||
instance_id = request.match_info["id"].lower()
|
||||
instance = await PluginInstance.get(instance_id)
|
||||
if not instance:
|
||||
return resp.instance_not_found
|
||||
return resp.found(instance.to_dict())
|
||||
|
||||
|
||||
async def _create_instance(instance_id: str, data: dict) -> web.Response:
|
||||
plugin_type = data.get("type")
|
||||
primary_user = data.get("primary_user")
|
||||
if not plugin_type:
|
||||
return resp.plugin_type_required
|
||||
elif not primary_user:
|
||||
return resp.primary_user_required
|
||||
elif not await Client.get(primary_user):
|
||||
return resp.primary_user_not_found
|
||||
try:
|
||||
PluginLoader.find(plugin_type)
|
||||
except KeyError:
|
||||
return resp.plugin_type_not_found
|
||||
instance = await PluginInstance.get(instance_id, type=plugin_type, primary_user=primary_user)
|
||||
instance.enabled = data.get("enabled", True)
|
||||
instance.config_str = data.get("config") or ""
|
||||
await instance.update()
|
||||
await instance.load()
|
||||
await instance.start()
|
||||
return resp.created(instance.to_dict())
|
||||
|
||||
|
||||
async def _update_instance(instance: PluginInstance, data: dict) -> web.Response:
|
||||
if not await instance.update_primary_user(data.get("primary_user")):
|
||||
return resp.primary_user_not_found
|
||||
await instance.update_id(data.get("id"))
|
||||
await instance.update_enabled(data.get("enabled"))
|
||||
await instance.update_config(data.get("config"))
|
||||
await instance.update_started(data.get("started"))
|
||||
await instance.update_type(data.get("type"))
|
||||
return resp.updated(instance.to_dict())
|
||||
|
||||
|
||||
@routes.put("/instance/{id}")
|
||||
async def update_instance(request: web.Request) -> web.Response:
|
||||
instance_id = request.match_info["id"].lower()
|
||||
instance = await PluginInstance.get(instance_id)
|
||||
try:
|
||||
data = await request.json()
|
||||
except JSONDecodeError:
|
||||
return resp.body_not_json
|
||||
if not instance:
|
||||
return await _create_instance(instance_id, data)
|
||||
else:
|
||||
return await _update_instance(instance, data)
|
||||
|
||||
|
||||
@routes.delete("/instance/{id}")
|
||||
async def delete_instance(request: web.Request) -> web.Response:
|
||||
instance_id = request.match_info["id"].lower()
|
||||
instance = await PluginInstance.get(instance_id)
|
||||
if not instance:
|
||||
return resp.instance_not_found
|
||||
if instance.started:
|
||||
await instance.stop()
|
||||
await instance.delete()
|
||||
return resp.deleted
|
||||
161
maubot-src/maubot/management/api/instance_database.py
Normal file
161
maubot-src/maubot/management/api/instance_database.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from aiohttp import web
|
||||
from asyncpg import PostgresError
|
||||
import aiosqlite
|
||||
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
from ...instance import PluginInstance
|
||||
from ...lib.optionalalchemy import Engine, IntegrityError, OperationalError, asc, desc
|
||||
from .base import routes
|
||||
from .responses import resp
|
||||
|
||||
|
||||
@routes.get("/instance/{id}/database")
|
||||
async def get_database(request: web.Request) -> web.Response:
|
||||
instance_id = request.match_info["id"].lower()
|
||||
instance = await PluginInstance.get(instance_id)
|
||||
if not instance:
|
||||
return resp.instance_not_found
|
||||
elif not instance.inst_db:
|
||||
return resp.plugin_has_no_database
|
||||
return web.json_response(await instance.get_db_tables())
|
||||
|
||||
|
||||
@routes.get("/instance/{id}/database/{table}")
|
||||
async def get_table(request: web.Request) -> web.Response:
|
||||
instance_id = request.match_info["id"].lower()
|
||||
instance = await PluginInstance.get(instance_id)
|
||||
if not instance:
|
||||
return resp.instance_not_found
|
||||
elif not instance.inst_db:
|
||||
return resp.plugin_has_no_database
|
||||
tables = await instance.get_db_tables()
|
||||
try:
|
||||
table = tables[request.match_info.get("table", "")]
|
||||
except KeyError:
|
||||
return resp.table_not_found
|
||||
try:
|
||||
order = [tuple(order.split(":")) for order in request.query.getall("order")]
|
||||
order = [
|
||||
(
|
||||
(asc if sort.lower() == "asc" else desc)(table.columns[column])
|
||||
if sort
|
||||
else table.columns[column]
|
||||
)
|
||||
for column, sort in order
|
||||
]
|
||||
except KeyError:
|
||||
order = []
|
||||
limit = int(request.query.get("limit", "100"))
|
||||
if isinstance(instance.inst_db, Engine):
|
||||
return _execute_query_sqlalchemy(instance, table.select().order_by(*order).limit(limit))
|
||||
|
||||
|
||||
@routes.post("/instance/{id}/database/query")
|
||||
async def query(request: web.Request) -> web.Response:
|
||||
instance_id = request.match_info["id"].lower()
|
||||
instance = await PluginInstance.get(instance_id)
|
||||
if not instance:
|
||||
return resp.instance_not_found
|
||||
elif not instance.inst_db:
|
||||
return resp.plugin_has_no_database
|
||||
data = await request.json()
|
||||
try:
|
||||
sql_query = data["query"]
|
||||
except KeyError:
|
||||
return resp.query_missing
|
||||
rows_as_dict = data.get("rows_as_dict", False)
|
||||
if isinstance(instance.inst_db, Engine):
|
||||
return _execute_query_sqlalchemy(instance, sql_query, rows_as_dict)
|
||||
elif isinstance(instance.inst_db, Database):
|
||||
try:
|
||||
return await _execute_query_asyncpg(instance, sql_query, rows_as_dict)
|
||||
except (PostgresError, aiosqlite.Error) as e:
|
||||
return resp.sql_error(e, sql_query)
|
||||
else:
|
||||
return resp.unsupported_plugin_database
|
||||
|
||||
|
||||
def check_type(val):
|
||||
if isinstance(val, datetime):
|
||||
return val.isoformat()
|
||||
return val
|
||||
|
||||
|
||||
async def _execute_query_asyncpg(
|
||||
instance: PluginInstance, sql_query: str, rows_as_dict: bool = False
|
||||
) -> web.Response:
|
||||
data = {"ok": True, "query": sql_query}
|
||||
if sql_query.upper().startswith("SELECT"):
|
||||
res = await instance.inst_db.fetch(sql_query)
|
||||
data["rows"] = [
|
||||
(
|
||||
{key: check_type(value) for key, value in row.items()}
|
||||
if rows_as_dict
|
||||
else [check_type(value) for value in row]
|
||||
)
|
||||
for row in res
|
||||
]
|
||||
if len(res) > 0:
|
||||
# TODO can we find column names when there are no rows?
|
||||
data["columns"] = list(res[0].keys())
|
||||
else:
|
||||
res = await instance.inst_db.execute(sql_query)
|
||||
if isinstance(res, str):
|
||||
data["status_msg"] = res
|
||||
elif isinstance(res, aiosqlite.Cursor):
|
||||
data["rowcount"] = res.rowcount
|
||||
# data["inserted_primary_key"] = res.lastrowid
|
||||
else:
|
||||
data["status_msg"] = "unknown status"
|
||||
return web.json_response(data)
|
||||
|
||||
|
||||
def _execute_query_sqlalchemy(
|
||||
instance: PluginInstance, sql_query: str, rows_as_dict: bool = False
|
||||
) -> web.Response:
|
||||
assert isinstance(instance.inst_db, Engine)
|
||||
try:
|
||||
res = instance.inst_db.execute(sql_query)
|
||||
except IntegrityError as e:
|
||||
return resp.sql_integrity_error(e, sql_query)
|
||||
except OperationalError as e:
|
||||
return resp.sql_operational_error(e, sql_query)
|
||||
data = {
|
||||
"ok": True,
|
||||
"query": str(sql_query),
|
||||
}
|
||||
if res.returns_rows:
|
||||
data["rows"] = [
|
||||
(
|
||||
{key: check_type(value) for key, value in row.items()}
|
||||
if rows_as_dict
|
||||
else [check_type(value) for value in row]
|
||||
)
|
||||
for row in res
|
||||
]
|
||||
data["columns"] = res.keys()
|
||||
else:
|
||||
data["rowcount"] = res.rowcount
|
||||
if res.is_insert:
|
||||
data["inserted_primary_key"] = res.inserted_primary_key
|
||||
return web.json_response(data)
|
||||
172
maubot-src/maubot/management/api/log.py
Normal file
172
maubot-src/maubot/management/api/log.py
Normal file
@@ -0,0 +1,172 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web, web_ws
|
||||
|
||||
from mautrix.util import background_task
|
||||
|
||||
from .auth import is_valid_token
|
||||
from .base import routes
|
||||
|
||||
BUILTIN_ATTRS = {
|
||||
"args",
|
||||
"asctime",
|
||||
"created",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"module",
|
||||
"msecs",
|
||||
"message",
|
||||
"msg",
|
||||
"name",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"stack_info",
|
||||
"thread",
|
||||
"threadName",
|
||||
}
|
||||
INCLUDE_ATTRS = {
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"module",
|
||||
"name",
|
||||
"pathname",
|
||||
}
|
||||
EXCLUDE_ATTRS = BUILTIN_ATTRS - INCLUDE_ATTRS
|
||||
MAX_LINES = 2048
|
||||
|
||||
|
||||
class LogCollector(logging.Handler):
|
||||
lines: deque[dict]
|
||||
formatter: logging.Formatter
|
||||
listeners: list[web.WebSocketResponse]
|
||||
loop: asyncio.AbstractEventLoop
|
||||
|
||||
def __init__(self, level=logging.NOTSET) -> None:
|
||||
super().__init__(level)
|
||||
self.lines = deque(maxlen=MAX_LINES)
|
||||
self.formatter = logging.Formatter()
|
||||
self.listeners = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
self._emit(record)
|
||||
except Exception as e:
|
||||
print("Logging error:", e)
|
||||
|
||||
def _emit(self, record: logging.LogRecord) -> None:
|
||||
# JSON conversion based on Marsel Mavletkulov's json-log-formatter (MIT license)
|
||||
# https://github.com/marselester/json-log-formatter
|
||||
content = {
|
||||
name: value for name, value in record.__dict__.items() if name not in EXCLUDE_ATTRS
|
||||
}
|
||||
content["id"] = str(record.relativeCreated)
|
||||
content["msg"] = record.getMessage()
|
||||
content["time"] = datetime.fromtimestamp(record.created)
|
||||
|
||||
if record.exc_info:
|
||||
content["exc_info"] = self.formatter.formatException(record.exc_info)
|
||||
|
||||
for name, value in content.items():
|
||||
if isinstance(value, datetime):
|
||||
content[name] = value.astimezone().isoformat()
|
||||
asyncio.run_coroutine_threadsafe(self.send(content), loop=self.loop)
|
||||
self.lines.append(content)
|
||||
|
||||
async def send(self, record: dict) -> None:
|
||||
for ws in self.listeners:
|
||||
try:
|
||||
await ws.send_json(record)
|
||||
except Exception as e:
|
||||
print("Log sending error:", e)
|
||||
|
||||
|
||||
handler = LogCollector()
|
||||
log = logging.getLogger("maubot.server.websocket")
|
||||
sockets = []
|
||||
|
||||
|
||||
def init(loop: asyncio.AbstractEventLoop) -> None:
|
||||
logging.root.addHandler(handler)
|
||||
handler.loop = loop
|
||||
|
||||
|
||||
async def stop_all() -> None:
|
||||
log.debug("Closing log listener websockets")
|
||||
logging.root.removeHandler(handler)
|
||||
for socket in sockets:
|
||||
try:
|
||||
await socket.close(code=1012)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@routes.get("/logs")
|
||||
async def log_websocket(request: web.Request) -> web.WebSocketResponse:
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
sockets.append(ws)
|
||||
log.debug(f"Connection from {request.remote} opened")
|
||||
authenticated = False
|
||||
|
||||
async def close_if_not_authenticated():
|
||||
await asyncio.sleep(5)
|
||||
if not authenticated:
|
||||
await ws.close(code=4000)
|
||||
log.debug(f"Connection from {request.remote} terminated due to no authentication")
|
||||
|
||||
background_task.create(close_if_not_authenticated())
|
||||
|
||||
try:
|
||||
msg: web_ws.WSMessage
|
||||
async for msg in ws:
|
||||
if msg.type != web.WSMsgType.TEXT:
|
||||
continue
|
||||
if is_valid_token(msg.data):
|
||||
await ws.send_json({"auth_success": True})
|
||||
await ws.send_json({"history": list(handler.lines)})
|
||||
if not authenticated:
|
||||
log.debug(f"Connection from {request.remote} authenticated")
|
||||
handler.listeners.append(ws)
|
||||
authenticated = True
|
||||
elif not authenticated:
|
||||
await ws.send_json({"auth_success": False})
|
||||
except Exception:
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
if authenticated:
|
||||
handler.listeners.remove(ws)
|
||||
log.debug(f"Connection from {request.remote} closed")
|
||||
sockets.remove(ws)
|
||||
return ws
|
||||
41
maubot-src/maubot/management/api/login.py
Normal file
41
maubot-src/maubot/management/api/login.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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/>.
|
||||
import json
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .auth import create_token
|
||||
from .base import get_config, routes
|
||||
from .responses import resp
|
||||
|
||||
|
||||
@routes.post("/auth/login")
|
||||
async def login(request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return resp.body_not_json
|
||||
secret = data.get("secret")
|
||||
if secret and get_config()["server.unshared_secret"] == secret:
|
||||
user = data.get("user") or "root"
|
||||
return resp.logged_in(create_token(user))
|
||||
|
||||
username = data.get("username")
|
||||
password = data.get("password")
|
||||
if get_config().check_password(username, password):
|
||||
return resp.logged_in(create_token(username))
|
||||
|
||||
return resp.bad_auth
|
||||
78
maubot-src/maubot/management/api/middleware.py
Normal file
78
maubot-src/maubot/management/api/middleware.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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, Callable
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .auth import check_token
|
||||
from .base import get_config
|
||||
from .responses import resp
|
||||
|
||||
Handler = Callable[[web.Request], Awaitable[web.Response]]
|
||||
log = logging.getLogger("maubot.server")
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def auth(request: web.Request, handler: Handler) -> web.Response:
|
||||
subpath = request.path[len("/_matrix/maubot/v1") :]
|
||||
if (
|
||||
subpath.startswith("/auth/")
|
||||
or subpath.startswith("/client/auth_external_sso/complete/")
|
||||
or subpath == "/features"
|
||||
or subpath == "/logs"
|
||||
):
|
||||
return await handler(request)
|
||||
err = check_token(request)
|
||||
if err is not None:
|
||||
return err
|
||||
return await handler(request)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def error(request: web.Request, handler: Handler) -> web.Response:
|
||||
try:
|
||||
return await handler(request)
|
||||
except web.HTTPException as ex:
|
||||
if ex.status_code == 404:
|
||||
return resp.path_not_found
|
||||
elif ex.status_code == 405:
|
||||
return resp.method_not_allowed
|
||||
return web.json_response(
|
||||
{
|
||||
"httpexception": {
|
||||
"headers": {key: value for key, value in ex.headers.items()},
|
||||
"class": type(ex).__name__,
|
||||
"body": ex.text or base64.b64encode(ex.body),
|
||||
},
|
||||
"error": f"Unhandled HTTP {ex.status}: {ex.text[:128] or 'non-text response'}",
|
||||
"errcode": f"unhandled_http_{ex.status}",
|
||||
},
|
||||
status=ex.status,
|
||||
)
|
||||
except Exception:
|
||||
log.exception("Error in handler")
|
||||
return resp.internal_server_error
|
||||
|
||||
|
||||
req_no = 0
|
||||
|
||||
|
||||
def get_req_no():
|
||||
global req_no
|
||||
req_no += 1
|
||||
return req_no
|
||||
64
maubot-src/maubot/management/api/plugin.py
Normal file
64
maubot-src/maubot/management/api/plugin.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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/>.
|
||||
import traceback
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...loader import MaubotZipImportError, PluginLoader
|
||||
from .base import routes
|
||||
from .responses import resp
|
||||
|
||||
|
||||
@routes.get("/plugins")
|
||||
async def get_plugins(_) -> web.Response:
|
||||
return resp.found([plugin.to_dict() for plugin in PluginLoader.id_cache.values()])
|
||||
|
||||
|
||||
@routes.get("/plugin/{id}")
|
||||
async def get_plugin(request: web.Request) -> web.Response:
|
||||
plugin_id = request.match_info["id"]
|
||||
plugin = PluginLoader.id_cache.get(plugin_id)
|
||||
if not plugin:
|
||||
return resp.plugin_not_found
|
||||
return resp.found(plugin.to_dict())
|
||||
|
||||
|
||||
@routes.delete("/plugin/{id}")
|
||||
async def delete_plugin(request: web.Request) -> web.Response:
|
||||
plugin_id = request.match_info["id"]
|
||||
plugin = PluginLoader.id_cache.get(plugin_id)
|
||||
if not plugin:
|
||||
return resp.plugin_not_found
|
||||
elif len(plugin.references) > 0:
|
||||
return resp.plugin_in_use
|
||||
await plugin.delete()
|
||||
return resp.deleted
|
||||
|
||||
|
||||
@routes.post("/plugin/{id}/reload")
|
||||
async def reload_plugin(request: web.Request) -> web.Response:
|
||||
plugin_id = request.match_info["id"]
|
||||
plugin = PluginLoader.id_cache.get(plugin_id)
|
||||
if not plugin:
|
||||
return resp.plugin_not_found
|
||||
|
||||
await plugin.stop_instances()
|
||||
try:
|
||||
await plugin.reload()
|
||||
except MaubotZipImportError as e:
|
||||
return resp.plugin_reload_error(str(e), traceback.format_exc())
|
||||
await plugin.start_instances()
|
||||
return resp.ok
|
||||
145
maubot-src/maubot/management/api/plugin_upload.py
Normal file
145
maubot-src/maubot/management/api/plugin_upload.py
Normal file
@@ -0,0 +1,145 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 io import BytesIO
|
||||
from time import time
|
||||
import logging
|
||||
import os.path
|
||||
import re
|
||||
import traceback
|
||||
|
||||
from aiohttp import web
|
||||
from packaging.version import Version
|
||||
|
||||
from ...loader import DatabaseType, MaubotZipImportError, PluginLoader, ZippedPluginLoader
|
||||
from .base import get_config, routes
|
||||
from .responses import resp
|
||||
|
||||
try:
|
||||
import sqlalchemy
|
||||
|
||||
has_alchemy = True
|
||||
except ImportError:
|
||||
has_alchemy = False
|
||||
|
||||
log = logging.getLogger("maubot.server.upload")
|
||||
|
||||
|
||||
@routes.put("/plugin/{id}")
|
||||
async def put_plugin(request: web.Request) -> web.Response:
|
||||
plugin_id = request.match_info["id"]
|
||||
content = await request.read()
|
||||
file = BytesIO(content)
|
||||
try:
|
||||
pid, version, db_type = ZippedPluginLoader.verify_meta(file)
|
||||
except MaubotZipImportError as e:
|
||||
return resp.plugin_import_error(str(e), traceback.format_exc())
|
||||
if db_type == DatabaseType.SQLALCHEMY and not has_alchemy:
|
||||
return resp.sqlalchemy_not_installed
|
||||
if pid != plugin_id:
|
||||
return resp.pid_mismatch
|
||||
plugin = PluginLoader.id_cache.get(plugin_id, None)
|
||||
if not plugin:
|
||||
return await upload_new_plugin(content, pid, version)
|
||||
elif isinstance(plugin, ZippedPluginLoader):
|
||||
return await upload_replacement_plugin(plugin, content, version)
|
||||
else:
|
||||
return resp.unsupported_plugin_loader
|
||||
|
||||
|
||||
@routes.post("/plugins/upload")
|
||||
async def upload_plugin(request: web.Request) -> web.Response:
|
||||
content = await request.read()
|
||||
file = BytesIO(content)
|
||||
try:
|
||||
pid, version, db_type = ZippedPluginLoader.verify_meta(file)
|
||||
except MaubotZipImportError as e:
|
||||
return resp.plugin_import_error(str(e), traceback.format_exc())
|
||||
if db_type == DatabaseType.SQLALCHEMY and not has_alchemy:
|
||||
return resp.sqlalchemy_not_installed
|
||||
plugin = PluginLoader.id_cache.get(pid, None)
|
||||
if not plugin:
|
||||
return await upload_new_plugin(content, pid, version)
|
||||
elif not request.query.get("allow_override"):
|
||||
return resp.plugin_exists
|
||||
elif isinstance(plugin, ZippedPluginLoader):
|
||||
return await upload_replacement_plugin(plugin, content, version)
|
||||
else:
|
||||
return resp.unsupported_plugin_loader
|
||||
|
||||
|
||||
async def upload_new_plugin(content: bytes, pid: str, version: Version) -> web.Response:
|
||||
path = os.path.join(get_config()["plugin_directories.upload"], f"{pid}-v{version}.mbp")
|
||||
with open(path, "wb") as p:
|
||||
p.write(content)
|
||||
try:
|
||||
plugin = ZippedPluginLoader.get(path)
|
||||
except MaubotZipImportError as e:
|
||||
ZippedPluginLoader.trash(path)
|
||||
return resp.plugin_import_error(str(e), traceback.format_exc())
|
||||
return resp.created(plugin.to_dict())
|
||||
|
||||
|
||||
async def upload_replacement_plugin(
|
||||
plugin: ZippedPluginLoader, content: bytes, new_version: Version
|
||||
) -> web.Response:
|
||||
dirname = os.path.dirname(plugin.path)
|
||||
old_filename = os.path.basename(plugin.path)
|
||||
if str(plugin.meta.version) in old_filename:
|
||||
replacement = (
|
||||
str(new_version)
|
||||
if plugin.meta.version != new_version
|
||||
else f"{new_version}-ts{int(time() * 1000)}"
|
||||
)
|
||||
filename = re.sub(
|
||||
f"{re.escape(str(plugin.meta.version))}(-ts[0-9]+)?", replacement, old_filename
|
||||
)
|
||||
else:
|
||||
filename = old_filename.rstrip(".mbp")
|
||||
filename = f"{filename}-v{new_version}.mbp"
|
||||
path = os.path.join(dirname, filename)
|
||||
with open(path, "wb") as p:
|
||||
p.write(content)
|
||||
old_path = plugin.path
|
||||
await plugin.stop_instances()
|
||||
try:
|
||||
await plugin.reload(new_path=path)
|
||||
except MaubotZipImportError as e:
|
||||
log.exception(f"Error loading updated version of {plugin.meta.id}, rolling back")
|
||||
try:
|
||||
await plugin.reload(new_path=old_path)
|
||||
await plugin.start_instances()
|
||||
except MaubotZipImportError:
|
||||
log.warning(f"Failed to roll back update of {plugin.meta.id}", exc_info=True)
|
||||
finally:
|
||||
ZippedPluginLoader.trash(path, reason="failed_update")
|
||||
return resp.plugin_import_error(str(e), traceback.format_exc())
|
||||
try:
|
||||
await plugin.start_instances()
|
||||
except Exception as e:
|
||||
log.exception(f"Error starting {plugin.meta.id} instances after update, rolling back")
|
||||
try:
|
||||
await plugin.stop_instances()
|
||||
await plugin.reload(new_path=old_path)
|
||||
await plugin.start_instances()
|
||||
except Exception:
|
||||
log.warning(f"Failed to roll back update of {plugin.meta.id}", exc_info=True)
|
||||
finally:
|
||||
ZippedPluginLoader.trash(path, reason="failed_update")
|
||||
return resp.plugin_reload_error(str(e), traceback.format_exc())
|
||||
|
||||
log.debug(f"Successfully updated {plugin.meta.id}, moving old version to trash")
|
||||
ZippedPluginLoader.trash(old_path, reason="update")
|
||||
return resp.updated(plugin.to_dict())
|
||||
510
maubot-src/maubot/management/api/responses.py
Normal file
510
maubot-src/maubot/management/api/responses.py
Normal file
@@ -0,0 +1,510 @@
|
||||
# maubot - A plugin-based Matrix bot system.
|
||||
# Copyright (C) 2022 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 __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from http import HTTPStatus
|
||||
|
||||
from aiohttp import web
|
||||
from asyncpg import PostgresError
|
||||
import aiosqlite
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
|
||||
|
||||
class _Response:
|
||||
@property
|
||||
def body_not_json(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Request body is not JSON",
|
||||
"errcode": "body_not_json",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@property
|
||||
def plugin_type_required(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Plugin type is required when creating plugin instances",
|
||||
"errcode": "plugin_type_required",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@property
|
||||
def primary_user_required(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Primary user is required when creating plugin instances",
|
||||
"errcode": "primary_user_required",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@property
|
||||
def bad_client_access_token(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Invalid access token",
|
||||
"errcode": "bad_client_access_token",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@property
|
||||
def bad_client_access_details(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Invalid homeserver or access token",
|
||||
"errcode": "bad_client_access_details",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@property
|
||||
def bad_client_connection_details(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Could not connect to homeserver",
|
||||
"errcode": "bad_client_connection_details",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
def mxid_mismatch(self, found: str) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": (
|
||||
"The Matrix user ID of the client and the user ID of the access token don't "
|
||||
f"match. Access token is for user {found}"
|
||||
),
|
||||
"errcode": "mxid_mismatch",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
def device_id_mismatch(self, found: str) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": (
|
||||
"The Matrix device ID of the client and the device ID of the access token "
|
||||
f"don't match. Access token is for device {found}"
|
||||
),
|
||||
"errcode": "mxid_mismatch",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@property
|
||||
def pid_mismatch(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "The ID in the path does not match the ID of the uploaded plugin",
|
||||
"errcode": "pid_mismatch",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@property
|
||||
def username_or_password_missing(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Username or password missing",
|
||||
"errcode": "username_or_password_missing",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@property
|
||||
def query_missing(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Query missing",
|
||||
"errcode": "query_missing",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def sql_error(error: PostgresError | aiosqlite.Error, query: str) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"query": query,
|
||||
"error": str(error),
|
||||
"errcode": "sql_error",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def sql_operational_error(error: OperationalError, query: str) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"query": query,
|
||||
"error": str(error.orig),
|
||||
"full_error": str(error),
|
||||
"errcode": "sql_operational_error",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def sql_integrity_error(error: IntegrityError, query: str) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"query": query,
|
||||
"error": str(error.orig),
|
||||
"full_error": str(error),
|
||||
"errcode": "sql_integrity_error",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@property
|
||||
def bad_auth(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Invalid username or password",
|
||||
"errcode": "invalid_auth",
|
||||
},
|
||||
status=HTTPStatus.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
@property
|
||||
def no_token(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Authorization token missing",
|
||||
"errcode": "auth_token_missing",
|
||||
},
|
||||
status=HTTPStatus.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
@property
|
||||
def invalid_token(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Invalid authorization token",
|
||||
"errcode": "auth_token_invalid",
|
||||
},
|
||||
status=HTTPStatus.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
@property
|
||||
def plugin_not_found(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Plugin not found",
|
||||
"errcode": "plugin_not_found",
|
||||
},
|
||||
status=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
@property
|
||||
def client_not_found(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Client not found",
|
||||
"errcode": "client_not_found",
|
||||
},
|
||||
status=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
@property
|
||||
def primary_user_not_found(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Client for given primary user not found",
|
||||
"errcode": "primary_user_not_found",
|
||||
},
|
||||
status=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
@property
|
||||
def instance_not_found(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Plugin instance not found",
|
||||
"errcode": "instance_not_found",
|
||||
},
|
||||
status=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
@property
|
||||
def plugin_type_not_found(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Given plugin type not found",
|
||||
"errcode": "plugin_type_not_found",
|
||||
},
|
||||
status=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
@property
|
||||
def path_not_found(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Resource not found",
|
||||
"errcode": "resource_not_found",
|
||||
},
|
||||
status=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
@property
|
||||
def server_not_found(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Registration target server not found",
|
||||
"errcode": "server_not_found",
|
||||
},
|
||||
status=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
@property
|
||||
def registration_secret_not_found(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Config does not have a registration secret for that server",
|
||||
"errcode": "registration_secret_not_found",
|
||||
},
|
||||
status=HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
@property
|
||||
def registration_no_sso(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "The register operation is only for registering with a password",
|
||||
"errcode": "registration_no_sso",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@property
|
||||
def sso_not_supported(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "That server does not seem to support single sign-on",
|
||||
"errcode": "sso_not_supported",
|
||||
},
|
||||
status=HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
|
||||
@property
|
||||
def plugin_has_no_database(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Given plugin does not have a database",
|
||||
"errcode": "plugin_has_no_database",
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def unsupported_plugin_database(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "The database type is not supported by this API",
|
||||
"errcode": "unsupported_plugin_database",
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def sqlalchemy_not_installed(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "This plugin requires a legacy database, but SQLAlchemy is not installed",
|
||||
"errcode": "unsupported_plugin_database",
|
||||
},
|
||||
status=HTTPStatus.NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
@property
|
||||
def table_not_found(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Given table not found in plugin database",
|
||||
"errcode": "table_not_found",
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def method_not_allowed(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Method not allowed",
|
||||
"errcode": "method_not_allowed",
|
||||
},
|
||||
status=HTTPStatus.METHOD_NOT_ALLOWED,
|
||||
)
|
||||
|
||||
@property
|
||||
def user_exists(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "There is already a client with the user ID of that token",
|
||||
"errcode": "user_exists",
|
||||
},
|
||||
status=HTTPStatus.CONFLICT,
|
||||
)
|
||||
|
||||
@property
|
||||
def plugin_exists(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "A plugin with the same ID as the uploaded plugin already exists",
|
||||
"errcode": "plugin_exists",
|
||||
},
|
||||
status=HTTPStatus.CONFLICT,
|
||||
)
|
||||
|
||||
@property
|
||||
def plugin_in_use(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Plugin instances of this type still exist",
|
||||
"errcode": "plugin_in_use",
|
||||
},
|
||||
status=HTTPStatus.PRECONDITION_FAILED,
|
||||
)
|
||||
|
||||
@property
|
||||
def client_in_use(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Plugin instances with this client as their primary user still exist",
|
||||
"errcode": "client_in_use",
|
||||
},
|
||||
status=HTTPStatus.PRECONDITION_FAILED,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def plugin_import_error(error: str, stacktrace: str) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": error,
|
||||
"stacktrace": stacktrace,
|
||||
"errcode": "plugin_invalid",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def plugin_reload_error(error: str, stacktrace: str) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": error,
|
||||
"stacktrace": stacktrace,
|
||||
"errcode": "plugin_reload_fail",
|
||||
},
|
||||
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@property
|
||||
def internal_server_error(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Internal server error",
|
||||
"errcode": "internal_server_error",
|
||||
},
|
||||
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@property
|
||||
def invalid_server(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Invalid registration server object in maubot configuration",
|
||||
"errcode": "invalid_server",
|
||||
},
|
||||
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@property
|
||||
def unsupported_plugin_loader(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Existing plugin with same ID uses unsupported plugin loader",
|
||||
"errcode": "unsupported_plugin_loader",
|
||||
},
|
||||
status=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
@property
|
||||
def client_has_keys(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Client already has cross-signing keys",
|
||||
"errcode": "client_has_keys",
|
||||
},
|
||||
status=HTTPStatus.CONFLICT,
|
||||
)
|
||||
|
||||
def internal_crypto_error(self, message: str) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": f"Internal crypto error: {message}",
|
||||
"errcode": "internal_crypto_error",
|
||||
},
|
||||
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@property
|
||||
def not_implemented(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "Not implemented",
|
||||
"errcode": "not_implemented",
|
||||
},
|
||||
status=HTTPStatus.NOT_IMPLEMENTED,
|
||||
)
|
||||
|
||||
@property
|
||||
def ok(self) -> web.Response:
|
||||
return web.json_response(
|
||||
{"success": True},
|
||||
status=HTTPStatus.OK,
|
||||
)
|
||||
|
||||
@property
|
||||
def deleted(self) -> web.Response:
|
||||
return web.Response(status=HTTPStatus.NO_CONTENT)
|
||||
|
||||
@staticmethod
|
||||
def found(data: dict) -> web.Response:
|
||||
return web.json_response(data, status=HTTPStatus.OK)
|
||||
|
||||
@staticmethod
|
||||
def updated(data: dict, is_login: bool = False) -> web.Response:
|
||||
return web.json_response(data, status=HTTPStatus.ACCEPTED if is_login else HTTPStatus.OK)
|
||||
|
||||
def logged_in(self, token: str) -> web.Response:
|
||||
return self.found({"token": token})
|
||||
|
||||
def pong(self, user: str, features: dict) -> web.Response:
|
||||
return self.found({"username": user, "features": features})
|
||||
|
||||
@staticmethod
|
||||
def created(data: dict) -> web.Response:
|
||||
return web.json_response(data, status=HTTPStatus.CREATED)
|
||||
|
||||
|
||||
resp = _Response()
|
||||
2
maubot-src/maubot/management/api/spec.md
Normal file
2
maubot-src/maubot/management/api/spec.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Maubot Management API
|
||||
This document has been moved to docs.mau.fi: <https://docs.mau.fi/maubot/management-api.html>
|
||||
728
maubot-src/maubot/management/api/spec.yaml
Normal file
728
maubot-src/maubot/management/api/spec.yaml
Normal file
@@ -0,0 +1,728 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Maubot Management
|
||||
version: 0.1.0
|
||||
description: The API to manage a [maubot](https://github.com/maubot/maubot) instance
|
||||
license:
|
||||
name: GNU Affero General Public License version 3
|
||||
url: 'https://github.com/maubot/maubot/blob/master/LICENSE'
|
||||
security:
|
||||
- bearer: []
|
||||
servers:
|
||||
- url: /_matrix/maubot/v1
|
||||
|
||||
paths:
|
||||
/auth/login:
|
||||
post:
|
||||
operationId: login
|
||||
summary: Log in with the unshared secret or username+password
|
||||
tags: [Authentication]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: Set either username+password or secret.
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
description: The unshared server secret for root login
|
||||
username:
|
||||
type: string
|
||||
description: The username for normal login
|
||||
password:
|
||||
type: string
|
||||
description: The password for normal login
|
||||
responses:
|
||||
200:
|
||||
description: Logged in successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
401:
|
||||
description: Invalid credentials
|
||||
/auth/ping:
|
||||
post:
|
||||
operationId: ping
|
||||
summary: Check if the given token is valid
|
||||
tags: [Authentication]
|
||||
responses:
|
||||
200:
|
||||
description: Token is OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
401:
|
||||
description: Token is not OK
|
||||
|
||||
/plugins:
|
||||
get:
|
||||
operationId: get_plugins
|
||||
summary: Get the list of installed plugins
|
||||
tags: [Plugins]
|
||||
responses:
|
||||
200:
|
||||
description: The list of plugins
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Plugin'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
/plugins/upload:
|
||||
post:
|
||||
operationId: upload_plugin
|
||||
summary: Upload a new plugin
|
||||
description: Upload a new plugin. If the plugin already exists, enabled instances will be restarted.
|
||||
tags: [Plugins]
|
||||
parameters:
|
||||
- name: allow_override
|
||||
in: query
|
||||
description: Set to allow overriding existing plugins
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
requestBody:
|
||||
content:
|
||||
application/zip:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
example: The plugin maubot archive (.mbp)
|
||||
responses:
|
||||
200:
|
||||
description: Plugin uploaded and replaced current version successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Plugin'
|
||||
201:
|
||||
description: New plugin uploaded successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Plugin'
|
||||
400:
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
409:
|
||||
description: Plugin already exists and allow_override was not specified.
|
||||
'/plugin/{id}':
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
description: The ID of the plugin to get
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
operationId: get_plugin
|
||||
summary: Get information about a specific plugin
|
||||
tags: [Plugins]
|
||||
responses:
|
||||
200:
|
||||
description: Plugin found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Plugin'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
404:
|
||||
$ref: '#/components/responses/PluginNotFound'
|
||||
delete:
|
||||
operationId: delete_plugin
|
||||
summary: Delete a plugin
|
||||
description: Delete a plugin. All instances of the plugin must be deleted before deleting the plugin.
|
||||
tags: [Plugins]
|
||||
responses:
|
||||
204:
|
||||
description: Plugin deleted
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
404:
|
||||
$ref: '#/components/responses/PluginNotFound'
|
||||
412:
|
||||
description: One or more plugin instances of this type exist
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
put:
|
||||
operationId: put_plugin
|
||||
summary: Upload a new or replacement plugin
|
||||
description: |
|
||||
Upload a new or replacement plugin with the specified ID.
|
||||
A HTTP 400 will be returned if the ID of the uploaded plugin
|
||||
doesn't match the ID in the path. If the plugin already
|
||||
exists, enabled instances will be restarted.
|
||||
tags: [Plugins]
|
||||
requestBody:
|
||||
content:
|
||||
application/zip:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
example: The plugin maubot archive (.mbp)
|
||||
responses:
|
||||
200:
|
||||
description: Plugin uploaded and replaced current version successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Plugin'
|
||||
201:
|
||||
description: New plugin uploaded successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Plugin'
|
||||
400:
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
/plugin/{id}/reload:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
description: The ID of the plugin to get
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
post:
|
||||
operationId: reload_plugin
|
||||
summary: Reload a plugin from disk
|
||||
tags: [Plugins]
|
||||
responses:
|
||||
200:
|
||||
description: Plugin reloaded
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
404:
|
||||
$ref: '#/components/responses/PluginNotFound'
|
||||
|
||||
/instances:
|
||||
get:
|
||||
operationId: get_instances
|
||||
summary: Get all plugin instances
|
||||
tags: [Plugin instances]
|
||||
responses:
|
||||
200:
|
||||
description: The list of instances
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/PluginInstance'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'/instance/{id}':
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
description: The ID of the instance to get
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
operationId: get_instance
|
||||
summary: Get information about a specific plugin instance
|
||||
tags: [Plugin instances]
|
||||
responses:
|
||||
200:
|
||||
description: Plugin instance found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PluginInstance'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
404:
|
||||
$ref: '#/components/responses/InstanceNotFound'
|
||||
delete:
|
||||
operationId: delete_instance
|
||||
summary: Delete a specific plugin instance
|
||||
tags: [Plugin instances]
|
||||
responses:
|
||||
204:
|
||||
description: Plugin instance deleted
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
404:
|
||||
$ref: '#/components/responses/InstanceNotFound'
|
||||
put:
|
||||
operationId: update_instance
|
||||
summary: Create a plugin instance or edit the details of an existing plugin instance
|
||||
tags: [Plugin instances]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PluginInstance'
|
||||
responses:
|
||||
200:
|
||||
description: Plugin instance edited
|
||||
201:
|
||||
description: Plugin instance created
|
||||
400:
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
404:
|
||||
description: The referenced client or plugin type could not be found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
|
||||
'/clients':
|
||||
get:
|
||||
operationId: get_clients
|
||||
summary: Get the list of Matrix clients
|
||||
tags: [Clients]
|
||||
responses:
|
||||
200:
|
||||
description: The list of plugins
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MatrixClient'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
/client/new:
|
||||
post:
|
||||
operationId: create_client
|
||||
summary: Create a Matrix client
|
||||
tags: [Clients]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MatrixClient'
|
||||
responses:
|
||||
201:
|
||||
description: Client created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MatrixClient'
|
||||
400:
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
404:
|
||||
$ref: '#/components/responses/ClientNotFound'
|
||||
409:
|
||||
description: There is already a client with the user ID of that token.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
'/client/{id}':
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
description: The Matrix user ID of the client to get
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
operationId: get_client
|
||||
summary: Get information about a specific Matrix client
|
||||
tags: [Clients]
|
||||
responses:
|
||||
200:
|
||||
description: Client found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MatrixClient'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
404:
|
||||
$ref: '#/components/responses/ClientNotFound'
|
||||
put:
|
||||
operationId: update_client
|
||||
summary: Create or update a Matrix client
|
||||
tags: [Clients]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MatrixClient'
|
||||
responses:
|
||||
202:
|
||||
description: Client updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MatrixClient'
|
||||
201:
|
||||
description: Client created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MatrixClient'
|
||||
400:
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
delete:
|
||||
operationId: delete_client
|
||||
summary: Delete a Matrix client
|
||||
tags: [Clients]
|
||||
responses:
|
||||
204:
|
||||
description: Client deleted
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
404:
|
||||
$ref: '#/components/responses/ClientNotFound'
|
||||
412:
|
||||
description: One or more plugin instances with this as their primary client exist
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
'/client/{id}/clearcache':
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
description: The Matrix user ID of the client to change
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
put:
|
||||
operationId: clear_client_cache
|
||||
summary: Clear the sync/state cache of a Matrix client
|
||||
tags: [Clients]
|
||||
responses:
|
||||
200:
|
||||
description: Cache cleared
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
404:
|
||||
$ref: '#/components/responses/ClientNotFound'
|
||||
/client/auth/servers:
|
||||
get:
|
||||
operationId: get_client_auth_servers
|
||||
summary: Get the list of servers you can register or log in on via the maubot server
|
||||
tags: [Clients]
|
||||
responses:
|
||||
200:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: Key-value map from server name to homeserver URL
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: The homeserver URL
|
||||
example:
|
||||
maunium.net: https://maunium.net
|
||||
example.com: https://matrix.example.org
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'/client/auth/{server}/register':
|
||||
parameters:
|
||||
- name: server
|
||||
in: path
|
||||
description: The server name to register the account on.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: update_client
|
||||
in: query
|
||||
description: Should maubot store the access details in a Client instead of returning them?
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
post:
|
||||
operationId: client_auth_register
|
||||
summary: |
|
||||
Register a new account on the given Matrix server using the shared registration
|
||||
secret configured into the maubot server.
|
||||
tags: [Clients]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MatrixAuthentication'
|
||||
responses:
|
||||
200:
|
||||
description: Registration successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
example: syt_123_456_789
|
||||
user_id:
|
||||
type: string
|
||||
example: '@putkiteippi:maunium.net'
|
||||
device_id:
|
||||
type: string
|
||||
example: maubot_F00BAR12
|
||||
201:
|
||||
description: Client created (when update_client is true)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MatrixClient'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
409:
|
||||
description: |
|
||||
There is already a client with the user ID of that token.
|
||||
This should usually not happen, because the user ID was just created.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
500:
|
||||
$ref: '#/components/responses/MatrixServerError'
|
||||
'/client/auth/{server}/login':
|
||||
parameters:
|
||||
- name: server
|
||||
in: path
|
||||
description: The server name to log in to.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: update_client
|
||||
in: query
|
||||
description: Should maubot store the access details in a Client instead of returning them?
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
post:
|
||||
operationId: client_auth_login
|
||||
summary: Log in to the given Matrix server via the maubot server
|
||||
tags: [Clients]
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MatrixAuthentication'
|
||||
responses:
|
||||
200:
|
||||
description: Login successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user_id:
|
||||
type: string
|
||||
example: '@putkiteippi:maunium.net'
|
||||
access_token:
|
||||
type: string
|
||||
example: syt_123_456_789
|
||||
device_id:
|
||||
type: string
|
||||
example: maubot_F00BAR12
|
||||
201:
|
||||
description: Client created (when update_client is true)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MatrixClient'
|
||||
202:
|
||||
description: Client updated (when update_client is true)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MatrixClient'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
500:
|
||||
$ref: '#/components/responses/MatrixServerError'
|
||||
|
||||
components:
|
||||
responses:
|
||||
Unauthorized:
|
||||
description: Invalid or missing access token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
PluginNotFound:
|
||||
description: Plugin not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
ClientNotFound:
|
||||
description: Client not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
InstanceNotFound:
|
||||
description: Plugin instance not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
BadRequest:
|
||||
description: Bad request (e.g. bad request body)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
MatrixServerError:
|
||||
description: The Matrix server returned an error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
errcode:
|
||||
type: string
|
||||
description: The `errcode` returned by the server.
|
||||
error:
|
||||
type: string
|
||||
description: The human-readable error returned by the server.
|
||||
http_status:
|
||||
type: integer
|
||||
description: The HTTP status returned by the server.
|
||||
|
||||
securitySchemes:
|
||||
bearer:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: Required authentication for all endpoints
|
||||
schemas:
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: A human-readable error message
|
||||
errcode:
|
||||
type: string
|
||||
description: A simple error code
|
||||
Plugin:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
example: xyz.maubot.jesaribot
|
||||
version:
|
||||
type: string
|
||||
example: 2.0.0
|
||||
instances:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/PluginInstance'
|
||||
PluginInstance:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
example: jesaribot
|
||||
type:
|
||||
type: string
|
||||
example: xyz.maubot.jesaribot
|
||||
enabled:
|
||||
type: boolean
|
||||
example: true
|
||||
started:
|
||||
type: boolean
|
||||
example: true
|
||||
primary_user:
|
||||
type: string
|
||||
example: '@putkiteippi:maunium.net'
|
||||
config:
|
||||
type: string
|
||||
example: "YAML"
|
||||
MatrixClient:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
example: '@putkiteippi:maunium.net'
|
||||
readOnly: true
|
||||
description: The Matrix user ID of this client.
|
||||
homeserver:
|
||||
type: string
|
||||
example: 'https://maunium.net'
|
||||
description: The homeserver URL for this client.
|
||||
access_token:
|
||||
type: string
|
||||
description: The Matrix access token for this client.
|
||||
device_id:
|
||||
type: string
|
||||
description: The Matrix device ID corresponding to the access token.
|
||||
fingerprint:
|
||||
type: string
|
||||
description: The encryption device fingerprint for verification.
|
||||
enabled:
|
||||
type: boolean
|
||||
example: true
|
||||
description: Whether or not this client is enabled.
|
||||
started:
|
||||
type: boolean
|
||||
example: true
|
||||
description: Whether or not this client and its instances have been started.
|
||||
sync:
|
||||
type: boolean
|
||||
example: true
|
||||
description: Whether or not syncing is enabled on this client.
|
||||
sync_ok:
|
||||
type: boolean
|
||||
example: true
|
||||
description: Whether or not the previous sync was successful on this client.
|
||||
autojoin:
|
||||
type: boolean
|
||||
example: true
|
||||
description: Whether or not this client should automatically join rooms when invited.
|
||||
displayname:
|
||||
type: string
|
||||
example: J. E. Saarinen
|
||||
description: The display name for this client.
|
||||
avatar_url:
|
||||
type: string
|
||||
example: 'mxc://maunium.net/FsPQQTntCCqhJMFtwArmJdaU'
|
||||
description: The content URI of the avatar for this client.
|
||||
instances:
|
||||
type: array
|
||||
readOnly: true
|
||||
items:
|
||||
$ref: '#/components/schemas/PluginInstance'
|
||||
MatrixAuthentication:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
example: putkiteippi
|
||||
description: The user ID localpart to register/log in as.
|
||||
password:
|
||||
type: string
|
||||
example: p455w0rd
|
||||
description: The password for/of the user.
|
||||
67
maubot-src/maubot/management/frontend/.eslintrc.json
Normal file
67
maubot-src/maubot/management/frontend/.eslintrc.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"extends": "react-app",
|
||||
"plugins": [
|
||||
"import"
|
||||
],
|
||||
"rules": {
|
||||
"indent": ["error", 4, {
|
||||
"ignoredNodes": [
|
||||
"JSXAttribute",
|
||||
"JSXSpreadAttribute"
|
||||
],
|
||||
"FunctionDeclaration": {"parameters": "first"},
|
||||
"FunctionExpression": {"parameters": "first"},
|
||||
"CallExpression": {"arguments": "first"},
|
||||
"ArrayExpression": "first",
|
||||
"ObjectExpression": "first",
|
||||
"ImportDeclaration": "first"
|
||||
}],
|
||||
"react/jsx-indent-props": ["error", "first"],
|
||||
"object-curly-newline": ["error", {
|
||||
"consistent": true
|
||||
}],
|
||||
"object-curly-spacing": ["error", "always", {
|
||||
"arraysInObjects": false,
|
||||
"objectsInObjects": false
|
||||
}],
|
||||
"array-bracket-spacing": ["error", "never"],
|
||||
"one-var": ["error", {
|
||||
"initialized": "never",
|
||||
"uninitialized": "always"
|
||||
}],
|
||||
"one-var-declaration-per-line": ["error", "initializations"],
|
||||
"quotes": ["error", "double"],
|
||||
"semi": ["error", "never"],
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"max-len": ["warn", 100],
|
||||
"space-before-function-paren": ["error", {
|
||||
"anonymous": "never",
|
||||
"named": "never",
|
||||
"asyncArrow": "always"
|
||||
}],
|
||||
"func-style": ["warn", "declaration", {"allowArrowFunctions": true}],
|
||||
"id-length": ["warn", {"max": 40, "exceptions": ["i", "j", "x", "y", "_"]}],
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"new-cap": ["warn", {
|
||||
"newIsCap": true,
|
||||
"capIsNew": true
|
||||
}],
|
||||
"no-empty": ["error", {
|
||||
"allowEmptyCatch": true
|
||||
}],
|
||||
"eol-last": ["error", "always"],
|
||||
"no-console": "off",
|
||||
"import/no-nodejs-modules": "error",
|
||||
"import/order": ["warn", {
|
||||
"groups": [
|
||||
"builtin",
|
||||
"external",
|
||||
"internal",
|
||||
"parent",
|
||||
"sibling",
|
||||
"index"
|
||||
],
|
||||
"newlines-between": "never"
|
||||
}]
|
||||
}
|
||||
}
|
||||
5
maubot-src/maubot/management/frontend/.gitignore
vendored
Normal file
5
maubot-src/maubot/management/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/node_modules
|
||||
/build
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
27
maubot-src/maubot/management/frontend/.sass-lint.yml
Normal file
27
maubot-src/maubot/management/frontend/.sass-lint.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
options:
|
||||
merge-default-rules: false
|
||||
formatter: html
|
||||
max-warnings: 50
|
||||
|
||||
files:
|
||||
include: 'src/style/**/*.sass'
|
||||
|
||||
rules:
|
||||
extends-before-mixins: 2
|
||||
extends-before-declarations: 2
|
||||
placeholder-in-extend: 2
|
||||
mixins-before-declarations:
|
||||
- 2
|
||||
- exclude:
|
||||
- breakpoint
|
||||
- mq
|
||||
no-warn: 1
|
||||
no-debug: 1
|
||||
hex-notation:
|
||||
- 2
|
||||
- style: uppercase
|
||||
indentation:
|
||||
- 2
|
||||
- size: 4
|
||||
property-sort-order:
|
||||
- 0
|
||||
40
maubot-src/maubot/management/frontend/package.json
Normal file
40
maubot-src/maubot/management/frontend/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "maubot-manager",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"author": "Tulir Asokan",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/maubot/maubot.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/maubot/maubot/issues"
|
||||
},
|
||||
"homepage": ".",
|
||||
"dependencies": {
|
||||
"react": "^17.0.2",
|
||||
"react-ace": "^9.4.1",
|
||||
"react-contextmenu": "^2.14.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-json-tree": "^0.16.1",
|
||||
"react-router-dom": "^5.3.0",
|
||||
"react-scripts": "5.0.0",
|
||||
"react-select": "^5.2.1",
|
||||
"sass": "^1.34.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 firefox versions",
|
||||
"last 2 and_ff versions",
|
||||
"last 2 chrome versions",
|
||||
"last 2 and_chr versions",
|
||||
"last 1 safari versions",
|
||||
"last 1 ios_saf versions"
|
||||
]
|
||||
}
|
||||
BIN
maubot-src/maubot/management/frontend/public/favicon.png
Normal file
BIN
maubot-src/maubot/management/frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
37
maubot-src/maubot/management/frontend/public/index.html
Normal file
37
maubot-src/maubot/management/frontend/public/index.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!--
|
||||
maubot - A plugin-based Matrix bot system.
|
||||
Copyright (C) 2022 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/>.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#50D367">
|
||||
<meta property="og:title" content="Maubot Manager"/>
|
||||
<meta property="og:description" content="Maubot management interface"/>
|
||||
<meta property="og:image" content="%PUBLIC_URL%/favicon.png"/>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
<title>Maubot Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user