mirror of
https://github.com/mautrix/signal.git
synced 2025-03-14 14:15:36 +00:00
Remove everything and add stub Go modules
This commit is contained in:
parent
14610ceb54
commit
cc5aa59962
74 changed files with 103 additions and 10531 deletions
|
@ -1,8 +1,6 @@
|
|||
.editorconfig
|
||||
logs
|
||||
.venv
|
||||
start
|
||||
config.yaml
|
||||
registration.yaml
|
||||
*.db
|
||||
*.pickle
|
||||
|
|
|
@ -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
|
||||
|
|
28
.github/workflows/go.yml
vendored
Normal file
28
.github/workflows/go.yml
vendored
Normal file
|
@ -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
|
26
.github/workflows/python-lint.yml
vendored
26
.github/workflows/python-lint.yml
vendored
|
@ -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
|
20
.gitignore
vendored
20
.gitignore
vendored
|
@ -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
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
include:
|
||||
- project: 'mautrix/ci'
|
||||
file: '/python.yml'
|
|
@ -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
|
||||
|
|
50
Dockerfile
50
Dockerfile
|
@ -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"]
|
|
@ -1,5 +0,0 @@
|
|||
include README.md
|
||||
include CHANGELOG.md
|
||||
include LICENSE
|
||||
include requirements.txt
|
||||
include optional-requirements.txt
|
23
README.md
23
README.md
|
@ -1,25 +1,8 @@
|
|||
# mautrix-signal
|
||||

|
||||
[](LICENSE)
|
||||
[](https://github.com/mautrix/signal/releases)
|
||||
[](https://mau.dev/mautrix/signal/container_registry)
|
||||
[](https://github.com/psf/black)
|
||||
[](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)
|
||||
|
|
107
ROADMAP.md
107
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
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
pre-commit>=2.10.1,<3
|
||||
isort>=5.10.1,<6
|
||||
black>=23,<24
|
|
@ -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
|
3
go.mod
Normal file
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
|||
module go.mau.fi/mautrix-signal
|
||||
|
||||
go 1.20
|
1
main.go
Normal file
1
main.go
Normal file
|
@ -0,0 +1 @@
|
|||
package main
|
|
@ -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.
|
|
@ -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()
|
||||
```
|
|
@ -1 +0,0 @@
|
|||
from .signald import SignaldClient
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -1,2 +0,0 @@
|
|||
__version__ = "0.4.2"
|
||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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()
|
|
@ -1,3 +0,0 @@
|
|||
from .auth import SECTION_AUTH
|
||||
from .conn import SECTION_CONNECTION
|
||||
from .signal import SECTION_SIGNAL
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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] <phone number>",
|
||||
)
|
||||
async def connect_existing(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp connect-existing [mxid] <phone number>`")
|
||||
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="<phone>",
|
||||
)
|
||||
async def register(evt: CommandEvent) -> None:
|
||||
if len(evt.args) == 0:
|
||||
await evt.reply("**Usage**: $cmdprefix+sp register [--voice] [--captcha <token>] <phone>")
|
||||
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 <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 <URI from QR code>`")
|
||||
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>`")
|
||||
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")
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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")
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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} <phone>` "
|
||||
"(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 <invite link>`")
|
||||
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 <recipient phone> [level] <safety number>`"
|
||||
)
|
||||
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] <type> <_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="<signal chat ID> [matrix room ID]",
|
||||
)
|
||||
async def bridge(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply(
|
||||
"**Usage:** `$cmdprefix+sp bridge <signal chat ID> [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}")
|
|
@ -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"
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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("*")
|
|
@ -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",
|
||||
]
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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)]
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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))
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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]
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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")
|
||||
]
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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,
|
||||
)
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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)
|
||||
)"""
|
||||
)
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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")
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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")
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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"
|
||||
)
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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<>''")
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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<>''")
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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")
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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")
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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")
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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")
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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")
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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]
|
|
@ -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)
|
|
@ -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]
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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("<HH", x.encode("utf-16le")))
|
||||
if (0x10000 <= ord(x) <= 0x10FFFF)
|
||||
else x
|
||||
for x in text
|
||||
)
|
||||
|
||||
|
||||
def del_surrogate(text: str) -> 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'<a href="https://matrix.to/#/{puppet.mxid}">{name}</a>')
|
||||
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
|
|
@ -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
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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()
|
File diff suppressed because it is too large
Load diff
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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'<a href="https://matrix.to/#/{sender.mxid}">{sender.name}</a>'
|
||||
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()
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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
|
|
@ -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
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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)
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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)
|
|
@ -1 +0,0 @@
|
|||
from .get_version import git_revision, git_tag, linkified_version, version
|
|
@ -1 +0,0 @@
|
|||
from .provisioning_api import ProvisioningAPI
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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
|
|
@ -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()
|
|
@ -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
|
3
pkg/libsignal/go.mod
Normal file
3
pkg/libsignal/go.mod
Normal file
|
@ -0,0 +1,3 @@
|
|||
module go.mau.fi/mautrix-signal/pkg/libsignal
|
||||
|
||||
go 1.20
|
1
pkg/libsignal/stub.go
Normal file
1
pkg/libsignal/stub.go
Normal file
|
@ -0,0 +1 @@
|
|||
package libsignal
|
|
@ -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"]
|
|
@ -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
|
|
@ -1,5 +0,0 @@
|
|||
[flake8]
|
||||
max-line-length = 99
|
||||
extend-ignore =
|
||||
# See https://github.com/PyCQA/pycodestyle/issues/373
|
||||
E203,
|
73
setup.py
73
setup.py
|
@ -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"]),
|
||||
],
|
||||
)
|
Loading…
Add table
Reference in a new issue