diff --git a/.dockerignore b/.dockerignore index b7c2055..31528c2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,6 @@ .editorconfig logs -.venv start config.yaml registration.yaml *.db -*.pickle diff --git a/.editorconfig b/.editorconfig index 125c817..a243231 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,14 +8,11 @@ charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -[*.py] -max_line_length = 99 - [*.md] trim_trailing_whitespace = false -[*.{yaml,yml,py,md}] +[*.{yaml,yml,sql}] indent_style = space -[{.gitlab-ci.yml,*.md,.github/workflows/*.yml,.pre-commit-config.yaml}] +[.gitlab-ci.yml] indent_size = 2 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..3861af0 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,28 @@ +name: Go + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: "1.20" + + - name: Install libolm + run: sudo apt-get install libolm-dev libolm3 + + - name: Install goimports + run: | + go install golang.org/x/tools/cmd/goimports@latest + export PATH="$HOME/go/bin:$PATH" + + - name: Install pre-commit + run: pip install pre-commit + + - name: Lint + run: pre-commit run -a diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml deleted file mode 100644 index f3f5663..0000000 --- a/.github/workflows/python-lint.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Python lint - -on: [push, pull_request] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: "3.11" - - uses: isort/isort-action@master - with: - sortPaths: "./mausignald ./mautrix_signal" - - uses: psf/black@stable - with: - src: "./mausignald ./mautrix_signal" - version: "23.1.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 diff --git a/.gitignore b/.gitignore index b20ee4e..9abe085 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,6 @@ -/.idea/ +*.yaml +!example-config.yaml +!.pre-commit-config.yaml -/.venv -/env/ -pip-selfcheck.json -*.pyc -__pycache__ -/build -/dist -/*.egg-info -/.eggs - -/config.yaml -/registration.yaml +*.db* *.log* -*.db -*.pickle -*.bak diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 7259931..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,3 +0,0 @@ -include: -- project: 'mautrix/ci' - file: '/python.yml' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dad4f68..1ef386e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,14 +7,9 @@ repos: - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - - repo: https://github.com/psf/black - rev: 23.1.0 + + - repo: https://github.com/tekwizely/pre-commit-golang + rev: v1.0.0-rc.1 hooks: - - id: black - language_version: python3 - files: ^(mausignald|mautrix_signal)/.*\.pyi?$ - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 - hooks: - - id: isort - files: ^(mausignald|mautrix_signal)/.*\.pyi?$ + - id: go-imports-repo + - id: go-vet-repo-mod diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 69033e9..0000000 --- a/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -FROM docker.io/alpine:3.17 - -RUN apk add --no-cache \ - python3 py3-pip py3-setuptools py3-wheel \ - py3-pillow \ - py3-aiohttp \ - py3-magic \ - py3-ruamel.yaml \ - py3-commonmark \ - py3-qrcode \ - py3-phonenumbers \ - #py3-prometheus-client \ - # Other dependencies - ffmpeg \ - py3-cryptography \ - py3-protobuf \ - py3-sniffio \ - py3-rfc3986 \ - py3-idna \ - py3-h11 \ - ca-certificates \ - su-exec \ - netcat-openbsd \ - # encryption - py3-olm \ - py3-cffi \ - py3-pycryptodome \ - py3-unpaddedbase64 \ - py3-future \ - bash \ - curl \ - jq \ - yq - -COPY requirements.txt /opt/mautrix-signal/requirements.txt -COPY optional-requirements.txt /opt/mautrix-signal/optional-requirements.txt -WORKDIR /opt/mautrix-signal -RUN apk add --virtual .build-deps python3-dev libffi-dev build-base \ - && pip3 install --no-cache-dir -r requirements.txt -r optional-requirements.txt \ - && apk del .build-deps - -COPY . /opt/mautrix-signal -RUN apk add git && pip3 install --no-cache-dir .[all] && apk del git \ - # This doesn't make the image smaller, but it's needed so that the `version` command works properly - && cp mautrix_signal/example-config.yaml . && rm -rf mautrix_signal .git build - -VOLUME /data -ENV UID=1337 GID=1337 - -CMD ["/opt/mautrix-signal/docker-run.sh"] diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index d8889bc..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -include README.md -include CHANGELOG.md -include LICENSE -include requirements.txt -include optional-requirements.txt diff --git a/README.md b/README.md index 5101133..e89df06 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,8 @@ -# mautrix-signal -![Languages](https://img.shields.io/github/languages/top/mautrix/signal.svg) -[![License](https://img.shields.io/github/license/mautrix/signal.svg)](LICENSE) -[![Release](https://img.shields.io/github/release/mautrix/signal/all.svg)](https://github.com/mautrix/signal/releases) -[![GitLab CI](https://mau.dev/mautrix/signal/badges/master/pipeline.svg)](https://mau.dev/mautrix/signal/container_registry) -[![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![Imports](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) - -A Matrix-Signal puppeting bridge. +# mautrix-signalgo +Go rewrite of mautrix-signal. ## Documentation -All setup and usage instructions are located on -[docs.mau.fi](https://docs.mau.fi/bridges/python/signal/index.html). -Some quick links: - -* [Bridge setup](https://docs.mau.fi/bridges/python/setup.html?bridge=signal) - (or [with Docker](https://docs.mau.fi/bridges/python/signal/docker-setup.html)) -* Basic usage: [Authentication](https://docs.mau.fi/bridges/python/signal/authentication.html) - -### Features & Roadmap -[ROADMAP.md](https://github.com/mautrix/signal/blob/master/ROADMAP.md) -contains a general overview of what is supported by the bridge. +This rewrite does not exist yet, so there's no documentation. ## Discussion Matrix room: [`#signal:maunium.net`](https://matrix.to/#/#signal:maunium.net) diff --git a/ROADMAP.md b/ROADMAP.md index 76c70eb..a3d05cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,72 +2,71 @@ * Matrix → Signal * [ ] Message content - * [x] Text + * [ ] Text * [ ] ‡Formatting - * [x] Mentions + * [ ] Mentions * [ ] Media - * [x] Images - * [x] Audio files - * [x] Files - * [x] Gifs - * [x] Locations + * [ ] Images + * [ ] Audio files + * [ ] Files + * [ ] Gifs + * [ ] Locations * [ ] Stickers - * [x] Message reactions - * [x] Message redactions - * [x] Group info changes - * [x] Name - * [x] Avatar - * [x] Membership actions - * [x] Join (accept invite) - * [x] Invite - * [x] Leave - * [x] Kick/Ban/Unban + * [ ] Message reactions + * [ ] Message redactions + * [ ] Group info changes + * [ ] Name + * [ ] Avatar + * [ ] Membership actions + * [ ] Join (accept invite) + * [ ] Invite + * [ ] Leave + * [ ] Kick/Ban/Unban * [ ] Typing notifications * [ ] Read receipts (currently partial support, only marks last message) - * [x] Delivery receipts (sent after message is bridged) + * [ ] Delivery receipts (sent after message is bridged) * Signal → Matrix - * [x] Message content - * [x] Text - * [x] Mentions - * [x] Media - * [x] Images - * [x] Voice notes - * [x] Files - * [x] Gifs - * [x] Contacts - * [x] Locations - * [x] Stickers - * [x] Message reactions - * [x] Remote deletions - * [x] Initial user and group profile info + * [ ] Message content + * [ ] Text + * [ ] Mentions + * [ ] Media + * [ ] Images + * [ ] Voice notes + * [ ] Files + * [ ] Gifs + * [ ] Contacts + * [ ] Locations + * [ ] Stickers + * [ ] Message reactions + * [ ] Remote deletions + * [ ] Initial user and group profile info * [ ] Profile info changes - * [x] When restarting bridge or syncing + * [ ] When restarting bridge or syncing * [ ] Real time - * [x] Groups + * [ ] Groups * [ ] Users - * [x] Membership actions - * [x] Join - * [x] Invite - * [x] Request join (via invite link, requires a client that supports knocks) - * [x] Leave - * [x] Kick/Ban/Unban - * [x] Group permissions - * [x] Typing notifications - * [x] Read receipts + * [ ] Membership actions + * [ ] Join + * [ ] Invite + * [ ] Request join (via invite link, requires a client that supports knocks) + * [ ] Leave + * [ ] Kick/Ban/Unban + * [ ] Group permissions + * [ ] Typing notifications + * [ ] Read receipts * [ ] Delivery receipts (there's no good way to bridge these) - * [x] Disappearing messages + * [ ] Disappearing messages * Misc - * [x] Automatic portal creation - * [x] At startup - * [x] When receiving message + * [ ] Automatic portal creation + * [ ] At startup + * [ ] When receiving message * [ ] Provisioning API for logging in - * [x] Linking as secondary device + * [ ] Linking as secondary device * [ ] Registering as primary device - * [x] Private chat/group creation by inviting Matrix puppet of Signal user to new room - * [x] Option to use own Matrix account for messages sent from other Signal clients - * [x] Automatic login with shared secret - * [x] Manual login with `login-matrix` - * [x] E2EE in Matrix rooms + * [ ] Private chat/group creation by inviting Matrix puppet of Signal user to new room + * [ ] Option to use own Matrix account for messages sent from other Signal clients + * [ ] Automatic login with shared secret + * [ ] Manual login with `login-matrix` + * [ ] E2EE in Matrix rooms -† Not possible in signald ‡ Not possible in Signal diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index 5cd14c2..0000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -pre-commit>=2.10.1,<3 -isort>=5.10.1,<6 -black>=23,<24 diff --git a/docker-run.sh b/docker-run.sh deleted file mode 100755 index c5df91d..0000000 --- a/docker-run.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/sh -if [ ! -z "$MAUTRIX_DIRECT_STARTUP" ]; then - if [ $(id -u) == 0 ]; then - echo "|------------------------------------------|" - echo "| Warning: running bridge unsafely as root |" - echo "|------------------------------------------|" - fi - exec python3 -m mautrix_signal -c /data/config.yaml -elif [ $(id -u) != 0 ]; then - echo "The startup script must run as root. It will use su-exec to drop permissions before running the bridge." - echo "To bypass the startup script, either set the `MAUTRIX_DIRECT_STARTUP` environment variable," - echo "or just use `python3 -m mautrix_signal -c /data/config.yaml` as the run command." - echo "Note that the config and registration will not be auto-generated when bypassing the startup script." - exit 1 -fi - -cd /opt/mautrix-signal - -function fixperms { - chown -R $UID:$GID /data - - # /opt/mautrix-signal is read-only, so disable file logging if it's pointing there. - if [[ "$(yq e '.logging.handlers.file.filename' /data/config.yaml)" == "./mautrix-signal.log" ]]; then - yq -I4 e -i 'del(.logging.root.handlers[] | select(. == "file"))' /data/config.yaml - yq -I4 e -i 'del(.logging.handlers.file)' /data/config.yaml - fi -} - -if [ ! -f /data/config.yaml ]; then - cp example-config.yaml /data/config.yaml - yq -I4 e -i 'del(.logging.root.handlers[] | select(. == "file"))' /data/config.yaml - yq -I4 e -i 'del(.logging.handlers.file)' /data/config.yaml - yq -I4 e -i '.signal.socket_path = "/signald/signald.sock"' /data/config.yaml - yq -I4 e -i '.signal.outgoing_attachment_dir = "/signald/attachments"' /data/config.yaml - yq -I4 e -i '.signal.avatar_dir = "/signald/avatars"' /data/config.yaml - yq -I4 e -i '.signal.data_dir = "/signald/data"' /data/config.yaml - echo "Didn't find a config file." - echo "Copied default config file to /data/config.yaml" - echo "Modify that config file to your liking." - echo "Start the container again after that to generate the registration file." - fixperms - exit -fi - -if [ ! -f /data/registration.yaml ]; then - python3 -m mautrix_signal -g -c /data/config.yaml -r /data/registration.yaml || exit $? - echo "Didn't find a registration file." - echo "Generated one for you." - echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it." - fixperms - exit -fi - -fixperms -exec su-exec $UID:$GID python3 -m mautrix_signal -c /data/config.yaml diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7a5b646 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module go.mau.fi/mautrix-signal + +go 1.20 diff --git a/main.go b/main.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/main.go @@ -0,0 +1 @@ +package main diff --git a/mausignald/LICENSE b/mausignald/LICENSE deleted file mode 100644 index a612ad9..0000000 --- a/mausignald/LICENSE +++ /dev/null @@ -1,373 +0,0 @@ -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. diff --git a/mausignald/README.md b/mausignald/README.md deleted file mode 100644 index 5844d11..0000000 --- a/mausignald/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# mausignald -A Python/Asyncio library to communicate with [signald](https://gitlab.com/thefinn93/signald). - -## Installation -TODO - -## Usage -```python -import asyncio -from mausignald import SignaldClient -from mausignald.types import Message - -client = SignaldClient() -username = "+1234567890" - - -async def qr_callback(uri: str) -> None: - import os - # This uses the qrencode cli tool for showing the QR code in the terminal - # For proper apps, you might want to use the qrcode Python library to render an image instead. - os.system(f"qrencode -t ansiutf8 '{uri}'") - - -async def handle_message(message: Message) -> None: - # The Message event includes most things that happen in Signal, such as: - # * messages from other users (message.data_message) - # * typing notifications (message.typing) - # * read receipts (message.receipt) - # * messages synced from your other devices (message.sync_message.sent) - # * read receipts synced from your other devices (message.sync_message.read_messages) - - print(f"Got message: {message}") - if message.data_message: - # This is a normal message from another user - # Let's mark it as read - await client.send_receipt(username, message.source, [message.data_message.timestamp], read=True) - - -async def main(): - # Event handlers should be added before connecting to make sure you don't miss anything - client.add_event_handler(Message, handle_message) - - # Connect to the signald socket - await client.connect() - - # If you haven't logged in yet, either - # register: - await client.register(username) - sms_code = input("Enter SMS code:") - await client.verify(username, sms_code) - # or link: - await client.link(qr_callback) - - # Always send a subscribe request when starting, otherwise you won't get messages - await client.subscribe(username) - -loop = asyncio.get_event_loop() -loop.run_until_complete(main()) -# The main function exits after it's done connecting/registering/subscribing, -# so tell the asyncio event loop to keep running anyway. -loop.run_forever() -``` diff --git a/mausignald/__init__.py b/mausignald/__init__.py deleted file mode 100644 index ad4fa69..0000000 --- a/mausignald/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .signald import SignaldClient diff --git a/mausignald/errors.py b/mausignald/errors.py deleted file mode 100644 index cd65fe2..0000000 --- a/mausignald/errors.py +++ /dev/null @@ -1,155 +0,0 @@ -# Copyright (c) 2022 Tulir Asokan -# -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -from __future__ import annotations - -from typing import Any - - -class RPCError(Exception): - pass - - -class UnexpectedError(RPCError): - pass - - -class UnexpectedResponse(RPCError): - def __init__(self, resp_type: str, data: Any) -> None: - super().__init__(f"Got unexpected response type {resp_type}") - self.resp_type = resp_type - self.data = data - - -class NotConnected(RPCError): - pass - - -class ResponseError(RPCError): - def __init__( - self, - data: dict[str, Any], - error_type: str | None = None, - message_override: str | None = None, - ) -> None: - self.data = data - msg = message_override or data["message"] - if error_type: - msg = f"{error_type}: {msg}" - super().__init__(msg) - - -class TimeoutException(ResponseError): - pass - - -class UnknownIdentityKey(ResponseError): - pass - - -class CaptchaRequiredError(ResponseError): - pass - - -class AuthorizationFailedError(ResponseError): - pass - - -class ScanTimeoutError(ResponseError): - pass - - -class UserAlreadyExistsError(ResponseError): - def __init__(self, data: dict[str, Any]) -> None: - super().__init__(data, message_override="You're already logged in") - - -class OwnProfileKeyDoesNotExistError(ResponseError): - def __init__(self, data: dict[str, Any]) -> None: - super().__init__( - data, - message_override=( - "Cannot find own profile key. Please make sure you have a Signal profile name set." - ), - ) - - -class RequestValidationFailure(ResponseError): - def __init__(self, data: dict[str, Any]) -> None: - results = data["validationResults"] - result_str = ", ".join(results) if isinstance(results, list) else str(results) - super().__init__(data, message_override=result_str) - - -class InternalError(ResponseError): - """ - If you find yourself using this, please file an issue against signald. We want to make - explicit error types at the protocol for anything a client might normally expect. - """ - - def __init__(self, data: dict[str, Any]) -> None: - exceptions = data.get("exceptions", []) - self.exceptions = exceptions - message = data.get("message") - super().__init__(data, error_type=", ".join(exceptions), message_override=message) - - -class AttachmentTooLargeError(ResponseError): - def __init__(self, data: dict[str, Any]) -> None: - self.filename = data.get("filename", "") - super().__init__(data, message_override="File is over the 100MB limit.") - - -class UnregisteredUserError(ResponseError): - pass - - -class ProfileUnavailableError(ResponseError): - pass - - -class NoSuchAccountError(ResponseError): - pass - - -class GroupPatchNotAcceptedError(ResponseError): - pass - - -response_error_types = { - "invalid_request": RequestValidationFailure, - "TimeoutException": TimeoutException, - "UserAlreadyExists": UserAlreadyExistsError, - "RequestValidationFailure": RequestValidationFailure, - "UnknownIdentityKey": UnknownIdentityKey, - "CaptchaRequiredError": CaptchaRequiredError, - "InternalError": InternalError, - "AttachmentTooLargeError": AttachmentTooLargeError, - "AuthorizationFailedError": AuthorizationFailedError, - "ScanTimeoutError": ScanTimeoutError, - "OwnProfileKeyDoesNotExistError": OwnProfileKeyDoesNotExistError, - "UnregisteredUserError": UnregisteredUserError, - "ProfileUnavailableError": ProfileUnavailableError, - "NoSuchAccountError": NoSuchAccountError, - "GroupPatchNotAcceptedError": GroupPatchNotAcceptedError, - # TODO add rest from https://gitlab.com/signald/signald/-/tree/main/src/main/java/io/finn/signald/clientprotocol/v1/exceptions -} - - -def make_response_error(data: dict[str, Any]) -> ResponseError: - error_data = data["error"] - if isinstance(error_data, str): - error_data = {"message": error_data} - elif not isinstance(error_data, dict): - error_data = {"message": str(error_data)} - if "message" not in error_data: - error_data["message"] = "no message, see signald logs" - error_type = data.get("error_type") - try: - error_class = response_error_types[error_type] - except KeyError: - return ResponseError(error_data, error_type=error_type) - else: - return error_class(error_data) diff --git a/mausignald/rpc.py b/mausignald/rpc.py deleted file mode 100644 index 6a59979..0000000 --- a/mausignald/rpc.py +++ /dev/null @@ -1,245 +0,0 @@ -# Copyright (c) 2022 Tulir Asokan -# -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -from __future__ import annotations - -from typing import Any, Awaitable, Callable, Dict -from uuid import UUID, uuid4 -import asyncio -import json -import logging - -from mautrix.util import background_task -from mautrix.util.logging import TraceLogger - -from .errors import NotConnected, UnexpectedError, UnexpectedResponse, make_response_error - -EventHandler = Callable[[Dict[str, Any]], Awaitable[None]] - -# These are synthetic RPC events for registering callbacks on socket -# connect and disconnect. -CONNECT_EVENT = "_socket_connected" -DISCONNECT_EVENT = "_socket_disconnected" -_SOCKET_LIMIT = 1024 * 1024 # 1 MiB - - -class SignaldRPCClient: - loop: asyncio.AbstractEventLoop - log: TraceLogger - - socket_path: str - _reader: asyncio.StreamReader | None - _writer: asyncio.StreamWriter | None - is_connected: bool - _connect_future: asyncio.Future - _communicate_task: asyncio.Task | None - - _response_waiters: dict[UUID, asyncio.Future] - _rpc_event_handlers: dict[str, list[EventHandler]] - - def __init__( - self, - socket_path: str, - log: TraceLogger | None = None, - loop: asyncio.AbstractEventLoop | None = None, - ) -> None: - self.socket_path = socket_path - self.log = log or logging.getLogger("mausignald") - self.loop = loop or asyncio.get_event_loop() - self._reader = None - self._writer = None - self._communicate_task = None - self.is_connected = False - self._connect_future = self.loop.create_future() - self._response_waiters = {} - self._rpc_event_handlers = {CONNECT_EVENT: [], DISCONNECT_EVENT: []} - self.add_rpc_handler(DISCONNECT_EVENT, self._abandon_responses) - - async def wait_for_connected(self, timeout: int | None = None) -> bool: - if self.is_connected: - return True - await asyncio.wait_for(asyncio.shield(self._connect_future), timeout) - return self.is_connected - - async def connect(self) -> None: - if self._writer is not None: - return - - self._communicate_task = asyncio.create_task(self._communicate_forever()) - await self._connect_future - - async def _communicate_forever(self) -> None: - while True: - try: - await self._communicate() - except Exception: - self.log.exception("Unknown error in signald socket") - await asyncio.sleep(30) - - async def _communicate(self) -> None: - try: - self.log.debug(f"Connecting to {self.socket_path}...") - self._reader, self._writer = await asyncio.open_unix_connection( - self.socket_path, limit=_SOCKET_LIMIT - ) - except OSError as e: - self.log.error(f"Connection to {self.socket_path} failed: {e}") - await asyncio.sleep(5) - return - - read_loop = asyncio.create_task(self._try_read_loop()) - self.is_connected = True - background_task.create(self._run_rpc_handler(CONNECT_EVENT, {})) - self._connect_future.set_result(True) - - await read_loop - self.is_connected = False - self._connect_future = self.loop.create_future() - await self._run_rpc_handler(DISCONNECT_EVENT, {}) - - async def disconnect(self) -> None: - if self._writer is not None: - self._writer.write_eof() - await self._writer.drain() - if self._communicate_task: - self._communicate_task.cancel() - self._communicate_task = None - self._writer = None - self._reader = None - self.is_connected = False - self._connect_future = self.loop.create_future() - - def add_rpc_handler(self, method: str, handler: EventHandler) -> None: - self._rpc_event_handlers.setdefault(method, []).append(handler) - - def remove_rpc_handler(self, method: str, handler: EventHandler) -> None: - self._rpc_event_handlers.setdefault(method, []).remove(handler) - - async def _run_rpc_handler(self, command: str, req: dict[str, Any]) -> None: - try: - handlers = self._rpc_event_handlers[command] - except KeyError: - self.log.warning("No handlers for RPC request %s", command) - self.log.trace("Data unhandled request: %s", req) - else: - for handler in handlers: - try: - await handler(req) - except Exception: - self.log.exception("Exception in RPC event handler") - - def _run_response_handlers(self, req_id: UUID, command: str, req: Any) -> None: - try: - waiter = self._response_waiters.pop(req_id) - except KeyError: - self.log.debug(f"Nobody waiting for response to {req_id}") - return - data = req.get("data") - if command == "unexpected_error": - try: - waiter.set_exception(UnexpectedError(data["message"])) - except KeyError: - waiter.set_exception(UnexpectedError("Unexpected error with no message")) - # elif data and "error" in data and isinstance(data["error"], (str, dict)): - # waiter.set_exception(make_response_error(data)) - elif "error" in req and isinstance(req["error"], (str, dict)): - waiter.set_exception(make_response_error(req)) - else: - waiter.set_result((command, data)) - - async def _handle_incoming_line(self, line: str) -> None: - try: - req = json.loads(line) - except json.JSONDecodeError: - self.log.debug(f"Got non-JSON data from server: {line}") - return - try: - req_type = req["type"] - except KeyError: - self.log.debug(f"Got invalid request from server: {line}") - return - - self.log.trace("Got data from server: %s", req) - - req_id = req.get("id") - if req_id is None: - background_task.create(self._run_rpc_handler(req_type, req)) - else: - self._run_response_handlers(UUID(req_id), req_type, req) - - async def _try_read_loop(self) -> None: - try: - await self._read_loop() - except Exception: - self.log.exception("Fatal error in read loop") - else: - self.log.debug("Reader disconnected") - finally: - self._reader = None - self._writer = None - - async def _read_loop(self) -> None: - while self._reader is not None and not self._reader.at_eof(): - line = await self._reader.readline() - if not line: - continue - try: - line_str = line.decode("utf-8") - except UnicodeDecodeError: - self.log.exception("Got non-unicode request from server: %s", line) - continue - try: - await self._handle_incoming_line(line_str) - except Exception: - self.log.exception("Failed to handle incoming request %s", line_str) - - def _create_request( - self, command: str, req_id: UUID | None = None, **data: Any - ) -> tuple[asyncio.Future, dict[str, Any]]: - req_id = req_id or uuid4() - req = {"id": str(req_id), "type": command, **data} - self.log.debug("Request %s: %s", req_id, command) - self.log.trace("Request %s: %s with data: %s", req_id, command, data) - return self._wait_response(req_id), req - - def _wait_response(self, req_id: UUID) -> asyncio.Future: - try: - future = self._response_waiters[req_id] - except KeyError: - future = self._response_waiters[req_id] = self.loop.create_future() - return future - - async def _abandon_responses(self, unused_data: dict[str, Any]) -> None: - for req_id, waiter in self._response_waiters.items(): - if not waiter.done(): - self.log.trace(f"Abandoning response for {req_id}") - waiter.set_exception( - NotConnected("Disconnected from signald before RPC completed") - ) - - async def _send_request(self, data: dict[str, Any]) -> None: - if self._writer is None: - raise NotConnected("Not connected to signald") - - self._writer.write(json.dumps(data).encode("utf-8")) - self._writer.write(b"\n") - await self._writer.drain() - self.log.trace("Sent data to server server: %s", data) - - async def _raw_request( - self, command: str, req_id: UUID | None = None, **data: Any - ) -> tuple[str, dict[str, Any]]: - future, data = self._create_request(command, req_id, **data) - await self._send_request(data) - return await asyncio.shield(future) - - async def _request(self, command: str, expected_response: str, **data: Any) -> Any: - resp_type, resp_data = await self._raw_request(command, **data) - if resp_type != expected_response: - raise UnexpectedResponse(resp_type, resp_data) - return resp_data - - async def request_v1(self, command: str, **data: Any) -> Any: - return await self._request(command, expected_response=command, version="v1", **data) diff --git a/mausignald/signald.py b/mausignald/signald.py deleted file mode 100644 index a1ecd2f..0000000 --- a/mausignald/signald.py +++ /dev/null @@ -1,533 +0,0 @@ -# Copyright (c) 2022 Tulir Asokan -# -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -from __future__ import annotations - -from typing import Any, Awaitable, Callable, Type, TypeVar -from uuid import UUID -import asyncio - -from mautrix.util.logging import TraceLogger - -from .errors import AuthorizationFailedError, NoSuchAccountError, RPCError, UnexpectedResponse -from .rpc import CONNECT_EVENT, DISCONNECT_EVENT, SignaldRPCClient -from .types import ( - Account, - Address, - Attachment, - DeviceInfo, - ErrorMessage, - GetIdentitiesResponse, - GroupAccessControl, - GroupID, - GroupMember, - GroupV2, - IncomingMessage, - JoinGroupResponse, - LinkPreview, - LinkSession, - Mention, - MessageResendSuccessEvent, - Profile, - ProofRequiredType, - Quote, - Reaction, - SendMessageResponse, - StorageChange, - TrustLevel, - WebsocketConnectionState, - WebsocketConnectionStateChangeEvent, -) - -T = TypeVar("T") -EventHandler = Callable[[T], Awaitable[None]] - - -class SignaldClient(SignaldRPCClient): - _event_handlers: dict[Type[T], list[EventHandler]] - _subscriptions: set[str] - - def __init__( - self, - socket_path: str = "/var/run/signald/signald.sock", - log: TraceLogger | None = None, - loop: asyncio.AbstractEventLoop | None = None, - ) -> None: - super().__init__(socket_path, log, loop) - self._event_handlers = {} - self._subscriptions = set() - self.add_rpc_handler("IncomingMessage", self._parse_message) - self.add_rpc_handler("ProtocolInvalidMessageError", self._parse_error) - self.add_rpc_handler("WebSocketConnectionState", self._websocket_connection_state_change) - self.add_rpc_handler("version", self._log_version) - self.add_rpc_handler("StorageChange", self._parse_storage_change) - self.add_rpc_handler("MessageResendSuccess", self._parse_message_resend_request) - self.add_rpc_handler(CONNECT_EVENT, self._resubscribe) - self.add_rpc_handler(DISCONNECT_EVENT, self._on_disconnect) - - def add_event_handler(self, event_class: Type[T], handler: EventHandler) -> None: - self._event_handlers.setdefault(event_class, []).append(handler) - - def remove_event_handler(self, event_class: Type[T], handler: EventHandler) -> None: - self._event_handlers.setdefault(event_class, []).remove(handler) - - async def _run_event_handler(self, event: T) -> None: - try: - handlers = self._event_handlers[type(event)] - except KeyError: - self.log.warning(f"No handlers for {type(event)}") - else: - for handler in handlers: - try: - await handler(event) - except Exception: - self.log.exception("Exception in event handler") - - async def _parse_error(self, data: dict[str, Any]) -> None: - if not data.get("error"): - return - await self._run_event_handler(ErrorMessage.deserialize(data)) - - async def _parse_storage_change(self, data: dict[str, Any]) -> None: - if data["type"] != "StorageChange": - return - await self._run_event_handler(StorageChange.deserialize(data)) - - async def _parse_message_resend_request(self, data: dict[str, Any]) -> None: - if data["type"] != "MesaageResendSuccess": - return - await self._run_event_handler(MessageResendSuccessEvent.deserialize(data)) - - async def _parse_message(self, data: dict[str, Any]) -> None: - event_type = data["type"] - event_data = data["data"] - event_class = { - "IncomingMessage": IncomingMessage, - }[event_type] - event = event_class.deserialize(event_data) - await self._run_event_handler(event) - - async def _log_version(self, data: dict[str, Any]) -> None: - name = data["data"]["name"] - version = data["data"]["version"] - self.log.info(f"Connected to {name} v{version}") - - async def _websocket_connection_state_change(self, change_event: dict[str, Any]) -> None: - evt = WebsocketConnectionStateChangeEvent.deserialize( - { - "account": change_event["account"], - **change_event["data"], - } - ) - await self._run_event_handler(evt) - - async def subscribe(self, username: str) -> bool: - try: - await self.request_v1("subscribe", account=username) - self._subscriptions.add(username) - return True - except RPCError as e: - self.log.debug("Failed to subscribe to %s: %s", username, e) - state = WebsocketConnectionState.DISCONNECTED - if isinstance(e, (AuthorizationFailedError, NoSuchAccountError)): - state = WebsocketConnectionState.AUTHENTICATION_FAILED - evt = WebsocketConnectionStateChangeEvent(state=state, account=username) - await self._run_event_handler(evt) - return False - - async def unsubscribe(self, username: str) -> bool: - try: - await self.request_v1("unsubscribe", account=username) - self._subscriptions.discard(username) - return True - except RPCError as e: - self.log.debug("Failed to unsubscribe from %s: %s", username, e) - return False - - async def _resubscribe(self, unused_data: dict[str, Any]) -> None: - if self._subscriptions: - self.log.debug("Resubscribing to users") - for username in list(self._subscriptions): - await self.subscribe(username) - - async def _on_disconnect(self, *_) -> None: - if self._subscriptions: - self.log.debug("Notifying of disconnection from users") - for username in self._subscriptions: - evt = WebsocketConnectionStateChangeEvent( - state=WebsocketConnectionState.SOCKET_DISCONNECTED, - account=username, - exception="Disconnected from signald", - ) - await self._run_event_handler(evt) - - async def register(self, phone: str, voice: bool = False, captcha: str | None = None) -> str: - resp = await self.request_v1("register", account=phone, voice=voice, captcha=captcha) - return resp["account_id"] - - async def verify(self, username: str, code: str) -> Account: - resp = await self.request_v1("verify", account=username, code=code) - return Account.deserialize(resp) - - async def start_link(self) -> LinkSession: - return LinkSession.deserialize(await self.request_v1("generate_linking_uri")) - - async def wait_for_scan(self, session_id: str) -> None: - await self.request_v1("wait_for_scan", session_id=session_id) - - async def finish_link( - self, session_id: str, device_name: str = "mausignald", overwrite: bool = False - ) -> Account: - resp = await self.request_v1( - "finish_link", device_name=device_name, session_id=session_id, overwrite=overwrite - ) - return Account.deserialize(resp) - - @staticmethod - def _recipient_to_args( - recipient: UUID | Address | GroupID, simple_name: bool = False - ) -> dict[str, Any]: - if isinstance(recipient, UUID): - recipient = Address(uuid=recipient) - if isinstance(recipient, Address): - recipient = recipient.serialize() - field_name = "address" if simple_name else "recipientAddress" - else: - field_name = "group" if simple_name else "recipientGroupId" - return {field_name: recipient} - - async def react( - self, - username: str, - recipient: UUID | Address | GroupID, - reaction: Reaction, - req_id: UUID | None = None, - ) -> None: - await self.request_v1( - "react", - username=username, - reaction=reaction.serialize(), - req_id=req_id, - **self._recipient_to_args(recipient), - ) - - async def remote_delete( - self, username: str, recipient: UUID | Address | GroupID, timestamp: int - ) -> None: - await self.request_v1( - "remote_delete", - account=username, - timestamp=timestamp, - **self._recipient_to_args(recipient, simple_name=True), - ) - - async def send_raw( - self, - username: str, - recipient: UUID | Address | GroupID, - body: str, - quote: Quote | None = None, - attachments: list[Attachment] | None = None, - mentions: list[Mention] | None = None, - previews: list[LinkPreview] | None = None, - timestamp: int | None = None, - req_id: UUID | None = None, - ) -> SendMessageResponse: - serialized_quote = quote.serialize() if quote else None - serialized_attachments = [attachment.serialize() for attachment in (attachments or [])] - serialized_mentions = [mention.serialize() for mention in (mentions or [])] - serialized_previews = [preview.serialize() for preview in (previews or [])] - resp = await self.request_v1( - "send", - username=username, - messageBody=body, - attachments=serialized_attachments, - quote=serialized_quote, - mentions=serialized_mentions, - previews=serialized_previews, - timestamp=timestamp, - req_id=req_id, - **self._recipient_to_args(recipient), - ) - return SendMessageResponse.deserialize(resp) - - async def send( - self, - username: str, - recipient: UUID | Address | GroupID, - body: str, - quote: Quote | None = None, - attachments: list[Attachment] | None = None, - mentions: list[Mention] | None = None, - previews: list[LinkPreview] | None = None, - timestamp: int | None = None, - req_id: UUID | None = None, - ) -> None: - resp = await self.send_raw( - username, recipient, body, quote, attachments, mentions, previews, timestamp, req_id - ) - - # We handle unregisteredFailure a little differently than other errors. If there are no - # successful sends, then we show an error with the unregisteredFailure details, otherwise - # we ignore it. - errors = [] - unregistered_failures = [] - successful_send_count = 0 - for result in resp.results: - number = result.address.number_or_uuid - if result.network_failure: - errors.append(f"Network failure occurred while sending message to {number}.") - elif result.unregistered_failure: - unregistered_failures.append( - f"Unregistered failure occurred while sending message to {number}." - ) - elif result.identity_failure: - errors.append( - f"Identity failure occurred while sending message to {number}. New identity: " - f"{result.identity_failure}" - ) - elif result.proof_required_failure: - prf = result.proof_required_failure - self.log.warning( - f"Proof Required Failure {prf.options}. Retry after: {prf.retry_after}. " - f"Token: {prf.token}. Message: {prf.message}." - ) - errors.append( - f"Proof required failure occurred while sending message to {number}. Message: " - f"{prf.message}" - ) - if ProofRequiredType.RECAPTCHA in prf.options: - errors.append("RECAPTCHA required.") - elif ProofRequiredType.PUSH_CHALLENGE in prf.options: - # Just submit the challenge automatically. - await self.request_v1("submit_challenge") - else: - successful_send_count += 1 - self.log.info( - f"Successfully sent message to {successful_send_count}/{len(resp.results)} users in " - f"{recipient} with {len(unregistered_failures)} unregistered failures" - ) - if len(unregistered_failures) == len(resp.results): - errors.extend(unregistered_failures) - if errors: - raise Exception("\n".join(errors)) - - async def send_receipt( - self, - username: str, - sender: Address, - timestamps: list[int], - when: int | None = None, - read: bool = False, - ) -> None: - if not read: - # TODO implement - return - await self.request_v1( - "mark_read", account=username, timestamps=timestamps, when=when, to=sender.serialize() - ) - - async def list_accounts(self) -> list[Account]: - resp = await self.request_v1("list_accounts") - return [Account.deserialize(acc) for acc in resp.get("accounts", [])] - - async def delete_account(self, username: str, server: bool = False) -> None: - await self.request_v1("delete_account", account=username, server=server) - - async def get_linked_devices(self, username: str) -> list[DeviceInfo]: - resp = await self.request_v1("get_linked_devices", account=username) - return [DeviceInfo.deserialize(dev) for dev in resp.get("devices", [])] - - async def add_linked_device(self, username: str, uri: str) -> None: - await self.request_v1("add_device", account=username, uri=uri) - - async def remove_linked_device(self, username: str, device_id: int) -> None: - await self.request_v1("remove_linked_device", account=username, deviceId=device_id) - - async def list_contacts(self, username: str, use_cache: bool = False) -> list[Profile]: - kwargs = {"async": use_cache} - resp = await self.request_v1("list_contacts", account=username, **kwargs) - return [Profile.deserialize(contact) for contact in resp["profiles"]] - - async def list_groups(self, username: str) -> list[GroupV2]: - resp = await self.request_v1("list_groups", account=username) - return [GroupV2.deserialize(group) for group in resp.get("groups", [])] - - async def join_group(self, username: str, uri: str) -> JoinGroupResponse: - resp = await self.request_v1("join_group", account=username, uri=uri) - return JoinGroupResponse.deserialize(resp) - - async def leave_group(self, username: str, group_id: GroupID) -> None: - await self.request_v1("leave_group", account=username, groupID=group_id) - - async def ban_user(self, username: str, group_id: GroupID, users: list[Address]) -> GroupV2: - serialized_users = [user.serialize() for user in (users or [])] - resp = await self.request_v1( - "ban_user", account=username, group_id=group_id, users=serialized_users - ) - return GroupV2.deserialize(resp) - - async def unban_user(self, username: str, group_id: GroupID, users: list[Address]) -> GroupV2: - serialized_users = [user.serialize() for user in (users or [])] - resp = await self.request_v1( - "unban_user", account=username, group_id=group_id, users=serialized_users - ) - return GroupV2.deserialize(resp) - - async def approve_membership( - self, username: str, group_id: GroupID, members: list[Address] - ) -> GroupV2: - serialized_members = [member.serialize() for member in (members or [])] - resp = await self.request_v1( - "approve_membership", account=username, groupID=group_id, members=serialized_members - ) - return GroupV2.deserialize(resp) - - async def refuse_membership( - self, username: str, group_id: GroupID, members: list[Address], also_ban: bool = False - ) -> GroupV2: - serialized_members = [member.serialize() for member in (members or [])] - resp = await self.request_v1( - "refuse_membership", - account=username, - group_id=group_id, - members=serialized_members, - also_ban=also_ban, - ) - return GroupV2.deserialize(resp) - - async def update_group( - self, - username: str, - group_id: GroupID, - title: str | None = None, - description: str | None = None, - avatar_path: str | None = None, - add_members: list[Address] | None = None, - remove_members: list[Address] | None = None, - update_access_control: GroupAccessControl | None = None, - update_role: GroupMember | None = None, - ) -> GroupV2 | None: - update_params = { - key: value - for key, value in { - "groupID": group_id, - "avatar": avatar_path, - "title": title, - "description": description, - "addMembers": [addr.serialize() for addr in add_members] if add_members else None, - "removeMembers": ( - [addr.serialize() for addr in remove_members] if remove_members else None - ), - "updateAccessControl": ( - update_access_control.serialize() if update_access_control else None - ), - "updateRole": (update_role.serialize() if update_role else None), - }.items() - if value is not None - } - resp = await self.request_v1("update_group", account=username, **update_params) - if "v2" in resp: - return GroupV2.deserialize(resp["v2"]) - elif "v1" in resp: - raise RuntimeError("v1 groups are no longer supported") - else: - return None - - async def accept_invitation(self, username: str, group_id: GroupID) -> GroupV2: - resp = await self.request_v1("accept_invitation", account=username, groupID=group_id) - return GroupV2.deserialize(resp) - - async def get_group( - self, username: str, group_id: GroupID, revision: int = -1 - ) -> GroupV2 | None: - resp = await self.request_v1( - "get_group", account=username, groupID=group_id, revision=revision - ) - if "id" not in resp: - return None - return GroupV2.deserialize(resp) - - async def create_group( - self, - username: str, - title: str, - avatar_path: str | None = None, - member_role_administrator: bool = False, - members: list[Address] | None = None, - ) -> GroupV2 | None: - create_params = { - "avatar": avatar_path, - "member_role": "ADMINISTRATOR" if member_role_administrator else "DEFAULT", - "title": title, - "members": [addr.serialize() for addr in members], - } - create_params = {k: v for k, v in create_params.items() if v is not None} - resp = await self.request_v1("create_group", account=username, **create_params) - if "id" not in resp: - return None - return GroupV2.deserialize(resp) - - async def get_profile( - self, username: str, address: Address, use_cache: bool = False - ) -> Profile | None: - try: - # async is a reserved keyword, so can't pass it as a normal parameter - kwargs = {"async": use_cache} - resp = await self.request_v1( - "get_profile", account=username, address=address.serialize(), **kwargs - ) - except UnexpectedResponse as e: - if e.resp_type == "profile_not_available": - return None - raise - return Profile.deserialize(resp) - - async def get_identities(self, username: str, address: Address) -> GetIdentitiesResponse: - resp = await self.request_v1( - "get_identities", account=username, address=address.serialize() - ) - return GetIdentitiesResponse.deserialize(resp) - - async def set_profile( - self, username: str, name: str | None = None, avatar_path: str | None = None - ) -> None: - args = {} - if name is not None: - args["name"] = name - if avatar_path is not None: - args["avatarFile"] = avatar_path - await self.request_v1("set_profile", account=username, **args) - - async def trust( - self, - username: str, - recipient: Address, - trust_level: TrustLevel | str, - safety_number: str | None = None, - qr_code_data: str | None = None, - ) -> None: - args = {} - if safety_number: - if qr_code_data: - raise ValueError("only one of safety_number and qr_code_data must be set") - args["safety_number"] = safety_number - elif qr_code_data: - args["qr_code_data"] = qr_code_data - else: - raise ValueError("safety_number or qr_code_data is required") - await self.request_v1( - "trust", - account=username, - **args, - trust_level=trust_level.value if isinstance(trust_level, TrustLevel) else trust_level, - address=recipient.serialize(), - ) - - async def find_uuid(self, username: str, number: str) -> UUID | None: - resp = await self.request_v1( - "resolve_address", partial=Address(number=number).serialize(), account=username - ) - return Address.deserialize(resp).uuid diff --git a/mausignald/types.py b/mausignald/types.py deleted file mode 100644 index 5b10ab9..0000000 --- a/mausignald/types.py +++ /dev/null @@ -1,733 +0,0 @@ -# Copyright (c) 2022 Tulir Asokan -# -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -from typing import Dict, List, NewType, Optional -from datetime import datetime, timedelta -from uuid import UUID - -from attr import dataclass - -from mautrix.types import ExtensibleEnum, SerializableAttrs, SerializableEnum, field - -GroupID = NewType("GroupID", str) - - -@dataclass(frozen=True, eq=False) -class Address(SerializableAttrs): - uuid: Optional[UUID] = None - number: Optional[str] = None - - @property - def is_valid(self) -> bool: - return bool(self.number) or bool(self.uuid) - - @property - def best_identifier(self) -> str: - return str(self.uuid) if self.uuid else self.number - - @property - def number_or_uuid(self) -> str: - return self.number or str(self.uuid) - - def __eq__(self, other: "Address") -> bool: - if not isinstance(other, Address): - return False - if self.uuid and other.uuid: - return self.uuid == other.uuid - elif self.number and other.number: - return self.number == other.number - return False - - def __hash__(self) -> int: - if self.uuid: - return hash(self.uuid) - return hash(self.number) - - @classmethod - def parse(cls, value: str) -> "Address": - return Address(number=value) if value.startswith("+") else Address(uuid=UUID(value)) - - -@dataclass -class Account(SerializableAttrs): - account_id: str - device_id: int - address: Address - pending: bool = False - pni: Optional[str] = None - - -def pluralizer(val: int) -> str: - if val == 1: - return "" - return "s" - - -@dataclass -class DeviceInfo(SerializableAttrs): - id: int - created: int - last_seen: int = field(json="lastSeen") - name: Optional[str] = None - - @property - def name_with_default(self) -> str: - if self.name: - return self.name - return "primary device" if self.id == 1 else "unnamed device" - - @property - def created_fmt(self) -> str: - return datetime.utcfromtimestamp(self.created / 1000).strftime("%Y-%m-%d %H:%M:%S UTC") - - @property - def last_seen_fmt(self) -> str: - dt = datetime.utcfromtimestamp(self.last_seen / 1000) - now = datetime.utcnow() - if dt.date() == now.date(): - return "today" - elif (dt + timedelta(days=1)).date() == now.date(): - return "yesterday" - day_diff = (now - dt).days - if day_diff < 30: - return f"{day_diff} day{pluralizer(day_diff)} ago" - return dt.strftime("%Y-%m-%d") - - -@dataclass -class LinkSession(SerializableAttrs): - uri: str - session_id: str - - -class TrustLevel(SerializableEnum): - TRUSTED_UNVERIFIED = "TRUSTED_UNVERIFIED" - TRUSTED_VERIFIED = "TRUSTED_VERIFIED" - UNTRUSTED = "UNTRUSTED" - - @property - def human_str(self) -> str: - if self == TrustLevel.TRUSTED_VERIFIED: - return "trusted" - elif self == TrustLevel.TRUSTED_UNVERIFIED: - return "trusted (unverified)" - elif self == TrustLevel.UNTRUSTED: - return "untrusted" - return "unknown" - - -@dataclass -class Identity(SerializableAttrs): - trust_level: TrustLevel - added: int - safety_number: str - qr_code_data: str - - -@dataclass -class GetIdentitiesResponse(SerializableAttrs): - address: Address - identities: List[Identity] - - -@dataclass -class Capabilities(SerializableAttrs): - gv2: bool = False - storage: bool = False - gv1_migration: bool = field(default=False, json="gv1-migration") - announcement_group: bool = False - change_number: bool = False - sender_key: bool = False - stories: bool = False - - -@dataclass -class Profile(SerializableAttrs): - address: Optional[Address] = None - name: str = "" - contact_name: str = "" - profile_name: str = "" - about: str = "" - avatar: str = "" - color: str = "" - emoji: str = "" - inbox_position: Optional[int] = None - mobilecoin_address: Optional[str] = None - expiration_time: Optional[int] = None - capabilities: Optional[Capabilities] = None - # visible_badge_ids: List[str] - - -class AccessControlMode(SerializableEnum): - UNKNOWN = "UNKNOWN" - ANY = "ANY" - MEMBER = "MEMBER" - ADMINISTRATOR = "ADMINISTRATOR" - UNSATISFIABLE = "UNSATISFIABLE" - UNRECOGNIZED = "UNRECOGNIZED" - - -class AnnouncementsMode(SerializableEnum): - UNKNOWN = "UNKNOWN" - ENABLED = "ENABLED" - DISABLED = "DISABLED" - - -@dataclass -class GroupAccessControl(SerializableAttrs): - attributes: Optional[AccessControlMode] = None - link: Optional[AccessControlMode] = None - members: Optional[AccessControlMode] = None - - -class GroupMemberRole(SerializableEnum): - UNKNOWN = "UNKNOWN" - DEFAULT = "DEFAULT" - ADMINISTRATOR = "ADMINISTRATOR" - UNRECOGNIZED = "UNRECOGNIZED" - - -@dataclass -class GroupMember(SerializableAttrs): - uuid: UUID - joined_revision: int = 0 - role: GroupMemberRole = GroupMemberRole.UNKNOWN - - @property - def address(self) -> Address: - return Address(uuid=self.uuid) - - -@dataclass -class BannedGroupMember(SerializableAttrs): - uuid: UUID - timestamp: int - - -@dataclass -class GroupChange(SerializableAttrs): - revision: int - editor: Address - delete_members: Optional[List[Address]] = None - delete_pending_members: Optional[List[Address]] = None - delete_requesting_members: Optional[List[Address]] = None - modified_profile_keys: Optional[List[GroupMember]] = None - modify_member_roles: Optional[List[GroupMember]] = None - new_access_control: Optional[GroupAccessControl] = None - new_avatar: bool = False - new_banned_members: Optional[List[GroupMember]] = None - new_description: Optional[str] = None - new_invite_link_password: bool = False - new_is_announcement_group: Optional[AnnouncementsMode] = None - new_members: Optional[List[GroupMember]] = None - new_pending_members: Optional[List[GroupMember]] = None - new_requesting_members: Optional[List[GroupMember]] = None - new_timer: Optional[int] = None - new_title: Optional[str] = None - new_unbanned_members: Optional[List[GroupMember]] = None - promote_pending_members: Optional[List[GroupMember]] = None - promote_requesting_members: Optional[List[GroupMember]] = None - - -@dataclass(kw_only=True) -class GroupV2ID(SerializableAttrs): - id: GroupID - revision: Optional[int] = None - removed: Optional[bool] = False - group_change: Optional[GroupChange] = None - - -@dataclass(kw_only=True) -class GroupV2(GroupV2ID, SerializableAttrs): - title: str = None - description: Optional[str] = None - avatar: Optional[str] = None - timer: Optional[int] = None - master_key: Optional[str] = field(default=None, json="masterKey") - invite_link: Optional[str] = field(default=None, json="inviteLink") - access_control: GroupAccessControl = field( - factory=lambda: GroupAccessControl(), json="accessControl" - ) - members: List[Address] = None - member_detail: List[GroupMember] = field(factory=lambda: [], json="memberDetail") - pending_members: List[Address] = field(factory=lambda: [], json="pendingMembers") - pending_member_detail: List[GroupMember] = field( - factory=lambda: [], json="pendingMemberDetail" - ) - requesting_members: List[Address] = field(factory=lambda: [], json="requestingMembers") - announcements: AnnouncementsMode = field(default=AnnouncementsMode.UNKNOWN) - banned_members: Optional[List[BannedGroupMember]] = None - - -@dataclass -class Attachment(SerializableAttrs): - width: int = 0 - height: int = 0 - caption: Optional[str] = None - preview: Optional[str] = None - blurhash: Optional[str] = None - voice_note: bool = field(default=False, json="voiceNote") - content_type: Optional[str] = field(default=None, json="contentType") - custom_filename: Optional[str] = field(default=None, json="customFilename") - - # Only for incoming - id: Optional[str] = None - incoming_filename: Optional[str] = field(default=None, json="storedFilename") - digest: Optional[str] = None - size: Optional[int] = None - - # Only for outgoing - outgoing_filename: Optional[str] = field(default=None, json="filename") - - -@dataclass -class Mention(SerializableAttrs): - uuid: UUID - length: int - start: int = 0 - - -@dataclass -class QuotedAttachment(SerializableAttrs): - content_type: Optional[str] = field(default=None, json="contentType") - filename: Optional[str] = field(default=None, json="fileName") - - -@dataclass -class Quote(SerializableAttrs): - id: int - author: Address - text: Optional[str] = None - attachments: Optional[List[QuotedAttachment]] = None - mentions: Optional[List[Mention]] = None - - -@dataclass(kw_only=True) -class Reaction(SerializableAttrs): - emoji: str - remove: bool = False - target_author: Address = field(json="targetAuthor") - target_sent_timestamp: int = field(json="targetSentTimestamp") - - -@dataclass -class Sticker(SerializableAttrs): - attachment: Attachment - pack_id: str = field(json="packID") - pack_key: str = field(json="packKey") - sticker_id: int = field(json="stickerID") - - -@dataclass -class RemoteDelete(SerializableAttrs): - target_sent_timestamp: int - - -class SharedContactDetailType(SerializableEnum): - HOME = "HOME" - WORK = "WORK" - MOBILE = "MOBILE" - CUSTOM = "CUSTOM" - - -@dataclass -class SharedContactDetail(SerializableAttrs): - type: SharedContactDetailType - value: str - label: Optional[str] = None - - @property - def type_or_label(self) -> str: - if self.type != SharedContactDetailType.CUSTOM: - return self.type.value.title() - return self.label - - -@dataclass -class SharedContactAvatar(SerializableAttrs): - attachment: Attachment - is_profile: bool - - -@dataclass -class SharedContactName(SerializableAttrs): - display: Optional[str] = None - given: Optional[str] = None - middle: Optional[str] = None - family: Optional[str] = None - prefix: Optional[str] = None - suffix: Optional[str] = None - - @property - def parts(self) -> List[str]: - return [self.prefix, self.given, self.middle, self.family, self.suffix] - - def __str__(self) -> str: - if self.display: - return self.display - return " ".join(part for part in self.parts if part) - - -@dataclass -class SharedContactAddress(SerializableAttrs): - type: SharedContactDetailType - label: Optional[str] = None - street: Optional[str] = None - pobox: Optional[str] = None - neighborhood: Optional[str] = None - city: Optional[str] = None - region: Optional[str] = None - postcode: Optional[str] = None - country: Optional[str] = None - - -@dataclass -class SharedContact(SerializableAttrs): - name: SharedContactName - organization: Optional[str] = None - avatar: Optional[SharedContactAvatar] = None - email: List[SharedContactDetail] = field(factory=lambda: []) - phone: List[SharedContactDetail] = field(factory=lambda: []) - address: Optional[SharedContactAddress] = None - - -@dataclass -class LinkPreview(SerializableAttrs): - url: str - title: str - description: str - attachment: Optional[Attachment] = None - - -@dataclass -class MessageData(SerializableAttrs): - timestamp: int - - body: Optional[str] = None - quote: Optional[Quote] = None - reaction: Optional[Reaction] = None - attachments: List[Attachment] = field(factory=lambda: []) - sticker: Optional[Sticker] = None - mentions: List[Mention] = field(factory=lambda: []) - contacts: List[SharedContact] = field(factory=lambda: []) - - group_v2: Optional[GroupV2ID] = field(default=None, json="groupV2") - - end_session: bool = field(default=False, json="endSession") - expires_in_seconds: int = field(default=0, json="expiresInSeconds") - is_expiration_update: bool = field(default=False) - profile_key_update: bool = field(default=False, json="profileKeyUpdate") - view_once: bool = field(default=False, json="viewOnce") - - remote_delete: Optional[RemoteDelete] = field(default=None, json="remoteDelete") - - previews: List[LinkPreview] = field(factory=lambda: []) - - @property - def is_message(self) -> bool: - return bool(self.body or self.attachments or self.sticker or self.contacts) - - -@dataclass -class SentSyncMessage(SerializableAttrs): - message: MessageData - timestamp: int - expiration_start_timestamp: Optional[int] = field( - default=None, json="expirationStartTimestamp" - ) - is_recipient_update: bool = field(default=False, json="isRecipientUpdate") - unidentified_status: Dict[str, bool] = field(factory=lambda: {}) - destination: Optional[Address] = None - - -class TypingAction(SerializableEnum): - UNKNOWN = "UNKNOWN" - STARTED = "STARTED" - STOPPED = "STOPPED" - - -@dataclass -class TypingMessage(SerializableAttrs): - action: TypingAction - timestamp: int - group_id: Optional[GroupID] = None - - -@dataclass -class OwnReadReceipt(SerializableAttrs): - sender: Address - timestamp: int - - -class ReceiptType(SerializableEnum): - UNKNOWN = "UNKNOWN" - DELIVERY = "DELIVERY" - READ = "READ" - VIEWED = "VIEWED" - - -@dataclass -class ReceiptMessage(SerializableAttrs): - type: ReceiptType - timestamps: List[int] - when: int - - -@dataclass -class DecryptionErrorMessage(SerializableAttrs): - timestamp: int - device_id: int - ratchet_key: Optional[str] = None - - -@dataclass -class ContactSyncMeta(SerializableAttrs): - id: Optional[str] = None - - -@dataclass -class ConfigItem(SerializableAttrs): - present: bool = False - - -@dataclass -class ClientConfiguration(SerializableAttrs): - read_receipts: Optional[ConfigItem] = field(factory=lambda: ConfigItem(), json="readReceipts") - typing_indicators: Optional[ConfigItem] = field( - factory=lambda: ConfigItem(), json="typingIndicators" - ) - link_previews: Optional[ConfigItem] = field(factory=lambda: ConfigItem(), json="linkPreviews") - unidentified_delivery_indicators: Optional[ConfigItem] = field( - factory=lambda: ConfigItem(), json="unidentifiedDeliveryIndicators" - ) - - -class StickerPackOperation(ExtensibleEnum): - INSTALL = "INSTALL" - # there are very likely others - - -@dataclass -class StickerPackOperations(SerializableAttrs): - type: StickerPackOperation - pack_id: str = field(json="packID") - pack_key: str = field(json="packKey") - - -@dataclass -class SyncMessage(SerializableAttrs): - sent: Optional[SentSyncMessage] = None - read_messages: Optional[List[OwnReadReceipt]] = field(default=None, json="readMessages") - contacts: Optional[ContactSyncMeta] = None - groups: Optional[ContactSyncMeta] = None - configuration: Optional[ClientConfiguration] = None - # blocked_list: Optional[???] = field(default=None, json="blockedList") - sticker_pack_operations: Optional[List[StickerPackOperations]] = field( - default=None, json="stickerPackOperations" - ) - contacts_complete: bool = field(default=False, json="contactsComplete") - - -class OfferMessageType(SerializableEnum): - AUDIO_CALL = "audio_call" - VIDEO_CALL = "video_call" - - -@dataclass -class OfferMessage(SerializableAttrs): - id: int - type: OfferMessageType - opaque: Optional[str] = None - sdp: Optional[str] = None - - -@dataclass -class AnswerMessage(SerializableAttrs): - id: int - opaque: Optional[str] = None - sdp: Optional[str] = None - - -@dataclass -class ICEUpdateMessage(SerializableAttrs): - id: int - opaque: Optional[str] = None - sdp: Optional[str] = None - - -@dataclass -class BusyMessage(SerializableAttrs): - id: int - - -class HangupMessageType(SerializableEnum): - NORMAL = "normal" - ACCEPTED = "accepted" - DECLINED = "declined" - BUSY = "busy" - NEED_PERMISSION = "need_permission" - - -@dataclass -class HangupMessage(SerializableAttrs): - id: int - type: HangupMessageType - device_id: int - legacy: bool = False - - -@dataclass -class CallMessage(SerializableAttrs): - offer_message: Optional[OfferMessage] = None - hangup_message: Optional[HangupMessage] = None - answer_message: Optional[AnswerMessage] = None - busy_message: Optional[BusyMessage] = None - ice_update_message: Optional[List[ICEUpdateMessage]] = None - multi_ring: bool = False - destination_device_id: Optional[int] = None - - -class MessageType(SerializableEnum): - CIPHERTEXT = "CIPHERTEXT" - PLAINTEXT_CONTENT = "PLAINTEXT_CONTENT" - UNIDENTIFIED_SENDER = "UNIDENTIFIED_SENDER" - RECEIPT = "RECEIPT" - PREKEY_BUNDLE = "PREKEY_BUNDLE" - KEY_EXCHANGE = "KEY_EXCHANGE" - UNKNOWN = "UNKNOWN" - - -@dataclass(kw_only=True) -class IncomingMessage(SerializableAttrs): - account: str - source: Address - timestamp: int - - type: MessageType - source_device: Optional[int] = None - server_guid: str - server_receiver_timestamp: int - server_deliver_timestamp: int - has_content: bool - unidentified_sender: bool - has_legacy_message: bool - - call_message: Optional[CallMessage] = field(default=None) - data_message: Optional[MessageData] = field(default=None) - sync_message: Optional[SyncMessage] = field(default=None) - typing_message: Optional[TypingMessage] = None - receipt_message: Optional[ReceiptMessage] = None - decryption_error_message: Optional[DecryptionErrorMessage] = None - - -@dataclass(kw_only=True) -class ErrorMessageData(SerializableAttrs): - sender: str - timestamp: int - message: str - sender_device: int - content_hint: int - - -@dataclass(kw_only=True) -class ErrorMessage(SerializableAttrs): - type: str - version: str - data: ErrorMessageData - error: bool - account: str - - -@dataclass(kw_only=True) -class StorageChangeData(SerializableAttrs): - version: int - - -@dataclass(kw_only=True) -class StorageChange(SerializableAttrs): - type: str - version: str - data: StorageChangeData - account: str - - -class WebsocketConnectionState(SerializableEnum): - # States from signald itself - DISCONNECTED = "DISCONNECTED" - CONNECTING = "CONNECTING" - CONNECTED = "CONNECTED" - RECONNECTING = "RECONNECTING" - DISCONNECTING = "DISCONNECTING" - AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED" - FAILED = "FAILED" - - # Socket disconnect state - SOCKET_DISCONNECTED = "SOCKET_DISCONNECTED" - - -class WebsocketType(SerializableEnum): - IDENTIFIED = "IDENTIFIED" - UNIDENTIFIED = "UNIDENTIFIED" - - -@dataclass -class MessageResendSuccessEvent(SerializableAttrs): - account: str - timestamp: int - - -@dataclass -class WebsocketConnectionStateChangeEvent(SerializableAttrs): - state: WebsocketConnectionState - account: str - socket: Optional[WebsocketType] = None - exception: Optional[str] = None - - -@dataclass -class JoinGroupResponse(SerializableAttrs): - group_id: str = field(json="groupID") - pending_admin_approval: bool = field(json="pendingAdminApproval") - member_count: Optional[int] = field(json="memberCount", default=None) - revision: Optional[int] = None - title: Optional[str] = None - description: Optional[str] = None - - -class ProofRequiredType(SerializableEnum): - RECAPTCHA = "RECAPTCHA" - PUSH_CHALLENGE = "PUSH_CHALLENGE" - - -@dataclass -class ProofRequiredError(SerializableAttrs): - options: List[ProofRequiredType] = field(factory=lambda: []) - message: Optional[str] = None - retry_after: Optional[int] = None - token: Optional[str] = None - - -@dataclass -class SendSuccessData(SerializableAttrs): - devices: List[int] = field(factory=lambda: []) - duration: Optional[int] = None - needs_sync: bool = field(json="needsSync", default=False) - unidentified: bool = field(json="unidentified", default=False) - - -@dataclass -class SendMessageResult(SerializableAttrs): - address: Address - success: Optional[SendSuccessData] = None - proof_required_failure: Optional[ProofRequiredError] = None - identity_failure: Optional[str] = field(json="identityFailure", default=None) - network_failure: bool = field(json="networkFailure", default=False) - unregistered_failure: bool = field(json="unregisteredFailure", default=False) - - -@dataclass -class SendMessageResponse(SerializableAttrs): - results: List[SendMessageResult] - timestamp: int diff --git a/mautrix_signal/__init__.py b/mautrix_signal/__init__.py deleted file mode 100644 index caffae2..0000000 --- a/mautrix_signal/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__version__ = "0.4.2" -__author__ = "Tulir Asokan " diff --git a/mautrix_signal/__main__.py b/mautrix_signal/__main__.py deleted file mode 100644 index 15a2285..0000000 --- a/mautrix_signal/__main__.py +++ /dev/null @@ -1,148 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2020 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from typing import Any, Dict -from random import uniform -import asyncio -import logging - -from mautrix.bridge import Bridge -from mautrix.types import RoomID, UserID - -from . import commands -from .config import Config -from .db import init as init_db, upgrade_table -from .matrix import MatrixHandler -from .portal import Portal -from .puppet import Puppet -from .signal import SignalHandler -from .user import User -from .version import linkified_version, version -from .web import ProvisioningAPI - -SYNC_JITTER = 10 - - -class SignalBridge(Bridge): - module = "mautrix_signal" - name = "mautrix-signal" - command = "python -m mautrix-signal" - description = "A Matrix-Signal puppeting bridge." - repo_url = "https://github.com/mautrix/signal" - version = version - markdown_version = linkified_version - config_class = Config - matrix_class = MatrixHandler - upgrade_table = upgrade_table - - matrix: MatrixHandler - signal: SignalHandler - config: Config - provisioning_api: ProvisioningAPI - periodic_sync_task: asyncio.Task - - def prepare_db(self) -> None: - super().prepare_db() - init_db(self.db) - - def prepare_bridge(self) -> None: - self.signal = SignalHandler(self) - super().prepare_bridge() - cfg = self.config["bridge.provisioning"] - self.provisioning_api = ProvisioningAPI( - self, cfg["shared_secret"], cfg["segment_key"], cfg["segment_user_id"] - ) - self.az.app.add_subapp(cfg["prefix"], self.provisioning_api.app) - - async def start(self) -> None: - User.init_cls(self) - self.add_startup_actions(Puppet.init_cls(self)) - Portal.init_cls(self) - self.add_startup_actions(Portal.restart_scheduled_disappearing()) - if self.config["bridge.resend_bridge_info"]: - self.add_startup_actions(self.resend_bridge_info()) - self.add_startup_actions(self.signal.start()) - await super().start() - self.periodic_sync_task = asyncio.create_task(self._periodic_sync_loop()) - - @staticmethod - async def _actual_periodic_sync_loop(log: logging.Logger, interval: int) -> None: - while True: - try: - await asyncio.sleep(interval) - except asyncio.CancelledError: - return - log.info("Executing periodic syncs") - for user in User.by_username.values(): - # Add some randomness to the sync to avoid a thundering herd - await asyncio.sleep(uniform(0, SYNC_JITTER)) - try: - await user.sync() - except asyncio.CancelledError: - return - except Exception: - log.exception("Error while syncing %s", user.mxid) - - async def _periodic_sync_loop(self) -> None: - log = logging.getLogger("mau.periodic_sync") - interval = self.config["bridge.periodic_sync"] - if interval <= 0: - log.debug("Periodic sync is not enabled") - return - log.debug("Starting periodic sync loop") - await self._actual_periodic_sync_loop(log, interval) - log.debug("Periodic sync stopped") - - def prepare_stop(self) -> None: - self.add_shutdown_actions(self.signal.stop()) - for puppet in Puppet.by_custom_mxid.values(): - puppet.stop() - - async def resend_bridge_info(self) -> None: - self.config["bridge.resend_bridge_info"] = False - self.config.save() - self.log.info("Re-sending bridge info state event to all portals") - async for portal in Portal.all_with_room(): - await portal.update_bridge_info() - self.log.info("Finished re-sending bridge info state events") - - async def get_user(self, user_id: UserID, create: bool = True) -> User: - return await User.get_by_mxid(user_id, create=create) - - async def get_portal(self, room_id: RoomID) -> Portal: - return await Portal.get_by_mxid(room_id) - - async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet: - return await Puppet.get_by_mxid(user_id, create=create) - - async def get_double_puppet(self, user_id: UserID) -> Puppet: - return await Puppet.get_by_custom_mxid(user_id) - - def is_bridge_ghost(self, user_id: UserID) -> bool: - return bool(Puppet.get_id_from_mxid(user_id)) - - async def count_logged_in_users(self) -> int: - return len([user for user in User.by_username.values() if user.username]) - - async def manhole_global_namespace(self, user_id: UserID) -> Dict[str, Any]: - return { - **await super().manhole_global_namespace(user_id), - "User": User, - "Portal": Portal, - "Puppet": Puppet, - } - - -SignalBridge().run() diff --git a/mautrix_signal/commands/__init__.py b/mautrix_signal/commands/__init__.py deleted file mode 100644 index 344ac29..0000000 --- a/mautrix_signal/commands/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .auth import SECTION_AUTH -from .conn import SECTION_CONNECTION -from .signal import SECTION_SIGNAL diff --git a/mautrix_signal/commands/auth.py b/mautrix_signal/commands/auth.py deleted file mode 100644 index 501f71a..0000000 --- a/mautrix_signal/commands/auth.py +++ /dev/null @@ -1,296 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2020 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from typing import Union -import io - -from mausignald.errors import ( - AuthorizationFailedError, - CaptchaRequiredError, - ScanTimeoutError, - TimeoutException, - UnexpectedResponse, -) -from mautrix.appservice import IntentAPI -from mautrix.bridge.commands import HelpSection, command_handler -from mautrix.types import EventID, ImageInfo, MediaMessageEventContent, MessageType, UserID - -from .. import puppet as pu -from ..util import normalize_number -from .typehint import CommandEvent - -try: - import PIL as _ - import qrcode -except ImportError: - qrcode = None - -SECTION_AUTH = HelpSection("Authentication", 10, "") - - -async def make_qr( - intent: IntentAPI, data: Union[str, bytes], body: str = None -) -> MediaMessageEventContent: - # TODO always encrypt QR codes? - buffer = io.BytesIO() - image = qrcode.make(data) - size = image.pixel_size - image.save(buffer, "PNG") - qr = buffer.getvalue() - mxc = await intent.upload_media(qr, "image/png", "qr.png", len(qr)) - return MediaMessageEventContent( - body=body or data, - url=mxc, - msgtype=MessageType.IMAGE, - info=ImageInfo(mimetype="image/png", size=len(qr), width=size, height=size), - ) - - -@command_handler( - needs_auth=False, - management_only=True, - needs_admin=True, - help_section=SECTION_AUTH, - help_text="Connect to an existing account on signald", - help_args="[mxid] ", -) -async def connect_existing(evt: CommandEvent) -> EventID: - if len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp connect-existing [mxid] `") - if evt.args[0].startswith("@"): - evt.sender = await evt.bridge.get_user(UserID(evt.args[0])) - evt.args = evt.args[1:] - if await evt.sender.is_logged_in(): - return await evt.reply( - "You're already logged in. " - "If you want to relink, log out with `$cmdprefix+sp logout` first." - ) - try: - account_id = normalize_number("".join(evt.args)) - except Exception: - return await evt.reply("Please enter the phone number in international format (E.164)") - accounts = await evt.bridge.signal.list_accounts() - for account in accounts: - if account.account_id == account_id: - await evt.sender.on_signin(account) - return await evt.reply( - f"Successfully logged in as {pu.Puppet.fmt_phone(evt.sender.username)} " - f"(device #{account.device_id})" - ) - return await evt.reply(f"Account with ID {account_id} not found in signald") - - -@command_handler( - needs_auth=False, - management_only=True, - help_section=SECTION_AUTH, - help_text="Link the bridge as a secondary device", - help_args="[device name]", - aliases=["login"], -) -async def link(evt: CommandEvent) -> None: - if qrcode is None: - await evt.reply("Can't generate QR code: qrcode and/or PIL not installed") - return - if await evt.sender.is_logged_in(): - await evt.reply( - "You're already logged in. " - "If you want to relink, log out with `$cmdprefix+sp logout` first." - ) - return - # TODO make default device name configurable - device_name = " ".join(evt.args) or "Mautrix-Signal bridge" - - sess = await evt.bridge.signal.start_link() - content = await make_qr(evt.az.intent, sess.uri) - event_id = await evt.az.intent.send_message(evt.room_id, content) - try: - account = await evt.bridge.signal.finish_link( - session_id=sess.session_id, overwrite=True, device_name=device_name - ) - except (TimeoutException, ScanTimeoutError): - await evt.reply("Linking timed out, please try again.") - except Exception: - evt.log.exception("Fatal error while waiting for linking to finish") - await evt.reply( - "Fatal error while waiting for linking to finish (see logs for more details)" - ) - else: - await evt.sender.on_signin(account) - await evt.reply( - f"Successfully logged in as {pu.Puppet.fmt_phone(evt.sender.username)} " - f"(device #{account.device_id})" - ) - finally: - await evt.main_intent.redact(evt.room_id, event_id) - - -@command_handler( - needs_auth=False, - management_only=True, - help_section=SECTION_AUTH, - is_enabled_for=lambda evt: evt.config["signal.registration_enabled"], - help_text="Sign into Signal as the primary device", - help_args="", -) -async def register(evt: CommandEvent) -> None: - if len(evt.args) == 0: - await evt.reply("**Usage**: $cmdprefix+sp register [--voice] [--captcha ] ") - return - if await evt.sender.is_logged_in(): - await evt.reply( - "You're already logged in. " - "If you want to re-register, log out with `$cmdprefix+sp logout` first." - ) - return - voice = False - captcha = None - while True: - flag = evt.args[0].lower() - if flag == "--voice" or flag == "-v": - voice = True - evt.args = evt.args[1:] - elif flag == "--captcha" or flag == "-c": - if "=" in evt.args[0]: - captcha = evt.args[0].split("=", 1)[1] - evt.args = evt.args[1:] - else: - captcha = evt.args[1] - evt.args = evt.args[2:] - else: - break - try: - phone = normalize_number(evt.args[0]) - except Exception: - await evt.reply("Please enter the phone number in international format (E.164)") - return - try: - username = await evt.bridge.signal.register(phone, voice=voice, captcha=captcha) - evt.sender.command_status = { - "action": "Register", - "room_id": evt.room_id, - "next": enter_register_code, - "username": username, - } - await evt.reply("Register SMS requested, please enter the code here.") - except CaptchaRequiredError: - await evt.reply( - "Captcha required. Please follow the instructions at https://signald.org/articles/captcha/ " - "to obtain a captcha token and paste it here." - ) - evt.sender.command_status = { - "action": "Register", - "room_id": evt.room_id, - "next": enter_captcha_token, - "voice": voice, - "phone": phone, - } - - -async def enter_captcha_token(evt: CommandEvent) -> None: - captcha = evt.args[0] - phone = evt.sender.command_status["phone"] - voice = evt.sender.command_status["voice"] - username = await evt.bridge.signal.register(phone, voice=voice, captcha=captcha) - evt.sender.command_status = { - "action": "Register", - "room_id": evt.room_id, - "next": enter_register_code, - "username": username, - } - await evt.reply("Register SMS requested, please enter the code here.") - - -async def enter_register_code(evt: CommandEvent) -> None: - try: - username = evt.sender.command_status["username"] - account = await evt.bridge.signal.verify(username, code=evt.args[0]) - except UnexpectedResponse as e: - if e.resp_type == "error": - await evt.reply(e.data) - else: - raise - else: - await evt.sender.on_signin(account) - await evt.reply( - f"Successfully logged in as {pu.Puppet.fmt_phone(evt.sender.username)}." - f"\n\n**N.B.** You must set a Signal profile name with `$cmdprefix+sp " - f"set-profile-name ` before you can participate in new groups." - ) - - -@command_handler( - needs_auth=True, - management_only=True, - help_section=SECTION_AUTH, - help_text="Remove all local data about your Signal link", -) -async def logout(evt: CommandEvent) -> None: - if not evt.sender.username: - await evt.reply("You're not logged in") - return - await evt.sender.logout() - await evt.reply("Successfully logged out") - - -@command_handler( - needs_auth=True, - management_only=True, - help_section=SECTION_AUTH, - help_text="List devices linked to your Signal account", -) -async def list_devices(evt: CommandEvent) -> None: - devices = await evt.bridge.signal.get_linked_devices(evt.sender.username) - await evt.reply( - "\n".join( - f"* #{dev.id}: {dev.name_with_default} (created {dev.created_fmt}, last seen " - f"{dev.last_seen_fmt})" - for dev in devices - ) - ) - - -@command_handler( - needs_auth=True, - management_only=True, - help_section=SECTION_AUTH, - help_text="Add a device with a `sgnl://linkdevice?...` URI from a QR code", -) -async def add_linked_device(evt: CommandEvent) -> EventID: - if len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp add-linked-device `") - try: - await evt.bridge.signal.add_linked_device(evt.sender.username, evt.args[0]) - except AuthorizationFailedError as e: - return await evt.reply(f"{e} Only the primary device can add linked devices.") - else: - return await evt.reply("Device linked successfully") - - -@command_handler( - needs_auth=True, - management_only=True, - help_section=SECTION_AUTH, - help_text="Remove a linked device", -) -async def remove_linked_device(evt: CommandEvent) -> EventID: - if len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp remove-linked-device `") - device_id = int(evt.args[0]) - try: - await evt.bridge.signal.remove_linked_device(evt.sender.username, device_id) - except AuthorizationFailedError as e: - return await evt.reply(f"{e} Only the primary device can remove linked devices.") - return await evt.reply("Device removed") diff --git a/mautrix_signal/commands/conn.py b/mautrix_signal/commands/conn.py deleted file mode 100644 index 4e7ff62..0000000 --- a/mautrix_signal/commands/conn.py +++ /dev/null @@ -1,87 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from mautrix.bridge.commands import HelpSection, command_handler -from mautrix.types import EventID - -from .typehint import CommandEvent - -SECTION_CONNECTION = HelpSection("Connection management", 15, "") - - -@command_handler( - needs_auth=False, - management_only=True, - help_section=SECTION_CONNECTION, - help_text="Mark this room as your bridge notice room.", -) -async def set_notice_room(evt: CommandEvent) -> None: - evt.sender.notice_room = evt.room_id - await evt.sender.update() - await evt.reply("This room has been marked as your bridge notice room") - - -@command_handler( - needs_auth=True, - management_only=False, - help_section=SECTION_CONNECTION, - help_text="Relay messages in this room through your Signal account.", -) -async def set_relay(evt: CommandEvent) -> EventID: - if not evt.config["bridge.relay.enabled"]: - return await evt.reply("Relay mode is not enabled in this instance of the bridge.") - elif not evt.is_portal: - return await evt.reply("This is not a portal room.") - await evt.portal.set_relay_user(evt.sender) - return await evt.reply( - "Messages from non-logged-in users in this room will now be bridged " - "through your Signal account." - ) - - -@command_handler( - needs_auth=True, - management_only=False, - help_section=SECTION_CONNECTION, - help_text="Stop relaying messages in this room.", -) -async def unset_relay(evt: CommandEvent) -> EventID: - if not evt.config["bridge.relay.enabled"]: - return await evt.reply("Relay mode is not enabled in this instance of the bridge.") - elif not evt.is_portal: - return await evt.reply("This is not a portal room.") - elif not evt.portal.has_relay: - return await evt.reply("This room does not have a relay user set.") - await evt.portal.set_relay_user(None) - return await evt.reply("Messages from non-logged-in users will no longer be bridged.") - - -# @command_handler(needs_auth=False, management_only=True, help_section=SECTION_CONNECTION, -# help_text="Check if you're logged into Twitter") -# async def ping(evt: CommandEvent) -> None: -# if evt.sender.username: -# await evt.reply("") -# user_info = await evt.sender.get_info() -# await evt.reply(f"You're logged in as {user_info.name} " -# f"([@{evt.sender.username}](https://twitter.com/{evt.sender.username}), " -# f"user ID: {evt.sender.twid})") - - -# TODO request syncs or something -# @command_handler(needs_auth=True, management_only=False, help_section=SECTION_CONNECTION, -# help_text="Synchronize portals") -# async def sync(evt: CommandEvent) -> None: -# await evt.sender.sync() -# await evt.reply("Synchronization complete") diff --git a/mautrix_signal/commands/signal.py b/mautrix_signal/commands/signal.py deleted file mode 100644 index ba5310d..0000000 --- a/mautrix_signal/commands/signal.py +++ /dev/null @@ -1,579 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from __future__ import annotations - -from typing import Awaitable -import base64 -import json - -from mausignald.errors import UnknownIdentityKey, UnregisteredUserError -from mausignald.types import Address, GroupID, TrustLevel -from mautrix.bridge import RejectMatrixInvite -from mautrix.bridge.commands import SECTION_ADMIN, HelpSection, command_handler -from mautrix.types import ( - ContentURI, - EventID, - EventType, - JoinRule, - PowerLevelStateEventContent, - RoomID, -) -from mautrix.util import background_task - -from .. import portal as po, puppet as pu -from ..util import normalize_number, user_has_power_level -from .auth import make_qr -from .typehint import CommandEvent -from .util import get_initial_state - -try: - import PIL as _ - import qrcode -except ImportError: - qrcode = None - -SECTION_SIGNAL = HelpSection("Signal actions", 20, "") - - -async def _get_puppet_from_cmd(evt: CommandEvent) -> pu.Puppet | None: - try: - phone = normalize_number("".join(evt.args)) - except Exception: - await evt.reply( - f"**Usage:** `$cmdprefix+sp {evt.command} ` " - "(enter phone number in international format)" - ) - return None - - puppet: pu.Puppet = await pu.Puppet.get_by_number(phone) - if not puppet: - if not evt.sender.username: - await evt.reply("UUID of user not known") - return None - try: - uuid = await evt.bridge.signal.find_uuid(evt.sender.username, phone) - except UnregisteredUserError: - await evt.reply("User not registered") - return None - - if uuid: - puppet = await pu.Puppet.get_by_uuid(uuid) - else: - await evt.reply("UUID of user not found") - return None - return puppet - - -def _format_safety_number(number: str) -> str: - line_size = 20 - chunk_size = 5 - return "\n".join( - " ".join( - [ - number[chunk : chunk + chunk_size] - for chunk in range(line, line + line_size, chunk_size) - ] - ) - for line in range(0, len(number), line_size) - ) - - -def _pill(puppet: "pu.Puppet") -> str: - return f"[{puppet.name}](https://matrix.to/#/{puppet.mxid})" - - -@command_handler( - needs_auth=True, - management_only=False, - help_section=SECTION_SIGNAL, - help_text="Open a private chat portal with a specific phone number", - help_args="<_phone_>", -) -async def pm(evt: CommandEvent) -> None: - puppet = await _get_puppet_from_cmd(evt) - if not puppet: - return - portal = await po.Portal.get_by_chat_id(puppet.uuid, receiver=evt.sender.username, create=True) - if portal.mxid: - await evt.reply( - f"You already have a private chat with {puppet.name}: " - f"[{portal.mxid}](https://matrix.to/#/{portal.mxid})" - ) - await portal.main_intent.invite_user(portal.mxid, evt.sender.mxid) - return - - await portal.create_matrix_room(evt.sender, puppet.address) - await evt.reply(f"Created a portal room with {_pill(puppet)} and invited you to it") - - -@command_handler( - needs_auth=True, - management_only=False, - help_section=SECTION_SIGNAL, - help_text="Join a Signal group with an invite link", - help_args="<_link_>", -) -async def join(evt: CommandEvent) -> EventID: - if len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp join `") - try: - resp = await evt.bridge.signal.join_group(evt.sender.username, evt.args[0]) - if resp.pending_admin_approval: - return await evt.reply( - f"Successfully requested to join {resp.title}, waiting for admin approval." - ) - else: - return await evt.reply(f"Successfully joined {resp.title}") - except Exception: - evt.log.exception("Error trying to join group") - await evt.reply("Failed to join group (see logs for more details)") - - -@command_handler( - needs_auth=True, - management_only=False, - help_section=SECTION_SIGNAL, - help_text="Get the invite link to the current group", -) -async def invite_link(evt: CommandEvent) -> EventID: - if not evt.is_portal: - return await evt.reply("This is not a portal room.") - group = await evt.bridge.signal.get_group( - evt.sender.username, evt.portal.chat_id, evt.portal.revision - ) - if not group: - await evt.reply("Failed to get group info") - elif not group.invite_link: - await evt.reply("Invite link not available") - else: - await evt.reply(group.invite_link) - - -@command_handler( - needs_auth=True, - management_only=False, - help_section=SECTION_SIGNAL, - help_text="View the safety number of a specific user", - help_args="[--qr] [_phone_]", -) -async def safety_number(evt: CommandEvent) -> None: - show_qr = evt.args and evt.args[0].lower() == "--qr" - if show_qr: - if not qrcode: - await evt.reply("Can't generate QR code: qrcode and/or PIL not installed") - return - evt.args = evt.args[1:] - if len(evt.args) == 0 and evt.portal and evt.portal.is_direct: - puppet = await evt.portal.get_dm_puppet() - else: - puppet = await _get_puppet_from_cmd(evt) - if not puppet: - return - - resp = await evt.bridge.signal.get_identities(evt.sender.username, puppet.address) - if not resp.identities: - await evt.reply(f"No identities found for {_pill(puppet)}") - return - most_recent = resp.identities[0] - for identity in resp.identities: - if identity.added > most_recent.added: - most_recent = identity - uuid = resp.address.uuid or "unknown" - await evt.reply( - f"### {puppet.name}\n\n" - f"**UUID:** {uuid} \n" - f"**Trust level:** {most_recent.trust_level} \n" - f"**Safety number:**\n" - f"```\n{_format_safety_number(most_recent.safety_number)}\n```" - ) - if show_qr and most_recent.qr_code_data: - data = base64.b64decode(most_recent.qr_code_data) - content = await make_qr(evt.main_intent, data, "verification-qr.png") - await evt.main_intent.send_message(evt.room_id, content) - - -@command_handler( - needs_auth=True, - management_only=False, - help_section=SECTION_SIGNAL, - help_text="Set your Signal profile name", - help_args="<_name_>", -) -async def set_profile_name(evt: CommandEvent) -> None: - await evt.bridge.signal.set_profile(evt.sender.username, name=" ".join(evt.args)) - await evt.reply("Successfully updated profile name") - - -_trust_levels = [x.value for x in TrustLevel] - - -@command_handler( - needs_auth=True, - management_only=False, - help_section=SECTION_SIGNAL, - help_text="Mark another user's safety number as trusted", - help_args="<_recipient phone_> [_level_] <_safety number_>", -) -async def mark_trusted(evt: CommandEvent) -> EventID: - if len(evt.args) < 2: - return await evt.reply( - "**Usage:** `$cmdprefix+sp mark-trusted [level] `" - ) - number = normalize_number(evt.args[0]) - remaining_args = evt.args[1:] - trust_level = TrustLevel.TRUSTED_VERIFIED - if len(evt.args) > 2 and evt.args[1].upper() in _trust_levels: - trust_level = TrustLevel(evt.args[1]) - remaining_args = evt.args[2:] - safety_num = "".join(remaining_args).replace("\n", "") - if len(safety_num) != 60 or not safety_num.isdecimal(): - return await evt.reply("That doesn't look like a valid safety number") - try: - await evt.bridge.signal.trust( - evt.sender.username, - Address(number=number), - safety_number=safety_num, - trust_level=trust_level, - ) - except UnknownIdentityKey as e: - return await evt.reply(f"Failed to mark {number} as {trust_level.human_str}: {e}") - return await evt.reply(f"Successfully marked {number} as {trust_level.human_str}") - - -@command_handler( - needs_admin=False, - needs_auth=True, - help_section=SECTION_SIGNAL, - help_text="Sync data from Signal", -) -async def sync(evt: CommandEvent) -> None: - await evt.sender.sync() - await evt.reply("Sync complete") - - -@command_handler( - needs_admin=True, - needs_auth=False, - help_section=SECTION_ADMIN, - help_text="Send raw requests to signald", - help_args="[--user] <_json_>", -) -async def raw(evt: CommandEvent) -> None: - add_username = False - while True: - flag = evt.args[0].lower() - if flag == "--user": - add_username = True - else: - break - evt.args = evt.args[1:] - type = evt.args[0] - version = "v0" - if "." in type: - version, type = type.split(".", 1) - try: - args = json.loads(" ".join(evt.args[1:])) - except json.JSONDecodeError as e: - await evt.reply(f"JSON decode error: {e}") - return - if add_username: - if version == "v0" or (version == "v1" and type in ("send", "react")): - args["username"] = evt.sender.username - else: - args["account"] = evt.sender.username - if version: - args["version"] = version - - try: - resp_type, resp_data = await evt.bridge.signal._raw_request(type, **args) - except Exception as e: - await evt.reply(f"Error sending request: {e}") - else: - if resp_data is None: - await evt.reply(f"Got reply `{resp_type}` with no content") - else: - await evt.reply( - f"Got reply `{resp_type}`:\n\n```json\n{json.dumps(resp_data, indent=2)}\n```" - ) - - -missing_power_warning = ( - "Warning: The bridge bot ([{bot_mxid}](https://matrix.to/#/{bot_mxid})) does not have " - "sufficient privileges to change power levels on Matrix. Power level changes will not be " - "bridged." -) - -low_power_warning = ( - "Warning: The bridge bot ([{bot_mxid}](https://matrix.to/#/{bot_mxid})) has a power level " - "below or equal to 50. Bridged moderator rights are currently hardcoded to PL 50, so the " - "bridge bot must have a higher level to properly bridge them." -) - -meta_power_warning = ( - "Warning: Permissions for changing name, topic and avatar cannot be set separately on Signal. " - "Changes to those may not be bridged properly, unless the permissions are set to the same " - "level or lower than state_default." -) - - -@command_handler( - needs_auth=True, - management_only=False, - help_section=SECTION_SIGNAL, - help_text="Create a Signal group for the current Matrix room.", -) -async def create(evt: CommandEvent) -> EventID: - if evt.portal: - return await evt.reply("This is already a portal room.") - - title, about, levels, encrypted, avatar_url, join_rule = await get_initial_state( - evt.az.intent, evt.room_id - ) - - if not title: - return await evt.reply("Please set a room name before creating a Signal group.") - - portal = po.Portal( - chat_id=GroupID(""), - mxid=evt.room_id, - name=title, - topic=about or "", - encrypted=encrypted, - receiver="", - avatar_url=avatar_url, - ) - await warn_missing_power(levels, evt) - - await portal.create_signal_group(evt.sender, levels, join_rule) - - -@command_handler( - name="id", - needs_auth=True, - management_only=False, - help_section=SECTION_SIGNAL, - help_text="Get the ID of the Signal chat where this room is bridged.", -) -async def get_id(evt: CommandEvent) -> EventID: - if evt.portal: - return await evt.reply(f"This room is bridged to Signal chat ID `{evt.portal.chat_id}`.") - await evt.reply("This is not a portal room.") - - -@command_handler( - needs_auth=True, - management_only=False, - help_section=SECTION_SIGNAL, - help_text="Bridge the current Matrix room to the Signal chat with the given ID.", - help_args=" [matrix room ID]", -) -async def bridge(evt: CommandEvent) -> EventID: - if len(evt.args) == 0: - return await evt.reply( - "**Usage:** `$cmdprefix+sp bridge [matrix room ID]`" - ) - room_id = RoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id - that_this = "This" if room_id == evt.room_id else "That" - - portal = await po.Portal.get_by_mxid(room_id) - if portal: - return await evt.reply(f"{that_this} room is already a portal room.") - - if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"): - return await evt.reply(f"You do not have the permissions to bridge {that_this} room.") - - portal = await po.Portal.get_by_chat_id(GroupID(evt.args[0]), create=True) - if portal.mxid: - has_portal_message = ( - "That Signal chat already has a portal at " - f"[{portal.mxid}](https://matrix.to/#/{portal.mxid}). " - ) - if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"): - return await evt.reply( - f"{has_portal_message}" - "Additionally, you do not have the permissions to unbridge that room." - ) - evt.sender.command_status = { - "next": confirm_bridge, - "action": "Room bridging", - "mxid": portal.mxid, - "bridge_to_mxid": room_id, - "chat_id": portal.chat_id, - } - return await evt.reply( - f"{has_portal_message}" - "However, you have the permissions to unbridge that room.\n\n" - "To delete that portal completely and continue bridging, use " - "`$cmdprefix+sp delete-and-continue`. To unbridge the portal " - "without kicking Matrix users, use `$cmdprefix+sp unbridge-and-" - "continue`. To cancel, use `$cmdprefix+sp cancel`" - ) - evt.sender.command_status = { - "next": confirm_bridge, - "action": "Room bridging", - "bridge_to_mxid": room_id, - "chat_id": portal.chat_id, - } - return await evt.reply( - "That Signal chat has no existing portal. To confirm bridging the " - "chat to this room, use `$cmdprefix+sp continue`" - ) - - -async def cleanup_old_portal_while_bridging( - evt: CommandEvent, portal: po.Portal -) -> tuple[bool, Awaitable[None] | None]: - if not portal.mxid: - await evt.reply( - "The portal seems to have lost its Matrix room between you" - "calling `$cmdprefix+sp bridge` and this command.\n\n" - "Continuing without touching previous Matrix room..." - ) - return True, None - elif evt.args[0] == "delete-and-continue": - return True, portal.cleanup_portal("Portal deleted (moving to another room)") - elif evt.args[0] == "unbridge-and-continue": - return True, portal.cleanup_portal( - "Room unbridged (portal moving to another room)", puppets_only=True - ) - else: - await evt.reply( - "The chat you were trying to bridge already has a Matrix portal room.\n\n" - "Please use `$cmdprefix+sp delete-and-continue` or `$cmdprefix+sp unbridge-and-" - "continue` to either delete or unbridge the existing room (respectively) and " - "continue with the bridging.\n\n" - "If you changed your mind, use `$cmdprefix+sp cancel` to cancel." - ) - return False, None - - -async def confirm_bridge(evt: CommandEvent) -> EventID | None: - status = evt.sender.command_status - try: - portal = await po.Portal.get_by_chat_id(status["chat_id"]) - bridge_to_mxid = status["bridge_to_mxid"] - except KeyError: - evt.sender.command_status = None - return await evt.reply( - "Fatal error: chat_id missing from command_status. " - "This shouldn't happen unless you're messing with the command handler code." - ) - - is_logged_in = await evt.sender.is_logged_in() - - if "mxid" in status: - ok, coro = await cleanup_old_portal_while_bridging(evt, portal) - if not ok: - return None - elif coro: - await evt.reply("Cleaning up previous portal room...") - await coro - elif portal.mxid: - evt.sender.command_status = None - return await evt.reply( - "The portal seems to have created a Matrix room between you " - "calling `$cmdprefix+sp bridge` and this command.\n\n" - "Please start over by calling the bridge command again." - ) - elif evt.args[0] != "continue": - return await evt.reply( - "Please use `$cmdprefix+sp continue` to confirm the bridging or " - "`$cmdprefix+sp cancel` to cancel." - ) - evt.sender.command_status = None - async with portal._create_room_lock: - await _locked_confirm_bridge( - evt, portal=portal, room_id=bridge_to_mxid, is_logged_in=is_logged_in - ) - - -async def _locked_confirm_bridge( - evt: CommandEvent, portal: po.Portal, room_id: RoomID, is_logged_in: bool -) -> EventID | None: - try: - group = await evt.bridge.signal.get_group( - evt.sender.username, portal.chat_id, portal.revision - ) - except Exception: - evt.log.exception("Failed to get_group(%s) for manual bridging.", portal.chat_id) - if is_logged_in: - return await evt.reply( - "Failed to get info of signal chat. You are logged in, are you in that chat?" - ) - else: - return await evt.reply( - "Failed to get info of signal chat. " - "You're not logged in, this should not happen." - ) - - portal.mxid = room_id - portal.by_mxid[portal.mxid] = portal - ( - portal.title, - portal.about, - levels, - portal.encrypted, - portal.photo_id, - join_rule, - ) = await get_initial_state(evt.az.intent, evt.room_id) - await portal.save() - await portal.update_bridge_info() - - background_task.create(portal.update_matrix_room(evt.sender, group)) - - await warn_missing_power(levels, evt) - - return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.") - - -async def warn_missing_power(levels: PowerLevelStateEventContent, evt: CommandEvent) -> None: - bot_pl = levels.get_user_level(evt.az.bot_mxid) - if bot_pl < levels.get_event_level(EventType.ROOM_POWER_LEVELS): - await evt.reply(missing_power_warning.format(bot_mxid=evt.az.bot_mxid)) - elif bot_pl <= 50: - await evt.reply(low_power_warning.format(bot_mxid=evt.az.bot_mxid)) - if levels.state_default < 50 and ( - levels.events[EventType.ROOM_NAME] >= 50 - or levels.events[EventType.ROOM_AVATAR] >= 50 - or levels.events[EventType.ROOM_TOPIC] >= 50 - ): - await evt.reply(meta_power_warning) - - -@command_handler( - needs_auth=False, - management_only=False, - help_section=SECTION_SIGNAL, - help_text="Invite a Signal user by phone number", - help_args="<_phone_>", -) -async def invite(evt: CommandEvent) -> EventID | None: - if not evt.is_portal: - return await evt.reply("This is not a portal room.") - portal = evt.portal - puppet = await _get_puppet_from_cmd(evt) - if not puppet: - return None - levels = await portal.main_intent.get_power_levels(portal.mxid) - if levels.get_user_level(puppet.mxid) < levels.invite: - return await evt.reply("You do not have permissions to invite users to this room") - - try: - info = await portal.handle_matrix_invite(evt.sender, puppet) - sender, is_relay = await portal.get_relay_sender(evt.sender, "updating info") - await portal.update_info(sender, info) - except RejectMatrixInvite as e: - return await evt.reply(f"Failed to invite {puppet.name}: {e}") diff --git a/mautrix_signal/commands/typehint.py b/mautrix_signal/commands/typehint.py deleted file mode 100644 index 21d4177..0000000 --- a/mautrix_signal/commands/typehint.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import TYPE_CHECKING - -from mautrix.bridge.commands import CommandEvent as BaseCommandEvent - -if TYPE_CHECKING: - from ..__main__ import SignalBridge - from ..portal import Portal - from ..user import User - - -class CommandEvent(BaseCommandEvent): - bridge: "SignalBridge" - sender: "User" - portal: "Portal" diff --git a/mautrix_signal/commands/util.py b/mautrix_signal/commands/util.py deleted file mode 100644 index 20d8e4e..0000000 --- a/mautrix_signal/commands/util.py +++ /dev/null @@ -1,58 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from mautrix.appservice import IntentAPI -from mautrix.types import ContentURI, EventType, JoinRule, PowerLevelStateEventContent, RoomID - - -async def get_initial_state( - intent: IntentAPI, room_id: RoomID -) -> tuple[ - str | None, - str | None, - PowerLevelStateEventContent | None, - bool, - ContentURI | None, - JoinRule | None, -]: - state = await intent.get_state(room_id) - title: str | None = None - about: str | None = None - levels: PowerLevelStateEventContent | None = None - encrypted: bool = False - avatar_url: ContentURI | None = None - join_rule: JoinRule | None = None - for event in state: - try: - if event.type == EventType.ROOM_NAME: - title = event.content.name - elif event.type == EventType.ROOM_TOPIC: - about = event.content.topic - elif event.type == EventType.ROOM_POWER_LEVELS: - levels = event.content - elif event.type == EventType.ROOM_CANONICAL_ALIAS: - title = title or event.content.canonical_alias - elif event.type == EventType.ROOM_ENCRYPTION: - encrypted = True - elif event.type == EventType.ROOM_AVATAR: - avatar_url = event.content.url - elif event.type == EventType.ROOM_JOIN_RULES: - join_rule = event.content.join_rule - except KeyError: - # Some state event probably has empty content - pass - return title, about, levels, encrypted, avatar_url, join_rule diff --git a/mautrix_signal/config.py b/mautrix_signal/config.py deleted file mode 100644 index 2885b98..0000000 --- a/mautrix_signal/config.py +++ /dev/null @@ -1,119 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from typing import Any, List, NamedTuple -import os - -from mautrix.bridge.config import BaseBridgeConfig -from mautrix.client import Client -from mautrix.types import UserID -from mautrix.util.config import ConfigUpdateHelper, ForbiddenDefault, ForbiddenKey - -Permissions = NamedTuple("Permissions", relay=bool, user=bool, admin=bool, level=str) - - -class Config(BaseBridgeConfig): - @property - def forbidden_defaults(self) -> List[ForbiddenDefault]: - return [ - *super().forbidden_defaults, - ForbiddenDefault("appservice.database", "postgres://username:password@hostname/db"), - ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")), - ] - - def do_update(self, helper: ConfigUpdateHelper) -> None: - super().do_update(helper) - copy, copy_dict, base = helper - - copy("signal.socket_path") - copy("signal.outgoing_attachment_dir") - copy("signal.avatar_dir") - copy("signal.data_dir") - copy("signal.delete_unknown_accounts_on_start") - copy("signal.remove_file_after_handling") - copy("signal.registration_enabled") - copy("signal.enable_disappearing_messages_in_groups") - - copy("metrics.enabled") - copy("metrics.listen_port") - - copy("bridge.username_template") - copy("bridge.displayname_template") - if self["bridge.allow_contact_list_name_updates"]: - base["bridge.contact_list_names"] = "allow" - else: - copy("bridge.contact_list_names") - copy("bridge.displayname_preference") - - copy("bridge.autocreate_group_portal") - copy("bridge.autocreate_contact_portal") - copy("bridge.sync_with_custom_puppets") - copy("bridge.public_portals") - copy("bridge.sync_direct_chat_list") - copy("bridge.double_puppet_server_map") - copy("bridge.double_puppet_allow_discovery") - copy("bridge.create_group_on_invite") - if self["bridge.login_shared_secret"]: - base["bridge.login_shared_secret_map"] = { - base["homeserver.domain"]: self["bridge.login_shared_secret"] - } - else: - copy("bridge.login_shared_secret_map") - copy("bridge.federate_rooms") - copy("bridge.private_chat_portal_meta") - copy("bridge.delivery_receipts") - copy("bridge.delivery_error_reports") - copy("bridge.message_status_events") - copy("bridge.resend_bridge_info") - copy("bridge.periodic_sync") - - copy("bridge.provisioning.enabled") - copy("bridge.provisioning.prefix") - if base["bridge.provisioning.prefix"].endswith("/v1"): - base["bridge.provisioning.prefix"] = base["bridge.provisioning.prefix"][: -len("/v1")] - copy("bridge.provisioning.shared_secret") - if base["bridge.provisioning.shared_secret"] == "generate": - base["bridge.provisioning.shared_secret"] = self._new_token() - copy("bridge.provisioning.segment_key") - copy("bridge.provisioning.segment_user_id") - - copy("bridge.command_prefix") - - copy_dict("bridge.permissions") - - copy("bridge.relay.enabled") - copy_dict("bridge.relay.message_formats") - copy("bridge.relay.relaybot") - copy("bridge.relay.invite") - copy("bridge.bridge_matrix_leave") - copy("bridge.location_format") - - def _get_permissions(self, key: str) -> Permissions: - level = self["bridge.permissions"].get(key, "") - admin = level == "admin" - user = level == "user" or admin - relay = level == "relay" or user - return Permissions(relay, user, admin, level) - - def get_permissions(self, mxid: UserID) -> Permissions: - permissions = self["bridge.permissions"] - if mxid in permissions: - return self._get_permissions(mxid) - - _, homeserver = Client.parse_user_id(mxid) - if homeserver in permissions: - return self._get_permissions(homeserver) - - return self._get_permissions("*") diff --git a/mautrix_signal/db/__init__.py b/mautrix_signal/db/__init__.py deleted file mode 100644 index d50ba57..0000000 --- a/mautrix_signal/db/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -from mautrix.util.async_db import Database - -from .disappearing_message import DisappearingMessage -from .message import Message -from .portal import Portal -from .puppet import Puppet -from .reaction import Reaction -from .upgrade import upgrade_table -from .user import User -from .util import ensure_uuid - - -def init(db: Database) -> None: - for table in (User, Puppet, Portal, Message, Reaction, DisappearingMessage): - table.db = db - - -__all__ = [ - "upgrade_table", - "init", - "User", - "Puppet", - "Portal", - "Message", - "Reaction", - "DisappearingMessage", -] diff --git a/mautrix_signal/db/disappearing_message.py b/mautrix_signal/db/disappearing_message.py deleted file mode 100644 index a4589db..0000000 --- a/mautrix_signal/db/disappearing_message.py +++ /dev/null @@ -1,78 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2021 Sumner Evans -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar - -import asyncpg - -from mautrix.bridge import AbstractDisappearingMessage -from mautrix.types import EventID, RoomID -from mautrix.util.async_db import Database - -fake_db = Database.create("") if TYPE_CHECKING else None - - -class DisappearingMessage(AbstractDisappearingMessage): - db: ClassVar[Database] = fake_db - - async def insert(self) -> None: - q = """ - INSERT INTO disappearing_message (room_id, mxid, expiration_seconds, expiration_ts) - VALUES ($1, $2, $3, $4) - """ - await self.db.execute( - q, self.room_id, self.event_id, self.expiration_seconds, self.expiration_ts - ) - - async def update(self) -> None: - q = "UPDATE disappearing_message SET expiration_ts=$3 WHERE room_id=$1 AND mxid=$2" - await self.db.execute(q, self.room_id, self.event_id, self.expiration_ts) - - async def delete(self) -> None: - q = "DELETE from disappearing_message WHERE room_id=$1 AND mxid=$2" - await self.db.execute(q, self.room_id, self.event_id) - - @classmethod - def _from_row(cls, row: asyncpg.Record) -> DisappearingMessage: - return cls(row["room_id"], row["mxid"], row["expiration_seconds"], row["expiration_ts"]) - - @classmethod - async def get(cls, room_id: RoomID, event_id: EventID) -> DisappearingMessage | None: - q = """ - SELECT room_id, mxid, expiration_seconds, expiration_ts FROM disappearing_message - WHERE room_id=$1 AND mxid=$2 - """ - try: - return cls._from_row(await cls.db.fetchrow(q, room_id, event_id)) - except Exception: - return None - - @classmethod - async def get_all_scheduled(cls) -> list[DisappearingMessage]: - q = """ - SELECT room_id, mxid, expiration_seconds, expiration_ts FROM disappearing_message - WHERE expiration_ts IS NOT NULL - """ - return [cls._from_row(r) for r in await cls.db.fetch(q)] - - @classmethod - async def get_unscheduled_for_room(cls, room_id: RoomID) -> list[DisappearingMessage]: - q = """ - SELECT room_id, mxid, expiration_seconds, expiration_ts FROM disappearing_message - WHERE room_id = $1 AND expiration_ts IS NULL - """ - return [cls._from_row(r) for r in await cls.db.fetch(q, room_id)] diff --git a/mautrix_signal/db/message.py b/mautrix_signal/db/message.py deleted file mode 100644 index 5ae691a..0000000 --- a/mautrix_signal/db/message.py +++ /dev/null @@ -1,144 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2020 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar -from uuid import UUID - -from attr import dataclass -import asyncpg - -from mausignald.types import GroupID -from mautrix.types import EventID, RoomID -from mautrix.util.async_db import Database, Scheme - -from .util import ensure_uuid - -fake_db = Database.create("") if TYPE_CHECKING else None - - -@dataclass -class Message: - db: ClassVar[Database] = fake_db - - mxid: EventID - mx_room: RoomID - sender: UUID - timestamp: int - signal_chat_id: GroupID | UUID - signal_receiver: str - - async def insert(self) -> None: - q = """ - INSERT INTO message (mxid, mx_room, sender, timestamp, signal_chat_id, signal_receiver) - VALUES ($1, $2, $3, $4, $5, $6) - """ - await self.db.execute( - q, - self.mxid, - self.mx_room, - self.sender, - self.timestamp, - str(self.signal_chat_id), - self.signal_receiver, - ) - - async def delete(self) -> None: - q = """ - DELETE FROM message - WHERE sender=$1 AND timestamp=$2 AND signal_chat_id=$3 AND signal_receiver=$4 - """ - await self.db.execute( - q, - self.sender, - self.timestamp, - str(self.signal_chat_id), - self.signal_receiver, - ) - - @classmethod - async def delete_all(cls, room_id: RoomID) -> None: - await cls.db.execute("DELETE FROM message WHERE mx_room=$1", room_id) - - @classmethod - def _from_row(cls, row: asyncpg.Record | None) -> Message | None: - if row is None: - return None - data = {**row} - chat_id = data.pop("signal_chat_id") - if data["signal_receiver"]: - chat_id = ensure_uuid(chat_id) - sender = ensure_uuid(data.pop("sender")) - return cls(signal_chat_id=chat_id, sender=sender, **data) - - @classmethod - async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Message | None: - q = """ - SELECT mxid, mx_room, sender, timestamp, signal_chat_id, signal_receiver FROM message - WHERE mxid=$1 AND mx_room=$2 - """ - return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room)) - - @classmethod - async def get_by_signal_id( - cls, - sender: UUID, - timestamp: int, - signal_chat_id: GroupID | UUID, - signal_receiver: str = "", - ) -> Message | None: - q = """ - SELECT mxid, mx_room, sender, timestamp, signal_chat_id, signal_receiver FROM message - WHERE sender=$1 AND timestamp=$2 AND signal_chat_id=$3 AND signal_receiver=$4 - """ - return cls._from_row( - await cls.db.fetchrow(q, sender, timestamp, str(signal_chat_id), signal_receiver) - ) - - @classmethod - async def find_by_timestamps(cls, timestamps: list[int]) -> list[Message]: - if cls.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH): - q = """ - SELECT mxid, mx_room, sender, timestamp, signal_chat_id, signal_receiver FROM message - WHERE timestamp=ANY($1) - """ - rows = await cls.db.fetch(q, timestamps) - else: - placeholders = ", ".join("?" for _ in range(len(timestamps))) - q = f""" - SELECT mxid, mx_room, sender, timestamp, signal_chat_id, signal_receiver FROM message - WHERE timestamp IN ({placeholders}) - """ - rows = await cls.db.fetch(q, *timestamps) - return [cls._from_row(row) for row in rows] - - @classmethod - async def find_by_sender_timestamp(cls, sender: UUID, timestamp: int) -> Message | None: - q = """ - SELECT mxid, mx_room, sender, timestamp, signal_chat_id, signal_receiver FROM message - WHERE sender=$1 AND timestamp=$2 - """ - return cls._from_row(await cls.db.fetchrow(q, sender, timestamp)) - - @classmethod - async def get_first_before(cls, mx_room: RoomID, timestamp: int) -> Message | None: - q = """ - SELECT mxid, mx_room, sender, timestamp, signal_chat_id, signal_receiver FROM message - WHERE mx_room=$1 AND timestamp <= $2 - ORDER BY timestamp DESC - LIMIT 1 - """ - return cls._from_row(await cls.db.fetchrow(q, mx_room, timestamp)) diff --git a/mautrix_signal/db/portal.py b/mautrix_signal/db/portal.py deleted file mode 100644 index c00ffab..0000000 --- a/mautrix_signal/db/portal.py +++ /dev/null @@ -1,128 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar -from uuid import UUID - -from attr import dataclass -import asyncpg - -from mausignald.types import GroupID -from mautrix.types import ContentURI, RoomID, UserID -from mautrix.util.async_db import Database - -from .util import ensure_uuid - -fake_db = Database.create("") if TYPE_CHECKING else None - - -@dataclass -class Portal: - db: ClassVar[Database] = fake_db - - chat_id: GroupID | UUID - receiver: str - mxid: RoomID | None - name: str | None - topic: str | None - avatar_hash: str | None - avatar_url: ContentURI | None - name_set: bool - avatar_set: bool - revision: int - encrypted: bool - relay_user_id: UserID | None - expiration_time: int | None - - @property - def _values(self): - return ( - str(self.chat_id), - self.receiver, - self.mxid, - self.name, - self.topic, - self.avatar_hash, - self.avatar_url, - self.name_set, - self.avatar_set, - self.revision, - self.encrypted, - self.relay_user_id, - self.expiration_time, - ) - - async def insert(self) -> None: - q = """ - INSERT INTO portal ( - chat_id, receiver, mxid, name, topic, avatar_hash, avatar_url, name_set, avatar_set, - revision, encrypted, relay_user_id, expiration_time - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - """ - await self.db.execute(q, *self._values) - - async def update(self) -> None: - q = """ - UPDATE portal SET mxid=$3, name=$4, topic=$5, avatar_hash=$6, avatar_url=$7, name_set=$8, - avatar_set=$9, revision=$10, encrypted=$11, relay_user_id=$12, - expiration_time=$13 - WHERE chat_id=$1 AND receiver=$2 - """ - await self.db.execute(q, *self._values) - - @classmethod - def _from_row(cls, row: asyncpg.Record | None) -> Portal | None: - if row is None: - return None - data = {**row} - chat_id = data.pop("chat_id") - if data["receiver"]: - chat_id = ensure_uuid(chat_id) - return cls(chat_id=chat_id, **data) - - _columns = ( - "chat_id, receiver, mxid, name, topic, avatar_hash, avatar_url, name_set, avatar_set, " - "revision, encrypted, relay_user_id, expiration_time" - ) - - @classmethod - async def get_by_mxid(cls, mxid: RoomID) -> Portal | None: - q = f"SELECT {cls._columns} FROM portal WHERE mxid=$1" - return cls._from_row(await cls.db.fetchrow(q, mxid)) - - @classmethod - async def get_by_chat_id(cls, chat_id: GroupID | UUID, receiver: str = "") -> Portal | None: - q = f"SELECT {cls._columns} FROM portal WHERE chat_id=$1 AND receiver=$2" - return cls._from_row(await cls.db.fetchrow(q, str(chat_id), receiver)) - - @classmethod - async def find_private_chats_of(cls, receiver: str) -> list[Portal]: - q = f"SELECT {cls._columns} FROM portal WHERE receiver=$1" - rows = await cls.db.fetch(q, receiver) - return [cls._from_row(row) for row in rows] - - @classmethod - async def find_private_chats_with(cls, other_user: UUID) -> list[Portal]: - q = f"SELECT {cls._columns} FROM portal WHERE chat_id=$1 AND receiver<>''" - rows = await cls.db.fetch(q, str(other_user)) - return [cls._from_row(row) for row in rows] - - @classmethod - async def all_with_room(cls) -> list[Portal]: - q = f"SELECT {cls._columns} FROM portal WHERE mxid IS NOT NULL" - rows = await cls.db.fetch(q) - return [cls._from_row(row) for row in rows] diff --git a/mautrix_signal/db/puppet.py b/mautrix_signal/db/puppet.py deleted file mode 100644 index 3b723b0..0000000 --- a/mautrix_signal/db/puppet.py +++ /dev/null @@ -1,139 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2020 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar -from uuid import UUID - -from attr import dataclass -from yarl import URL -import asyncpg - -from mautrix.types import ContentURI, SyncToken, UserID -from mautrix.util.async_db import Connection, Database - -fake_db = Database.create("") if TYPE_CHECKING else None - - -@dataclass -class Puppet: - db: ClassVar[Database] = fake_db - - uuid: UUID - number: str | None - name: str | None - name_quality: int - avatar_hash: str | None - avatar_url: ContentURI | None - name_set: bool - avatar_set: bool - is_registered: bool - - custom_mxid: UserID | None - access_token: str | None - next_batch: SyncToken | None - base_url: URL | None - - @property - def _base_url_str(self) -> str | None: - return str(self.base_url) if self.base_url else None - - @property - def _values(self): - return ( - self.uuid, - self.number, - self.name, - self.name_quality, - self.avatar_hash, - self.avatar_url, - self.name_set, - self.avatar_set, - self.is_registered, - self.custom_mxid, - self.access_token, - self.next_batch, - self._base_url_str, - ) - - async def _delete_existing_number(self, conn: Connection) -> None: - if not self.number: - return - await conn.execute( - "UPDATE puppet SET number=null WHERE number=$1 AND uuid<>$2", self.number, self.uuid - ) - - async def insert(self) -> None: - q = """ - INSERT INTO puppet (uuid, number, name, name_quality, avatar_hash, avatar_url, - name_set, avatar_set, is_registered, - custom_mxid, access_token, next_batch, base_url) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - """ - async with self.db.acquire() as conn, conn.transaction(): - await self._delete_existing_number(conn) - await conn.execute(q, *self._values) - - async def _update_number(self) -> None: - async with self.db.acquire() as conn, conn.transaction(): - await self._delete_existing_number(conn) - await conn.execute("UPDATE puppet SET number=$1 WHERE uuid=$2", self.number, self.uuid) - - async def update(self) -> None: - q = """ - UPDATE puppet - SET number=$2, name=$3, name_quality=$4, avatar_hash=$5, avatar_url=$6, - name_set=$7, avatar_set=$8, is_registered=$9, - custom_mxid=$10, access_token=$11, next_batch=$12, base_url=$13 - WHERE uuid=$1 - """ - await self.db.execute(q, *self._values) - - @classmethod - def _from_row(cls, row: asyncpg.Record | None) -> Puppet | None: - if not row: - return None - data = {**row} - base_url_str = data.pop("base_url") - base_url = URL(base_url_str) if base_url_str is not None else None - return cls(base_url=base_url, **data) - - _select_base = ( - "SELECT uuid, number, name, name_quality, avatar_hash, avatar_url, name_set, avatar_set, " - " is_registered, custom_mxid, access_token, next_batch, base_url " - "FROM puppet" - ) - - @classmethod - async def get_by_uuid(cls, uuid: UUID) -> Puppet | None: - return cls._from_row(await cls.db.fetchrow(f"{cls._select_base} WHERE uuid=$1", uuid)) - - @classmethod - async def get_by_number(cls, number: str) -> Puppet | None: - return cls._from_row(await cls.db.fetchrow(f"{cls._select_base} WHERE number=$1", number)) - - @classmethod - async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None: - return cls._from_row( - await cls.db.fetchrow(f"{cls._select_base} WHERE custom_mxid=$1", mxid) - ) - - @classmethod - async def all_with_custom_mxid(cls) -> list[Puppet]: - return [ - cls._from_row(row) - for row in await cls.db.fetch(f"{cls._select_base} WHERE custom_mxid IS NOT NULL") - ] diff --git a/mautrix_signal/db/reaction.py b/mautrix_signal/db/reaction.py deleted file mode 100644 index 54d9b9b..0000000 --- a/mautrix_signal/db/reaction.py +++ /dev/null @@ -1,138 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2020 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar -from uuid import UUID - -from attr import dataclass -import asyncpg - -from mausignald.types import GroupID -from mautrix.types import EventID, RoomID -from mautrix.util.async_db import Database - -from .util import ensure_uuid - -fake_db = Database.create("") if TYPE_CHECKING else None - - -@dataclass -class Reaction: - db: ClassVar[Database] = fake_db - - mxid: EventID - mx_room: RoomID - signal_chat_id: GroupID | UUID - signal_receiver: str - msg_author: UUID - msg_timestamp: int - author: UUID - emoji: str - - async def insert(self) -> None: - q = ( - "INSERT INTO reaction (mxid, mx_room, signal_chat_id, signal_receiver, msg_author," - " msg_timestamp, author, emoji) " - "VALUES ($1, $2, $3, $4, $5, $6, $7, $8)" - ) - await self.db.execute( - q, - self.mxid, - self.mx_room, - str(self.signal_chat_id), - self.signal_receiver, - self.msg_author, - self.msg_timestamp, - self.author, - self.emoji, - ) - - async def edit(self, mx_room: RoomID, mxid: EventID, emoji: str) -> None: - await self.db.execute( - "UPDATE reaction SET mxid=$1, mx_room=$2, emoji=$3 " - "WHERE signal_chat_id=$4 AND signal_receiver=$5" - " AND msg_author=$6 AND msg_timestamp=$7 AND author=$8", - mxid, - mx_room, - emoji, - str(self.signal_chat_id), - self.signal_receiver, - self.msg_author, - self.msg_timestamp, - self.author, - ) - - async def delete(self) -> None: - q = ( - "DELETE FROM reaction WHERE signal_chat_id=$1 AND signal_receiver=$2" - " AND msg_author=$3 AND msg_timestamp=$4 AND author=$5" - ) - await self.db.execute( - q, - str(self.signal_chat_id), - self.signal_receiver, - self.msg_author, - self.msg_timestamp, - self.author, - ) - - @classmethod - def _from_row(cls, row: asyncpg.Record | None) -> Reaction | None: - if row is None: - return None - data = {**row} - chat_id = data.pop("signal_chat_id") - if data["signal_receiver"]: - chat_id = ensure_uuid(chat_id) - msg_author = ensure_uuid(data.pop("msg_author")) - author = ensure_uuid(data.pop("author")) - return cls(signal_chat_id=chat_id, msg_author=msg_author, author=author, **data) - - @classmethod - async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Reaction | None: - q = ( - "SELECT mxid, mx_room, signal_chat_id, signal_receiver," - " msg_author, msg_timestamp, author, emoji " - "FROM reaction WHERE mxid=$1 AND mx_room=$2" - ) - return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room)) - - @classmethod - async def get_by_signal_id( - cls, - chat_id: GroupID | UUID, - receiver: str, - msg_author: UUID, - msg_timestamp: int, - author: UUID, - ) -> Reaction | None: - q = ( - "SELECT mxid, mx_room, signal_chat_id, signal_receiver," - " msg_author, msg_timestamp, author, emoji " - "FROM reaction WHERE signal_chat_id=$1 AND signal_receiver=$2" - " AND msg_author=$3 AND msg_timestamp=$4 AND author=$5" - ) - return cls._from_row( - await cls.db.fetchrow( - q, - str(chat_id), - receiver, - msg_author, - msg_timestamp, - author, - ) - ) diff --git a/mautrix_signal/db/upgrade/__init__.py b/mautrix_signal/db/upgrade/__init__.py deleted file mode 100644 index fea373a..0000000 --- a/mautrix_signal/db/upgrade/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from mautrix.util.async_db import UpgradeTable - -upgrade_table = UpgradeTable() - -from . import ( - v00_latest_revision, - v02_portal_avatar_info, - v03_puppet_base_url, - v04_phone_sender_identifier, - v05_puppet_avatar_info, - v06_portal_revision, - v07_portal_relay_user, - v08_disappearing_messages, - v09_group_topic, - v10_puppet_name_quality, - v11_drop_number_support, -) diff --git a/mautrix_signal/db/upgrade/v00_latest_revision.py b/mautrix_signal/db/upgrade/v00_latest_revision.py deleted file mode 100644 index 88db2de..0000000 --- a/mautrix_signal/db/upgrade/v00_latest_revision.py +++ /dev/null @@ -1,126 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from mautrix.util.async_db import Connection - -from . import upgrade_table - - -@upgrade_table.register(description="Initial revision", upgrades_to=11) -async def upgrade_latest(conn: Connection) -> None: - await conn.execute( - """CREATE TABLE portal ( - chat_id TEXT, - receiver TEXT, - mxid TEXT, - name TEXT, - topic TEXT, - encrypted BOOLEAN NOT NULL DEFAULT false, - avatar_hash TEXT, - avatar_url TEXT, - name_set BOOLEAN NOT NULL DEFAULT false, - avatar_set BOOLEAN NOT NULL DEFAULT false, - revision INTEGER NOT NULL DEFAULT 0, - expiration_time BIGINT, - relay_user_id TEXT, - - PRIMARY KEY (chat_id, receiver) - )""" - ) - await conn.execute( - """CREATE TABLE "user" ( - mxid TEXT PRIMARY KEY, - username TEXT, - uuid UUID, - notice_room TEXT - )""" - ) - await conn.execute( - """CREATE TABLE puppet ( - uuid UUID PRIMARY KEY, - number TEXT UNIQUE, - name TEXT, - name_quality INTEGER NOT NULL DEFAULT 0, - avatar_hash TEXT, - avatar_url TEXT, - name_set BOOLEAN NOT NULL DEFAULT false, - avatar_set BOOLEAN NOT NULL DEFAULT false, - - is_registered BOOLEAN NOT NULL DEFAULT false, - - custom_mxid TEXT, - access_token TEXT, - next_batch TEXT, - base_url TEXT - )""" - ) - await conn.execute( - """CREATE TABLE user_portal ( - "user" TEXT, - portal TEXT, - portal_receiver TEXT, - in_community BOOLEAN NOT NULL DEFAULT false, - - FOREIGN KEY (portal, portal_receiver) REFERENCES portal(chat_id, receiver) - ON UPDATE CASCADE ON DELETE CASCADE - )""" - ) - await conn.execute( - """CREATE TABLE message ( - mxid TEXT NOT NULL, - mx_room TEXT NOT NULL, - sender UUID, - timestamp BIGINT, - signal_chat_id TEXT, - signal_receiver TEXT, - - PRIMARY KEY (sender, timestamp, signal_chat_id, signal_receiver), - FOREIGN KEY (signal_chat_id, signal_receiver) REFERENCES portal(chat_id, receiver) ON DELETE CASCADE, - FOREIGN KEY (sender) REFERENCES puppet(uuid) ON DELETE CASCADE, - UNIQUE (mxid, mx_room) - )""" - ) - await conn.execute( - """CREATE TABLE reaction ( - mxid TEXT NOT NULL, - mx_room TEXT NOT NULL, - - signal_chat_id TEXT NOT NULL, - signal_receiver TEXT NOT NULL, - msg_author UUID NOT NULL, - msg_timestamp BIGINT NOT NULL, - author UUID NOT NULL, - - emoji TEXT NOT NULL, - - PRIMARY KEY (signal_chat_id, signal_receiver, msg_author, msg_timestamp, author), - CONSTRAINT reaction_message_fkey - FOREIGN KEY (msg_author, msg_timestamp, signal_chat_id, signal_receiver) - REFERENCES message(sender, timestamp, signal_chat_id, signal_receiver) - ON DELETE CASCADE, - FOREIGN KEY (author) REFERENCES puppet(uuid) ON DELETE CASCADE, - UNIQUE (mxid, mx_room) - )""" - ) - await conn.execute( - """CREATE TABLE disappearing_message ( - room_id TEXT, - mxid TEXT, - expiration_seconds BIGINT, - expiration_ts BIGINT, - - PRIMARY KEY (room_id, mxid) - )""" - ) diff --git a/mautrix_signal/db/upgrade/v02_portal_avatar_info.py b/mautrix_signal/db/upgrade/v02_portal_avatar_info.py deleted file mode 100644 index de511cc..0000000 --- a/mautrix_signal/db/upgrade/v02_portal_avatar_info.py +++ /dev/null @@ -1,24 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from mautrix.util.async_db import Connection - -from . import upgrade_table - - -@upgrade_table.register(description="Add avatar info to portal table") -async def upgrade_v2(conn: Connection) -> None: - await conn.execute("ALTER TABLE portal ADD COLUMN avatar_hash TEXT") - await conn.execute("ALTER TABLE portal ADD COLUMN avatar_url TEXT") diff --git a/mautrix_signal/db/upgrade/v03_puppet_base_url.py b/mautrix_signal/db/upgrade/v03_puppet_base_url.py deleted file mode 100644 index 71644c3..0000000 --- a/mautrix_signal/db/upgrade/v03_puppet_base_url.py +++ /dev/null @@ -1,23 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from mautrix.util.async_db import Connection - -from . import upgrade_table - - -@upgrade_table.register(description="Add double puppeting base_url to puppet table") -async def upgrade_v3(conn: Connection) -> None: - await conn.execute("ALTER TABLE puppet ADD COLUMN base_url TEXT") diff --git a/mautrix_signal/db/upgrade/v04_phone_sender_identifier.py b/mautrix_signal/db/upgrade/v04_phone_sender_identifier.py deleted file mode 100644 index 606b2e3..0000000 --- a/mautrix_signal/db/upgrade/v04_phone_sender_identifier.py +++ /dev/null @@ -1,38 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from mautrix.util.async_db import Connection, Scheme - -from . import upgrade_table - - -@upgrade_table.register(description="Allow phone numbers as message sender identifiers") -async def upgrade_v4(conn: Connection, scheme: Scheme) -> None: - assert scheme != Scheme.SQLITE, "There shouldn't be any SQLites with this old schemes" - - cname = await conn.fetchval( - "SELECT constraint_name FROM information_schema.table_constraints " - "WHERE table_name='reaction' AND constraint_name LIKE '%_fkey'" - ) - await conn.execute(f"ALTER TABLE reaction DROP CONSTRAINT {cname}") - await conn.execute("ALTER TABLE reaction ALTER COLUMN msg_author SET DATA TYPE TEXT") - await conn.execute("ALTER TABLE reaction ALTER COLUMN author SET DATA TYPE TEXT") - await conn.execute("ALTER TABLE message ALTER COLUMN sender SET DATA TYPE TEXT") - await conn.execute( - f"ALTER TABLE reaction ADD CONSTRAINT {cname} " - "FOREIGN KEY (msg_author, msg_timestamp, signal_chat_id, signal_receiver) " - " REFERENCES message(sender, timestamp, signal_chat_id, signal_receiver) " - " ON DELETE CASCADE ON UPDATE CASCADE" - ) diff --git a/mautrix_signal/db/upgrade/v05_puppet_avatar_info.py b/mautrix_signal/db/upgrade/v05_puppet_avatar_info.py deleted file mode 100644 index 5548b39..0000000 --- a/mautrix_signal/db/upgrade/v05_puppet_avatar_info.py +++ /dev/null @@ -1,27 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from mautrix.util.async_db import Connection - -from . import upgrade_table - - -@upgrade_table.register(description="Add avatar info to puppet table") -async def upgrade_v5(conn: Connection) -> None: - await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_hash TEXT") - await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_url TEXT") - await conn.execute("ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false") - await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false") - await conn.execute("UPDATE puppet SET name_set=true WHERE name<>''") diff --git a/mautrix_signal/db/upgrade/v06_portal_revision.py b/mautrix_signal/db/upgrade/v06_portal_revision.py deleted file mode 100644 index 0f9ef52..0000000 --- a/mautrix_signal/db/upgrade/v06_portal_revision.py +++ /dev/null @@ -1,27 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from mautrix.util.async_db import Connection - -from . import upgrade_table - - -@upgrade_table.register(description="Add revision to portal table") -async def upgrade_v6(conn: Connection) -> None: - await conn.execute("ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false") - await conn.execute("ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false") - await conn.execute("ALTER TABLE portal ADD COLUMN revision INTEGER NOT NULL DEFAULT 0") - await conn.execute("UPDATE portal SET name_set=true WHERE name<>''") - await conn.execute("UPDATE portal SET avatar_set=true WHERE avatar_hash<>''") diff --git a/mautrix_signal/db/upgrade/v07_portal_relay_user.py b/mautrix_signal/db/upgrade/v07_portal_relay_user.py deleted file mode 100644 index 73bf775..0000000 --- a/mautrix_signal/db/upgrade/v07_portal_relay_user.py +++ /dev/null @@ -1,23 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from mautrix.util.async_db import Connection - -from . import upgrade_table - - -@upgrade_table.register(description="Add relay user field to portal table") -async def upgrade_v7(conn: Connection) -> None: - await conn.execute("ALTER TABLE portal ADD COLUMN relay_user_id TEXT") diff --git a/mautrix_signal/db/upgrade/v08_disappearing_messages.py b/mautrix_signal/db/upgrade/v08_disappearing_messages.py deleted file mode 100644 index 21505a7..0000000 --- a/mautrix_signal/db/upgrade/v08_disappearing_messages.py +++ /dev/null @@ -1,33 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from mautrix.util.async_db import Connection - -from . import upgrade_table - - -@upgrade_table.register(description="Add support for disappearing messages") -async def upgrade_v8(conn: Connection) -> None: - await conn.execute( - """CREATE TABLE disappearing_message ( - room_id TEXT, - mxid TEXT, - expiration_seconds BIGINT, - expiration_ts BIGINT, - - PRIMARY KEY (room_id, mxid) - )""" - ) - await conn.execute("ALTER TABLE portal ADD COLUMN expiration_time BIGINT") diff --git a/mautrix_signal/db/upgrade/v09_group_topic.py b/mautrix_signal/db/upgrade/v09_group_topic.py deleted file mode 100644 index d076f57..0000000 --- a/mautrix_signal/db/upgrade/v09_group_topic.py +++ /dev/null @@ -1,23 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from mautrix.util.async_db import Connection - -from . import upgrade_table - - -@upgrade_table.register(description="Add support for group descriptions") -async def upgrade_v9(conn: Connection) -> None: - await conn.execute("ALTER TABLE portal ADD COLUMN topic TEXT") diff --git a/mautrix_signal/db/upgrade/v10_puppet_name_quality.py b/mautrix_signal/db/upgrade/v10_puppet_name_quality.py deleted file mode 100644 index 960e7f9..0000000 --- a/mautrix_signal/db/upgrade/v10_puppet_name_quality.py +++ /dev/null @@ -1,23 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from mautrix.util.async_db import Connection - -from . import upgrade_table - - -@upgrade_table.register(description="Store puppet name quality in database") -async def upgrade_v10(conn: Connection) -> None: - await conn.execute("ALTER TABLE puppet ADD COLUMN name_quality INTEGER NOT NULL DEFAULT 0") diff --git a/mautrix_signal/db/upgrade/v11_drop_number_support.py b/mautrix_signal/db/upgrade/v11_drop_number_support.py deleted file mode 100644 index 6942782..0000000 --- a/mautrix_signal/db/upgrade/v11_drop_number_support.py +++ /dev/null @@ -1,120 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from mautrix.util.async_db import Connection, Scheme - -from . import upgrade_table - - -@upgrade_table.register(description="Drop support for phone numbers as puppet identifiers") -async def upgrade_v11(conn: Connection, scheme: Scheme) -> None: - await conn.execute("DELETE FROM portal WHERE chat_id LIKE '+%'") - await conn.execute("DELETE FROM message WHERE sender LIKE '+%'") - await conn.execute("DELETE FROM reaction WHERE author LIKE '+%'") - puppet_uuid_as_text = "puppet.uuid" if scheme == Scheme.SQLITE else "puppet.uuid::text" - await conn.execute( - f""" - DELETE FROM message WHERE sender IN ( - SELECT DISTINCT(message.sender) FROM message - LEFT JOIN puppet ON message.sender={puppet_uuid_as_text} - WHERE puppet.uuid IS NULL - ) - """ - ) - await conn.execute( - f""" - DELETE FROM reaction WHERE author IN ( - SELECT DISTINCT(reaction.author) FROM reaction - LEFT JOIN puppet ON reaction.author={puppet_uuid_as_text} - WHERE puppet.uuid IS NULL - ) - """ - ) - await conn.execute("DELETE FROM puppet WHERE uuid IS NULL") - if scheme in (Scheme.POSTGRES, Scheme.COCKROACH): - await conn.execute( - """ - ALTER TABLE puppet - DROP CONSTRAINT puppet_uuid_key, - ADD CONSTRAINT puppet_pkey PRIMARY KEY (uuid) - """ - ) - await conn.execute("ALTER TABLE puppet DROP COLUMN number_registered") - await conn.execute("ALTER TABLE puppet RENAME COLUMN uuid_registered TO is_registered") - for c_row in await conn.fetch( - "SELECT constraint_name FROM information_schema.table_constraints tc " - "WHERE tc.constraint_type='FOREIGN KEY' AND tc.table_name='reaction'" - ): - constraint_name = c_row["constraint_name"] - if constraint_name.startswith("reaction_msg_author_"): - await conn.execute(f"ALTER TABLE reaction DROP CONSTRAINT {constraint_name}") - await conn.execute("ALTER TABLE message ALTER COLUMN sender TYPE UUID USING sender::uuid") - await conn.execute( - "ALTER TABLE reaction ALTER COLUMN msg_author TYPE UUID USING msg_author::uuid" - ) - await conn.execute( - """ - ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey - FOREIGN KEY (msg_author, msg_timestamp, signal_chat_id, signal_receiver) - REFERENCES message(sender, timestamp, signal_chat_id, signal_receiver) - ON DELETE CASCADE - """ - ) - await conn.execute("ALTER TABLE reaction ALTER COLUMN author TYPE UUID USING author::uuid") - await conn.execute( - """ - ALTER TABLE message ADD CONSTRAINT message_sender_fkey - FOREIGN KEY (sender) REFERENCES puppet(uuid) ON DELETE CASCADE - """ - ) - await conn.execute( - """ - ALTER TABLE reaction ADD CONSTRAINT reaction_author_fkey - FOREIGN KEY (author) REFERENCES puppet(uuid) ON DELETE CASCADE - """ - ) - else: - await conn.execute( - """CREATE TABLE new_puppet ( - uuid UUID PRIMARY KEY, - number TEXT UNIQUE, - name TEXT, - name_quality INTEGER NOT NULL DEFAULT 0, - avatar_hash TEXT, - avatar_url TEXT, - name_set BOOLEAN NOT NULL DEFAULT false, - avatar_set BOOLEAN NOT NULL DEFAULT false, - - is_registered BOOLEAN NOT NULL DEFAULT false, - - custom_mxid TEXT, - access_token TEXT, - next_batch TEXT, - base_url TEXT - )""" - ) - await conn.execute( - """ - INSERT INTO new_puppet ( - uuid, number, name, name_quality, avatar_hash, avatar_url, name_set, avatar_set, - is_registered, custom_mxid, access_token, next_batch, base_url - ) - SELECT uuid, number, name, name_quality, avatar_hash, avatar_url, name_set, avatar_set, - uuid_registered, custom_mxid, access_token, next_batch, base_url - FROM puppet - """ - ) - await conn.execute("DROP TABLE puppet") - await conn.execute("ALTER TABLE new_puppet RENAME TO puppet") diff --git a/mautrix_signal/db/user.py b/mautrix_signal/db/user.py deleted file mode 100644 index 34528ba..0000000 --- a/mautrix_signal/db/user.py +++ /dev/null @@ -1,74 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar -from uuid import UUID - -from attr import dataclass - -from mautrix.types import RoomID, UserID -from mautrix.util.async_db import Database - -fake_db = Database.create("") if TYPE_CHECKING else None - - -@dataclass -class User: - db: ClassVar[Database] = fake_db - - mxid: UserID - username: str | None - uuid: UUID | None - notice_room: RoomID | None - - async def insert(self) -> None: - q = 'INSERT INTO "user" (mxid, username, uuid, notice_room) VALUES ($1, $2, $3, $4)' - await self.db.execute(q, self.mxid, self.username, self.uuid, self.notice_room) - - async def update(self) -> None: - q = 'UPDATE "user" SET username=$1, uuid=$2, notice_room=$3 WHERE mxid=$4' - await self.db.execute(q, self.username, self.uuid, self.notice_room, self.mxid) - - @classmethod - async def get_by_mxid(cls, mxid: UserID) -> User | None: - q = 'SELECT mxid, username, uuid, notice_room FROM "user" WHERE mxid=$1' - row = await cls.db.fetchrow(q, mxid) - if not row: - return None - return cls(**row) - - @classmethod - async def get_by_username(cls, username: str) -> User | None: - q = 'SELECT mxid, username, uuid, notice_room FROM "user" WHERE username=$1' - row = await cls.db.fetchrow(q, username) - if not row: - return None - return cls(**row) - - @classmethod - async def get_by_uuid(cls, uuid: UUID) -> User | None: - q = 'SELECT mxid, username, uuid, notice_room FROM "user" WHERE uuid=$1' - row = await cls.db.fetchrow(q, uuid) - if not row: - return None - return cls(**row) - - @classmethod - async def all_logged_in(cls) -> list[User]: - q = 'SELECT mxid, username, uuid, notice_room FROM "user" WHERE username IS NOT NULL' - rows = await cls.db.fetch(q) - return [cls(**row) for row in rows] diff --git a/mautrix_signal/db/util.py b/mautrix_signal/db/util.py deleted file mode 100644 index 9347c22..0000000 --- a/mautrix_signal/db/util.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations - -from uuid import UUID -import sqlite3 - - -def ensure_uuid(val: bytes | str | UUID) -> UUID: - if not isinstance(val, UUID): - if isinstance(val, bytes): - val = val.decode("utf-8") - return UUID(val) - return val - - -sqlite3.register_adapter(UUID, str) -sqlite3.register_converter("UUID", ensure_uuid) diff --git a/mautrix_signal/example-config.yaml b/mautrix_signal/example-config.yaml deleted file mode 100644 index b13e9ed..0000000 --- a/mautrix_signal/example-config.yaml +++ /dev/null @@ -1,352 +0,0 @@ -# Homeserver details -homeserver: - # The address that this appservice can use to connect to the homeserver. - address: https://matrix.example.com - # The domain of the homeserver (also known as server_name, used for MXIDs, etc). - domain: example.com - # Whether or not to verify the SSL certificate of the homeserver. - # Only applies if address starts with https:// - verify_ssl: true - # What software is the homeserver running? - # Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here. - software: standard - # Number of retries for all HTTP requests if the homeserver isn't reachable. - http_retry_count: 4 - # The URL to push real-time bridge status to. - # If set, the bridge will make POST requests to this URL whenever a user's Signal connection state changes. - # The bridge will use the appservice as_token to authorize requests. - status_endpoint: null - # Endpoint for reporting per-message status. - message_send_checkpoint_endpoint: null - # Maximum number of simultaneous HTTP connections to the homeserver. - connection_limit: 100 - # Whether asynchronous uploads via MSC2246 should be enabled for media. - # Requires a media repo that supports MSC2246. - async_media: false - -# Application service host/registration related details -# Changing these values requires regeneration of the registration. -appservice: - # The address that the homeserver can use to connect to this appservice. - address: http://localhost:29328 - # When using https:// the TLS certificate and key files for the address. - tls_cert: false - tls_key: false - - # The hostname and port where this appservice should listen. - hostname: 0.0.0.0 - port: 29328 - # The maximum body size of appservice API requests (from the homeserver) in mebibytes - # Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s - max_body_size: 1 - - # The full URI to the database. SQLite and Postgres are supported. - # Format examples: - # SQLite: sqlite:///filename.db - # Postgres: postgres://username:password@hostname/dbname - database: postgres://username:password@hostname/db - # Additional arguments for asyncpg.create_pool() or sqlite3.connect() - # https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.pool.create_pool - # https://docs.python.org/3/library/sqlite3.html#sqlite3.connect - # For sqlite, min_size is used as the connection thread pool size and max_size is ignored. - # Additionally, SQLite supports init_commands as an array of SQL queries to run on connect (e.g. to set PRAGMAs). - database_opts: - min_size: 1 - max_size: 10 - - # The unique ID of this appservice. - id: signal - # Username of the appservice bot. - bot_username: signalbot - # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty - # to leave display name/avatar as-is. - bot_displayname: Signal bridge bot - bot_avatar: mxc://maunium.net/wPJgTQbZOtpBFmDNkiNEMDUp - - # Whether or not to receive ephemeral events via appservice transactions. - # Requires MSC2409 support (i.e. Synapse 1.22+). - # You should disable bridge -> sync_with_custom_puppets when this is enabled. - ephemeral_events: true - - # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. - as_token: "This value is generated when generating the registration" - hs_token: "This value is generated when generating the registration" - -# Prometheus telemetry config. Requires prometheus-client to be installed. -metrics: - enabled: false - listen_port: 8000 - -# Manhole config. -manhole: - # Whether or not opening the manhole is allowed. - enabled: false - # The path for the unix socket. - path: /var/tmp/mautrix-signal.manhole - # The list of UIDs who can be added to the whitelist. - # If empty, any UIDs can be specified in the open-manhole command. - whitelist: - - 0 - -signal: - # Path to signald unix socket - socket_path: /var/run/signald/signald.sock - # Directory for temp files when sending files to Signal. This should be an - # absolute path that signald can read. For attachments in the other direction, - # make sure signald is configured to use an absolute path as the data directory. - outgoing_attachment_dir: /tmp - # Directory where signald stores avatars for groups. - avatar_dir: ~/.config/signald/avatars - # Directory where signald stores auth data. Used to delete data when logging out. - data_dir: ~/.config/signald/data - # Whether or not unknown signald accounts should be deleted when the bridge is started. - # When this is enabled, any UserInUse errors should be resolved by restarting the bridge. - delete_unknown_accounts_on_start: false - # Whether or not message attachments should be removed from disk after they're bridged. - remove_file_after_handling: true - # Whether or not users can register a primary device - registration_enabled: true - # Whether or not to enable disappearing messages in groups. If enabled, then the expiration - # time of the messages will be determined by the first users to read the message, rather - # than individually. If the bridge has a single user, this can be turned on safely. - enable_disappearing_messages_in_groups: false - -# Bridge config -bridge: - # Localpart template of MXIDs for Signal users. - # {userid} is replaced with the UUID of the Signal user. - username_template: "signal_{userid}" - # Displayname template for Signal users. - # {displayname} is replaced with the displayname of the Signal user, which is the first - # available variable in displayname_preference. The variables in displayname_preference - # can also be used here directly. - displayname_template: "{displayname} (Signal)" - # Whether or not contact list displaynames should be used. - # Possible values: disallow, allow, prefer - # - # Multi-user instances are recommended to disallow contact list names, as otherwise there can - # be conflicts between names from different users' contact lists. - contact_list_names: disallow - # Available variables: full_name, first_name, last_name, phone, uuid - displayname_preference: - - full_name - - phone - - # Whether or not to create portals for all groups on login/connect. - autocreate_group_portal: true - # Whether or not to create portals for all contacts on login/connect. - autocreate_contact_portal: false - # Whether or not to make portals of Signal groups in which joining via invite link does - # not need to be approved by an administrator publicly joinable on Matrix. - public_portals: false - # Whether or not to use /sync to get read receipts and typing notifications - # when double puppeting is enabled - sync_with_custom_puppets: false - # Whether or not to update the m.direct account data event when double puppeting is enabled. - # Note that updating the m.direct event is not atomic (except with mautrix-asmux) - # and is therefore prone to race conditions. - sync_direct_chat_list: false - # Allow using double puppeting from any server with a valid client .well-known file. - double_puppet_allow_discovery: false - # Servers to allow double puppeting from, even if double_puppet_allow_discovery is false. - double_puppet_server_map: - example.com: https://example.com - # Shared secret for https://github.com/devture/matrix-synapse-shared-secret-auth - # - # If set, custom puppets will be enabled automatically for local users - # instead of users having to find an access token and run `login-matrix` - # manually. - # If using this for other servers than the bridge's server, - # you must also set the URL in the double_puppet_server_map. - login_shared_secret_map: - example.com: foo - # Whether or not created rooms should have federation enabled. - # If false, created portal rooms will never be federated. - federate_rooms: true - # End-to-bridge encryption support options. - # - # See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info. - encryption: - # Allow encryption, work in group chat rooms with e2ee enabled - allow: false - # Default to encryption, force-enable encryption in all portals the bridge creates - # This will cause the bridge bot to be in private chats for the encryption to work properly. - default: false - # Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data. - appservice: false - # Require encryption, drop any unencrypted messages. - require: false - # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. - # You must use a client that supports requesting keys from other users to use this feature. - allow_key_sharing: false - # What level of device verification should be required from users? - # - # Valid levels: - # unverified - Send keys to all device in the room. - # cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys. - # cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes). - # cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot. - # Note that creating user signatures from the bridge bot is not currently possible. - # verified - Require manual per-device verification - # (currently only possible by modifying the `trust` column in the `crypto_device` database table). - verification_levels: - # Minimum level for which the bridge should send keys to when bridging messages from Telegram to Matrix. - receive: unverified - # Minimum level that the bridge should accept for incoming Matrix messages. - send: unverified - # Minimum level that the bridge should require for accepting key requests. - share: cross-signed-tofu - # Options for Megolm room key rotation. These options allow you to - # configure the m.room.encryption event content. See: - # https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for - # more information about that event. - rotation: - # Enable custom Megolm room key rotation settings. Note that these - # settings will only apply to rooms created after this option is - # set. - enable_custom: false - # The maximum number of milliseconds a session should be used - # before changing it. The Matrix spec recommends 604800000 (a week) - # as the default. - milliseconds: 604800000 - # The maximum number of messages that should be sent with a given a - # session before changing it. The Matrix spec recommends 100 as the - # default. - messages: 100 - - # Whether or not to explicitly set the avatar and room name for private - # chat portal rooms. This will be implicitly enabled if encryption.default is true. - private_chat_portal_meta: false - # Whether or not the bridge should send a read receipt from the bridge bot when a message has - # been sent to Signal. This let's you check manually whether the bridge is receiving your - # messages. - # Note that this is not related to Signal delivery receipts. - delivery_receipts: false - # Whether or not delivery errors should be reported as messages in the Matrix room. - delivery_error_reports: true - # Whether the bridge should send the message status as a custom com.beeper.message_send_status event. - message_status_events: false - # Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run. - # This field will automatically be changed back to false after it, - # except if the config file is not writable. - resend_bridge_info: false - # Interval at which to resync contacts (in seconds). - periodic_sync: 0 - # Should leaving the room on Matrix make the user leave on Signal? - bridge_matrix_leave: true - # Should the bridge auto-create a group chat on Signal when a ghost is invited to a room? - # Requires the user to have sufficient power level and double puppeting enabled. - create_group_on_invite: true - - # Provisioning API part of the web server for automated portal creation and fetching information. - # Used by things like mautrix-manager (https://github.com/tulir/mautrix-manager). - provisioning: - # Whether or not the provisioning API should be enabled. - enabled: true - # The prefix to use in the provisioning API endpoints. - prefix: /_matrix/provision - # The shared secret to authorize users of the API. - # Set to "generate" to generate and save a new token. - shared_secret: generate - # Segment API key to enable analytics tracking for web server - # endpoints. Set to null to disable. - # Currently the only events are login start, QR code scan, and login - # success/failure. - segment_key: null - # Optional user_id to use when sending Segment events. If null, defaults to using mxID. - segment_user_id: null - - # The prefix for commands. Only required in non-management rooms. - command_prefix: "!signal" - - # Messages sent upon joining a management room. - # Markdown is supported. The defaults are listed below. - management_room_text: - # Sent when joining a room. - welcome: "Hello, I'm a Signal bridge bot." - # Sent when joining a management room and the user is already logged in. - welcome_connected: "Use `help` for help." - # Sent when joining a management room and the user is not logged in. - welcome_unconnected: "Use `help` for help or `link` to log in." - # Optional extra text sent when joining a management room. - additional_help: "" - - # Send each message separately (for readability in some clients) - management_room_multiple_messages: false - - # Permissions for using the bridge. - # Permitted values: - # relay - Allowed to be relayed through the bridge, no access to commands. - # user - Use the bridge with puppeting. - # admin - Use and administrate the bridge. - # Permitted keys: - # * - All Matrix users - # domain - All users on that homeserver - # mxid - Specific user - permissions: - "*": "relay" - "example.com": "user" - "@admin:example.com": "admin" - - relay: - # Whether relay mode should be allowed. If allowed, `!signal set-relay` can be used to turn any - # authenticated user into a relaybot for that chat. - enabled: false - # The formats to use when sending messages to Signal via a relay user. - # - # Available variables: - # $sender_displayname - The display name of the sender (e.g. Example User) - # $sender_username - The username (Matrix ID localpart) of the sender (e.g. exampleuser) - # $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com) - # $message - The message content - message_formats: - m.text: '$sender_displayname: $message' - m.notice: '$sender_displayname: $message' - m.emote: '* $sender_displayname $message' - m.file: '$sender_displayname sent a file' - m.image: '$sender_displayname sent an image' - m.audio: '$sender_displayname sent an audio file' - m.video: '$sender_displayname sent a video' - m.location: '$sender_displayname sent a location' - # Specify a dedicated relay account. Must be a regular matrix account logged into this bridge - # and double puppeting working to auto-accept invites. When this user is invited to a room - # it will automatically be set as the relay user. May be overridden with `set-relay` or `unset-relay` - relaybot: '@relaybot:example.com' - # Whether or not invites from non-logged-in users should be relayed - invite: true - - # Format for generating URLs from location messages for sending to Signal - # Google Maps: 'https://www.google.com/maps/place/{lat},{long}' - # OpenStreetMap: 'https://www.openstreetmap.org/?mlat={lat}&mlon={long}' - location_format: 'https://www.google.com/maps/place/{lat},{long}' - -# 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: - (): mautrix_signal.util.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: ./mautrix-signal.log - maxBytes: 10485760 - backupCount: 10 - console: - class: logging.StreamHandler - formatter: colored - loggers: - mau: - level: DEBUG - aiohttp: - level: INFO - root: - level: DEBUG - handlers: [file, console] diff --git a/mautrix_signal/formatter.py b/mautrix_signal/formatter.py deleted file mode 100644 index a9c46b5..0000000 --- a/mautrix_signal/formatter.py +++ /dev/null @@ -1,158 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import cast -import html -import struct - -from mausignald.types import Address, Mention, MessageData -from mautrix.types import Format, MessageType, TextMessageEventContent, UserID -from mautrix.util.formatter import ( - EntityString, - EntityType, - MarkdownString, - MatrixParser as BaseMatrixParser, - SemiAbstractEntity, -) - -from . import puppet as pu, user as u - - -# Helper methods from from https://github.com/LonamiWebs/Telethon/blob/master/telethon/helpers.py -# I don't know if this is how Signal actually calculates lengths, -# but it seems to work better than plain len() -def add_surrogate(text: str) -> str: - return "".join( - "".join(chr(y) for y in struct.unpack(" str: - return text.encode("utf-16", "surrogatepass").decode("utf-16") - - -async def signal_to_matrix(message: MessageData) -> TextMessageEventContent: - content = TextMessageEventContent(msgtype=MessageType.TEXT, body=message.body) - surrogated_text = add_surrogate(message.body) - if message.mentions: - text_chunks = [] - html_chunks = [] - last_offset = 0 - for mention in message.mentions: - before = surrogated_text[last_offset : mention.start] - last_offset = mention.start + mention.length - - text_chunks.append(before) - html_chunks.append(html.escape(before)) - puppet = await pu.Puppet.get_by_uuid(mention.uuid) - name = add_surrogate(puppet.name or puppet.mxid) - text_chunks.append(name) - html_chunks.append(f'{name}') - end = surrogated_text[last_offset:] - text_chunks.append(end) - html_chunks.append(html.escape(end)) - content.body = del_surrogate("".join(text_chunks)) - content.format = Format.HTML - content.formatted_body = del_surrogate("".join(html_chunks)) - return content - - -class MentionEntity(Mention, SemiAbstractEntity): - @property - def offset(self) -> int: - return self.start - - @offset.setter - def offset(self, val: int) -> None: - self.start = val - - def copy(self) -> MentionEntity: - return MentionEntity(uuid=self.uuid, length=self.length, start=self.start) - - -# TODO this has a lot of duplication with mautrix-facebook, maybe move to mautrix-python -class SignalFormatString(EntityString[MentionEntity, EntityType], MarkdownString): - def format(self, entity_type: EntityType, **kwargs) -> SignalFormatString: - prefix = suffix = "" - if entity_type == EntityType.USER_MENTION: - self.entities.append( - MentionEntity(uuid=kwargs["uuid"], start=0, length=len(self.text)), - ) - return self - elif entity_type == EntityType.BOLD: - prefix = suffix = "**" - elif entity_type == EntityType.ITALIC: - prefix = suffix = "_" - elif entity_type == EntityType.STRIKETHROUGH: - prefix = suffix = "~~" - elif entity_type == EntityType.URL: - if kwargs["url"] != self.text: - suffix = f" ({kwargs['url']})" - elif entity_type == EntityType.PREFORMATTED: - prefix = f"```{kwargs['language']}\n" - suffix = "\n```" - elif entity_type == EntityType.INLINE_CODE: - prefix = suffix = "`" - elif entity_type == EntityType.BLOCKQUOTE: - children = self.trim().split("\n") - children = [child.prepend("> ") for child in children] - return self.join(children, "\n") - elif entity_type == EntityType.HEADER: - prefix = "#" * kwargs["size"] + " " - else: - return self - - self._offset_entities(len(prefix)) - self.text = f"{prefix}{self.text}{suffix}" - return self - - -class MatrixParser(BaseMatrixParser[SignalFormatString]): - fs = SignalFormatString - - async def user_pill_to_fstring( - self, msg: SignalFormatString, user_id: UserID - ) -> SignalFormatString: - user = await u.User.get_by_mxid(user_id, create=False) - if user and user.uuid: - uuid = user.uuid - else: - puppet = await pu.Puppet.get_by_mxid(user_id, create=False) - if puppet: - uuid = puppet.uuid - else: - return msg - return msg.format(self.e.USER_MENTION, uuid=uuid) - - async def parse(self, data: str) -> SignalFormatString: - return cast(SignalFormatString, await super().parse(data)) - - -async def matrix_to_signal(content: TextMessageEventContent) -> tuple[str, list[Mention]]: - if content.msgtype == MessageType.EMOTE: - content.body = f"/me {content.body}" - if content.formatted_body: - content.formatted_body = f"/me {content.formatted_body}" - if content.format == Format.HTML and content.formatted_body: - parsed = await MatrixParser().parse(add_surrogate(content.formatted_body)) - text, mentions = del_surrogate(parsed.text), parsed.entities - else: - text, mentions = content.body, [] - return text, mentions diff --git a/mautrix_signal/get_version.py b/mautrix_signal/get_version.py deleted file mode 100644 index 0b001b4..0000000 --- a/mautrix_signal/get_version.py +++ /dev/null @@ -1,49 +0,0 @@ -import os -import shutil -import subprocess - -from . import __version__ - -cmd_env = { - "PATH": os.environ["PATH"], - "HOME": os.environ["HOME"], - "LANG": "C", - "LC_ALL": "C", -} - - -def run(cmd): - return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, env=cmd_env) - - -if os.path.exists(".git") and shutil.which("git"): - try: - git_revision = run(["git", "rev-parse", "HEAD"]).strip().decode("ascii") - git_revision_url = f"https://github.com/mautrix/signal/commit/{git_revision}" - git_revision = git_revision[:8] - except (subprocess.SubprocessError, OSError): - git_revision = "unknown" - git_revision_url = None - - try: - git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii") - except (subprocess.SubprocessError, OSError): - git_tag = None -else: - git_revision = "unknown" - git_revision_url = None - git_tag = None - -git_tag_url = f"https://github.com/mautrix/signal/releases/tag/{git_tag}" if git_tag else None - -if git_tag and __version__ == git_tag[1:].replace("-", ""): - version = __version__ - linkified_version = f"[{version}]({git_tag_url})" -else: - if not __version__.endswith("+dev"): - __version__ += "+dev" - version = f"{__version__}.{git_revision}" - if git_revision_url: - linkified_version = f"{__version__}.[{git_revision}]({git_revision_url})" - else: - linkified_version = version diff --git a/mautrix_signal/matrix.py b/mautrix_signal/matrix.py deleted file mode 100644 index 8fa7975..0000000 --- a/mautrix_signal/matrix.py +++ /dev/null @@ -1,366 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import TYPE_CHECKING - -from mausignald.types import Address, GroupID -from mautrix.bridge import BaseMatrixHandler, RejectMatrixInvite -from mautrix.types import ( - Event, - EventID, - EventType, - PresenceEvent, - ReactionEvent, - ReactionEventContent, - ReceiptEvent, - RedactionEvent, - RelationType, - RoomID, - SingleReceiptEventContent, - StateEvent, - TypingEvent, - UserID, -) - -from . import portal as po, puppet as pu, signal as s, user as u -from .commands.util import get_initial_state -from .db import Message as DBMessage - -if TYPE_CHECKING: - from .__main__ import SignalBridge - - -class MatrixHandler(BaseMatrixHandler): - signal: s.SignalHandler - - def __init__(self, bridge: "SignalBridge") -> None: - prefix, suffix = bridge.config["bridge.username_template"].format(userid=":").split(":") - homeserver = bridge.config["homeserver.domain"] - self.user_id_prefix = f"@{prefix}" - self.user_id_suffix = f"{suffix}:{homeserver}" - self.signal = bridge.signal - - super().__init__(bridge=bridge) - - async def handle_puppet_group_invite( - self, - room_id: RoomID, - puppet: pu.Puppet, - invited_by: u.User, - evt: StateEvent, - members: list[UserID], - ) -> None: - double_puppet = await pu.Puppet.get_by_custom_mxid(invited_by.mxid) - if ( - not double_puppet - or self.az.bot_mxid in members - or not self.config["bridge.create_group_on_invite"] - ): - if self.az.bot_mxid not in members: - await puppet.default_mxid_intent.leave_room( - room_id, - reason="This ghost does not join multi-user rooms without the bridge bot.", - ) - else: - await puppet.default_mxid_intent.send_notice( - room_id, - "This ghost will remain inactive " - "until a Signal Group is created for this room.", - ) - return - - await double_puppet.intent.invite_user(room_id, self.az.bot_mxid) - - title, about, levels, encrypted, avatar_url, join_rule = await get_initial_state( - double_puppet.intent, room_id - ) - - portal = po.Portal( - chat_id=GroupID(""), - mxid=evt.room_id, - name=title, - topic=about or "", - encrypted=encrypted, - receiver="", - avatar_url=avatar_url, - ) - await portal.az.intent.ensure_joined(room_id) - invited_by_level = levels.get_user_level(invited_by.mxid) - if invited_by_level > levels.get_user_level(self.az.bot_mxid): - levels.users[self.az.bot_mxid] = 100 if invited_by_level >= 100 else invited_by_level - await double_puppet.intent.set_power_levels(room_id, levels) - - await portal.create_signal_group(invited_by, levels, join_rule) - - async def handle_invite( - self, room_id: RoomID, user_id: UserID, inviter: u.User, event_id: EventID - ) -> None: - user = await u.User.get_by_mxid(user_id, create=False) - if not user or not await user.is_logged_in(): - return - portal = await po.Portal.get_by_mxid(room_id) - if portal and not portal.is_direct: - try: - await portal.handle_matrix_invite(inviter, user) - except RejectMatrixInvite as e: - await portal.main_intent.send_notice( - portal.mxid, f"Failed to invite {user.mxid} on Signal: {e}" - ) - - async def send_welcome_message(self, room_id: RoomID, inviter: u.User) -> None: - await super().send_welcome_message(room_id, inviter) - if not inviter.notice_room: - inviter.notice_room = room_id - await inviter.update() - await self.az.intent.send_notice( - room_id, "This room has been marked as your Signal bridge notice room." - ) - - async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None: - portal = await po.Portal.get_by_mxid(room_id) - if not portal: - return - - user = await u.User.get_by_mxid(user_id, create=False) - if not user: - return - - await portal.handle_matrix_leave(user) - - async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None: - portal = await po.Portal.get_by_mxid(room_id) - if not portal: - return - - user = await u.User.get_by_mxid(user_id, create=False) - if not user: - return - - await portal.handle_matrix_join(user) - - async def handle_kick_ban( - self, - action: str, - room_id: RoomID, - user_id: UserID, - sender: UserID, - reason: str, - event_id: EventID, - ) -> None: - self.log.debug(f"{user_id} was {action} from {room_id} by {sender} for {reason}") - portal = await po.Portal.get_by_mxid(room_id) - if not portal: - return - - if user_id == self.az.bot_mxid: - if portal.is_direct: - await portal.unbridge() - return - - sender = await u.User.get_by_mxid(sender) - sender, is_relay = await portal.get_relay_sender(sender, "kick/ban") - if not sender: - return - - user = await pu.Puppet.get_by_mxid(user_id) - if not user: - user = await u.User.get_by_mxid(user_id, create=False) - if not user or not await user.is_logged_in(): - return - if action == "banned": - await portal.ban_matrix(user, sender) - elif action == "kicked": - await portal.kick_matrix(user, sender) - else: - await portal.unban_matrix(user, sender) - - async def handle_kick( - self, room_id: RoomID, user_id: UserID, kicked_by: UserID, reason: str, event_id: EventID - ) -> None: - await self.handle_kick_ban("kicked", room_id, user_id, kicked_by, reason, event_id) - - async def handle_unban( - self, room_id: RoomID, user_id: UserID, unbanned_by: UserID, reason: str, event_id: EventID - ) -> None: - await self.handle_kick_ban("unbanned", room_id, user_id, unbanned_by, reason, event_id) - - async def handle_ban( - self, room_id: RoomID, user_id: UserID, banned_by: UserID, reason: str, event_id: EventID - ) -> None: - await self.handle_kick_ban("banned", room_id, user_id, banned_by, reason, event_id) - - async def handle_accept_knock( - self, room_id: RoomID, user_id: UserID, sender: UserID, reason: str, event_id: EventID - ) -> None: - self.log.debug(f"Knock {user_id} to {room_id} was accepted: {reason}") - portal = await po.Portal.get_by_mxid(room_id) - if not portal: - return - sender = await u.User.get_by_mxid(sender) - sender, is_relay = await portal.get_relay_sender(sender, "knock accept") - if not sender: - return - - user = await pu.Puppet.get_by_mxid(user_id) - if not user: - user = await u.User.get_by_mxid(user_id, create=False) - if not user or not await user.is_logged_in(): - return - await portal.matrix_accept_knock(sender, user) - - async def handle_reject_knock( - self, room_id: RoomID, user_id: UserID, sender: UserID, reason: str, event_id: EventID - ) -> None: - self.log.debug(f"Knock from {user_id} to {room_id} was rejected: {reason}") - portal = await po.Portal.get_by_mxid(room_id) - if not portal: - return - sender = await u.User.get_by_mxid(sender) - sender, is_relay = await portal.get_relay_sender(sender, "knock reject") - if not sender: - return - - user = await pu.Puppet.get_by_mxid(user_id) - if not user: - user = await u.User.get_by_mxid(user_id, create=False) - if not user or not await user.is_logged_in(): - return - await portal.matrix_reject_knock(sender, user) - - @classmethod - async def handle_reaction( - cls, room_id: RoomID, user_id: UserID, event_id: EventID, content: ReactionEventContent - ) -> None: - if content.relates_to.rel_type != RelationType.ANNOTATION: - cls.log.debug( - f"Ignoring m.reaction event in {room_id} from {user_id} with unexpected " - f"relation type {content.relates_to.rel_type}" - ) - return - user = await u.User.get_by_mxid(user_id) - if not user: - return - - portal = await po.Portal.get_by_mxid(room_id) - if not portal: - return - - await portal.handle_matrix_reaction( - user, event_id, content.relates_to.event_id, content.relates_to.key - ) - - @staticmethod - async def handle_redaction( - room_id: RoomID, user_id: UserID, event_id: EventID, redaction_event_id: EventID - ) -> None: - user = await u.User.get_by_mxid(user_id) - if not user: - return - - portal = await po.Portal.get_by_mxid(room_id) - if not portal: - return - - await portal.handle_matrix_redaction(user, event_id, redaction_event_id) - - async def handle_read_receipt( - self, - user: u.User, - portal: po.Portal, - event_id: EventID, - data: SingleReceiptEventContent, - ) -> None: - message = await DBMessage.get_by_mxid( - event_id, portal.mxid - ) or await DBMessage.get_first_before(portal.mxid, data.ts) - if not message: - user.log.warning("Skipping sending read receipt for event ID: %s", event_id) - return - - user.log.trace(f"Sending read receipt for {message.timestamp} to {message.sender}") - try: - await self.signal.send_receipt( - user.username, - Address(uuid=message.sender), - timestamps=[message.timestamp], - when=data.ts, - read=True, - ) - except Exception as e: - await user.handle_auth_failure(e) - - async def handle_typing(self, room_id: RoomID, typing: list[UserID]) -> None: - pass - # portal = await po.Portal.get_by_mxid(room_id) - # if not portal: - # return - # - # for user_id in typing: - # user = await u.User.get_by_mxid(user_id, create=False) - # if not user or not user.username: - # continue - # # TODO - - async def handle_event(self, evt: Event) -> None: - if evt.type == EventType.REACTION: - evt: ReactionEvent - await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content) - elif evt.type == EventType.ROOM_REDACTION: - evt: RedactionEvent - await self.handle_redaction(evt.room_id, evt.sender, evt.redacts, evt.event_id) - - async def handle_ephemeral_event( - self, evt: ReceiptEvent | PresenceEvent | TypingEvent - ) -> None: - if evt.type == EventType.TYPING: - await self.handle_typing(evt.room_id, evt.content.user_ids) - else: - await super().handle_ephemeral_event(evt) - - async def handle_state_event(self, evt: StateEvent) -> None: - if evt.type not in ( - EventType.ROOM_NAME, - EventType.ROOM_TOPIC, - EventType.ROOM_AVATAR, - EventType.ROOM_POWER_LEVELS, - EventType.ROOM_JOIN_RULES, - ): - return - - user = await u.User.get_by_mxid(evt.sender) - if not user: - return - portal = await po.Portal.get_by_mxid(evt.room_id) - if not portal: - return - - if evt.type == EventType.ROOM_NAME: - await portal.handle_matrix_name(user, evt.content.name) - elif evt.type == EventType.ROOM_AVATAR: - await portal.handle_matrix_avatar(user, evt.content.url) - elif evt.type == EventType.ROOM_TOPIC: - await portal.handle_matrix_topic(user, evt.content.topic) - elif evt.type == EventType.ROOM_POWER_LEVELS: - await portal.handle_matrix_power_level(user, evt.content, evt.unsigned.prev_content) - elif evt.type == EventType.ROOM_JOIN_RULES: - await portal.handle_matrix_join_rules(user, evt.content.join_rule) - - async def allow_message(self, user: u.User) -> bool: - return user.relay_whitelisted - - async def allow_bridging_message(self, user: u.User, portal: po.Portal) -> bool: - return portal.has_relay or await user.is_logged_in() diff --git a/mautrix_signal/portal.py b/mautrix_signal/portal.py deleted file mode 100644 index 74047ff..0000000 --- a/mautrix_signal/portal.py +++ /dev/null @@ -1,2590 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, Union, cast -from collections import deque -from uuid import UUID, uuid4 -import asyncio -import hashlib -import mimetypes -import os -import os.path -import pathlib -import time - -from mausignald.errors import ( - AttachmentTooLargeError, - GroupPatchNotAcceptedError, - NotConnected, - ProfileUnavailableError, - RPCError, -) -from mausignald.types import ( - AccessControlMode, - Address, - AnnouncementsMode, - Attachment, - GroupAccessControl, - GroupChange, - GroupID, - GroupMember, - GroupMemberRole, - GroupV2, - GroupV2ID, - LinkPreview, - Mention, - MessageData, - Profile, - Quote, - QuotedAttachment, - Reaction, - SharedContact, - Sticker, -) -from mautrix.appservice import AppService, IntentAPI -from mautrix.bridge import BasePortal, RejectMatrixInvite, async_getter_lock -from mautrix.errors import IntentError, MatrixError, MBadState, MForbidden -from mautrix.types import ( - AudioInfo, - BeeperMessageStatusEventContent, - ContentURI, - EncryptedEvent, - EncryptedFile, - EventID, - EventType, - FileInfo, - ImageInfo, - JoinRule, - MediaMessageEventContent, - Membership, - MessageEvent, - MessageEventContent, - MessageStatus, - MessageStatusReason, - MessageType, - PowerLevelStateEventContent, - RelatesTo, - RelationType, - RoomID, - TextMessageEventContent, - UserID, - VideoInfo, -) -from mautrix.util import background_task, ffmpeg, variation_selector -from mautrix.util.format_duration import format_duration -from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus - -from . import matrix as m, puppet as p, signal as s, user as u -from .config import Config -from .db import ( - DisappearingMessage, - Message as DBMessage, - Portal as DBPortal, - Reaction as DBReaction, -) -from .formatter import matrix_to_signal, signal_to_matrix - -if TYPE_CHECKING: - from .__main__ import SignalBridge - -try: - from mautrix.crypto.attachments import decrypt_attachment, encrypt_attachment -except ImportError: - encrypt_attachment = decrypt_attachment = None - -try: - from signalstickers_client import StickersClient - from signalstickers_client.models import StickerPack -except ImportError: - StickersClient = StickerPack = None - -try: - from mautrix.util import magic -except ImportError: - magic = None - -StateBridge = EventType.find("m.bridge", EventType.Class.STATE) -StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE) -ChatInfo = Union[GroupV2, Profile, Address] -MAX_MATRIX_MESSAGE_SIZE = 30000 -BEEPER_LINK_PREVIEWS_KEY = "com.beeper.linkpreviews" -BEEPER_IMAGE_ENCRYPTION_KEY = "beeper:image:encryption" - - -class UnknownReactionTarget(Exception): - pass - - -class Portal(DBPortal, BasePortal): - by_mxid: dict[RoomID, Portal] = {} - by_chat_id: dict[tuple[str, str], Portal] = {} - _sticker_meta_cache: dict[str, StickerPack] = {} - disappearing_msg_class = DisappearingMessage - config: Config - matrix: m.MatrixHandler - signal: s.SignalHandler - az: AppService - private_chat_portal_meta: bool - expiration_time: int | None - - _main_intent: IntentAPI | None - _create_room_lock: asyncio.Lock - _msgts_dedup: deque[tuple[UUID, int]] - _reaction_dedup: deque[tuple[UUID, int, str, UUID, bool]] - _reaction_lock: asyncio.Lock - _pending_members: set[UUID] | None - _expiration_lock: asyncio.Lock - - def __init__( - self, - chat_id: GroupID | UUID, - receiver: str, - mxid: RoomID | None = None, - name: str | None = None, - topic: str | None = None, - avatar_hash: str | None = None, - avatar_url: ContentURI | None = None, - name_set: bool = False, - avatar_set: bool = False, - revision: int = 0, - encrypted: bool = False, - relay_user_id: UserID | None = None, - expiration_time: int | None = None, - ) -> None: - super().__init__( - chat_id=chat_id, - receiver=receiver, - mxid=mxid, - name=name, - topic=topic, - avatar_hash=avatar_hash, - avatar_url=avatar_url, - name_set=name_set, - avatar_set=avatar_set, - revision=revision, - encrypted=encrypted, - relay_user_id=relay_user_id, - expiration_time=expiration_time, - ) - BasePortal.__init__(self) - self._create_room_lock = asyncio.Lock() - self.log = self.log.getChild(str(self.chat_id)) - self._main_intent = None - self._msgts_dedup = deque(maxlen=100) - self._reaction_dedup = deque(maxlen=100) - self._last_participant_update = set() - self._reaction_lock = asyncio.Lock() - self._pending_members = None - self._relay_user = None - self._expiration_lock = asyncio.Lock() - - @property - def main_intent(self) -> IntentAPI: - if not self._main_intent: - raise ValueError("Portal must be postinit()ed before main_intent can be used") - return self._main_intent - - @property - def is_direct(self) -> bool: - return isinstance(self.chat_id, UUID) - - @property - def disappearing_enabled(self) -> bool: - return self.is_direct or self.config["signal.enable_disappearing_messages_in_groups"] - - @classmethod - def init_cls(cls, bridge: "SignalBridge") -> None: - cls.config = bridge.config - cls.matrix = bridge.matrix - cls.signal = bridge.signal - cls.az = bridge.az - cls.loop = bridge.loop - BasePortal.bridge = bridge - cls.private_chat_portal_meta = cls.config["bridge.private_chat_portal_meta"] - - # region Misc - - async def _send_delivery_receipt(self, event_id: EventID) -> None: - if event_id and self.config["bridge.delivery_receipts"]: - try: - await self.az.intent.mark_read(self.mxid, event_id) - except Exception: - self.log.exception("Failed to send delivery receipt for %s", event_id) - - async def _upsert_reaction( - self, - existing: DBReaction, - intent: IntentAPI, - mxid: EventID, - sender: p.Puppet | u.User, - message: DBMessage, - emoji: str, - ) -> None: - if existing: - self.log.debug( - f"_upsert_reaction redacting {existing.mxid} and inserting {mxid}" - f" (message: {message.mxid})" - ) - try: - await intent.redact(existing.mx_room, existing.mxid) - except MForbidden: - self.log.debug("Unexpected MForbidden redacting reaction", exc_info=True) - await existing.edit(emoji=emoji, mxid=mxid, mx_room=message.mx_room) - else: - self.log.debug(f"_upsert_reaction inserting {mxid} (message: {message.mxid})") - await DBReaction( - mxid=mxid, - mx_room=message.mx_room, - emoji=emoji, - signal_chat_id=self.chat_id, - signal_receiver=self.receiver, - msg_author=message.sender, - msg_timestamp=message.timestamp, - author=sender.uuid, - ).insert() - - # endregion - # region Matrix event handling - - @staticmethod - async def _make_attachment(message: MediaMessageEventContent, path: str) -> Attachment: - outgoing_filename = path - if message.msgtype == MessageType.AUDIO: - outgoing_filename = await ffmpeg.convert_path( - path, ".m4a", output_args=("-c:a", "aac"), remove_input=True - ) - message.info.mimetype = "audio/mp4" - attachment = Attachment( - custom_filename=message.body, - content_type=message.info.mimetype, - outgoing_filename=str(outgoing_filename), - ) - info = message.info - attachment.width = info.get("w", info.get("width", 0)) - attachment.height = info.get("h", info.get("height", 0)) - attachment.voice_note = message.msgtype == MessageType.AUDIO - return attachment - - def _write_outgoing_file(self, data: bytes) -> str: - dir = pathlib.Path(self.config["signal.outgoing_attachment_dir"]) - path = dir.joinpath(f"mautrix-signal-{str(uuid4())}") - try: - with open(path, "wb") as file: - file.write(data) - except FileNotFoundError: - dir.mkdir(mode=0o755, parents=True, exist_ok=True) - with open(path, "wb") as file: - file.write(data) - return str(path) - - async def _download_matrix_media(self, message: MediaMessageEventContent) -> str: - # Signal limits files to 100 MB - if message.info and message.info.size and message.info.size > 100 * 10**6: - raise AttachmentTooLargeError({"filename": message.body}) - if message.file: - data = await self.main_intent.download_media(message.file.url) - data = decrypt_attachment( - data, message.file.key.key, message.file.hashes.get("sha256"), message.file.iv - ) - else: - data = await self.main_intent.download_media(message.url) - return self._write_outgoing_file(data) - - async def handle_matrix_message( - self, sender: u.User, message: MessageEventContent, event_id: EventID - ) -> None: - try: - await self._handle_matrix_message(sender, message, event_id) - except Exception as e: - self.log.exception(f"Failed to handle Matrix message {event_id}") - status = ( - MessageSendCheckpointStatus.UNSUPPORTED - if isinstance(e, AttachmentTooLargeError) - else MessageSendCheckpointStatus.PERM_FAILURE - ) - sender.send_remote_checkpoint( - status, event_id, self.mxid, EventType.ROOM_MESSAGE, message.msgtype, error=e - ) - await sender.handle_auth_failure(e) - await self._send_error_notice("message", e) - background_task.create(self._send_message_status(event_id, e)) - - async def _send_error_notice(self, type_name: str, err: Exception) -> None: - if not self.config["bridge.delivery_error_reports"]: - return - message = f"{type(err).__name__}: {err}" - if isinstance(err, NotConnected): - message = "There was an error connecting to signald." - elif isinstance(err, UnknownReactionTarget): - message = "Could not find message to react to on Signal." - await self._send_message( - self.main_intent, - TextMessageEventContent( - msgtype=MessageType.NOTICE, - body=f"\u26a0 Your {type_name} was not bridged: {message}", - ), - ) - - async def _send_message_status(self, event_id: EventID, err: Exception | None) -> None: - if not self.config["bridge.message_status_events"]: - return - intent = self.az.intent if self.encrypted else self.main_intent - status = BeeperMessageStatusEventContent( - network=self.bridge_info_state_key, - relates_to=RelatesTo( - rel_type=RelationType.REFERENCE, - event_id=event_id, - ), - ) - if err: - status.reason = MessageStatusReason.GENERIC_ERROR - status.error = str(err) - if isinstance(err, AttachmentTooLargeError): - status.reason = MessageStatusReason.UNSUPPORTED - status.status = MessageStatus.FAIL - status.message = "too large file (maximum is 100MB)" - elif isinstance(err, UnknownReactionTarget): - status.status = MessageStatus.FAIL - else: - status.status = MessageStatus.RETRIABLE - else: - status.status = MessageStatus.SUCCESS - - await intent.send_message_event( - room_id=self.mxid, - event_type=EventType.BEEPER_MESSAGE_STATUS, - content=status, - ) - - async def _beeper_link_preview_to_signal( - self, beeper_link_preview: dict[str, Any] - ) -> LinkPreview | None: - link_preview = LinkPreview( - url=beeper_link_preview["matched_url"], - title=beeper_link_preview.get("og:title", ""), - description=beeper_link_preview.get("og:description", ""), - ) - if BEEPER_IMAGE_ENCRYPTION_KEY in beeper_link_preview or "og:image" in beeper_link_preview: - if BEEPER_IMAGE_ENCRYPTION_KEY in beeper_link_preview: - file = EncryptedFile.deserialize(beeper_link_preview[BEEPER_IMAGE_ENCRYPTION_KEY]) - data = await self.main_intent.download_media(file.url) - data = decrypt_attachment(data, file.key.key, file.hashes.get("sha256"), file.iv) - else: - data = await self.main_intent.download_media(beeper_link_preview["og:image"]) - - attachment_path = self._write_outgoing_file(data) - link_preview.attachment = Attachment( - content_type=beeper_link_preview.get("og:image:type"), - outgoing_filename=attachment_path, - width=beeper_link_preview.get("og:image:width", 0), - height=beeper_link_preview.get("og:image:height", 0), - size=beeper_link_preview.get("matrix:image:size", 0), - ) - return link_preview - - async def _handle_matrix_message( - self, sender: u.User, message: MessageEventContent, event_id: EventID - ) -> None: - orig_sender = sender - sender, is_relay = await self.get_relay_sender(sender, f"message {event_id}") - if not sender: - orig_sender.send_remote_checkpoint( - status=MessageSendCheckpointStatus.PERM_FAILURE, - event_id=event_id, - room_id=self.mxid, - event_type=EventType.ROOM_MESSAGE, - message_type=message.msgtype, - error="user is not logged in", - ) - return - elif is_relay: - await self.apply_relay_message_format(orig_sender, message) - - request_id = int(time.time() * 1000) - self._msgts_dedup.appendleft((sender.uuid, request_id)) - - quote = None - if message.get_reply_to(): - reply = await DBMessage.get_by_mxid(message.get_reply_to(), self.mxid) - # TODO include actual text? either store in db or fetch event from homeserver - if reply is not None: - quote = Quote(id=reply.timestamp, author=Address(uuid=reply.sender), text="") - # TODO only send this when it's actually a reply to an attachment? - # Neither Signal Android nor iOS seem to care though, so this works too - quote.attachments = [QuotedAttachment("", "")] - - attachments: list[Attachment] | None = None - attachment_path: str | None = None - mentions: list[Mention] | None = None - link_previews: list[LinkPreview] | None = None - if message.msgtype.is_text: - text, mentions = await matrix_to_signal(message) - message_previews = message.get(BEEPER_LINK_PREVIEWS_KEY, []) - potential_link_previews: list[LinkPreview | None] = cast( - list, - await asyncio.gather( - *(self._beeper_link_preview_to_signal(m) for m in message_previews) - ), - ) - link_previews = [p for p in potential_link_previews if p is not None] - elif message.msgtype.is_media: - attachment_path = await self._download_matrix_media(message) - attachment = await self._make_attachment(message, attachment_path) - attachments = [attachment] - text = message.body if is_relay else None - self.log.trace("Formed outgoing attachment %s", attachment) - elif message.msgtype == MessageType.LOCATION: - try: - lat, long = message.geo_uri[len("geo:") :].split(";")[0].split(",") - text = self.config["bridge.location_format"].format( - lat=float(lat), long=float(long) - ) - except (ValueError, KeyError, IndexError) as e: - orig_sender.send_remote_checkpoint( - status=MessageSendCheckpointStatus.PERM_FAILURE, - event_id=event_id, - room_id=self.mxid, - event_type=EventType.ROOM_MESSAGE, - message_type=message.msgtype, - error=str(e), - ) - self.log.warning(f"Malformed geo URI in {event_id}: {e}") - return - extev = message.get("org.matrix.msc3488.location", None) - # TODO support relay mode with extensible event location descriptions - if extev and not is_relay: - body = extev.get("description") - else: - body = message.body - if body: - text = f"{body}\n{text}" - else: - self.log.debug(f"Unknown msgtype {message.msgtype} in Matrix message {event_id}") - return - - self.log.debug(f"Sending Matrix message {event_id} to Signal with timestamp {request_id}") - retry_count = await self._signal_send_with_retries( - sender, - event_id, - message_type=message.msgtype, - send_fn=lambda *args, **kwargs: self.signal.send(**kwargs), - event_type=EventType.ROOM_MESSAGE, - username=sender.username, - recipient=self.chat_id, - body=text, - mentions=mentions, - previews=link_previews, - quote=quote, - attachments=attachments, - timestamp=request_id, - ) - - msg = DBMessage( - mxid=event_id, - mx_room=self.mxid, - sender=sender.uuid, - timestamp=request_id, - signal_chat_id=self.chat_id, - signal_receiver=self.receiver, - ) - await msg.insert() - self.log.debug(f"Handled Matrix message {event_id} -> {request_id}") - if attachment_path and self.config["signal.remove_file_after_handling"]: - try: - os.remove(attachment_path) - except FileNotFoundError: - pass - - # Handle disappearing messages - if self.expiration_time and self.disappearing_enabled: - dm = DisappearingMessage(self.mxid, event_id, self.expiration_time) - dm.start_timer() - await dm.insert() - background_task.create(self._disappear_event(dm)) - - sender.send_remote_checkpoint( - MessageSendCheckpointStatus.SUCCESS, - event_id, - self.mxid, - EventType.ROOM_MESSAGE, - message.msgtype, - retry_num=retry_count, - ) - await self._send_delivery_receipt(event_id) - background_task.create(self._send_message_status(event_id, err=None)) - - async def _signal_send_with_retries( - self, - sender: u.User, - event_id: EventID, - send_fn: Callable, - event_type: EventType, - message_type: MessageType | None = None, - **send_args, - ) -> int: - retry_count = 4 - last_error_type = NotConnected - for retry_num in range(retry_count): - try: - req_id = uuid4() - self.log.info( - f"Send attempt {retry_num}. Attempting to send {event_id} with {req_id}" - ) - await send_fn(sender, event_id, req_id=req_id, **send_args) - return retry_num - except (NotConnected, UnknownReactionTarget) as e: - if retry_num >= retry_count - 1: - break - last_error_type = type(e) - # Only handle NotConnected and UnknownReactionTarget exceptions so that other - # exceptions actually continue to error. - sleep_seconds = retry_num * 2 + 1 - msg = ( - f"Not connected to signald. Going to sleep for {sleep_seconds}s. Error: {e}" - if isinstance(e, NotConnected) - else f"UnknownReactionTarget: Going to sleep for {sleep_seconds}s. Error: {e}" - ) - self.log.exception(msg) - sender.send_remote_checkpoint( - MessageSendCheckpointStatus.WILL_RETRY, - event_id, - self.mxid, - event_type, - message_type=message_type, - error=msg, - retry_num=retry_num, - ) - - await asyncio.sleep(sleep_seconds) - except Exception as e: - await sender.handle_auth_failure(e) - raise - event_type_name = { - EventType.ROOM_MESSAGE: "message", - EventType.REACTION: "reaction", - }.get(event_type, str(event_type)) - raise last_error_type(f"Failed to send {event_type_name} after {retry_count} retries.") - - async def handle_matrix_reaction( - self, sender: u.User, event_id: EventID, reacting_to: EventID, emoji: str - ) -> None: - if not await sender.is_logged_in(): - self.log.trace(f"Ignoring reaction by non-logged-in user {sender.mxid}") - return - - # Signal doesn't seem to use variation selectors at all - emoji = variation_selector.remove(emoji) - try: - retry_count = await self._signal_send_with_retries( - sender, - event_id, - send_fn=self._handle_matrix_reaction, - event_type=EventType.REACTION, - reacting_to=reacting_to, - emoji=emoji, - ) - except Exception as e: - self.log.exception(f"Failed to handle Matrix reaction {event_id} to {reacting_to}") - sender.send_remote_checkpoint( - MessageSendCheckpointStatus.PERM_FAILURE, - event_id, - self.mxid, - EventType.REACTION, - error=e, - ) - await self._send_error_notice("reaction", e) - await sender.handle_auth_failure(e) - background_task.create(self._send_message_status(event_id, e)) - else: - sender.send_remote_checkpoint( - MessageSendCheckpointStatus.SUCCESS, - event_id, - self.mxid, - EventType.REACTION, - retry_num=retry_count, - ) - await self._send_delivery_receipt(event_id) - background_task.create(self._send_message_status(event_id, err=None)) - - async def _handle_matrix_reaction( - self, - sender: u.User, - event_id: EventID, - reacting_to: EventID, - emoji: str, - req_id: UUID | None = None, - ) -> None: - message = await DBMessage.get_by_mxid(reacting_to, self.mxid) - if not message: - self.log.debug(f"Ignoring reaction to unknown event {reacting_to}") - raise UnknownReactionTarget(f"Ignoring reaction to unknown event {reacting_to}") - - async with self._reaction_lock: - existing = await DBReaction.get_by_signal_id( - self.chat_id, self.receiver, message.sender, message.timestamp, sender.uuid - ) - if existing and existing.emoji == emoji: - return - - dedup_id = (message.sender, message.timestamp, emoji, sender.uuid, False) - self._reaction_dedup.appendleft(dedup_id) - - reaction = Reaction( - emoji=emoji, - remove=False, - target_author=Address(uuid=message.sender), - target_sent_timestamp=message.timestamp, - ) - self.log.trace(f"{sender.mxid} reacted to {message.timestamp} with {emoji}") - await self.signal.react( - sender.username, recipient=self.chat_id, reaction=reaction, req_id=req_id - ) - - await self._upsert_reaction( - existing, self.main_intent, event_id, sender, message, emoji - ) - - async def handle_matrix_redaction( - self, sender: u.User, event_id: EventID, redaction_event_id: EventID - ) -> None: - if not await sender.is_logged_in(): - return - - message = await DBMessage.get_by_mxid(event_id, self.mxid) - if message: - try: - await message.delete() - await self.signal.remote_delete( - sender.username, recipient=self.chat_id, timestamp=message.timestamp - ) - except Exception as e: - self.log.exception( - f"Failed to handle Matrix redaction {redaction_event_id} of " - f"message {event_id} ({message.timestamp})" - ) - sender.send_remote_checkpoint( - MessageSendCheckpointStatus.PERM_FAILURE, - redaction_event_id, - self.mxid, - EventType.ROOM_REDACTION, - error=e, - ) - await sender.handle_auth_failure(e) - background_task.create(self._send_error_notice("message deletion", e)) - background_task.create(self._send_message_status(event_id, e)) - else: - self.log.trace(f"Removed {message} after Matrix redaction") - sender.send_remote_checkpoint( - MessageSendCheckpointStatus.SUCCESS, - redaction_event_id, - self.mxid, - EventType.ROOM_REDACTION, - ) - await self._send_delivery_receipt(redaction_event_id) - background_task.create(self._send_message_status(redaction_event_id, err=None)) - return - - reaction = await DBReaction.get_by_mxid(event_id, self.mxid) - if reaction: - try: - await reaction.delete() - remove_reaction = Reaction( - emoji=reaction.emoji, - remove=True, - target_author=Address(uuid=reaction.msg_author), - target_sent_timestamp=reaction.msg_timestamp, - ) - await self.signal.react( - username=sender.username, recipient=self.chat_id, reaction=remove_reaction - ) - except Exception as e: - self.log.exception( - f"Failed to handle Matrix redaction {redaction_event_id} of " - f"reaction {event_id} to {reaction.msg_timestamp}" - ) - sender.send_remote_checkpoint( - MessageSendCheckpointStatus.PERM_FAILURE, - redaction_event_id, - self.mxid, - EventType.ROOM_REDACTION, - error=e, - ) - await sender.handle_auth_failure(e) - background_task.create(self._send_error_notice("reaction deletion", e)) - background_task.create(self._send_message_status(event_id, e)) - else: - self.log.trace(f"Removed {reaction} after Matrix redaction") - sender.send_remote_checkpoint( - MessageSendCheckpointStatus.SUCCESS, - redaction_event_id, - self.mxid, - EventType.ROOM_REDACTION, - ) - await self._send_delivery_receipt(redaction_event_id) - background_task.create(self._send_message_status(redaction_event_id, err=None)) - return - - sender.send_remote_checkpoint( - MessageSendCheckpointStatus.PERM_FAILURE, - redaction_event_id, - self.mxid, - EventType.ROOM_REDACTION, - error="No message or reaction found for redaction", - ) - status_err = UnknownReactionTarget("No message or reaction found for redaction") - background_task.create(self._send_message_status(redaction_event_id, err=status_err)) - - async def handle_matrix_join(self, user: u.User) -> None: - if self.is_direct or not await user.is_logged_in(): - return - if self._pending_members is None: - self.log.debug( - f"{user.mxid} ({user.uuid}) joined room, but pending_members is None," - " updating chat info" - ) - await self.update_info(user, GroupV2ID(id=self.chat_id)) - if self._pending_members is None: - self.log.warning( - f"Didn't get pending member list after info update, {user.mxid} ({user.uuid}) may" - "not be in the group on Signal." - ) - elif user.uuid in self._pending_members: - self.log.debug(f"{user.mxid} ({user.uuid}) joined room, accepting invite on Signal") - try: - resp = await self.signal.accept_invitation(user.username, self.chat_id) - self._pending_members.remove(user.uuid) - except RPCError as e: - await self.main_intent.send_notice( - self.mxid, f"\u26a0 Failed to accept invite on Signal: {e}" - ) - await user.handle_auth_failure(e) - else: - await self.update_info(user, resp) - - async def handle_matrix_leave(self, user: u.User) -> None: - if not await user.is_logged_in(): - return - if self.is_direct: - self.log.info(f"{user.mxid} left private chat portal with {self.chat_id}") - if user.username == self.receiver: - self.log.info( - f"{user.mxid} was the recipient of this portal. Cleaning up and deleting..." - ) - await self.cleanup_and_delete() - else: - self.log.debug(f"{user.mxid} left portal to {self.chat_id}") - if self.config["bridge.bridge_matrix_leave"]: - await self.signal.leave_group(user.username, self.chat_id) - # TODO cleanup if empty - - async def kick_matrix(self, user: u.User | p.Puppet, source: u.User) -> None: - try: - await self.signal.update_group( - source.username, self.chat_id, remove_members=[user.address] - ) - except Exception as e: - self.log.exception(f"Failed to kick Signal user: {e}") - info = await self.signal.get_group(source.username, self.chat_id) - if user.address in info.members: - await self.main_intent.invite_user( - self.mxid, - user.mxid, - check_cache=True, - reason=f"Failed to kick Signal user: {e}", - ) - await user.intent_for(self).ensure_joined(self.mxid) - - async def ban_matrix(self, user: u.User | p.Puppet, source: u.User) -> None: - try: - await self.signal.ban_user(source.username, self.chat_id, users=[user.address]) - except Exception as e: - self.log.exception(f"Failed to ban Signal user: {e}") - info = await self.signal.get_group(source.username, self.chat_id) - is_banned = False - if info.banned_members: - for member in info.banned_members: - is_banned = user.uuid == member.uuid or is_banned - if not is_banned: - await self.main_intent.unban_user( - self.mxid, user.mxid, reason=f"Failed to ban Signal user: {e}" - ) - if user.address in info.members: - await self.main_intent.invite_user( - self.mxid, - user.mxid, - check_cache=True, - ) - await user.intent_for(self).ensure_joined(self.mxid) - - async def unban_matrix(self, user: u.User | p.Puppet, source: u.User) -> None: - try: - await self.signal.unban_user(source.username, self.chat_id, users=[user.address]) - except Exception as e: - self.log.exception(f"Failed to unban Signal user: {e}") - info = await self.signal.get_group(source.username, self.chat_id) - if info.banned_members: - for member in info.banned_members: - if member.uuid == user.uuid: - await self.main_intent.ban_user( - self.mxid, user.mxid, reason=f"Failed to unban Signal user: {e}" - ) - return - - async def handle_matrix_invite( - self, invited_by: u.User, user: u.User | p.Puppet - ) -> GroupV2 | None: - if self.is_direct: - raise RejectMatrixInvite("You can't invite additional users to private chats.") - invited_by, is_relay = await self.get_relay_sender(invited_by, "invite") - if is_relay: - if not self.config["bridge.relay.invite"]: - raise RejectMatrixInvite("Relaying invites is not enabled on this bridge") - if not invited_by: - raise RejectMatrixInvite("There is no relay in this room") - update_meta = None - try: - update_meta = await self.signal.update_group( - invited_by.username, self.chat_id, add_members=[user.address] - ) - self.revision = update_meta.revision - except GroupPatchNotAcceptedError as e: - update_meta = await self.signal.get_group(invited_by.username, self.chat_id) - if ( - user.address not in update_meta.members - and user.address not in update_meta.pending_members - ): - raise RejectMatrixInvite(str(e)) from e - except RPCError as e: - raise RejectMatrixInvite(str(e)) from e - if user.mxid == self.config["bridge.relay.relaybot"] != "@relaybot:example.com": - await self._handle_relaybot_invited(user) - power_levels = await self.main_intent.get_power_levels(self.mxid) - invitee_pl = power_levels.get_user_level(user.mxid) - if invitee_pl >= 50: - group_member = GroupMember(uuid=user.uuid, role=GroupMemberRole.ADMINISTRATOR) - try: - update_meta = await self.signal.update_group( - invited_by.username, self.chat_id, update_role=group_member - ) - self.revision = update_meta.revision - except Exception as e: - self.log.exception(f"Failed to update Signal member role: {e}") - await self._update_power_levels( - await self.signal.get_group(invited_by.username, self.chat_id) - ) - return update_meta - - async def _handle_relaybot_invited(self, user: u.User) -> None: - if not self.config["bridge.relay.enabled"]: - await self.main_intent.send_notice( - self.mxid, "Relay mode is not enabled in this instance of the bridge." - ) - else: - await self.set_relay_user(user) - await self.main_intent.send_notice( - self.mxid, - "Messages from non-logged-in users in this room will now be bridged " - "through the relaybot's Signal account.", - ) - - async def handle_matrix_name(self, user: u.User, name: str) -> None: - if self.name == name or self.is_direct or not name: - return - sender, is_relay = await self.get_relay_sender(user, "name change") - if not sender: - return - self.name = name - self.log.debug( - f"{user.mxid} changed the group name, sending to Signal through {sender.username}" - ) - try: - await self.signal.update_group(sender.username, self.chat_id, title=name) - except Exception as e: - self.log.exception("Failed to update Signal group name") - await user.handle_auth_failure(e) - self.name = None - - async def handle_matrix_topic(self, user: u.User, topic: str) -> None: - if self.topic == topic or self.is_direct or not topic: - return - sender, is_relay = await self.get_relay_sender(user, "topic change") - if not sender: - return - self.topic = topic - self.log.debug( - f"{user.mxid} changed the group topic, sending to Signal through {sender.username}" - ) - try: - await self.signal.update_group(sender.username, self.chat_id, description=topic) - except Exception: - self.log.exception("Failed to update Signal group description") - self.name = None - - async def handle_matrix_avatar(self, user: u.User, url: ContentURI) -> None: - if self.is_direct or not url: - return - sender, is_relay = await self.get_relay_sender(user, "avatar change") - if not sender: - return - - data = await self.main_intent.download_media(url) - new_hash = hashlib.sha256(data).hexdigest() - if new_hash == self.avatar_hash and self.avatar_set: - self.log.debug(f"New avatar from Matrix set by {user.mxid} is same as current one") - return - self.avatar_url = url - self.avatar_hash = new_hash - path = self._write_outgoing_file(data) - self.log.debug( - f"{user.mxid} changed the group avatar, sending to Signal through {sender.username}" - ) - try: - await self.signal.update_group(sender.username, self.chat_id, avatar_path=path) - self.avatar_set = True - except Exception as e: - self.log.exception("Failed to update Signal group avatar") - await user.handle_auth_failure(e) - self.avatar_set = False - if self.config["signal.remove_file_after_handling"]: - try: - os.remove(path) - except FileNotFoundError: - pass - - async def handle_matrix_power_level( - self, - sender: u.User, - levels: PowerLevelStateEventContent, - prev_content: PowerLevelStateEventContent | None = None, - ) -> None: - old_users = prev_content.users if prev_content else None - new_users = levels.users - changes = {} - sender, is_relay = await self.get_relay_sender(sender, "power level change") - if not sender: - return - - if not old_users: - changes = new_users - else: - for user, level in new_users.items(): - if ( - user - and user != self.main_intent.mxid - and (user not in old_users or level != old_users[user]) - ): - changes[user] = level - for user, level in old_users.items(): - if user and user != self.main_intent.mxid and user not in new_users: - changes[user] = levels.users_default - if changes: - for user, level in changes.items(): - uuid = p.Puppet.get_id_from_mxid(user) - if not uuid: - mx_user = await u.User.get_by_mxid(user, create=False) - if not mx_user or not mx_user.is_logged_in: - continue - uuid = mx_user.uuid - if not uuid: - continue - signal_role = ( - GroupMemberRole.DEFAULT if level < 50 else GroupMemberRole.ADMINISTRATOR - ) - group_member = GroupMember(uuid=uuid, role=signal_role) - try: - update_meta = await self.signal.update_group( - sender.username, self.chat_id, update_role=group_member - ) - self.revision = update_meta.revision - except Exception as e: - self.log.exception(f"Failed to update Signal member role: {e}") - await self._update_power_levels( - await self.signal.get_group(sender.username, self.chat_id) - ) - return - if not prev_content or levels.invite != prev_content.invite: - try: - update_meta = await self.signal.update_group( - username=sender.username, - group_id=self.chat_id, - update_access_control=GroupAccessControl( - members=( - AccessControlMode.MEMBER - if levels.invite == 0 - else AccessControlMode.ADMINISTRATOR - ), - attributes=None, - link=None, - ), - ) - self.revision = update_meta.revision - except Exception as e: - self.log.exception(f"Failed to update Signal member add permission: {e}") - await self._update_power_levels( - await self.signal.get_group(sender.username, self.chat_id) - ) - return - if not prev_content or levels.state_default != prev_content.state_default: - try: - update_meta = await self.signal.update_group( - username=sender.username, - group_id=self.chat_id, - update_access_control=GroupAccessControl( - attributes=( - AccessControlMode.MEMBER - if levels.state_default == 0 - else AccessControlMode.ADMINISTRATOR - ), - members=None, - link=None, - ), - ) - self.revision = update_meta.revision - except Exception as e: - self.log.exception(f"Failed to update Signal metadata change permission: {e}") - await self._update_power_levels( - await self.signal.get_group(sender.username, self.chat_id) - ) - - async def handle_matrix_join_rules(self, sender: u.User, join_rule: JoinRule) -> None: - if join_rule == JoinRule.PUBLIC: - link_access = AccessControlMode.ANY - elif join_rule == JoinRule.INVITE: - link_access = AccessControlMode.UNSATISFIABLE - else: - link_access = AccessControlMode.ADMINISTRATOR - sender, is_relay = await self.get_relay_sender(sender, "join_rule change") - if not sender: - return - - try: - update_meta = await self.signal.update_group( - sender.username, - self.chat_id, - update_access_control=GroupAccessControl( - attributes=None, members=None, link=link_access - ), - ) - self.revision = update_meta.revision - except Exception as e: - self.log.exception(f"Failed to update Signal link access control: {e}") - await self._update_join_rules( - await self.signal.get_group(sender.username, self.chat_id) - ) - - async def matrix_accept_knock(self, sender: u.User, user: p.Puppet | u.User) -> None: - try: - await self.signal.approve_membership( - sender.username, self.chat_id, members=[user.address] - ) - if isinstance(user, p.Puppet): - await user.intent_for(self).ensure_joined(self.mxid) - except RPCError as e: - info = await self.signal.get_group(sender.username, self.chat_id) - if user.address in info.requesting_members: - raise RejectMatrixInvite(str(e)) from e - power_levels = await self.main_intent.get_power_levels(self.mxid) - invitee_pl = power_levels.get_user_level(user.mxid) - if invitee_pl >= 50: - group_member = GroupMember(uuid=user.uuid, role=GroupMemberRole.ADMINISTRATOR) - try: - update_meta = await self.signal.update_group( - sender.username, self.chat_id, update_role=group_member - ) - self.revision = update_meta.revision - except Exception as e: - self.log.exception(f"Failed to update Signal member role: {e}") - await self._update_power_levels( - await self.signal.get_group(sender.username, self.chat_id) - ) - - async def matrix_reject_knock(self, sender: u.User, user: p.Puppet | u.User) -> None: - try: - await self.signal.refuse_membership( - sender.username, self.chat_id, members=[user.address] - ) - except RPCError as e: - info = await self.signal.get_group(sender.username, self.chat_id) - if user.address in info.requesting_members: - await user.intent_for(self).knock( - self.mxid, - reason=f"refusing membership failed: {e}", - servers=[self.config["homeserver.domain"]], - ) - - # endregion - # region Signal event handling - - async def _find_quote_event_id(self, quote: Quote | None) -> MessageEvent | EventID | None: - if not quote: - return None - - puppet = await p.Puppet.get_by_address(quote.author, create=False) - if not puppet: - return None - reply_msg = await DBMessage.get_by_signal_id( - puppet.uuid, quote.id, self.chat_id, self.receiver - ) - if not reply_msg: - return None - try: - evt = await self.main_intent.get_event(self.mxid, reply_msg.mxid) - if isinstance(evt, EncryptedEvent): - return await self.matrix.e2ee.decrypt(evt, wait_session_timeout=0) - return evt - except MatrixError: - return reply_msg.mxid - - async def _signal_link_preview_to_beeper( - self, link_preview: LinkPreview, intent: IntentAPI - ) -> dict[str, Any]: - beeper_link_preview: dict[str, Any] = { - "matched_url": link_preview.url, - "og:title": link_preview.title, - "og:url": link_preview.url, - "og:description": link_preview.description, - } - - # Upload an image corresponding to the link preview if it exists. - if link_preview.attachment and link_preview.attachment.incoming_filename: - beeper_link_preview["og:image:type"] = link_preview.attachment.content_type - beeper_link_preview["og:image:height"] = link_preview.attachment.height - beeper_link_preview["og:image:width"] = link_preview.attachment.width - beeper_link_preview["matrix:image:size"] = link_preview.attachment.size - - with open(link_preview.attachment.incoming_filename, "rb") as file: - data = file.read() - if self.config["signal.remove_file_after_handling"]: - os.remove(link_preview.attachment.incoming_filename) - - upload_mime_type = link_preview.attachment.content_type - if self.encrypted and encrypt_attachment: - data, beeper_link_preview[BEEPER_IMAGE_ENCRYPTION_KEY] = encrypt_attachment(data) - upload_mime_type = "application/octet-stream" - - upload_uri = await intent.upload_media( - data, - mime_type=upload_mime_type, - filename=link_preview.attachment.id, - async_upload=self.config["homeserver.async_media"], - ) - if BEEPER_IMAGE_ENCRYPTION_KEY in beeper_link_preview: - beeper_link_preview[BEEPER_IMAGE_ENCRYPTION_KEY].url = upload_uri - beeper_link_preview[BEEPER_IMAGE_ENCRYPTION_KEY] = beeper_link_preview[ - BEEPER_IMAGE_ENCRYPTION_KEY - ].serialize() - else: - beeper_link_preview["og:image"] = upload_uri - return beeper_link_preview - - async def handle_signal_message( - self, source: u.User, sender: p.Puppet, message: MessageData - ) -> None: - if (sender.uuid, message.timestamp) in self._msgts_dedup: - self.log.debug( - f"Ignoring message {message.timestamp} by {sender.uuid} as it was already handled " - "(message.timestamp in dedup queue)" - ) - await self.signal.send_receipt( - source.username, sender.address, timestamps=[message.timestamp] - ) - return - self._msgts_dedup.appendleft((sender.uuid, message.timestamp)) - old_message = await DBMessage.get_by_signal_id( - sender.uuid, message.timestamp, self.chat_id, self.receiver - ) - if old_message is not None: - self.log.debug( - f"Ignoring message {message.timestamp} by {sender.uuid} as it was already handled " - "(message.id found in database)" - ) - await self.signal.send_receipt( - source.username, sender.address, timestamps=[message.timestamp] - ) - return - self.log.debug(f"Started handling message {message.timestamp} by {sender.uuid}") - self.log.trace(f"Message content: {message}") - intent = sender.intent_for(self) - await intent.set_typing(self.mxid, timeout=0) - event_id = None - reply_to = await self._find_quote_event_id(message.quote) - - if message.sticker: - if message.sticker.attachment.incoming_filename: - content = await self._handle_signal_attachment( - intent, message.sticker.attachment, sticker=True - ) - elif StickersClient: - content = await self._handle_signal_sticker(intent, message.sticker) - else: - self.log.debug( - f"Not handling sticker in {message.timestamp}: no incoming_filename and " - "signalstickers-client not installed." - ) - return - - if content: - if message.sticker.attachment.blurhash: - content.info["blurhash"] = message.sticker.attachment.blurhash - content.info["xyz.amorgan.blurhash"] = message.sticker.attachment.blurhash - await self._add_sticker_meta(message.sticker, content) - if reply_to and not message.body: - content.set_reply(reply_to) - reply_to = None - content.msgtype = None - event_id = await self._send_message( - intent, content, timestamp=message.timestamp, event_type=EventType.STICKER - ) - - for contact in message.contacts: - content = await self._handle_signal_contact(contact) - if reply_to and not message.body: - content.set_reply(reply_to) - reply_to = None - event_id = await self._send_message(intent, content, timestamp=message.timestamp) - - is_first_text = True - for attachment in message.attachments: - as_text = ( - is_first_text - and attachment.content_type == "text/x-signal-plain" - and attachment.size < MAX_MATRIX_MESSAGE_SIZE - ) - - content = await self._handle_signal_attachment(intent, attachment, text=as_text) - if as_text: - is_first_text = False - message.body = "" - if reply_to and not message.body: - # If there's no text, set the first image as the reply - content.set_reply(reply_to) - reply_to = None - event_id = await self._send_message(intent, content, timestamp=message.timestamp) - - if message.body: - content = await signal_to_matrix(message) - if message.previews: - content[BEEPER_LINK_PREVIEWS_KEY] = await asyncio.gather( - *(self._signal_link_preview_to_beeper(p, intent) for p in message.previews) - ) - - if reply_to: - content.set_reply(reply_to) - event_id = await self._send_message(intent, content, timestamp=message.timestamp) - - if event_id: - msg = DBMessage( - mxid=event_id, - mx_room=self.mxid, - sender=sender.uuid, - timestamp=message.timestamp, - signal_chat_id=self.chat_id, - signal_receiver=self.receiver, - ) - await msg.insert() - await self.signal.send_receipt( - source.username, sender.address, timestamps=[message.timestamp] - ) - await self._send_delivery_receipt(event_id) - self.log.debug(f"Handled Signal message {message.timestamp} -> {event_id}") - - if message.expires_in_seconds and self.disappearing_enabled: - dm = DisappearingMessage(self.mxid, event_id, message.expires_in_seconds) - # Start the timer immediately for own messages - if sender.uuid == source.uuid: - dm.start_timer() - await dm.insert() - background_task.create(self._disappear_event(dm)) - self.log.debug( - f"{event_id} set to be redacted in {message.expires_in_seconds} seconds" - ) - else: - await dm.insert() - self.log.debug( - f"{event_id} set to be redacted {message.expires_in_seconds} seconds" - " after room is read" - ) - else: - self.log.debug(f"Didn't get event ID for {message.timestamp}") - - async def handle_signal_kicked(self, user: u.User, sender: p.Puppet) -> None: - self.log.debug(f"{user.mxid} was kicked by {sender.number} from {self.mxid}") - await self._kick_with_puppet(user, sender) - - async def handle_signal_group_change(self, group_change: GroupChange, source: u.User) -> None: - if self.revision < group_change.revision: - self.revision = group_change.revision - else: - return - editor = await p.Puppet.get_by_address(group_change.editor) - if not editor: - self.log.warning(f"Didn't get puppet for group change editor {group_change.editor}") - return - editor_intent = editor.intent_for(self) - if ( - group_change.delete_members - or group_change.delete_pending_members - or group_change.delete_requesting_members - ): - for address in ( - (group_change.delete_members or []) - + (group_change.delete_pending_members or []) - + (group_change.delete_requesting_members or []) - ): - users = [ - await p.Puppet.get_by_address(address), - await u.User.get_by_address(address), - ] - for user in users: - if not user: - continue - if user == editor: - await editor_intent.leave_room(self.mxid) - else: - await self._kick_with_puppet(user, editor) - - if group_change.modify_member_roles: - levels = await editor.intent_for(self).get_power_levels(self.mxid) - for group_member in group_change.modify_member_roles: - users = [ - await p.Puppet.get_by_uuid(group_member.uuid), - await u.User.get_by_uuid(group_member.uuid), - ] - for user in users: - if not user: - continue - if group_member.role == GroupMemberRole.ADMINISTRATOR: - if levels.users.get(user.mxid, 0) < 50: - levels.users[user.mxid] = 50 - levels.users = {k: v for k, v in sorted(list(levels.users.items()))} - elif levels.users.get(user.mxid, 0) >= 50: - levels.users.pop(user.mxid, 0) - await self._try_with_puppet( - lambda i: i.set_power_levels(self.mxid, levels), puppet=editor - ) - - if group_change.new_banned_members: - for banned_member in group_change.new_banned_members: - users = [ - await p.Puppet.get_by_uuid(banned_member.uuid), - await u.User.get_by_uuid(banned_member.uuid), - ] - for user in users: - if not user: - continue - try: - await editor_intent.ban_user(self.mxid, user.mxid) - except MForbidden: - try: - await self.main_intent.ban_user( - self.mxid, user.mxid, reason=f"banned by {editor.name}" - ) - except MForbidden as e: - self.log.debug(f"Could not ban {user.mxid}: {e}") - except MBadState as e: - self.log.debug(f"Could not ban {user.mxid}: {e}") - - if group_change.new_unbanned_members: - for banned_member in group_change.new_unbanned_members: - users = [ - await p.Puppet.get_by_uuid(banned_member.uuid), - await u.User.get_by_uuid(banned_member.uuid), - ] - for user in users: - if not user: - continue - try: - await editor_intent.unban_user(self.mxid, user.mxid) - except MForbidden: - try: - await self.main_intent.unban_user( - self.mxid, user.mxid, reason=f"unbanned by {editor.name}" - ) - except MForbidden as e: - self.log.debug(f"Could not unban {user.mxid}: {e}") - except MBadState as e: - self.log.debug(f"Could not unban {user.mxid}: {e}") - - if ( - group_change.new_members - or group_change.new_pending_members - or group_change.promote_requesting_members - ): - banned_users = await self.az.intent.get_room_members(self.mxid, (Membership.BAN,)) - for group_member in ( - (group_change.new_members or []) - + (group_change.new_pending_members or []) - + (group_change.promote_requesting_members or []) - ): - puppet = await p.Puppet.get_by_uuid(group_member.uuid) - await source.sync_contact(group_member.address) - users = [puppet, await u.User.get_by_uuid(group_member.uuid)] - for user in users: - if not user: - continue - if user.mxid in banned_users: - await self._try_with_puppet( - lambda i: i.unban_user(self.mxid, user.mxid), puppet=editor - ) - try: - await editor_intent.invite_user(self.mxid, user.mxid, check_cache=True) - except (MForbidden, IntentError): - try: - await self.main_intent.invite_user( - self.mxid, - user.mxid, - reason=f"invited by {editor.name}", - check_cache=True, - ) - except (MForbidden, IntentError) as e: - self.log.debug(f"{editor.name} could not invite {user.mxid}: {e}") - except MBadState as e: - self.log.debug(f"{editor.name} could not invite {user.mxid}: {e}") - if group_member in (group_change.new_members or []) + ( - group_change.promote_requesting_members or [] - ) and isinstance(user, p.Puppet): - try: - await user.intent_for(self).ensure_joined(self.mxid) - except IntentError as e: - self.log.debug(f"{user.name} could not join group: {e}") - - if group_change.promote_pending_members: - for group_member in group_change.promote_pending_members: - await source.sync_contact(group_member.address) - user = await p.Puppet.get_by_uuid(group_member.uuid) - if not user: - continue - try: - await user.intent_for(self).ensure_joined(self.mxid) - except IntentError as e: - self.log.debug(f"{user.name} could not join group: {e}") - - if group_change.new_requesting_members: - for group_member in group_change.new_requesting_members: - try: - await source.sync_contact(group_member.address) - except ProfileUnavailableError: - self.log.debug( - f"Profile of puppet with uuid {group_member.uuid} is unavailable" - ) - user = await p.Puppet.get_by_uuid(group_member.uuid) - try: - await user.intent_for(self).knock_room(self.mxid, reason="via invite link") - except (MForbidden, MBadState) as e: - self.log.debug(f"{user.name} failed knock: {e}") - - if group_change.new_access_control: - ac = group_change.new_access_control - if ac.attributes or ac.members: - levels = await editor.intent_for(self).get_power_levels(self.mxid) - if ac.attributes: - meta_edit_level = 50 if ac.attributes == AccessControlMode.ADMINISTRATOR else 0 - levels.events[EventType.ROOM_NAME] = meta_edit_level - levels.events[EventType.ROOM_AVATAR] = meta_edit_level - levels.events[EventType.ROOM_TOPIC] = meta_edit_level - if ac.members: - levels.invite = 50 if ac.members == AccessControlMode.ADMINISTRATOR else 0 - await self._try_with_puppet( - lambda i: i.set_power_levels(self.mxid, levels), puppet=editor - ) - if ac.link: - new_join_rule = await self._get_new_join_rule(ac.link) - if new_join_rule: - await self._try_with_puppet( - lambda i: i.set_join_rule(self.mxid, new_join_rule), puppet=editor - ) - - if group_change.new_is_announcement_group: - levels = await editor.intent_for(self).get_power_levels(self.mxid) - if group_change.new_is_announcement_group == AnnouncementsMode.ENABLED: - levels.events_default = 50 - elif group_change.new_is_announcement_group == AnnouncementsMode.DISABLED: - levels.events_default = 0 - await self._try_with_puppet( - lambda i: i.set_power_levels(self.mxid, levels), puppet=editor - ) - - changed = False - if group_change.new_description: - changed = await self._update_topic(group_change.new_description, editor) - if group_change.new_title: - changed = await self._update_name(group_change.new_title, editor) or changed - if group_change.new_avatar: - changed = ( - await self._update_avatar( - await self.signal.get_group( - source.username, self.chat_id, group_change.revision - ), - editor, - ) - or changed - ) - if group_change.new_timer: - changed = ( - await self.update_expires_in_seconds(editor, group_change.new_timer) or changed - ) - - if changed: - await self.update_bridge_info() - await self.update() - - @staticmethod - async def _make_media_content( - attachment: Attachment, data: bytes - ) -> tuple[MediaMessageEventContent, bytes]: - if attachment.content_type.startswith("image/"): - msgtype = MessageType.IMAGE - info = ImageInfo( - mimetype=attachment.content_type, width=attachment.width, height=attachment.height - ) - elif attachment.content_type.startswith("video/"): - msgtype = MessageType.VIDEO - info = VideoInfo( - mimetype=attachment.content_type, width=attachment.width, height=attachment.height - ) - elif attachment.voice_note or attachment.content_type.startswith("audio/"): - msgtype = MessageType.AUDIO - info = AudioInfo( - mimetype=attachment.content_type if not attachment.voice_note else "audio/ogg" - ) - else: - msgtype = MessageType.FILE - info = FileInfo(mimetype=attachment.content_type) - info.size = attachment.size or len(data) - if not attachment.custom_filename: - ext = mimetypes.guess_extension(info.mimetype) or "" - attachment.custom_filename = attachment.id + ext - else: - for ext in mimetypes.guess_all_extensions(info.mimetype): - if attachment.custom_filename.endswith(ext): - break - else: - attachment.custom_filename += mimetypes.guess_extension(info.mimetype) or "" - if attachment.blurhash: - info["blurhash"] = attachment.blurhash - info["xyz.amorgan.blurhash"] = attachment.blurhash - content = MediaMessageEventContent( - msgtype=msgtype, info=info, body=attachment.custom_filename - ) - - # If this is a voice note, add the additional voice message metadata and convert to OGG. - if attachment.voice_note: - content["org.matrix.msc1767.file"] = { - "url": content.url, - "name": content.body, - **(content.file.serialize() if content.file else {}), - **(content.info.serialize() if content.info else {}), - } - content["org.matrix.msc3245.voice"] = {} - data = await ffmpeg.convert_bytes( - data, ".ogg", output_args=("-c:a", "libopus"), input_mime=attachment.content_type - ) - info.size = len(data) - - return content, data - - async def _handle_signal_attachment( - self, intent: IntentAPI, attachment: Attachment, sticker: bool = False, text: bool = False - ) -> MediaMessageEventContent | TextMessageEventContent: - if not attachment.incoming_filename: - self.log.warning("Failed to bridge attachment, no incoming filename: %s", attachment) - return TextMessageEventContent( - msgtype=MessageType.NOTICE, body="Missing attachment data" - ) - file_size = attachment.size or os.path.getsize(attachment.incoming_filename) - if file_size > self.matrix.media_config.upload_size: - self.log.warning( - f"Dropping attachment {attachment.id}: file too large " - f"({file_size} > {self.matrix.media_config.upload_size})" - ) - return TextMessageEventContent(msgtype=MessageType.NOTICE, body="Attachment too large") - - self.log.trace(f"Reuploading attachment {attachment}") - if not attachment.content_type: - attachment.content_type = ( - magic.mimetype(attachment.incoming_filename) - if magic is not None - else "application/octet-stream" - ) - - with open(attachment.incoming_filename, "rb") as file: - data = file.read() - if self.config["signal.remove_file_after_handling"]: - os.remove(attachment.incoming_filename) - - if text: - assert attachment.content_type == "text/x-signal-plain" - assert attachment.size < MAX_MATRIX_MESSAGE_SIZE - content = TextMessageEventContent(msgtype=MessageType.TEXT, body=data.decode("utf-8")) - return content - - content, data = await self._make_media_content(attachment, data) - if sticker: - self._adjust_sticker_size(content.info) - - await self._upload_attachment(intent, content, data, attachment.id) - return content - - @staticmethod - async def _handle_signal_contact(contact: SharedContact) -> TextMessageEventContent: - msg = f"Shared contact: {contact.name!s}" - if contact.phone: - msg += "\n" - for phone in contact.phone: - msg += f"\nPhone: {phone.value} ({phone.type_or_label})" - if contact.email: - msg += "\n" - for email in contact.email: - msg += f"\nEmail: {email.value} ({email.type_or_label})" - content = TextMessageEventContent(msgtype=MessageType.TEXT, body=msg) - content["fi.mau.signal.contact"] = contact.serialize() - return content - - async def _add_sticker_meta(self, sticker: Sticker, content: MediaMessageEventContent) -> None: - try: - pack = self._sticker_meta_cache[sticker.pack_id] - except KeyError: - self.log.debug(f"Fetching sticker pack metadata for {sticker.pack_id}") - try: - async with StickersClient() as client: - pack = await client.get_pack_metadata(sticker.pack_id, sticker.pack_key) - self._sticker_meta_cache[sticker.pack_id] = pack - except Exception: - self.log.warning( - f"Failed to fetch pack metadata for {sticker.pack_id}", exc_info=True - ) - pack = None - if not pack: - content.info["fi.mau.signal.sticker"] = { - "id": sticker.sticker_id, - "pack": { - "id": sticker.pack_id, - "key": sticker.pack_key, - }, - } - return - sticker_meta = pack.stickers[sticker.sticker_id] - content.body = sticker_meta.emoji - content.info["fi.mau.signal.sticker"] = { - "id": sticker.sticker_id, - "emoji": sticker_meta.emoji, - "pack": { - "id": pack.id, - "key": pack.key, - "title": pack.title, - "author": pack.author, - }, - } - - @staticmethod - def _adjust_sticker_size(info: ImageInfo) -> None: - if info.width > 256 or info.height > 256: - if info.width == info.height: - info.width = info.height = 256 - elif info.width > info.height: - info.height = int(info.height / (info.width / 256)) - info.width = 256 - else: - info.width = int(info.width / (info.height / 256)) - info.height = 256 - - async def _handle_signal_sticker( - self, intent: IntentAPI, sticker: Sticker - ) -> MediaMessageEventContent | None: - try: - self.log.debug(f"Fetching sticker {sticker.pack_id}#{sticker.sticker_id}") - async with StickersClient() as client: - data = await client.download_sticker( - sticker.sticker_id, sticker.pack_id, sticker.pack_key - ) - except Exception: - self.log.warning(f"Failed to download sticker {sticker.sticker_id}", exc_info=True) - return None - info = ImageInfo( - mimetype=sticker.attachment.content_type, - size=len(data), - width=sticker.attachment.width, - height=sticker.attachment.height, - ) - self._adjust_sticker_size(info) - if magic: - info.mimetype = magic.mimetype(data) - ext = mimetypes.guess_extension(info.mimetype) - if not ext and info.mimetype == "image/webp": - ext = ".webp" - content = MediaMessageEventContent( - msgtype=MessageType.IMAGE, info=info, body=f"sticker{ext}" - ) - await self._upload_attachment(intent, content, data, sticker.attachment.id) - return content - - async def _upload_attachment( - self, intent: IntentAPI, content: MediaMessageEventContent, data: bytes, id: str - ) -> None: - upload_mime_type = content.info.mimetype - if self.encrypted and encrypt_attachment: - data, content.file = encrypt_attachment(data) - upload_mime_type = "application/octet-stream" - - content.url = await intent.upload_media( - data, - mime_type=upload_mime_type, - filename=id, - async_upload=self.config["homeserver.async_media"], - ) - if content.file: - content.file.url = content.url - content.url = None - # This is a hack for bad clients like Element iOS that require a thumbnail - if content.info.mimetype.startswith("image/"): - if content.file: - content.info.thumbnail_file = content.file - elif content.url: - content.info.thumbnail_url = content.url - - async def handle_signal_reaction( - self, sender: p.Puppet, reaction: Reaction, timestamp: int - ) -> None: - author_puppet = await p.Puppet.get_by_address(reaction.target_author, create=False) - if not author_puppet: - return None - target_id = reaction.target_sent_timestamp - async with self._reaction_lock: - dedup_id = ( - author_puppet.uuid, - target_id, - reaction.emoji, - sender.uuid, - reaction.remove, - ) - if dedup_id in self._reaction_dedup: - return - self._reaction_dedup.appendleft(dedup_id) - - existing = await DBReaction.get_by_signal_id( - self.chat_id, self.receiver, author_puppet.uuid, target_id, sender.uuid - ) - - if reaction.remove: - if existing: - try: - await sender.intent_for(self).redact(existing.mx_room, existing.mxid) - except IntentError: - await self.main_intent.redact(existing.mx_room, existing.mxid) - await existing.delete() - self.log.trace(f"Removed {existing} after Signal removal") - return - elif existing and existing.emoji == reaction.emoji: - return - - message = await DBMessage.get_by_signal_id( - author_puppet.uuid, target_id, self.chat_id, self.receiver - ) - if not message: - self.log.debug(f"Ignoring reaction to unknown message {target_id}") - return - - intent = sender.intent_for(self) - matrix_emoji = variation_selector.add(reaction.emoji) - mxid = await intent.react(message.mx_room, message.mxid, matrix_emoji, timestamp=timestamp) - self.log.debug(f"{sender.uuid} reacted to {message.mxid} -> {mxid}") - await self._upsert_reaction(existing, intent, mxid, sender, message, reaction.emoji) - - async def handle_signal_delete(self, sender: p.Puppet, message_ts: int) -> None: - message = await DBMessage.get_by_signal_id( - sender.uuid, message_ts, self.chat_id, self.receiver - ) - if not message: - return - await message.delete() - try: - await sender.intent_for(self).redact(message.mx_room, message.mxid) - except MForbidden: - await self.main_intent.redact(message.mx_room, message.mxid) - - # endregion - # region Matrix -> Signal metadata - - async def create_signal_group( - self, source: u.User, levels: PowerLevelStateEventContent, join_rule: JoinRule - ) -> None: - user_mxids = await self.az.intent.get_room_members( - self.mxid, (Membership.JOIN, Membership.INVITE) - ) - invitee_addresses = [] - relaybot_mxid = self.config["bridge.relay.relaybot"] - relaybot = None - for mxid in user_mxids: - mx_user = await u.User.get_by_mxid(mxid, create=False) - if mx_user and mx_user.address and mx_user.username != source.username: - invitee_addresses.append(mx_user.address) - if mxid == relaybot_mxid != "@relaybot:example.com": - relaybot = mx_user - puppet = await p.Puppet.get_by_mxid(mxid, create=False) - if puppet: - invitee_addresses.append(puppet.address) - avatar_path: str | None = None - if self.avatar_url: - avatar_data = await self.az.intent.download_media(self.avatar_url) - self.avatar_hash = hashlib.sha256(avatar_data).hexdigest() - avatar_path = self._write_outgoing_file(avatar_data) - signal_chat = await self.signal.create_group( - source.username, title=self.name, members=invitee_addresses, avatar_path=avatar_path - ) - self.name_set = bool(self.name and signal_chat.title) - self.avatar_set = bool(self.avatar_url and self.avatar_hash and signal_chat.avatar) - self.chat_id = signal_chat.id - await self._postinit() - await self.insert() - if avatar_path and self.config["signal.remove_file_after_handling"]: - try: - os.remove(avatar_path) - except FileNotFoundError: - pass - if self.topic: - await self.signal.update_group(source.username, self.chat_id, description=self.topic) - await self.handle_matrix_power_level(source, levels) - await self.handle_matrix_join_rules(source, join_rule) - await self.update() - await self.update_bridge_info() - if relaybot: - await self._handle_relaybot_invited(relaybot) - await self.main_intent.send_notice(self.mxid, f"Signal group created. ID: {self.chat_id}") - - async def bridge_signal_group( - self, source: u.User, levels: PowerLevelStateEventContent - ) -> None: - await self._postinit() - await self.insert() - await self.handle_matrix_power_level(source, levels) - await self.update() - await self.update_bridge_info() - - # endregion - # region Updating portal info - - async def update_info(self, source: u.User, info: ChatInfo | GroupV2ID) -> None: - if self.is_direct: - if not isinstance(info, (Profile, Address)): - raise ValueError(f"Unexpected type for direct chat update_info: {type(info)}") - if not self.name or not self.topic: - puppet = await self.get_dm_puppet() - if not puppet.name: - await puppet.update_info(info, source) - self.name = puppet.name - if puppet.number and not self.topic: - self.topic = puppet.fmt_phone(puppet.number) - if self.mxid: - # This is only for automatically updating the topic in existing portals - await self.update_puppet_number(self.topic) - return - - if isinstance(info, GroupV2ID): - try: - info = await self.signal.get_group(source.username, info.id, info.revision or -1) - except Exception as e: - await source.handle_auth_failure(e) - raise - if not info: - self.log.debug( - f"Failed to get full group v2 info through {source.username}, " - "cancelling update" - ) - return - - changed = False - if self.revision < info.revision: - self.revision = info.revision - changed = True - elif self.revision > info.revision: - self.log.warning( - f"Got outdated info when syncing through {source.username} " - f"({info.revision} < {self.revision}), ignoring..." - ) - return - changed = await self._update_name(info.title) or changed - changed = await self._update_topic(info.description) or changed - changed = await self._update_avatar(info) or changed - await self._update_participants(source, info) - try: - await self._update_power_levels(info) - except Exception: - self.log.warning("Error updating power levels", exc_info=True) - try: - await self._update_join_rules(info) - except: - self.log.warning("Error updating join rules", exc_info=True) - if changed: - await self.update_bridge_info() - await self.update() - - async def update_expires_in_seconds(self, sender: p.Puppet, expires_in_seconds: int) -> bool: - if expires_in_seconds == 0: - expires_in_seconds = None - if self.expiration_time == expires_in_seconds: - return False - - assert self.mxid - self.log.debug(f"Setting portal expiration time to {expires_in_seconds}") - self.expiration_time = expires_in_seconds - await self.update() - - time_str = "Off" if expires_in_seconds is None else format_duration(expires_in_seconds) - body = f"Set the disappearing message timer to {time_str}" - content = TextMessageEventContent(msgtype=MessageType.NOTICE, body=body) - await self._send_message(sender.intent_for(self), content) - return True - - async def get_dm_puppet(self) -> p.Puppet | None: - if not self.is_direct: - return None - return await p.Puppet.get_by_uuid(self.chat_id) - - async def update_info_from_puppet(self, puppet: p.Puppet | None = None) -> None: - if not self.is_direct: - return - if not puppet: - puppet = await self.get_dm_puppet() - await self.update_puppet_name(puppet.name, save=False) - await self.update_puppet_avatar(puppet.avatar_hash, puppet.avatar_url, save=False) - if puppet.number: - await self.update_puppet_number(puppet.fmt_phone(puppet.number), save=False) - - async def update_puppet_number(self, number: str, save: bool = True) -> None: - if not self.encrypted and not self.private_chat_portal_meta: - return - - changed = await self._update_topic(number) - if changed and save: - await self.update_bridge_info() - await self.update() - - async def update_puppet_avatar( - self, new_hash: str, avatar_url: ContentURI, save: bool = True - ) -> None: - if not self.encrypted and not self.private_chat_portal_meta: - return - - if self.avatar_hash != new_hash or not self.avatar_set: - self.avatar_hash = new_hash - self.avatar_url = avatar_url - if self.mxid: - try: - await self.main_intent.set_room_avatar(self.mxid, avatar_url) - self.avatar_set = True - except Exception: - self.log.exception("Error setting avatar") - self.avatar_set = False - if save: - await self.update_bridge_info() - await self.update() - - async def update_puppet_name(self, name: str, save: bool = True) -> None: - if not self.encrypted and not self.private_chat_portal_meta: - return - - changed = await self._update_name(name) - - if changed and save: - await self.update_bridge_info() - await self.update() - - async def _update_name(self, name: str, sender: p.Puppet | None = None) -> bool: - if self.name != name or not self.name_set: - self.name = name - if self.mxid: - try: - await self._try_with_puppet( - lambda i: i.set_room_name(self.mxid, self.name), puppet=sender - ) - self.name_set = True - except Exception: - self.log.exception("Error setting name") - self.name_set = False - return True - return False - - async def _update_topic(self, topic: str, sender: p.Puppet | None = None) -> bool: - if self.topic != topic: - self.topic = topic - if self.mxid: - try: - await self._try_with_puppet( - lambda i: i.set_room_topic(self.mxid, self.topic), puppet=sender - ) - except Exception: - self.log.exception("Error setting topic") - self.topic = None - return True - return False - - async def _try_with_puppet( - self, action: Callable[[IntentAPI], Awaitable[Any]], puppet: p.Puppet | None = None - ) -> None: - if puppet: - try: - await action(puppet.intent_for(self)) - except (MForbidden, IntentError): - await action(self.main_intent) - else: - await action(self.main_intent) - - async def _update_avatar(self, info: GroupV2, sender: p.Puppet | None = None) -> bool: - path = None - if isinstance(info, GroupV2): - path = info.avatar - res = await p.Puppet.upload_avatar(self, path, self.main_intent) - if res is False: - return False - self.avatar_hash, self.avatar_url = res - if not self.mxid: - return True - - try: - await self._try_with_puppet( - lambda i: i.set_room_avatar(self.mxid, self.avatar_url), puppet=sender - ) - self.avatar_set = True - except Exception: - self.log.exception("Error setting avatar") - self.avatar_set = False - return True - - async def _update_participants(self, source: u.User, info: GroupV2) -> None: - if not self.mxid: - return - - member_events = await self.main_intent.get_members(self.mxid) - remove_users: set[UserID] = { - UserID(evt.state_key) - for evt in member_events - if ( - evt.content.membership == Membership.JOIN - or evt.content.membership == Membership.INVITE - or evt.content.membership == Membership.KNOCK - ) - and evt.state_key != self.az.bot_mxid - } - unban_users: set[UserID] = { - UserID(evt.state_key) - for evt in member_events - if evt.content.membership == Membership.BAN and evt.state_key != self.az.bot_mxid - } - - self._pending_members = {addr.uuid for addr in info.pending_members} - - for member in info.banned_members: - user = await u.User.get_by_uuid(member.uuid) - if user: - unban_users.discard(user.mxid) - remove_users.discard(user.mxid) - try: - await self.main_intent.ban_user( - self.mxid, user.mxid, reason="Banned on Signal" - ) - except (MForbidden, MBadState) as e: - self.log.debug(f"Could not ban {user.mxid}: {e}") - puppet = await p.Puppet.get_by_address(Address(uuid=member.uuid)) - unban_users.discard(puppet.mxid) - remove_users.discard(puppet.mxid) - try: - await self.main_intent.ban_user(self.mxid, puppet.mxid, reason="Banned on Signal") - except (MForbidden, MBadState) as e: - self.log.debug(f"Could not ban {puppet.mxid}: {e}") - - for mxid in unban_users: - user = await u.User.get_by_mxid(mxid, create=False) - if user and await user.is_logged_in(): - try: - await self.main_intent.unban_user( - self.mxid, user.mxid, reason="Unbanned on Signal" - ) - except (MForbidden, MBadState) as e: - self.log.debug(f"Could not unban {user.mxid}: {e}") - puppet = await p.Puppet.get_by_mxid(mxid, create=False) - if puppet: - try: - await self.main_intent.unban_user( - self.mxid, puppet.mxid, reason="Unbanned on Signal" - ) - except (MForbidden, MBadState) as e: - self.log.debug(f"Could not unban {puppet.mxid}: {e}") - - for address in info.members + info.pending_members: - user = await u.User.get_by_address(address) - if user: - remove_users.discard(user.mxid) - try: - await self.main_intent.invite_user(self.mxid, user.mxid, check_cache=True) - except (MForbidden, IntentError, MBadState) as e: - self.log.debug(f"Failed to invite {user.mxid}: {e}") - - puppet = await p.Puppet.get_by_address(address) - if not puppet: - self.log.warning(f"Didn't find puppet for member {address}") - continue - await source.sync_contact(address) - try: - await self.main_intent.invite_user( - self.mxid, puppet.intent_for(self).mxid, check_cache=True - ) - except (MForbidden, IntentError, MBadState) as e: - self.log.debug(f"Could not invite {user.mxid}: {e}") - if address.uuid not in self._pending_members: - await puppet.intent_for(self).ensure_joined(self.mxid) - remove_users.discard(puppet.default_mxid) - - for address in info.requesting_members: - puppet = await p.Puppet.get_by_address(address) - if puppet: - remove_users.discard(puppet.mxid) - try: - await puppet.intent_for(self).knock_room( - self.mxid, - reason="via invite link", - servers=[self.config["homeserver.domain"]], - ) - except (MForbidden, IntentError) as e: - self.log.debug(f"Failed to bridge knock: {e}") - - for mxid in remove_users: - user = await u.User.get_by_mxid(mxid, create=False) - if user and await user.is_logged_in(): - try: - await self.main_intent.kick_user( - self.mxid, user.mxid, reason="not a member of this Signal group" - ) - except (MForbidden, MBadState) as e: - self.log.debug(f"could not kick {user.mxid}: {e}") - puppet = await p.Puppet.get_by_mxid(mxid, create=False) - if puppet: - try: - await self.main_intent.kick_user( - self.mxid, - puppet.intent_for(self).mxid, - reason="not a member of this Signal group", - ) - except (MForbidden, MBadState) as e: - self.log.debug(f"Could not kick {user.mxid}: {e}") - - async def _kick_with_puppet(self, user: p.Puppet | u.User, sender: p.Puppet) -> None: - try: - await sender.intent_for(self).kick_user(self.mxid, user.mxid) - except MForbidden: - try: - await self.main_intent.kick_user( - self.mxid, user.mxid, reason=f"removed by {sender.name}" - ) - except MForbidden as e: - self.log.debug(f"Could not remove {user.mxid}: {e}") - except MBadState as e: - self.log.debug(f"Could not remove {user.mxid}: {e}") - - async def _update_power_levels(self, info: ChatInfo) -> None: - if not self.mxid or not info: - return - - power_levels = await self.main_intent.get_power_levels(self.mxid) - power_levels = await self._get_power_levels(power_levels, info=info, is_initial=False) - await self.main_intent.set_power_levels(self.mxid, power_levels) - - async def _get_new_join_rule(self, link_access: AccessControlMode) -> JoinRule | None: - if not self.mxid: - return None - old_join_rule = await self._get_join_rule() - if link_access == AccessControlMode.ANY: - # Default to invite since chat that don't require admin approval don't allow knocks - join_rule = ( - JoinRule.PUBLIC if self.config["bridge.public_portals"] else JoinRule.INVITE - ) - allowed_join_rules = (JoinRule.PUBLIC, JoinRule.INVITE) - elif link_access == AccessControlMode.ADMINISTRATOR: - join_rule = JoinRule.KNOCK - # TODO remove getattr once mautrix-python is updated - allowed_join_rules = ( - JoinRule.KNOCK, - getattr(JoinRule, "KNOCK_RESTRICTED", "knock_restricted"), - ) - else: - join_rule = JoinRule.INVITE - allowed_join_rules = (JoinRule.INVITE,) - if old_join_rule in allowed_join_rules: - return None - return join_rule - - async def _update_join_rules(self, info: GroupV2) -> None: - if not self.mxid: - return - new_join_rule = await self._get_new_join_rule(info.access_control.link) - if new_join_rule: - await self.main_intent.set_join_rule(self.mxid, new_join_rule) - - async def _get_join_rule(self) -> JoinRule | None: - evt = await self.main_intent.get_state_event(self.mxid, EventType.ROOM_JOIN_RULES) - return evt.join_rule if evt else None - - # endregion - # region Bridge info state event - - @property - def bridge_info_state_key(self) -> str: - return f"net.maunium.signal://signal/{self.chat_id!s}" - - @property - def bridge_info(self) -> dict[str, Any]: - return { - "bridgebot": self.az.bot_mxid, - "creator": self.main_intent.mxid, - "protocol": { - "id": "signal", - "displayname": "Signal", - "avatar_url": self.config["appservice.bot_avatar"], - }, - "channel": { - "id": str(self.chat_id), - "displayname": self.name, - "avatar_url": self.avatar_url, - }, - } - - async def update_bridge_info(self) -> None: - if not self.mxid: - self.log.debug("Not updating bridge info: no Matrix room created") - return - try: - self.log.debug("Updating bridge info...") - await self.main_intent.send_state_event( - self.mxid, StateBridge, self.bridge_info, self.bridge_info_state_key - ) - # TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - await self.main_intent.send_state_event( - self.mxid, StateHalfShotBridge, self.bridge_info, self.bridge_info_state_key - ) - except Exception: - self.log.warning("Failed to update bridge info", exc_info=True) - - # endregion - # region Creating Matrix rooms - - async def update_matrix_room(self, source: u.User, info: ChatInfo) -> None: - if not self.is_direct and not isinstance(info, GroupV2): - raise ValueError(f"Unexpected type for updating group portal: {type(info)}") - elif self.is_direct and not isinstance(info, (Profile, Address)): - raise ValueError(f"Unexpected type for updating direct chat portal: {type(info)}") - try: - await self._update_matrix_room(source, info) - except Exception: - self.log.exception("Failed to update portal") - - async def create_matrix_room( - self, source: u.User, info: ChatInfo | GroupV2ID - ) -> RoomID | None: - if not self.is_direct and not isinstance(info, GroupV2ID): - raise ValueError(f"Unexpected type for creating group portal: {type(info)}") - elif self.is_direct and not isinstance(info, (Profile, Address)): - raise ValueError(f"Unexpected type for creating direct chat portal: {type(info)}") - if isinstance(info, GroupV2ID) and not isinstance(info, GroupV2): - self.log.debug( - f"create_matrix_room() called with {info}, fetching full info from signald" - ) - try: - info = await self.signal.get_group(source.username, info.id, info.revision or -1) - except Exception as e: - await source.handle_auth_failure(e) - raise - if not info: - self.log.warning(f"Full info not found, canceling room creation") - return None - else: - self.log.trace("get_group() returned full info: %s", info) - if self.mxid: - await self.update_matrix_room(source, info) - return self.mxid - async with self._create_room_lock: - return await self._create_matrix_room(source, info) - - def _get_invite_content(self, double_puppet: p.Puppet | None) -> dict[str, Any]: - invite_content = {} - if double_puppet: - invite_content["fi.mau.will_auto_accept"] = True - if self.is_direct: - invite_content["is_direct"] = True - return invite_content - - async def _update_matrix_room(self, source: u.User, info: ChatInfo) -> None: - puppet = await p.Puppet.get_by_custom_mxid(source.mxid) - await self.main_intent.invite_user( - self.mxid, - source.mxid, - check_cache=True, - extra_content=self._get_invite_content(puppet), - ) - if puppet: - did_join = await puppet.intent.ensure_joined(self.mxid) - if did_join and self.is_direct: - await source.update_direct_chats({self.main_intent.mxid: [self.mxid]}) - - await self.update_info(source, info) - - async def _get_power_levels( - self, - levels: PowerLevelStateEventContent | None = None, - info: ChatInfo = None, - is_initial: bool = False, - ) -> PowerLevelStateEventContent: - levels = levels or PowerLevelStateEventContent() - bot_pl = levels.get_user_level(self.az.bot_mxid) - levels.events_default = 0 - if self.is_direct: - levels.ban = 99 - levels.kick = 99 - levels.invite = 99 - levels.state_default = 0 - meta_edit_level = 0 - else: - ac = info.access_control - for detail in info.member_detail + info.pending_member_detail: - puppet = await p.Puppet.get_by_uuid(detail.uuid) - puppet_mxid = puppet.intent_for(self).mxid - current_level = levels.get_user_level(puppet_mxid) - if bot_pl > current_level and bot_pl >= 50: - level = current_level - user = await u.User.get_by_uuid(detail.uuid) - if user: - if current_level >= 50 and detail.role == GroupMemberRole.DEFAULT: - level = 0 - elif current_level < 50 and detail.role == GroupMemberRole.ADMINISTRATOR: - level = 50 - if level == 0: - levels.users.pop(user.mxid, None) - else: - levels.users[user.mxid] = level - else: - level = 50 if detail.role == GroupMemberRole.ADMINISTRATOR else 0 - if level == 0: - levels.users.pop(puppet_mxid, None) - else: - levels.users[puppet_mxid] = level - announcements = info.announcements - levels.ban = 50 - levels.kick = 50 - levels.invite = 50 if ac.members == AccessControlMode.ADMINISTRATOR else 0 - levels.state_default = 50 - meta_edit_level = 50 if ac.attributes == AccessControlMode.ADMINISTRATOR else 0 - if announcements == AnnouncementsMode.ENABLED: - levels.events_default = 50 - levels.events[EventType.REACTION] = 0 - levels.events[EventType.ROOM_NAME] = meta_edit_level - levels.events[EventType.ROOM_AVATAR] = meta_edit_level - levels.events[EventType.ROOM_TOPIC] = meta_edit_level - levels.events[EventType.ROOM_ENCRYPTION] = 50 if self.matrix.e2ee else 99 - levels.events[EventType.ROOM_TOMBSTONE] = 99 - levels.users_default = 0 - # Remote delete is only for your own messages - levels.redact = 99 - if self.main_intent.mxid not in levels.users: - levels.users[self.main_intent.mxid] = 9001 if is_initial else 100 - levels.users = {k: v for k, v in sorted(list(levels.users.items()))} - return levels - - async def _create_matrix_room(self, source: u.User, info: ChatInfo) -> RoomID | None: - if self.mxid: - await self._update_matrix_room(source, info) - return self.mxid - await self.update_info(source, info) - self.log.debug("Creating Matrix room") - name: str | None = None - power_levels = await self._get_power_levels(info=info, is_initial=True) - initial_state = [ - { - "type": str(StateBridge), - "state_key": self.bridge_info_state_key, - "content": self.bridge_info, - }, - { - # TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - "type": str(StateHalfShotBridge), - "state_key": self.bridge_info_state_key, - "content": self.bridge_info, - }, - { - "type": str(EventType.ROOM_POWER_LEVELS), - "content": power_levels.serialize(), - }, - ] - invites = [] - if self.config["bridge.encryption.default"] and self.matrix.e2ee: - self.encrypted = True - initial_state.append( - { - "type": str(EventType.ROOM_ENCRYPTION), - "content": self.get_encryption_state_event_json(), - } - ) - if self.is_direct: - invites.append(self.az.bot_mxid) - if self.is_direct and source.uuid == self.chat_id: - name = self.name = "Signal Note to Self" - elif self.encrypted or self.private_chat_portal_meta or not self.is_direct: - name = self.name - if self.avatar_url: - initial_state.append( - { - "type": str(EventType.ROOM_AVATAR), - "content": {"url": self.avatar_url}, - } - ) - - creation_content = {} - if not self.config["bridge.federate_rooms"]: - creation_content["m.federate"] = False - self.mxid = await self.main_intent.create_room( - name=name, - topic=self.topic, - is_direct=self.is_direct, - initial_state=initial_state, - invitees=invites, - creation_content=creation_content, - # Make sure the power level event in initial_state is allowed - # even if the server sends a default power level event before it. - # TODO remove this if the spec is changed to require servers to - # use the power level event in initial_state - power_level_override={"users": {self.main_intent.mxid: 9001}}, - ) - if not self.mxid: - raise Exception("Failed to create room: no mxid returned") - self.name_set = bool(name) - self.avatar_set = bool(self.avatar_url) - - if self.encrypted and self.matrix.e2ee and self.is_direct: - try: - await self.az.intent.ensure_joined(self.mxid) - except Exception: - self.log.warning("Failed to add bridge bot to new private chat {self.mxid}") - - puppet = await p.Puppet.get_by_custom_mxid(source.mxid) - await self.main_intent.invite_user( - self.mxid, source.mxid, extra_content=self._get_invite_content(puppet) - ) - if puppet: - try: - await source.update_direct_chats({self.main_intent.mxid: [self.mxid]}) - await puppet.intent.join_room_by_id(self.mxid) - except MatrixError: - self.log.debug( - "Failed to join custom puppet into newly created portal", exc_info=True - ) - - await self.update() - self.log.debug(f"Matrix room created: {self.mxid}") - self.by_mxid[self.mxid] = self - if not self.is_direct: - await self._update_participants(source, info) - - return self.mxid - - # endregion - # region Database getters - - async def _postinit(self) -> None: - self.by_chat_id[(str(self.chat_id), self.receiver)] = self - if self.mxid: - self.by_mxid[self.mxid] = self - if self.is_direct: - puppet = await self.get_dm_puppet() - self._main_intent = puppet.default_mxid_intent - elif not self.is_direct: - self._main_intent = self.az.intent - - async def delete(self) -> None: - await DBMessage.delete_all(self.mxid) - self.by_mxid.pop(self.mxid, None) - self.mxid = None - self.name_set = False - self.avatar_set = False - self.relay_user_id = None - self.topic = None - self.encrypted = False - await self.update() - - async def save(self) -> None: - await self.update() - - @classmethod - def all_with_room(cls) -> AsyncGenerator[Portal, None]: - return cls._db_to_portals(super().all_with_room()) - - @classmethod - def find_private_chats_with(cls, other_user: UUID) -> AsyncGenerator[Portal, None]: - return cls._db_to_portals(super().find_private_chats_with(other_user)) - - @classmethod - async def _db_to_portals(cls, query: Awaitable[list[Portal]]) -> AsyncGenerator[Portal, None]: - portals = await query - for index, portal in enumerate(portals): - try: - yield cls.by_chat_id[(str(portal.chat_id), portal.receiver)] - except KeyError: - await portal._postinit() - yield portal - - @classmethod - @async_getter_lock - async def get_by_mxid(cls, mxid: RoomID, /) -> Portal | None: - try: - return cls.by_mxid[mxid] - except KeyError: - pass - - portal = cast(cls, await super().get_by_mxid(mxid)) - if portal is not None: - await portal._postinit() - return portal - - return None - - @classmethod - @async_getter_lock - async def get_by_chat_id( - cls, chat_id: GroupID | UUID, /, *, receiver: str = "", create: bool = False - ) -> Portal | None: - if isinstance(chat_id, str): - receiver = "" - elif not isinstance(chat_id, UUID): - raise ValueError(f"Invalid chat ID type {type(chat_id)}") - elif not receiver: - raise ValueError("Direct chats must have a receiver") - - try: - return cls.by_chat_id[(str(chat_id), receiver)] - except KeyError: - pass - - portal = cast(cls, await super().get_by_chat_id(chat_id, receiver)) - if portal is not None: - await portal._postinit() - return portal - - if create: - portal = cls(chat_id, receiver) - await portal.insert() - await portal._postinit() - return portal - - return None - - # endregion diff --git a/mautrix_signal/puppet.py b/mautrix_signal/puppet.py deleted file mode 100644 index 7c42952..0000000 --- a/mautrix_signal/puppet.py +++ /dev/null @@ -1,487 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import TYPE_CHECKING, AsyncGenerator, AsyncIterable, Awaitable, cast -from uuid import UUID -import asyncio -import hashlib -import os.path - -from yarl import URL - -from mausignald.errors import UnregisteredUserError -from mausignald.types import Address, Profile -from mautrix.appservice import IntentAPI -from mautrix.bridge import BasePuppet, async_getter_lock -from mautrix.errors import MForbidden -from mautrix.types import ( - ContentURI, - EventType, - PowerLevelStateEventContent, - RoomID, - SyncToken, - UserID, -) -from mautrix.util import background_task -from mautrix.util.simple_template import SimpleTemplate - -from . import portal as p, signal, user as u -from .config import Config -from .db import Puppet as DBPuppet - -if TYPE_CHECKING: - from .__main__ import SignalBridge - -try: - import phonenumbers -except ImportError: - phonenumbers = None - - -class Puppet(DBPuppet, BasePuppet): - by_uuid: dict[UUID, Puppet] = {} - by_number: dict[str, Puppet] = {} - by_custom_mxid: dict[UserID, Puppet] = {} - hs_domain: str - mxid_template: SimpleTemplate[str] - - config: Config - signal: signal.SignalHandler - - default_mxid_intent: IntentAPI - default_mxid: UserID - - _uuid_lock: asyncio.Lock - _update_info_lock: asyncio.Lock - - def __init__( - self, - uuid: UUID, - number: str | None, - name: str | None = None, - name_quality: int = 0, - avatar_url: ContentURI | None = None, - avatar_hash: str | None = None, - name_set: bool = False, - avatar_set: bool = False, - is_registered: bool = False, - custom_mxid: UserID | None = None, - access_token: str | None = None, - next_batch: SyncToken | None = None, - base_url: URL | None = None, - ) -> None: - assert uuid, "UUID must be set for ghosts" - assert isinstance(uuid, UUID) - super().__init__( - uuid=uuid, - number=number, - name=name, - name_quality=name_quality, - avatar_url=avatar_url, - avatar_hash=avatar_hash, - name_set=name_set, - avatar_set=avatar_set, - is_registered=is_registered, - custom_mxid=custom_mxid, - access_token=access_token, - next_batch=next_batch, - base_url=base_url, - ) - self.log = self.log.getChild(str(uuid) if uuid else number) - - self.default_mxid = self.get_mxid_from_id(self.uuid) - self.default_mxid_intent = self.az.intent.user(self.default_mxid) - self.intent = self._fresh_intent() - - self._uuid_lock = asyncio.Lock() - self._update_info_lock = asyncio.Lock() - - @classmethod - def init_cls(cls, bridge: "SignalBridge") -> AsyncIterable[Awaitable[None]]: - cls.config = bridge.config - cls.loop = bridge.loop - cls.signal = bridge.signal - cls.mx = bridge.matrix - cls.az = bridge.az - cls.hs_domain = cls.config["homeserver.domain"] - cls.mxid_template = SimpleTemplate( - cls.config["bridge.username_template"], - "userid", - prefix="@", - suffix=f":{cls.hs_domain}", - type=str, - ) - cls.sync_with_custom_puppets = cls.config["bridge.sync_with_custom_puppets"] - - cls.homeserver_url_map = { - server: URL(url) - for server, url in cls.config["bridge.double_puppet_server_map"].items() - } - cls.allow_discover_url = cls.config["bridge.double_puppet_allow_discovery"] - cls.login_shared_secret_map = { - server: secret.encode("utf-8") - for server, secret in cls.config["bridge.login_shared_secret_map"].items() - } - cls.login_device_name = "Signal Bridge" - return (puppet.try_start() async for puppet in cls.all_with_custom_mxid()) - - def intent_for(self, portal: p.Portal) -> IntentAPI: - if portal.chat_id == self.uuid: - return self.default_mxid_intent - return self.intent - - @property - def address(self) -> Address: - return Address(uuid=self.uuid, number=self.number) - - async def handle_number_receive(self, number: str) -> None: - async with self._uuid_lock: - if self.number == number: - return - if self.number: - self.by_number.pop(self.number, None) - self.number = number - self._add_number_to_cache() - await self._update_number() - - async def _migrate_memberships(self, prev_intent: IntentAPI, new_intent: IntentAPI) -> None: - self.log.debug(f"Migrating memberships {prev_intent.mxid} -> {new_intent.mxid}") - try: - joined_rooms = await prev_intent.get_joined_rooms() - except MForbidden as e: - self.log.debug( - f"Got MForbidden ({e.message}) when getting joined rooms of old mxid, " - "assuming there are no rooms to rejoin" - ) - return - for room_id in joined_rooms: - await prev_intent.invite_user(room_id, self.default_mxid) - await self._migrate_powers(prev_intent, new_intent, room_id) - await prev_intent.leave_room(room_id) - await new_intent.join_room_by_id(room_id) - - async def _migrate_powers( - self, prev_intent: IntentAPI, new_intent: IntentAPI, room_id: RoomID - ) -> None: - try: - powers: PowerLevelStateEventContent - powers = await prev_intent.get_state_event(room_id, EventType.ROOM_POWER_LEVELS) - user_level = powers.get_user_level(prev_intent.mxid) - pl_state_level = powers.get_event_level(EventType.ROOM_POWER_LEVELS) - if user_level >= pl_state_level > powers.users_default: - powers.ensure_user_level(new_intent.mxid, user_level) - await prev_intent.send_state_event(room_id, EventType.ROOM_POWER_LEVELS, powers) - except Exception: - self.log.warning("Failed to migrate power levels", exc_info=True) - - async def update_info(self, info: Profile | Address, source: u.User) -> None: - update = False - address = info.address if isinstance(info, Profile) else info - if address.number and address.number != self.number: - await self.handle_number_receive(address.number) - update = True - self.log.debug("Updating info with %s (source: %s)", info, source.mxid) - async with self._update_info_lock: - if isinstance(info, Profile) or self.name is None: - update = await self._update_name(info) or update - if isinstance(info, Profile): - update = await self._update_avatar(info.avatar) or update - elif self.config["bridge.contact_list_names"] != "disallow" and self.number: - # Try to use a contact list avatar - update = await self._update_avatar(f"contact-{self.number}") or update - if update: - await self.update() - background_task.create(self._try_update_portal_meta()) - - @staticmethod - def fmt_phone(number: str) -> str: - if phonenumbers is None: - return number - parsed = phonenumbers.parse(number) - fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL - return phonenumbers.format_number(parsed, fmt) - - @classmethod - def _get_displayname(cls, info: Profile | Address) -> tuple[str, int]: - quality = 10 - if isinstance(info, Profile): - address = info.address - name = None - contact_names = cls.config["bridge.contact_list_names"] - if info.profile_name: - name = info.profile_name - quality = 90 if contact_names == "prefer" else 100 - if info.contact_name: - if contact_names == "prefer": - quality = 100 - name = info.contact_name - elif contact_names == "allow" and not name: - quality = 50 - name = info.contact_name - names = name.split("\x00") if name else [] - else: - address = info - names = [] - data = { - "first_name": names[0] if len(names) > 0 else "", - "last_name": names[-1] if len(names) > 1 else "", - "full_name": " ".join(names), - "phone": cls.fmt_phone(address.number) if address.number else None, - "uuid": str(address.uuid) if address.uuid else None, - "displayname": "Unknown user", - } - for pref in cls.config["bridge.displayname_preference"]: - value = data.get(pref.replace(" ", "_")) - if value: - data["displayname"] = value - break - - return cls.config["bridge.displayname_template"].format(**data), quality - - async def _update_name(self, info: Profile | Address) -> bool: - name, quality = self._get_displayname(info) - if quality >= self.name_quality and (name != self.name or not self.name_set): - self.log.debug( - "Updating name from '%s' to '%s' (quality: %d)", self.name, name, quality - ) - self.name = name - self.name_quality = quality - try: - await self.default_mxid_intent.set_displayname(self.name) - self.name_set = True - except Exception: - self.log.exception("Error setting displayname") - self.name_set = False - return True - elif name != self.name or not self.name_set: - self.log.debug( - "Not updating name from '%s' to '%s', new quality (%d) is lower than old (%d)", - self.name, - name, - quality, - self.name_quality, - ) - elif self.name_quality == 0: - # Name matches, but quality is not stored in database - store it now - self.name_quality = quality - return True - return False - - @staticmethod - async def upload_avatar( - self: Puppet | p.Portal, path: str, intent: IntentAPI - ) -> bool | tuple[str, ContentURI]: - if not path: - return False - if not path.startswith("/"): - path = os.path.join(self.config["signal.avatar_dir"], path) - try: - with open(path, "rb") as file: - data = file.read() - except FileNotFoundError: - return False - if not data: - return False - new_hash = hashlib.sha256(data).hexdigest() - if self.avatar_set and new_hash == self.avatar_hash: - return False - mxc = await intent.upload_media(data, async_upload=self.config["homeserver.async_media"]) - return new_hash, mxc - - async def _update_avatar(self, path: str) -> bool: - res = await Puppet.upload_avatar(self, path, self.default_mxid_intent) - if res is False: - return False - self.avatar_hash, self.avatar_url = res - try: - await self.default_mxid_intent.set_avatar_url(self.avatar_url) - self.avatar_set = True - except Exception: - self.log.exception("Error setting avatar") - self.avatar_set = False - return True - - async def _try_update_portal_meta(self) -> None: - try: - await self._update_portal_meta() - except Exception: - self.log.exception("Error updating portal meta") - - async def _update_portal_meta(self) -> None: - async for portal in p.Portal.find_private_chats_with(self.uuid): - if portal.receiver == self.number: - # This is a note to self chat, don't change the name - continue - try: - await portal.update_puppet_name(self.name) - await portal.update_puppet_avatar(self.avatar_hash, self.avatar_url) - if self.number: - await portal.update_puppet_number(self.fmt_phone(self.number)) - except Exception: - self.log.exception(f"Error updating portal meta for {portal.receiver}") - - async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool: - portal: p.Portal = await p.Portal.get_by_mxid(room_id) - # Leave all portals except the notes to self room - return not (portal and portal.is_direct and portal.chat_id == self.uuid) - - # region Database getters - - def _add_number_to_cache(self) -> None: - if self.number: - try: - existing = self.by_number[self.number] - if existing and existing.uuid != self.uuid and existing != self: - existing.number = None - except KeyError: - pass - self.by_number[self.number] = self - - def _add_to_cache(self) -> None: - self.by_uuid[self.uuid] = self - self._add_number_to_cache() - if self.custom_mxid: - self.by_custom_mxid[self.custom_mxid] = self - - async def save(self) -> None: - await self.update() - - @classmethod - async def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Puppet | None: - uuid = cls.get_id_from_mxid(mxid) - if not uuid: - return None - return await cls.get_by_uuid(uuid, create=create) - - @classmethod - @async_getter_lock - async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None: - try: - return cls.by_custom_mxid[mxid] - except KeyError: - pass - - puppet = cast(cls, await super().get_by_custom_mxid(mxid)) - if puppet: - puppet._add_to_cache() - return puppet - - return None - - @classmethod - def get_id_from_mxid(cls, mxid: UserID) -> UUID | None: - identifier = cls.mxid_template.parse(mxid) - if not identifier: - return None - try: - return UUID(identifier.upper()) - except ValueError: - return None - - @classmethod - def get_mxid_from_id(cls, uuid: UUID) -> UserID: - return UserID(cls.mxid_template.format_full(str(uuid).lower())) - - @classmethod - @async_getter_lock - async def get_by_number( - cls, number: str, /, *, resolve_via: str | None = None, raise_resolve: bool = False - ) -> Puppet | None: - try: - return cls.by_number[number] - except KeyError: - pass - - puppet = cast(cls, await super().get_by_number(number)) - if puppet is not None: - puppet._add_to_cache() - return puppet - - if resolve_via: - cls.log.debug( - f"Couldn't find puppet with number {number}, resolving UUID via {resolve_via}" - ) - try: - uuid = await cls.signal.find_uuid(resolve_via, number) - except UnregisteredUserError: - if raise_resolve: - raise - cls.log.debug(f"Resolving {number} via {resolve_via} threw UnregisteredUserError") - return None - except Exception: - if raise_resolve: - raise - cls.log.exception(f"Failed to resolve {number} via {resolve_via}") - return None - if uuid: - cls.log.debug(f"Found {uuid} for {number} after resolving via {resolve_via}") - return await cls.get_by_uuid(uuid, number=number) - else: - cls.log.debug(f"Didn't find UUID for {number} via {resolve_via}") - - return None - - @classmethod - async def get_by_address( - cls, - address: Address, - create: bool = True, - resolve_via: str | None = None, - raise_resolve: bool = False, - ) -> Puppet | None: - if not address.uuid: - return await cls.get_by_number( - address.number, resolve_via=resolve_via, raise_resolve=raise_resolve - ) - else: - return await cls.get_by_uuid(address.uuid, create=create, number=address.number) - - @classmethod - @async_getter_lock - async def get_by_uuid( - cls, uuid: UUID, /, *, create: bool = True, number: str | None = None - ) -> Puppet | None: - try: - return cls.by_uuid[uuid] - except KeyError: - pass - - puppet = cast(cls, await super().get_by_uuid(uuid)) - if puppet is not None: - puppet._add_to_cache() - return puppet - - if create: - puppet = cls(uuid, number) - await puppet.insert() - puppet._add_to_cache() - return puppet - - return None - - @classmethod - async def all_with_custom_mxid(cls) -> AsyncGenerator[Puppet, None]: - puppets = await super().all_with_custom_mxid() - puppet: cls - for index, puppet in enumerate(puppets): - try: - yield cls.by_uuid[puppet.uuid] - except KeyError: - puppet._add_to_cache() - yield puppet - - # endregion diff --git a/mautrix_signal/signal.py b/mautrix_signal/signal.py deleted file mode 100644 index dc838c6..0000000 --- a/mautrix_signal/signal.py +++ /dev/null @@ -1,430 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# 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 . -from __future__ import annotations - -from typing import TYPE_CHECKING, Awaitable -from uuid import UUID -import asyncio -import logging - -from mausignald import SignaldClient -from mausignald.types import ( - Address, - ErrorMessage, - IncomingMessage, - MessageData, - MessageResendSuccessEvent, - OfferMessageType, - OwnReadReceipt, - ReceiptMessage, - ReceiptType, - StorageChange, - TypingAction, - TypingMessage, - WebsocketConnectionStateChangeEvent, -) -from mautrix.types import EventID, EventType, Format, MessageType, TextMessageEventContent -from mautrix.util import background_task -from mautrix.util.logging import TraceLogger -from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus - -from . import portal as po, puppet as pu, user as u -from .db import Message as DBMessage -from .web.segment_analytics import track - -if TYPE_CHECKING: - from .__main__ import SignalBridge - -# Typing notifications seem to get resent every 10 seconds and the timeout is around 15 seconds -SIGNAL_TYPING_TIMEOUT = 15000 - - -class SignalHandler(SignaldClient): - log: TraceLogger = logging.getLogger("mau.signal") - loop: asyncio.AbstractEventLoop - data_dir: str - delete_unknown_accounts: bool - error_message_events: dict[tuple[UUID, str, int], Awaitable[EventID] | None] - - def __init__(self, bridge: "SignalBridge") -> None: - super().__init__(bridge.config["signal.socket_path"], loop=bridge.loop) - self.data_dir = bridge.config["signal.data_dir"] - self.delete_unknown_accounts = bridge.config["signal.delete_unknown_accounts_on_start"] - self.error_message_events = {} - self.add_event_handler(IncomingMessage, self.on_message) - self.add_event_handler(ErrorMessage, self.on_error_message) - self.add_event_handler(StorageChange, self.on_storage_change) - self.add_event_handler( - WebsocketConnectionStateChangeEvent, self.on_websocket_connection_state_change - ) - self.add_event_handler(MessageResendSuccessEvent, self.on_message_resend_success) - - async def on_message(self, evt: IncomingMessage) -> None: - sender = await pu.Puppet.get_by_address(evt.source, resolve_via=evt.account) - if not sender: - self.log.warning(f"Didn't find puppet for incoming message {evt.source}") - return - user = await u.User.get_by_username(evt.account) - # TODO add lots of logging - - if evt.data_message: - await self.handle_message(user, sender, evt.data_message) - if evt.typing_message: - await self.handle_typing(user, sender, evt.typing_message) - if evt.receipt_message: - await self.handle_receipt(sender, evt.receipt_message) - if evt.call_message: - await self.handle_call_message(user, sender, evt) - if evt.decryption_error_message: - await self.handle_decryption_error(user, sender, evt) - if evt.sync_message: - if evt.sync_message.read_messages: - await self.handle_own_receipts(sender, evt.sync_message.read_messages) - if evt.sync_message.sent: - if ( - evt.sync_message.sent.destination - and not evt.sync_message.sent.destination.uuid - ): - self.log.warning( - "Got sent message without destination UUID " - f"{evt.sync_message.sent.destination}" - ) - await self.handle_message( - user, - sender, - evt.sync_message.sent.message, - addr_override=evt.sync_message.sent.destination, - ) - if evt.sync_message.contacts or evt.sync_message.contacts_complete: - self.log.debug("Sync message includes contacts meta, syncing contacts...") - await user.sync_contacts() - if evt.sync_message.groups: - self.log.debug("Sync message includes groups meta, syncing groups...") - await user.sync_groups() - - try: - event_id_future = self.error_message_events.pop( - (sender.uuid, user.username, evt.timestamp) - ) - except KeyError: - pass - else: - self.log.debug(f"Got previously errored message {evt.timestamp} from {sender.address}") - event_id = await event_id_future if event_id_future is not None else None - if event_id is not None: - portal = await po.Portal.get_by_chat_id(sender.uuid, receiver=user.username) - if portal and portal.mxid: - await sender.intent_for(portal).redact(portal.mxid, event_id) - error = {"sender": str(sender.uuid), "timestamp": str(evt.timestamp)} - track(user, "$signal_inbound_error_redacted", error) - - async def on_error_message(self, err: ErrorMessage) -> None: - self.log.warning( - f"Error reading message from {err.data.sender}/{err.data.sender_device} " - f"(timestamp: {err.data.timestamp}, content hint: {err.data.content_hint}): " - f"{err.data.message}" - ) - - if err.data.content_hint == 2: - return - - sender = await pu.Puppet.get_by_address( - Address.parse(err.data.sender), resolve_via=err.account - ) - if not sender: - return - user = await u.User.get_by_username(err.account) - portal = await po.Portal.get_by_chat_id(sender.uuid, receiver=user.username) - if not portal or not portal.mxid: - return - - # Add the error to the error_message_events dictionary, then wait for 10 seconds until - # sending an error. If a success for the timestamp comes in before the 10 seconds is up, - # don't send the error message. - error_message_event_key = (sender.uuid, user.username, err.data.timestamp) - self.error_message_events[error_message_event_key] = None - - await asyncio.sleep(10) - - err_text = ( - "There was an error receiving a message. Check your Signal app for missing messages." - ) - if error_message_event_key in self.error_message_events: - fut = self.error_message_events[error_message_event_key] = self.loop.create_future() - event_id = None - try: - event_id = await portal._send_message( - intent=sender.intent_for(portal), - content=TextMessageEventContent(body=err_text, msgtype=MessageType.NOTICE), - ) - error = { - "message": err_text, - "sender": str(sender.uuid), - "timestamp": str(err.data.timestamp), - } - track(user, "$signal_inbound_error_displayed", error) - finally: - fut.set_result(event_id) - - async def on_storage_change(self, storage_change: StorageChange) -> None: - self.log.info("Handling StorageChange %s", str(storage_change)) - if user := await u.User.get_by_username(storage_change.account): - await user.sync() - - @staticmethod - async def on_websocket_connection_state_change( - evt: WebsocketConnectionStateChangeEvent, - ) -> None: - user = await u.User.get_by_username(evt.account) - user.on_websocket_connection_state_change(evt) - - @staticmethod - async def on_message_resend_success(evt: MessageResendSuccessEvent): - user = await u.User.get_by_username(evt.account) - await user.on_message_resend_success(evt) - - async def handle_message( - self, - user: u.User, - sender: pu.Puppet, - msg: MessageData, - addr_override: Address | None = None, - ) -> None: - try: - await self._handle_message(user, sender, msg, addr_override) - except Exception as e: - await user.handle_auth_failure(e) - raise - - async def _handle_message( - self, - user: u.User, - sender: pu.Puppet, - msg: MessageData, - addr_override: Address | None = None, - ) -> None: - if msg.profile_key_update: - background_task.create(user.sync_contact(sender.address, use_cache=False)) - return - if msg.group_v2: - portal = await po.Portal.get_by_chat_id(msg.group_v2.id, create=True) - else: - if addr_override and not addr_override.uuid: - target = await pu.Puppet.get_by_address(addr_override, resolve_via=user.username) - if not target: - self.log.warning( - f"Didn't find puppet for recipient of incoming message {addr_override}" - ) - return - portal = await po.Portal.get_by_chat_id( - addr_override.uuid if addr_override else sender.uuid, - receiver=user.username, - create=True, - ) - if addr_override and not sender.is_real_user: - portal.log.debug( - f"Ignoring own message {msg.timestamp} as user doesn't have double puppeting " - "enabled" - ) - return - assert portal - - # Handle the user being removed from the group. - if msg.group_v2 and msg.group_v2.removed: - if portal.mxid: - await portal.handle_signal_kicked(user, sender) - return - - if not portal.mxid: - if not msg.is_message and not msg.group_v2: - user.log.debug( - f"Ignoring message {msg.timestamp}," - " probably not bridgeable as there's no portal yet" - ) - return - await portal.create_matrix_room(user, msg.group_v2 or addr_override or sender.address) - if not portal.mxid: - user.log.warning( - f"Failed to create room for incoming message {msg.timestamp}, dropping message" - ) - return - elif ( - msg.group_v2 - and msg.group_v2.group_change - and msg.group_v2.revision == portal.revision + 1 - ): - self.log.debug( - f"Got update for {msg.group_v2.id} ({portal.revision} -> " - f"{msg.group_v2.revision}), applying diff" - ) - await portal.handle_signal_group_change(msg.group_v2.group_change, user) - elif msg.group_v2 and msg.group_v2.revision > portal.revision: - self.log.debug( - f"Got update with multiple revisions for {msg.group_v2.id} ({portal.revision} -> " - f"{msg.group_v2.revision}), resyncing info" - ) - await portal.update_info(user, msg.group_v2) - if msg.expires_in_seconds is not None and (msg.is_message or msg.is_expiration_update): - await portal.update_expires_in_seconds(sender, msg.expires_in_seconds) - if msg.reaction: - await portal.handle_signal_reaction(sender, msg.reaction, msg.timestamp) - if msg.is_message: - await portal.handle_signal_message(user, sender, msg) - if msg.remote_delete: - await portal.handle_signal_delete(sender, msg.remote_delete.target_sent_timestamp) - - @staticmethod - async def handle_call_message(user: u.User, sender: pu.Puppet, msg: IncomingMessage) -> None: - assert msg.call_message - portal = await po.Portal.get_by_chat_id(sender.uuid, receiver=user.username, create=True) - if not portal.mxid: - # FIXME - # await portal.create_matrix_room( - # user, (msg.group_v2 or msg.group or addr_override or sender.address) - # ) - # if not portal.mxid: - # user.log.debug( - # f"Failed to create room for incoming message {msg.timestamp}," - # " dropping message" - # ) - return - - msg_prefix_html = f'{sender.name}' - msg_prefix_text = f"{sender.name}" - msg_suffix = "" - if msg.call_message.offer_message: - call_type = { - OfferMessageType.AUDIO_CALL: "voice call", - OfferMessageType.VIDEO_CALL: "video call", - }.get(msg.call_message.offer_message.type, "call") - msg_suffix = ( - f" started a {call_type} on Signal. Use the native app to answer the call." - ) - msg_type = MessageType.TEXT - elif msg.call_message.hangup_message: - msg_suffix = " ended a call on Signal." - msg_type = MessageType.NOTICE - else: - portal.log.debug(f"Unhandled call message. Likely an ICE message. {msg.call_message}") - return - - await portal._send_message( - intent=sender.intent_for(portal), - content=TextMessageEventContent( - format=Format.HTML, - formatted_body=msg_prefix_html + msg_suffix, - body=msg_prefix_text + msg_suffix, - msgtype=msg_type, - ), - ) - - @staticmethod - async def handle_own_receipts(sender: pu.Puppet, receipts: list[OwnReadReceipt]) -> None: - for receipt in receipts: - puppet = await pu.Puppet.get_by_address(receipt.sender, create=False) - if not puppet: - continue - message = await DBMessage.find_by_sender_timestamp(puppet.uuid, receipt.timestamp) - if not message: - continue - portal = await po.Portal.get_by_mxid(message.mx_room) - if not portal or (portal.is_direct and not sender.is_real_user): - continue - await sender.intent_for(portal).mark_read(portal.mxid, message.mxid) - - @staticmethod - async def handle_typing(user: u.User, sender: pu.Puppet, typing: TypingMessage) -> None: - if typing.group_id: - portal = await po.Portal.get_by_chat_id(typing.group_id) - else: - portal = await po.Portal.get_by_chat_id(sender.uuid, receiver=user.username) - if not portal or not portal.mxid: - return - is_typing = typing.action == TypingAction.STARTED - await sender.intent_for(portal).set_typing( - portal.mxid, timeout=SIGNAL_TYPING_TIMEOUT if is_typing else 0 - ) - - @staticmethod - async def handle_receipt(sender: pu.Puppet, receipt: ReceiptMessage) -> None: - if receipt.type != ReceiptType.READ: - return - messages = await DBMessage.find_by_timestamps(receipt.timestamps) - for message in messages: - portal = await po.Portal.get_by_mxid(message.mx_room) - await sender.intent_for(portal).mark_read(portal.mxid, message.mxid) - - async def handle_decryption_error( - self, user: u.User, sender: pu.Puppet, msg: IncomingMessage - ) -> None: - # These messages mean that a message resend was requested. Signald will handle it, but we - # need to update the checkpoints. - assert msg.decryption_error_message - my_uuid = user.address.uuid - timestamp = msg.decryption_error_message.timestamp - self.log.debug(f"Got decryption error message for {my_uuid}/{timestamp}") - message = await DBMessage.find_by_sender_timestamp(my_uuid, timestamp) - if not message: - self.log.warning("Couldn't find message to referenced in decryption error") - return - self.log.debug( - f"Got decryption error message for {message.mxid} from {sender.uuid} " - f"in {message.mx_room}" - ) - portal = await po.Portal.get_by_mxid(message.mx_room) - if not portal or not portal.mxid: - self.log.warning("Couldn't find portal for message referenced in decryption error") - return - - evt = await portal.main_intent.get_event(message.mx_room, message.mxid) - if evt.content.get("fi.mau.double_puppet_source"): - self.log.debug( - "Message requested in decryption error is double-puppeted, not sending checkpoint" - ) - return - - user.send_remote_checkpoint( - status=MessageSendCheckpointStatus.DELIVERY_FAILED, - event_id=message.mxid, - room_id=message.mx_room, - event_type=EventType.ROOM_MESSAGE, - error=f"{sender.uuid} sent a decryption error message for this message", - ) - - async def start(self) -> None: - await self.connect() - known_usernames = set() - async for user in u.User.all_logged_in(): - # TODO report errors to user? - known_usernames.add(user.username) - if await self.subscribe(user.username): - self.log.info( - f"Successfully subscribed {user.username}, running sync in background" - ) - background_task.create(user.sync()) - else: - user.username = None - if self.delete_unknown_accounts: - self.log.debug("Checking for unknown accounts to delete") - for account in await self.list_accounts(): - if account.account_id not in known_usernames: - self.log.warning(f"Unknown account ID {account.account_id}, deleting...") - await self.delete_account(account.account_id) - else: - self.log.debug("No unknown accounts found") - - async def stop(self) -> None: - await self.disconnect() diff --git a/mautrix_signal/user.py b/mautrix_signal/user.py deleted file mode 100644 index 7cf3086..0000000 --- a/mautrix_signal/user.py +++ /dev/null @@ -1,474 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import TYPE_CHECKING, AsyncGenerator, cast -from asyncio.tasks import sleep -from datetime import datetime -from uuid import UUID -import asyncio - -from mausignald.errors import AuthorizationFailedError, ProfileUnavailableError -from mausignald.types import ( - Account, - Address, - GroupV2, - MessageResendSuccessEvent, - Profile, - WebsocketConnectionState, - WebsocketConnectionStateChangeEvent, -) -from mautrix.appservice import AppService -from mautrix.bridge import AutologinError, BaseUser, async_getter_lock -from mautrix.types import EventType, RoomID, UserID -from mautrix.util import background_task -from mautrix.util.bridge_state import BridgeState, BridgeStateEvent -from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus -from mautrix.util.opt_prometheus import Gauge - -from . import portal as po, puppet as pu -from .config import Config -from .db import Message as DBMessage, User as DBUser - -if TYPE_CHECKING: - from .__main__ import SignalBridge - -METRIC_CONNECTED = Gauge("bridge_connected", "Bridge users connected to Signal") -METRIC_LOGGED_IN = Gauge("bridge_logged_in", "Bridge users logged into Signal") - -BridgeState.human_readable_errors.update( - { - "logged-out": "You're not logged into Signal", - "signal-not-connected": None, - } -) - - -class User(DBUser, BaseUser): - by_mxid: dict[UserID, User] = {} - by_username: dict[str, User] = {} - by_uuid: dict[UUID, User] = {} - config: Config - az: AppService - loop: asyncio.AbstractEventLoop - bridge: "SignalBridge" - - relay_whitelisted: bool - is_admin: bool - permission_level: str - - _sync_lock: asyncio.Lock - _notice_room_lock: asyncio.Lock - _connected: bool - _state_id: str | None - _websocket_connection_state: BridgeStateEvent | None - _latest_non_transient_bridge_state: datetime | None - - def __init__( - self, - mxid: UserID, - username: str | None = None, - uuid: UUID | None = None, - notice_room: RoomID | None = None, - ) -> None: - super().__init__(mxid=mxid, username=username, uuid=uuid, notice_room=notice_room) - BaseUser.__init__(self) - self._notice_room_lock = asyncio.Lock() - self._sync_lock = asyncio.Lock() - self._connected = False - self._state_id = self.username - self._websocket_connection_state = None - self._latest_non_transient_bridge_state = None - perms = self.config.get_permissions(mxid) - self.relay_whitelisted, self.is_whitelisted, self.is_admin, self.permission_level = perms - - @classmethod - def init_cls(cls, bridge: "SignalBridge") -> None: - cls.bridge = bridge - cls.config = bridge.config - cls.az = bridge.az - cls.loop = bridge.loop - - @property - def address(self) -> Address | None: - if not self.username: - return None - return Address(uuid=self.uuid, number=self.username) - - async def is_logged_in(self) -> bool: - return bool(self.username) - - async def needs_relay(self, portal: po.Portal) -> bool: - return not await self.is_logged_in() or ( - portal.is_direct and portal.receiver != self.username - ) - - async def logout(self) -> None: - if not self.username: - return - username = self.username - if self.uuid and self.by_uuid.get(self.uuid) == self: - del self.by_uuid[self.uuid] - if self.username and self.by_username.get(self.username) == self: - del self.by_username[self.username] - self.username = None - self.uuid = None - await self.update() - await self.bridge.signal.unsubscribe(username) - # Wait a while for signald to finish disconnecting - await asyncio.sleep(1) - await self.bridge.signal.delete_account(username) - self._track_metric(METRIC_LOGGED_IN, False) - await self.push_bridge_state(BridgeStateEvent.LOGGED_OUT, remote_id=username) - - async def fill_bridge_state(self, state: BridgeState) -> None: - await super().fill_bridge_state(state) - if not state.remote_id: - state.remote_id = self._state_id - if self.address: - puppet = await self.get_puppet() - state.remote_name = puppet.name or self.username - - async def get_bridge_states(self) -> list[BridgeState]: - if not self.username: - return [] - state = BridgeState(state_event=BridgeStateEvent.UNKNOWN_ERROR) - if self.bridge.signal.is_connected and self._connected: - state.state_event = BridgeStateEvent.CONNECTED - else: - state.state_event = BridgeStateEvent.TRANSIENT_DISCONNECT - return [state] - - async def handle_auth_failure(self, e: Exception) -> None: - if isinstance(e, AuthorizationFailedError): - self.username = None - await self.push_bridge_state(BridgeStateEvent.BAD_CREDENTIALS, error=str(e)) - - async def get_puppet(self) -> pu.Puppet | None: - if not self.address: - return None - return await pu.Puppet.get_by_address(self.address) - - async def get_portal_with(self, puppet: pu.Puppet, create: bool = True) -> po.Portal | None: - if not self.username: - return None - return await po.Portal.get_by_chat_id(puppet.uuid, receiver=self.username, create=create) - - async def on_signin(self, account: Account) -> None: - self.username = account.account_id - self._state_id = account.account_id - self.uuid = account.address.uuid - self._add_to_cache() - await self.update() - self.log.debug(f"Subscribing to {self.username} / {self.uuid}") - if await self.bridge.signal.subscribe(self.username): - background_task.create(self.sync()) - self._track_metric(METRIC_LOGGED_IN, True) - self.log.debug("Successfully subscribed") - else: - self.log.warning("Failed to subscribe") - self.username = None - - def on_websocket_connection_state_change( - self, evt: WebsocketConnectionStateChangeEvent - ) -> None: - if evt.state == WebsocketConnectionState.CONNECTED: - self.log.info(f"Connected to Signal (ws: {evt.socket})") - self._track_metric(METRIC_CONNECTED, True) - self._track_metric(METRIC_LOGGED_IN, True) - self._connected = True - else: - if evt.exception: - self.log.error( - f"New {evt.socket} websocket state from signald {evt.state} " - f"with error {evt.exception}" - ) - else: - self.log.warning(f"New {evt.socket} websocket state from signald {evt.state}") - self._track_metric(METRIC_CONNECTED, False) - self._connected = False - - bridge_state = { - # Signald disconnected - WebsocketConnectionState.SOCKET_DISCONNECTED: BridgeStateEvent.TRANSIENT_DISCONNECT, - # Websocket state reported by signald - WebsocketConnectionState.DISCONNECTED: ( - None - if self._websocket_connection_state == BridgeStateEvent.BAD_CREDENTIALS - else BridgeStateEvent.TRANSIENT_DISCONNECT - ), - WebsocketConnectionState.CONNECTING: BridgeStateEvent.CONNECTING, - WebsocketConnectionState.CONNECTED: BridgeStateEvent.CONNECTED, - WebsocketConnectionState.RECONNECTING: BridgeStateEvent.TRANSIENT_DISCONNECT, - WebsocketConnectionState.DISCONNECTING: BridgeStateEvent.TRANSIENT_DISCONNECT, - WebsocketConnectionState.AUTHENTICATION_FAILED: BridgeStateEvent.BAD_CREDENTIALS, - WebsocketConnectionState.FAILED: BridgeStateEvent.TRANSIENT_DISCONNECT, - }.get(evt.state) - if bridge_state is None: - self.log.info(f"Websocket state {evt.state} seen, not reporting new bridge state") - return - - now = datetime.now() - if bridge_state in (BridgeStateEvent.TRANSIENT_DISCONNECT, BridgeStateEvent.CONNECTING): - self.log.debug( - f"New bridge state {bridge_state} is likely transient. Waiting 15 seconds to send." - ) - - async def wait_report_bridge_state(): - # Wait for 15 seconds (that should be enough for the bridge to get connected) - # before sending a TRANSIENT_DISCONNECT/CONNECTING. - await sleep(15) - if ( - self._latest_non_transient_bridge_state - and now > self._latest_non_transient_bridge_state - ): - background_task.create(self.push_bridge_state(bridge_state)) - - self._websocket_connection_state = bridge_state - - # Wait for another minute. If the bridge stays in TRANSIENT_DISCONNECT/CONNECTING - # for that long, something terrible has happened (signald failed to restart, the - # internet broke, etc.) - await sleep(60) - if ( - self._latest_non_transient_bridge_state - and now > self._latest_non_transient_bridge_state - ): - background_task.create( - self.push_bridge_state( - BridgeStateEvent.UNKNOWN_ERROR, - message="Failed to restore connection to Signal", - ) - ) - self._websocket_connection_state = BridgeStateEvent.UNKNOWN_ERROR - else: - self.log.info( - f"New state since last {bridge_state} push, " - "not transitioning to UNKNOWN_ERROR." - ) - - background_task.create(wait_report_bridge_state()) - elif self._websocket_connection_state == bridge_state: - self.log.info("Websocket state unchanged, not reporting new bridge state") - self._latest_non_transient_bridge_state = now - else: - if bridge_state == BridgeStateEvent.BAD_CREDENTIALS: - self.username = None - background_task.create(self.push_bridge_state(bridge_state)) - self._latest_non_transient_bridge_state = now - self._websocket_connection_state = bridge_state - - async def on_message_resend_success(self, evt: MessageResendSuccessEvent): - # These messages mean we need to resend the message to that user. - my_uuid = self.address.uuid - self.log.debug(f"Successfully resent message {my_uuid}/{evt.timestamp}") - message = await DBMessage.find_by_sender_timestamp(my_uuid, evt.timestamp) - if not message: - self.log.warning("Couldn't find message that was resent") - return - self.log.debug(f"Successfully resent {message.mxid} in {message.mx_room}") - self.send_remote_checkpoint( - status=MessageSendCheckpointStatus.SUCCESS, - event_id=message.mxid, - room_id=message.mx_room, - event_type=EventType.ROOM_MESSAGE, - ) - - async def _sync_puppet(self) -> None: - puppet = await self.get_puppet() - if not puppet: - self.log.warning(f"Didn't find puppet for own address {self.address}") - return - if puppet.uuid and not self.uuid: - self.uuid = puppet.uuid - self.by_uuid[self.uuid] = self - if puppet.custom_mxid != self.mxid and puppet.can_auto_login(self.mxid): - self.log.info("Automatically enabling custom puppet") - try: - await puppet.switch_mxid(access_token="auto", mxid=self.mxid) - except AutologinError as e: - self.log.warning(f"Failed to enable custom puppet: {e}") - - async def sync(self) -> None: - await self.sync_puppet() - await self.sync_contacts() - await self.sync_groups() - self.log.debug("Sync complete") - - async def sync_puppet(self) -> None: - try: - async with self._sync_lock: - await self._sync_puppet() - except Exception: - self.log.exception("Error while syncing own puppet") - - async def sync_contacts(self) -> None: - try: - async with self._sync_lock: - await self._sync_contacts() - except Exception as e: - self.log.exception("Error while syncing contacts") - await self.handle_auth_failure(e) - - async def sync_groups(self) -> None: - try: - async with self._sync_lock: - await self._sync_groups() - except Exception as e: - self.log.exception("Error while syncing groups") - await self.handle_auth_failure(e) - - async def sync_contact( - self, contact: Profile | Address, create_portals: bool = False, use_cache: bool = True - ) -> None: - self.log.trace("Syncing contact %s", contact) - try: - if isinstance(contact, Address): - address = contact - try: - profile = await self.bridge.signal.get_profile( - self.username, address, use_cache=use_cache - ) - except ProfileUnavailableError: - self.log.debug(f"Profile of {address} was not available when syncing") - profile = None - if profile and profile.name: - self.log.trace("Got profile for %s: %s", address, profile) - else: - address = contact.address - profile = contact - puppet = await pu.Puppet.get_by_address(address, resolve_via=self.username) - if not puppet: - self.log.debug(f"Didn't find puppet for {address} while syncing contact") - return - await puppet.update_info(profile or address, self) - if create_portals: - portal = await po.Portal.get_by_chat_id( - puppet.uuid, receiver=self.username, create=True - ) - await portal.create_matrix_room(self, profile or address) - except Exception as e: - await self.handle_auth_failure(e) - raise - - async def _sync_group_v2(self, group: GroupV2, create_portals: bool) -> None: - self.log.trace("Syncing group %s", group.id) - portal = await po.Portal.get_by_chat_id(group.id, create=True) - if create_portals: - await portal.create_matrix_room(self, group) - elif portal.mxid: - await portal.update_matrix_room(self, group) - - async def _sync_contacts(self) -> None: - create_contact_portal = self.config["bridge.autocreate_contact_portal"] - for contact in await self.bridge.signal.list_contacts(self.username): - try: - await self.sync_contact(contact, create_contact_portal) - except Exception: - self.log.exception(f"Failed to sync contact {contact.address}") - - async def _sync_groups(self) -> None: - create_group_portal = self.config["bridge.autocreate_group_portal"] - for group in await self.bridge.signal.list_groups(self.username): - try: - await self._sync_group_v2(group, create_group_portal) - except Exception: - self.log.exception(f"Failed to sync group {group.id}") - - # region Database getters - - def _add_to_cache(self) -> None: - self.by_mxid[self.mxid] = self - if self.username: - self.by_username[self.username] = self - if self.uuid: - self.by_uuid[self.uuid] = self - - @classmethod - @async_getter_lock - async def get_by_mxid(cls, mxid: UserID, /, *, create: bool = True) -> User | None: - # Never allow ghosts to be users - if pu.Puppet.get_id_from_mxid(mxid): - return None - try: - return cls.by_mxid[mxid] - except KeyError: - pass - - user = cast(cls, await super().get_by_mxid(mxid)) - if user is not None: - user._add_to_cache() - return user - - if create: - user = cls(mxid) - await user.insert() - user._add_to_cache() - return user - - return None - - @classmethod - @async_getter_lock - async def get_by_username(cls, username: str, /) -> User | None: - try: - return cls.by_username[username] - except KeyError: - pass - - user = cast(cls, await super().get_by_username(username)) - if user is not None: - user._add_to_cache() - return user - - return None - - @classmethod - @async_getter_lock - async def get_by_uuid(cls, uuid: UUID, /) -> User | None: - try: - return cls.by_uuid[uuid] - except KeyError: - pass - - user = cast(cls, await super().get_by_uuid(uuid)) - if user is not None: - user._add_to_cache() - return user - - return None - - @classmethod - async def get_by_address(cls, address: Address) -> User | None: - if address.uuid: - return await cls.get_by_uuid(address.uuid) - elif address.number: - return await cls.get_by_username(address.number) - else: - raise ValueError("Given address is blank") - - @classmethod - async def all_logged_in(cls) -> AsyncGenerator[User, None]: - users = await super().all_logged_in() - user: cls - for user in users: - try: - yield cls.by_mxid[user.mxid] - except KeyError: - user._add_to_cache() - yield user - - # endregion diff --git a/mautrix_signal/util/__init__.py b/mautrix_signal/util/__init__.py deleted file mode 100644 index d2c2305..0000000 --- a/mautrix_signal/util/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .color_log import ColorFormatter -from .normalize_number import normalize_number -from .user_has_power_level import user_has_power_level diff --git a/mautrix_signal/util/color_log.py b/mautrix_signal/util/color_log.py deleted file mode 100644 index 999963f..0000000 --- a/mautrix_signal/util/color_log.py +++ /dev/null @@ -1,25 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2020 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from mautrix.util.logging.color import PREFIX, RESET, ColorFormatter as BaseColorFormatter - -MAUSIGNALD_COLOR = PREFIX + "35;1m" # magenta - - -class ColorFormatter(BaseColorFormatter): - def _color_name(self, module: str) -> str: - if module.startswith("mausignald"): - return MAUSIGNALD_COLOR + module + RESET - return super()._color_name(module) diff --git a/mautrix_signal/util/normalize_number.py b/mautrix_signal/util/normalize_number.py deleted file mode 100644 index 014a28a..0000000 --- a/mautrix_signal/util/normalize_number.py +++ /dev/null @@ -1,24 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2022 Tulir Asokan, Sumner Evans -# -# 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 . - -remove_extra_chars = str.maketrans("", "", " .,-()") - - -def normalize_number(number: str) -> str: - phone = number.translate(remove_extra_chars) - if not number.startswith("+") or not phone[1:].isdecimal(): - raise Exception("Phone number must be entered in international format") - return phone diff --git a/mautrix_signal/util/user_has_power_level.py b/mautrix_signal/util/user_has_power_level.py deleted file mode 100644 index 56fba5d..0000000 --- a/mautrix_signal/util/user_has_power_level.py +++ /dev/null @@ -1,36 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2020 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from mautrix.appservice import IntentAPI -from mautrix.errors import MatrixRequestError -from mautrix.types import EventType, RoomID - -from .. import user as u - - -async def user_has_power_level( - room_id: RoomID, intent: IntentAPI, sender: u.User, event: str -) -> bool: - if sender.is_admin: - return True - # Make sure the state store contains the power levels. - try: - await intent.get_power_levels(room_id) - except MatrixRequestError: - return False - event_type = EventType.find(f"net.maunium.signal.{event}", t_class=EventType.Class.STATE) - return await intent.state_store.has_power_level(room_id, sender.mxid, event_type) diff --git a/mautrix_signal/version.py b/mautrix_signal/version.py deleted file mode 100644 index 0b22680..0000000 --- a/mautrix_signal/version.py +++ /dev/null @@ -1 +0,0 @@ -from .get_version import git_revision, git_tag, linkified_version, version diff --git a/mautrix_signal/web/__init__.py b/mautrix_signal/web/__init__.py deleted file mode 100644 index d263dce..0000000 --- a/mautrix_signal/web/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .provisioning_api import ProvisioningAPI diff --git a/mautrix_signal/web/provisioning_api.py b/mautrix_signal/web/provisioning_api.py deleted file mode 100644 index a083afc..0000000 --- a/mautrix_signal/web/provisioning_api.py +++ /dev/null @@ -1,469 +0,0 @@ -# mautrix-signal - A Matrix-Signal puppeting bridge -# Copyright (C) 2020 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import TYPE_CHECKING -import asyncio -import json -import logging - -from aiohttp import web - -from mausignald.errors import ( - InternalError, - ScanTimeoutError, - TimeoutException, - UnregisteredUserError, -) -from mausignald.types import Account, Address, Profile -from mautrix.types import JSON, UserID -from mautrix.util.logging import TraceLogger - -from .. import portal as po, puppet as pu, user as u -from ..util import normalize_number -from .segment_analytics import init as init_segment, track - -if TYPE_CHECKING: - from ..__main__ import SignalBridge - - -class ProvisioningAPI: - log: TraceLogger = logging.getLogger("mau.web.provisioning") - app: web.Application - bridge: "SignalBridge" - - def __init__( - self, - bridge: "SignalBridge", - shared_secret: str, - segment_key: str | None, - segment_user_id: str | None, - ) -> None: - self.bridge = bridge - self.app = web.Application() - self.shared_secret = shared_secret - - if segment_key: - init_segment(segment_key, segment_user_id) - - # Whoami - self.app.router.add_get("/v1/api/whoami", self.status) - self.app.router.add_get("/v2/whoami", self.status) - - # Logout - self.app.router.add_options("/v1/api/logout", self.login_options) - self.app.router.add_post("/v1/api/logout", self.logout) - self.app.router.add_options("/v2/logout", self.login_options) - self.app.router.add_post("/v2/logout", self.logout) - - # Link API (will be deprecated soon) - self.app.router.add_options("/v1/api/link", self.login_options) - self.app.router.add_options("/v1/api/link/wait", self.login_options) - self.app.router.add_post("/v1/api/link", self.link) - self.app.router.add_post("/v1/api/link/wait", self.link_wait) - - # New Login API - self.app.router.add_options("/v2/link/new", self.login_options) - self.app.router.add_options("/v2/link/wait/scan", self.login_options) - self.app.router.add_options("/v2/link/wait/account", self.login_options) - self.app.router.add_post("/v2/link/new", self.link_new) - self.app.router.add_post("/v2/link/wait/scan", self.link_wait_for_scan) - self.app.router.add_post("/v2/link/wait/account", self.link_wait_for_account) - - # Start new chat API - self.app.router.add_get("/v2/contacts", self.list_contacts) - self.app.router.add_get("/v2/resolve_identifier/{number}", self.resolve_identifier) - self.app.router.add_post("/v2/pm/{number}", self.start_pm) - - @property - def _acao_headers(self) -> dict[str, str]: - return { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "Authorization, Content-Type", - "Access-Control-Allow-Methods": "POST, OPTIONS", - } - - @property - def _headers(self) -> dict[str, str]: - return { - **self._acao_headers, - "Content-Type": "application/json", - } - - async def login_options(self, _: web.Request) -> web.Response: - return web.Response(status=200, headers=self._headers) - - async def check_token(self, request: web.Request) -> "u.User": - try: - token = request.headers["Authorization"] - token = token[len("Bearer ") :] - except KeyError: - raise web.HTTPBadRequest( - text='{"error": "Missing Authorization header"}', headers=self._headers - ) - except IndexError: - raise web.HTTPBadRequest( - text='{"error": "Malformed Authorization header"}', headers=self._headers - ) - if token != self.shared_secret: - raise web.HTTPForbidden(text='{"error": "Invalid token"}', headers=self._headers) - try: - user_id = request.query["user_id"] - except KeyError: - raise web.HTTPBadRequest( - text='{"error": "Missing user_id query param"}', headers=self._headers - ) - - try: - if not self.bridge.signal.is_connected: - await self.bridge.signal.wait_for_connected(timeout=10) - except asyncio.TimeoutError: - raise web.HTTPServiceUnavailable( - text=json.dumps({"error": "Cannot connect to signald"}), headers=self._headers - ) - - return await u.User.get_by_mxid(UserID(user_id)) - - async def check_token_and_logged_in(self, request: web.Request) -> "u.User": - user = await self.check_token(request) - if not await user.is_logged_in(): - error = {"error": "You're not logged in"} - raise web.HTTPNotFound(text=json.dumps(error), headers=self._headers) - return user - - async def status(self, request: web.Request) -> web.Response: - user = await self.check_token(request) - data = { - "permissions": user.permission_level, - "mxid": user.mxid, - "signal": None, - } - if await user.is_logged_in(): - try: - profile = await self.bridge.signal.get_profile( - username=user.username, address=user.address - ) - except Exception as e: - self.log.exception(f"Failed to get {user.username}'s profile for whoami") - await user.handle_auth_failure(e) - - data["signal"] = { - "number": user.username, - "ok": False, - "error": str(e), - } - else: - addr = profile.address if profile else None - number = addr.number if addr else None - uuid = addr.uuid if addr else None - data["signal"] = { - "number": number or user.username, - "uuid": str(uuid or user.uuid or ""), - "name": profile.name if profile else None, - "ok": True, - } - return web.json_response(data, headers=self._acao_headers) - - async def _shielded_link(self, user: "u.User", session_id: str, device_name: str) -> Account: - try: - self.log.debug(f"Starting finish link request for {user.mxid} / {session_id}") - account = await self.bridge.signal.finish_link( - session_id=session_id, device_name=device_name, overwrite=True - ) - except TimeoutException: - self.log.warning(f"Timed out waiting for linking to finish (session {session_id})") - raise - except Exception: - self.log.exception( - f"Fatal error while waiting for linking to finish (session {session_id})" - ) - raise - else: - await user.on_signin(account) - return account - - async def _try_shielded_link( - self, user: "u.User", session_id: str, device_name: str - ) -> web.Response: - try: - account = await asyncio.shield(self._shielded_link(user, session_id, device_name)) - except asyncio.CancelledError: - self.log.warning( - f"Client cancelled link wait request ({session_id}) before it finished" - ) - raise - except (TimeoutException, ScanTimeoutError): - raise web.HTTPBadRequest( - text='{"error": "Signal linking timed out"}', headers=self._headers - ) - except InternalError: - raise web.HTTPInternalServerError( - text='{"error": "Fatal error in Signal linking"}', headers=self._headers - ) - except Exception: - raise web.HTTPInternalServerError( - text='{"error": "Fatal error in Signal linking"}', headers=self._headers - ) - else: - return web.json_response(account.address.serialize()) - - # region Old Link API - - async def link(self, request: web.Request) -> web.Response: - user = await self.check_token(request) - - if await user.is_logged_in(): - raise web.HTTPConflict( - text="""{"error": "You're already logged in"}""", headers=self._headers - ) - - try: - data = await request.json() - except json.JSONDecodeError: - raise web.HTTPBadRequest(text='{"error": "Malformed JSON"}', headers=self._headers) - - device_name = data.get("device_name", "Mautrix-Signal bridge") - sess = await self.bridge.signal.start_link() - - user.command_status = { - "action": "Link", - "session_id": sess.session_id, - "device_name": device_name, - } - - self.log.debug(f"Returning linking URI for {user.mxid} / {sess.session_id}") - return web.json_response({"uri": sess.uri}, headers=self._acao_headers) - - async def link_wait(self, request: web.Request) -> web.Response: - user = await self.check_token(request) - if not user.command_status or user.command_status["action"] != "Link": - raise web.HTTPBadRequest( - text='{"error": "No Signal linking started"}', headers=self._headers - ) - session_id = user.command_status["session_id"] - device_name = user.command_status["device_name"] - return await self._try_shielded_link(user, session_id, device_name) - - # endregion - - # region New Link API - - async def _get_request_data(self, request: web.Request) -> tuple[u.User, dict]: - user = await self.check_token(request) - if await user.is_logged_in(): - error_text = """{"error": "You're already logged in"}""" - raise web.HTTPConflict(text=error_text, headers=self._headers) - - try: - return user, (await request.json()) - except json.JSONDecodeError: - raise web.HTTPBadRequest(text='{"error": "Malformed JSON"}', headers=self._headers) - - async def link_new(self, request: web.Request) -> web.Response: - """ - Starts a new link session. - - Params: none - - Returns a JSON object with the following fields: - - * session_id: a session ID that should be used for all future link-related commands - (wait_for_scan and wait_for_account). - * uri: a URI that should be used to display the QR code. - """ - user, _ = await self._get_request_data(request) - self.log.debug(f"Getting session ID and link URI for {user.mxid}") - try: - sess = await self.bridge.signal.start_link() - track(user, "$link_new_success") - self.log.debug( - f"Returning session ID and link URI for {user.mxid} / {sess.session_id}" - ) - return web.json_response(sess.serialize(), headers=self._acao_headers) - except Exception as e: - error = {"error": f"Getting a new link failed: {e}"} - track(user, "$link_new_failed", error) - raise web.HTTPBadRequest(text=json.dumps(error), headers=self._headers) - - async def link_wait_for_scan(self, request: web.Request) -> web.Response: - """ - Waits for the QR code associated with the provided session ID to be scanned. - - Params: a JSON object with the following field: - - * session_id: a session ID that you got from a call to /link/v2/new. - """ - user, request_data = await self._get_request_data(request) - try: - session_id = request_data["session_id"] - except KeyError: - error_text = '{"error": "session_id not provided"}' - raise web.HTTPBadRequest(text=error_text, headers=self._headers) - - try: - await self.bridge.signal.wait_for_scan(session_id) - track(user, "$qrcode_scanned") - except Exception as e: - error = {"error": f"Failed waiting for scan. Error: {e}"} - self.log.exception(error["error"]) - track(user, "$qrcode_scan_failed", error) - raise web.HTTPBadRequest(text=json.dumps(error), headers=self._headers) - else: - return web.json_response({}, headers=self._acao_headers) - - async def link_wait_for_account(self, request: web.Request) -> web.Response: - """ - Waits for the link to the user's phone to complete. - - Params: a JSON object with the following fields: - - * session_id: a session ID that you got from a call to /link/v2/new. - * device_name: the device name that will show up in Linked Devices on the user's device. - - Returns: a JSON object representing the user's account. - """ - user, request_data = await self._get_request_data(request) - try: - session_id = request_data["session_id"] - device_name = request_data.get("device_name", "Mautrix-Signal bridge") - except KeyError: - error = {"error": "session_id not provided"} - track(user, "$wait_for_account_failed", error) - raise web.HTTPBadRequest(text=json.dumps(error), headers=self._headers) - - try: - resp = await self._try_shielded_link(user, session_id, device_name) - track(user, "$wait_for_account_success") - return resp - except Exception as e: - error = {"error": f"Failed waiting for account. Error: {e}"} - self.log.exception(error["error"]) - track(user, "$wait_for_account_failed", error) - raise web.HTTPBadRequest(text=json.dumps(error), headers=self._headers) - - # endregion - - # region Logout - - async def logout(self, request: web.Request) -> web.Response: - try: - user = await self.check_token_and_logged_in(request) - await user.logout() - return web.json_response({}, headers=self._acao_headers) - except web.HTTPNotFound: - return web.json_response({"error": "You're not logged in"}, headers=self._acao_headers) - - # endregion - - # region Start new chat API - - async def list_contacts(self, request: web.Request) -> web.Response: - user = await self.check_token_and_logged_in(request) - contacts = await self.bridge.signal.list_contacts(user.username, use_cache=True) - - async def transform(profile: Profile) -> JSON: - assert profile.address - puppet = await pu.Puppet.get_by_address(profile.address, create=False) - avatar_url = puppet.avatar_url if puppet else None - return { - "name": profile.name, - "contact_name": profile.contact_name, - "profile_name": profile.profile_name, - "avatar_url": avatar_url, - "address": profile.address.serialize(), - } - - return web.json_response( - { - c.address.number: await transform(c) - for c in contacts - if c.address and c.address.number - }, - headers=self._acao_headers, - ) - - async def _resolve_identifier(self, number: str, user: u.User) -> pu.Puppet: - try: - number = normalize_number(number) - except Exception as e: - raise web.HTTPBadRequest(text=json.dumps({"error": str(e)}), headers=self._headers) - - try: - puppet: pu.Puppet = await pu.Puppet.get_by_number( - number, resolve_via=user.username, raise_resolve=True - ) - except UnregisteredUserError: - error = {"error": f"The phone number {number} is not a registered Signal account"} - raise web.HTTPNotFound(text=json.dumps(error), headers=self._headers) - except Exception: - self.log.exception(f"Unknown error fetching UUID for {number}") - error = {"error": "Unknown error while fetching UUID"} - raise web.HTTPInternalServerError(text=json.dumps(error), headers=self._headers) - if not puppet: - error = { - "error": ( - f"The phone number {number} doesn't seem to be a registered Signal account" - ) - } - raise web.HTTPNotFound(text=json.dumps(error), headers=self._headers) - return puppet - - async def start_pm(self, request: web.Request) -> web.Response: - user = await self.check_token_and_logged_in(request) - puppet = await self._resolve_identifier(request.match_info["number"], user) - - portal = await po.Portal.get_by_chat_id(puppet.uuid, receiver=user.username, create=True) - assert portal, "Portal.get_by_chat_id with create=True can't return None" - - if portal.mxid: - await portal.main_intent.invite_user(portal.mxid, user.mxid) - just_created = False - else: - await portal.create_matrix_room(user, puppet.address) - just_created = True - return web.json_response( - { - "room_id": portal.mxid, - "just_created": just_created, - "chat_id": puppet.address.serialize(), - "other_user": { - "mxid": puppet.mxid, - "displayname": puppet.name, - "avatar_url": puppet.avatar_url, - }, - }, - headers=self._acao_headers, - status=201 if just_created else 200, - ) - - async def resolve_identifier(self, request: web.Request) -> web.Response: - user = await self.check_token_and_logged_in(request) - puppet = await self._resolve_identifier(request.match_info["number"], user) - portal = await po.Portal.get_by_chat_id(puppet.uuid, receiver=user.username, create=False) - return web.json_response( - { - "room_id": portal.mxid if portal else None, - "chat_id": puppet.address.serialize(), - "other_user": { - "mxid": puppet.mxid, - "displayname": puppet.name, - "avatar_url": puppet.avatar_url, - }, - }, - headers=self._acao_headers, - ) - - # endregion diff --git a/mautrix_signal/web/segment_analytics.py b/mautrix_signal/web/segment_analytics.py deleted file mode 100644 index d9fe63d..0000000 --- a/mautrix_signal/web/segment_analytics.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -import logging - -from yarl import URL -import aiohttp - -from mautrix.util import background_task - -from .. import user as u - -log = logging.getLogger("mau.web.public.analytics") -segment_url: URL = URL("https://api.segment.io/v1/track") -http: aiohttp.ClientSession | None = None -segment_key: str | None = None -segment_user_id: str | None = None - - -async def _track(user: u.User, event: str, properties: dict) -> None: - await http.post( - segment_url, - json={ - "userId": segment_user_id or user.mxid, - "event": event, - "properties": {"bridge": "signal", **properties}, - }, - auth=aiohttp.BasicAuth(login=segment_key, encoding="utf-8"), - ) - log.debug(f"Tracked {event}") - - -def track(user: u.User, event: str, properties: dict | None = None): - if segment_key: - background_task.create(_track(user, event, properties or {})) - - -def init(key, user_id: str | None = None): - global segment_key, segment_user_id, http - segment_key = key - segment_user_id = user_id - http = aiohttp.ClientSession() diff --git a/optional-requirements.txt b/optional-requirements.txt deleted file mode 100644 index 4a03c71..0000000 --- a/optional-requirements.txt +++ /dev/null @@ -1,23 +0,0 @@ -# Format: #/name defines a new extras_require group called name -# Uncommented lines after the group definition insert things into that group. - -#/e2be -python-olm>=3,<4 -pycryptodome>=3,<4 -unpaddedbase64>=1,<3 - -#/metrics -prometheus_client>=0.6,<0.17 - -#/formattednumbers -phonenumbers>=8,<9 - -#/qrlink -qrcode>=6,<8 -Pillow>=4,<10 - -#/stickers -signalstickers-client>=3,<4 - -#/sqlite -aiosqlite>=0.16,<0.19 diff --git a/pkg/libsignal/go.mod b/pkg/libsignal/go.mod new file mode 100644 index 0000000..ea6f49f --- /dev/null +++ b/pkg/libsignal/go.mod @@ -0,0 +1,3 @@ +module go.mau.fi/mautrix-signal/pkg/libsignal + +go 1.20 diff --git a/pkg/libsignal/stub.go b/pkg/libsignal/stub.go new file mode 100644 index 0000000..1f79355 --- /dev/null +++ b/pkg/libsignal/stub.go @@ -0,0 +1 @@ +package libsignal diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index fe91da3..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,11 +0,0 @@ -[tool.isort] -profile = "black" -force_to_top = "typing" -from_first = true -combine_as_imports = true -known_first_party = "mautrix" -line_length = 99 - -[tool.black] -line-length = 99 -target-version = ["py38"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2224053..0000000 --- a/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -ruamel.yaml>=0.15.35,<0.18 -python-magic>=0.4,<0.5 -commonmark>=0.8,<0.10 -aiohttp>=3,<4 -yarl>=1,<2 -attrs>=19.1 -mautrix>=0.19.4,<0.20 -asyncpg>=0.20,<0.28 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 9a2bfda..0000000 --- a/setup.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[flake8] -max-line-length = 99 -extend-ignore = - # See https://github.com/PyCQA/pycodestyle/issues/373 - E203, diff --git a/setup.py b/setup.py deleted file mode 100644 index ec18b3e..0000000 --- a/setup.py +++ /dev/null @@ -1,73 +0,0 @@ -import setuptools - -from mautrix_signal.get_version import git_tag, git_revision, version, linkified_version - -with open("requirements.txt") as reqs: - install_requires = reqs.read().splitlines() - -with open("optional-requirements.txt") as reqs: - extras_require = {} - current = [] - for line in reqs.read().splitlines(): - if line.startswith("#/"): - extras_require[line[2:]] = current = [] - elif not line or line.startswith("#"): - continue - else: - current.append(line) - -extras_require["all"] = list({dep for deps in extras_require.values() for dep in deps}) - -try: - long_desc = open("README.md").read() -except IOError: - long_desc = "Failed to read README.md" - -with open("mautrix_signal/version.py", "w") as version_file: - version_file.write(f"""# Generated in setup.py - -git_tag = {git_tag!r} -git_revision = {git_revision!r} -version = {version!r} -linkified_version = {linkified_version!r} -""") - -setuptools.setup( - name="mautrix-signal", - version=version, - url="https://github.com/mautrix/signal", - project_urls={ - "Changelog": "https://github.com/mautrix/signal/blob/master/CHANGELOG.md", - }, - - author="Tulir Asokan", - author_email="tulir@maunium.net", - - description="A Matrix-Signal puppeting bridge.", - long_description=long_desc, - long_description_content_type="text/markdown", - - packages=setuptools.find_packages(), - - install_requires=install_requires, - extras_require=extras_require, - python_requires="~=3.8", - - classifiers=[ - "Development Status :: 4 - Beta", - "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", - "Topic :: Communications :: Chat", - "Framework :: AsyncIO", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - ], - package_data={"mautrix_signal": [ - "example-config.yaml", - ]}, - data_files=[ - (".", ["mautrix_signal/example-config.yaml"]), - ], -)