Remove everything and add stub Go modules

This commit is contained in:
Tulir Asokan 2023-03-22 00:12:44 +02:00
parent 14610ceb54
commit cc5aa59962
74 changed files with 103 additions and 10531 deletions

View file

@ -1,8 +1,6 @@
.editorconfig
logs
.venv
start
config.yaml
registration.yaml
*.db
*.pickle

View file

@ -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
View 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

View file

@ -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
View file

@ -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

View file

@ -1,3 +0,0 @@
include:
- project: 'mautrix/ci'
file: '/python.yml'

View file

@ -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

View file

@ -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"]

View file

@ -1,5 +0,0 @@
include README.md
include CHANGELOG.md
include LICENSE
include requirements.txt
include optional-requirements.txt

View file

@ -1,25 +1,8 @@
# mautrix-signal
![Languages](https://img.shields.io/github/languages/top/mautrix/signal.svg)
[![License](https://img.shields.io/github/license/mautrix/signal.svg)](LICENSE)
[![Release](https://img.shields.io/github/release/mautrix/signal/all.svg)](https://github.com/mautrix/signal/releases)
[![GitLab CI](https://mau.dev/mautrix/signal/badges/master/pipeline.svg)](https://mau.dev/mautrix/signal/container_registry)
[![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Imports](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/)
A Matrix-Signal puppeting bridge.
# mautrix-signalgo
Go rewrite of mautrix-signal.
## Documentation
All setup and usage instructions are located on
[docs.mau.fi](https://docs.mau.fi/bridges/python/signal/index.html).
Some quick links:
* [Bridge setup](https://docs.mau.fi/bridges/python/setup.html?bridge=signal)
(or [with Docker](https://docs.mau.fi/bridges/python/signal/docker-setup.html))
* Basic usage: [Authentication](https://docs.mau.fi/bridges/python/signal/authentication.html)
### Features & Roadmap
[ROADMAP.md](https://github.com/mautrix/signal/blob/master/ROADMAP.md)
contains a general overview of what is supported by the bridge.
This rewrite does not exist yet, so there's no documentation.
## Discussion
Matrix room: [`#signal:maunium.net`](https://matrix.to/#/#signal:maunium.net)

View file

@ -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

View file

@ -1,3 +0,0 @@
pre-commit>=2.10.1,<3
isort>=5.10.1,<6
black>=23,<24

View file

@ -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
View file

@ -0,0 +1,3 @@
module go.mau.fi/mautrix-signal
go 1.20

1
main.go Normal file
View file

@ -0,0 +1 @@
package main

View file

@ -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.

View file

@ -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()
```

View file

@ -1 +0,0 @@
from .signald import SignaldClient

View file

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

View file

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

View file

@ -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

View file

@ -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

View file

@ -1,2 +0,0 @@
__version__ = "0.4.2"
__author__ = "Tulir Asokan <tulir@maunium.net>"

View file

@ -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()

View file

@ -1,3 +0,0 @@
from .auth import SECTION_AUTH
from .conn import SECTION_CONNECTION
from .signal import SECTION_SIGNAL

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

@ -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("*")

View file

@ -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",
]

View file

@ -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)]

View file

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

View file

@ -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]

View file

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

View file

@ -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,
)
)

View file

@ -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,
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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]

View file

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

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

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

View file

@ -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

View file

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

View file

@ -1 +0,0 @@
from .get_version import git_revision, git_tag, linkified_version, version

View file

@ -1 +0,0 @@
from .provisioning_api import ProvisioningAPI

View file

@ -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

View file

@ -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()

View file

@ -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
View file

@ -0,0 +1,3 @@
module go.mau.fi/mautrix-signal/pkg/libsignal
go 1.20

1
pkg/libsignal/stub.go Normal file
View file

@ -0,0 +1 @@
package libsignal

View file

@ -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"]

View file

@ -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

View file

@ -1,5 +0,0 @@
[flake8]
max-line-length = 99
extend-ignore =
# See https://github.com/PyCQA/pycodestyle/issues/373
E203,

View file

@ -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"]),
],
)