1
0
Fork 0
mirror of https://github.com/mautrix/signal.git synced 2026-05-15 13:46:55 -04:00

Compare commits

..

1 commit

Author SHA1 Message Date
Sumner Evans
6892b7a751
staticcheck: remove unused variables and functions
Signed-off-by: Sumner Evans <me@sumnerevans.com>
2024-01-08 09:36:55 -07:00
240 changed files with 20730 additions and 48124 deletions

8
.envrc Normal file
View file

@ -0,0 +1,8 @@
if [[ $(uname -s) == "Linux" && $(uname --kernel-version | grep "NixOS") ]]; then
echo "The best OS (NixOS) has been detected. Using nice tools."
if ! has nix_direnv_version || ! nix_direnv_version 3.0.0; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.0/direnvrc" "sha256-21TMnI2xWX7HkSTjFFri2UaohXVj854mgvWapWrxRXg="
fi
use flake
fi

2
.gitattributes vendored
View file

@ -1,2 +0,0 @@
*.pb.go linguist-generated=true
*.pb.raw binary linguist-generated=true

View file

@ -1,18 +1,7 @@
--- ---
name: Bug report name: Bug report
about: If something is definitely wrong in the bridge (rather than just a setup issue), about: If something is definitely wrong in the bridge (rather than just a setup issue),
file a bug report. Remember to include relevant logs. Asking in the Matrix room first file a bug report. Remember to include relevant logs.
is strongly recommended. labels: bug
type: Bug
--- ---
<!-- Include relevant logs, the bridge version and other important details here -->
### Checklist
<!-- All items below are mandatory. Issues not following the rules may be closed without comment. -->
* [ ] This is an actual bug, not just a setup issue (see the [troubleshooting docs](https://docs.mau.fi/bridges/general/troubleshooting.html) or ask in the Matrix room for setup help).
* [ ] I am certain that sufficient information is included. Ask in the Matrix room first if not.
* [ ] The bug is still present on the main branch. The `!signal version` command output is: ``

View file

@ -1,6 +1,6 @@
--- ---
name: Enhancement request name: Enhancement request
about: Submit a feature request or other suggestion about: Submit a feature request or other suggestion
type: Feature labels: enhancement
--- ---

View file

@ -2,25 +2,16 @@ name: Go
on: [push, pull_request] on: [push, pull_request]
env:
GOTOOLCHAIN: local
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: ["1.25", "1.26"]
name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v5
with: with:
go-version: ${{ matrix.go-version }} go-version: "1.21"
cache: true cache: true
- name: Install libolm - name: Install libolm
@ -33,29 +24,28 @@ jobs:
export PATH="$HOME/go/bin:$PATH" export PATH="$HOME/go/bin:$PATH"
- name: Run pre-commit - name: Run pre-commit
uses: pre-commit/action@v3.0.1 uses: pre-commit/action@v3.0.0
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
go-version: ["1.25", "1.26"] go-version: ["1.20", "1.21"]
name: Test ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v5
with: with:
go-version: ${{ matrix.go-version }} go-version: ${{ matrix.go-version }}
cache: true cache: true
#- name: Set up gotestfmt - name: Set up gotestfmt
# uses: GoTestTools/gotestfmt-action@v2 uses: GoTestTools/gotestfmt-action@v2
# with: with:
# token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Install libolm - name: Install libolm
run: sudo apt-get install libolm-dev run: sudo apt-get install libolm-dev
@ -68,5 +58,4 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
export LIBRARY_PATH=. export LIBRARY_PATH=.
#go test -v -json ./... -cover | gotestfmt go test -v -json ./... -cover | gotestfmt
go test ./...

View file

@ -1,29 +0,0 @@
name: 'Lock old issues'
on:
schedule:
- cron: '0 12 * * *'
workflow_dispatch:
permissions:
issues: write
# pull-requests: write
# discussions: write
concurrency:
group: lock-threads
jobs:
lock-stale:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v6
id: lock
with:
issue-inactive-days: 90
process-only: issues
- name: Log processed threads
run: |
if [ '${{ steps.lock.outputs.issues }}' ]; then
echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"'
fi

3
.gitignore vendored
View file

@ -6,7 +6,6 @@
*.log* *.log*
/mautrix-signal /mautrix-signal
/mautrix-signalgo
/start /start
/libsignal_ffi.a /libsignal_ffi.a
.idea

View file

@ -1,9 +1,10 @@
include: include:
- project: 'mautrix/ci' - project: 'mautrix/ci'
file: '/gov2-as-default.yml' file: '/go.yml'
variables: variables:
BUILDER_IMAGE: dock.mau.dev/tulir/gomuks-build-docker/signal BUILDER_IMAGE: dock.mau.dev/tulir/gomuks-build-docker/signal
BINARY_NAME: mautrix-signal
# 32-bit arm builds aren't supported # 32-bit arm builds aren't supported
build arm: build arm:

View file

@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: v4.5.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
exclude_types: [markdown] exclude_types: [markdown]
@ -9,20 +9,16 @@ repos:
- id: check-added-large-files - id: check-added-large-files
- repo: https://github.com/tekwizely/pre-commit-golang - repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-rc.4 rev: v1.0.0-rc.1
hooks: hooks:
- id: go-imports - id: go-imports
exclude: "pb\\.go$" exclude: "pb\\.go$"
args:
- "-local"
- "go.mau.fi/mautrix-signal"
- "-w"
- id: go-vet-mod - id: go-vet-mod
# - id: go-staticcheck-repo-mod #- id: go-staticcheck-repo-mod
# TODO: reenable this and fix all the problems # TODO: reenable this and fix all the problems
- repo: https://github.com/beeper/pre-commit-go - repo: https://github.com/beeper/pre-commit-go
rev: v0.4.2 rev: v0.3.0
hooks: hooks:
- id: zerolog-ban-msgf - id: zerolog-ban-msgf
- id: zerolog-use-stringer - id: zerolog-use-stringer

View file

@ -1,282 +1,10 @@
# v26.04 # v0.5.0 (unreleased)
* Updated libsignal to v0.92.1
* Added support for admin message deletes from Signal.
* Added support for binary service IDs in storage service.
* Fixed `private_chat_portal_meta` option not setting DM room names correctly.
* Fixed panic if user is logged out during initial chat sync.
* Fixed avatar upload failing when creating new Signal group.
# v26.03
* Switched to sending binary service ID fields in outgoing messages.
* Added support for roundtripping large attachments via disk to avoid keeping
the entire file in memory during en/decryption.
# v26.02.2
* Added support for more new binary service ID fields.
# v26.02.1
* Updated libsignal to v0.87.5.
* Added support for new binary service ID fields that Signal 8.0 switched to.
# v26.02
* Bumped minimum Go version to 1.25.
* Updated libsignal to v0.87.1.
* Added automatic recovery for the session not found error from libsignal.
* Fixed sender key state not being cleared on logout properly.
# v26.01
* Updated libsignal to v0.86.12.
* Changed automatic contact list sync option to only sync every 3 days rather
than on every restart.
* Fixed sending messages to groups with no other registered members.
* Fixed sender key sends failing if some users had changed devices.
* Fixed timestamps of outgoing typing notifications in DMs.
# v25.12
* Updated libsignal to v0.86.8.
* Updated Docker image to Alpine 3.23.
* Added support for dropping incoming DMs from blocked contacts on Signal.
* Added support for sender key encryption when sending to groups, which makes
sending much faster and enables sending typing notifications.
* Added support for encryption retry receipts.
* Fixed bugs with handling poll votes.
* Fixed history transfer option not showing up when pairing with Signal Android.
* Fixed nicknames being cleared not being bridged
(thanks to [@Enzime] in [#623]).
[#623]: https://github.com/mautrix/signal/pull/623
[@Enzime]: https://github.com/Enzime
# v25.11
* Updated libsignal to v0.86.4.
* Added support for bridging invite state in groups for phone number invites.
* Added support for polls.
* Fixed PNI signature not being sent when replying to message requests.
* Fixed unnecessary repeating error notices when Signal is down.
* Fixed sticker size metadata on Matrix not matching how native Signal Desktop
renders them.
# v25.10
* Switched to calendar versioning.
* Updated libsignal to v0.84.0.
* Fixed backfill creating incorrect disappearing timer change notices.
# v0.8.7 (2025-09-16)
* Removed legacy provisioning API and database legacy migration.
Upgrading directly from versions prior to v0.7.0 is not supported.
* If you've been using the bridge since before v0.7.0 and have prevented the
bridge from writing to the config, you must either update the config
manually or allow the bridge to update it for you **before** upgrading to
this release (i.e. run v0.8.6 once with config writing allowed).
* Updated libsignal to v0.80.3.
* Added support for `com.beeper.disappearing_timer` state event, which stores
the disappearing setting of chats and allows changing the setting from Matrix.
* Added support for nicknames in displayname templates.
* Like contact list names, nicknames are not safe to use on multi-user instances.
* Added support for creating Signal groups.
* Fixed certain types of logouts not being detected properly.
# v0.8.6 (2025-08-16)
* Deprecated legacy provisioning API. The `/_matrix/provision/v2` endpoints will
be deleted in the next release.
* Bumped minimum Go version to 1.24.
* Updated libsignal to v0.78.2.
* Added support for "delete to me" of chats and messages.
* Added support for latest Signal backup/transfer protocol.
# v0.8.5 (2025-07-16)
* Updated libsignal to v0.76.1.
# v0.8.4 (2025-06-16)
* Updated libsignal to v0.74.1.
* Updated Docker image to Alpine 3.22.
* Fixed avatars when using direct media.
* Fixed starting chats with non-contact users.
* Fixed Matrix media being rejected if the mime type isn't specified.
# v0.8.3 (2025-05-16)
* Updated libsignal to v0.72.1.
* Added initial support for direct media access.
* Note that media is only kept on the Signal servers for 45 days, after which
any direct media links will permanently stop working.
* Added buffer for decrypted events to prevent losing messages if the bridge is
stopped in the middle of event handling.
* Fixed backfilling messages in existing portals after relogining.
# v0.8.2 (2025-04-16)
* Updated libsignal to v0.70.0.
* Fixed panics in some cases when the bridge was under heavy load.
# v0.8.1 (2025-03-16)
* Added QR refreshing when logging in.
* Updated libsignal to v0.67.4.
# v0.8.0 (2025-02-16)
* Bumped minimum Go version to 1.23.
* Added support for history transfer.
* Updated libsignal to v0.66.2.
# v0.7.5 (2025-01-16)
* Added support for bridging mp4 gifs in both directions.
* Added support for signaling supported features to clients using the
`com.beeper.room_features` state event.
* Updated Signal websocket authentication method.
* Fixed some cases where websocket would get stuck after a ping timeout.
# v0.7.4 (2024-12-16)
* Fixed syncing server-side storage after Signal login.
* Added support for new SSRE2 method of receiving the server-side storage key.
* Updated libsignal to v0.64.1.
* Updated Docker image to Alpine 3.21.
# v0.7.3 (2024-11-16)
* Updated libsignal to v0.62.0.
* Note for bridges running in systemd: the new version of libsignal may be
incompatible with the `MemoryDenyWriteExecute=true` option (see [#750]).
* Added basic support for Signal's new file upload protocol.
[#750]: https://github.com/mautrix/signal/issues/570
# v0.7.2 (2024-10-16)
* Updated to libsignal v0.58.3.
* Fixed spurious decryption error notices for Signal messages when the
websocket reconnects and receives old already-bridged messages.
* Fixed signalmeow not respecting account settings for choosing sender
certificate.
* Fixed bugs in storage service decryption, which could cause issues with
missing contact names among other things.
* Fixed call start notices only working once per direct chat.
# v0.7.1 (2024-09-16)
* Updated to libsignal v0.57.1.
* Dropped support for unauthenticated media on Matrix.
* Added support for Matrix->Signal power level bridging
(thanks to [@maltee1] in [#531]).
* Changed voice message conversion to convert to aac instead of m4a,
because Signal iOS doesn't appear to like ffmpeg's m4a files.
* Fixed outgoing sync messages not including disappearing start timestamp,
which would cause native clients to disappear messages at the wrong time.
* Re-added notices about decryption errors.
[#531]: https://github.com/mautrix/signal/pull/531
# v0.7.0 (2024-08-16)
* Bumped minimum Go version to 1.22.
* Updated to libsignal v0.55.0.
* Rewrote bridge using bridgev2 architecture.
* It is recommended to check the config file after upgrading. If you have
prevented the bridge from writing to the config, you should update it
manually.
* Thanks to [@maltee1] for reimplementing Matrix -> Signal membership
handling in the rewrite.
* If you are still somehow using a pre-v0.5.0 versions, upgrading to v0.6.3
is required before upgrading to v0.7.0 or higher.
# v0.6.3 (2024-07-16)
* Updated to libsignal v0.52.0.
* Fixed bridge losing track of user phone numbers in some cases.
* Fixed edge cases in handling new outgoing DMs started from other devices.
* Added `sync groups` command (thanks to [@maltee1] in [#490]).
* Fixed typo in location bridging example config
(thanks to [@AndrewFerr] in [#516]).
[#490]: https://github.com/mautrix/signal/pull/490
[#516]: https://github.com/mautrix/signal/pull/516
[@AndrewFerr]: https://github.com/mautrix/signal/pull/516
# v0.6.2 (2024-06-16)
* Updated to libsignal v0.51.0.
* Fixed voice messages not being rendered correctly in Element X.
* Fixed contact avatars not being bridged correctly even when enabled in
the bridge config.
* Implemented connector for the upcoming bridgev2 architecture.
# v0.6.1 (2024-05-16)
* Added support for bridging location messages from Matrix to Signal
(thanks to [@maltee1] in [#504]).
* Note that Signal doesn't support real location messages, so they're just
bridged as links. The link template is configurable.
* Fixed bridging long text messages from Signal
(thanks to [@maltee1] in [#506]).
* Improved handling of ping timeouts in Signal websocket.
[#504]: https://github.com/mautrix/signal/pull/504
[#506]: https://github.com/mautrix/signal/pull/506
# v0.6.0 (2024-04-16)
* Updated to libsignal v0.44.0.
* Refactored bridge to support Signal's new phone number identifier (PNI)
system in order to fix starting new chats and receiving messages from new
users.
* When starting a chat with a user you haven't talked to before, the portal
room will not have a ghost user for the recipient until they accept the
message request.
* Added support for syncing existing groups on login instead of having to wait
for new messages.
* Added notices if decrypting incoming message from Signal fails.
* Added bridging of group metadata from Matrix to Signal
(thanks to [@maltee1] in [#461]).
* Added command to create new Signal group for Matrix room
(thanks to [@maltee1] in [#461] and [#491]).
* Added commands for inviting users to Signal groups by phone number
(thanks to [@maltee1] in [#495]).
* Improved handling of missed Signal group metadata changes
(thanks to [@maltee1] in [#488]).
[#461]: https://github.com/mautrix/signal/pull/461
[#488]: https://github.com/mautrix/signal/pull/488
[#491]: https://github.com/mautrix/signal/pull/491
[#495]: https://github.com/mautrix/signal/pull/495
# v0.5.1 (2024-03-16)
* Updated to libsignal v0.41.0.
* Fixed sending messages to groups.
* Fixed some cases of ghost user info changing repeatedly on multi-user
instances.
* Fixed migrating SQLite databases from Python version.
# v0.5.0 (2024-02-16)
* Rewrote bridge in Go. * Rewrote bridge in Go.
* To migrate the bridge, simply upgrade in-place. The database and config * The bridge doesn't use signald anymore.
will be migrated automatically, although some parts of the config aren't * All users will have to re-link the bridge.
migrated (e.g. log config). If you prevented the bridge from writing to * Primary device mode is no longer supported.
the config file, you'll have to temporarily allow it or update it yourself.
* The bridge doesn't use signald anymore, all users will have to re-link the
bridge. signald can be deleted after upgrading.
* Primary device mode is no longer supported, signal-cli is recommended if
you don't want to use the official Signal mobile apps.
* Some old features are not yet supported (e.g. group management features).
* Renamed main branch from `master` to `main`. * Renamed main branch from `master` to `main`.
* Added support for edits and message formatting.
# v0.4.3 (2023-05-17) # v0.4.3 (2023-05-17)

View file

@ -1,17 +1,17 @@
# -- Build libsignal (with Rust) -- # -- Build libsignal (with Rust) --
FROM rust:1-alpine AS rust-builder FROM rust:1-alpine as rust-builder
RUN apk add --no-cache git make cmake protoc musl-dev g++ clang-dev protobuf-dev RUN apk add --no-cache git make cmake protoc musl-dev g++ clang-dev
WORKDIR /build WORKDIR /build
# Copy all files needed for Rust build, and no Go files # Copy all files needed for Rust build, and no Go files
COPY pkg/libsignalgo/libsignal/. pkg/libsignalgo/libsignal/. COPY pkg/libsignalgo/libsignal/. pkg/libsignalgo/libsignal/.
COPY build-rust.sh .
RUN ./build-rust.sh ENV RUSTFLAGS="-Ctarget-feature=-crt-static" RUSTC_WRAPPER=""
RUN cd pkg/libsignalgo/libsignal/ && cargo build -p libsignal-ffi --release
# -- Build mautrix-signal (with Go) -- # -- Build mautrix-signal (with Go) --
FROM golang:1-alpine3.23 AS go-builder FROM golang:1-alpine3.19 AS go-builder
RUN apk add --no-cache git ca-certificates build-base olm-dev zlib-dev RUN apk add --no-cache git ca-certificates build-base olm-dev
WORKDIR /build WORKDIR /build
# Copy all files needed for Go build, and no Rust files # Copy all files needed for Go build, and no Rust files
@ -19,27 +19,25 @@ COPY *.go go.* *.yaml *.sh ./
COPY pkg/signalmeow/. pkg/signalmeow/. COPY pkg/signalmeow/. pkg/signalmeow/.
COPY pkg/libsignalgo/* pkg/libsignalgo/ COPY pkg/libsignalgo/* pkg/libsignalgo/
COPY pkg/libsignalgo/resources/. pkg/libsignalgo/resources/. COPY pkg/libsignalgo/resources/. pkg/libsignalgo/resources/.
COPY pkg/msgconv/. pkg/msgconv/. COPY config/. config/.
COPY pkg/signalid/. pkg/signalid/. COPY database/. database/.
COPY pkg/connector/. pkg/connector/. COPY msgconv/. msgconv/.
COPY cmd/. cmd/.
COPY .git .git COPY .git .git
ENV LIBRARY_PATH=. ENV LIBRARY_PATH=.
COPY --from=rust-builder /build/pkg/libsignalgo/libsignal/target/*/libsignal_ffi.a ./ COPY --from=rust-builder /build/pkg/libsignalgo/libsignal/target/release/libsignal_ffi.a /build/libsignal_ffi.a
RUN <<EOF
EOF
RUN ./build-go.sh RUN ./build-go.sh
# -- Run mautrix-signal -- # -- Run mautrix-signal --
FROM alpine:3.23 FROM alpine:3.19
ENV UID=1337 \ ENV UID=1337 \
GID=1337 GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go olm RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq olm
COPY --from=go-builder /build/mautrix-signal /usr/bin/mautrix-signal COPY --from=go-builder /build/mautrix-signal /usr/bin/mautrix-signal
COPY --from=go-builder /build/example-config.yaml /opt/mautrix-signal/example-config.yaml
COPY --from=go-builder /build/docker-run.sh /docker-run.sh COPY --from=go-builder /build/docker-run.sh /docker-run.sh
VOLUME /data VOLUME /data

View file

@ -1,17 +1,14 @@
ARG DOCKER_HUB="docker.io" FROM alpine:3.19
FROM ${DOCKER_HUB}/alpine:3.23
ENV UID=1337 \ ENV UID=1337 \
GID=1337 GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq
ARG EXECUTABLE=./mautrix-signal ARG EXECUTABLE=./mautrix-signal
COPY $EXECUTABLE /usr/bin/mautrix-signal COPY $EXECUTABLE /usr/bin/mautrix-signal
COPY ./example-config.yaml /opt/mautrix-signal/example-config.yaml
COPY ./docker-run.sh /docker-run.sh COPY ./docker-run.sh /docker-run.sh
ENV BRIDGEV2=1
VOLUME /data VOLUME /data
WORKDIR /data
CMD ["/docker-run.sh"] CMD ["/docker-run.sh"]

View file

@ -1,15 +0,0 @@
The mautrix-signal developers grant the following special exceptions:
* to Beeper the right to embed the program in the Beeper clients and servers,
and use and distribute the collective work without applying the license to
the whole.
* to Element the right to distribute compiled binaries of the program as a part
of the Element Server Suite and other server bundles without applying the
license.
All exceptions are only valid under the condition that any modifications to
the source code of mautrix-signal (including signalmeow and libsignalgo) remain
publicly available under the terms of the GNU AGPL version 3 or later.
Note: mautrix-signal depends on libsignal, which is also licensed under the AGPL.
A license exception for libsignal must be acquired separately from Signal.

32
Makefile Normal file
View file

@ -0,0 +1,32 @@
.PHONY: all build_rust copy_library build_go clean
all: build_rust copy_library build_go
LIBRARY_NAME=libsignal-ffi
LIBRARY_FILENAME=libsignal_ffi.a
RUST_DIR=pkg/libsignalgo/libsignal
GO_BINARY=mautrix-signal
ifneq ($(DBG),1)
RUST_PROFILE=release
RUST_TARGET_SUBDIR=release
GO_GCFLAGS=
else
RUST_PROFILE=dev
RUST_TARGET_SUBDIR=debug
GO_GCFLAGS=all=-N -l
endif
build_rust:
cd $(RUST_DIR) && cargo build -p $(LIBRARY_NAME) --profile=$(RUST_PROFILE)
copy_library:
cp $(RUST_DIR)/target/$(RUST_TARGET_SUBDIR)/$(LIBRARY_FILENAME) .
build_go:
LIBRARY_PATH="$${LIBRARY_PATH}:." go build -gcflags "$(GO_GCFLAGS)" -ldflags "-X main.Tag=$$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'"
clean:
rm -f ./$(LIBRARY_FILENAME)
cd $(RUST_DIR) && cargo clean
rm -f $(GO_BINARY)

View file

@ -15,7 +15,8 @@ Some quick links:
* Basic usage: [Authentication](https://docs.mau.fi/bridges/go/signal/authentication.html) * Basic usage: [Authentication](https://docs.mau.fi/bridges/go/signal/authentication.html)
### Features & Roadmap ### Features & Roadmap
[ROADMAP.md](ROADMAP.md) contains a general overview of what is supported by the bridge. [ROADMAP.md](https://github.com/mautrix/signal/blob/main/ROADMAP.md)
contains a general overview of what is supported by the bridge.
## Discussion ## Discussion
Matrix room: [`#signal:maunium.net`](https://matrix.to/#/#signal:maunium.net) Matrix room: [`#signal:maunium.net`](https://matrix.to/#/#signal:maunium.net)

View file

@ -1,32 +1,30 @@
# Features & roadmap # Features & roadmap
* Matrix → Signal * Matrix → Signal
* [x] Message content * [ ] Message content
* [x] Text * [x] Text
* [x] Formatting * [x] Formatting
* [x] Mentions * [x] Mentions
* [x] Polls * [ ] Media
* [x] Media
* [x] Images * [x] Images
* [x] Audio files * [x] Audio files
* [x] Voice messages * [x] Voice messages
* [x] Files * [x] Files
* [x] Gifs * [x] Gifs
* [x] Locations * [ ] Locations
* [x] Stickers * [x] Stickers
* [x] Message edits * [x] Message edits
* [x] Message reactions * [x] Message reactions
* [x] Message redactions * [x] Message redactions
* [x] Group info changes * [ ] Group info changes
* [x] Name * [ ] Name
* [x] Avatar * [ ] Avatar
* [x] Topic * [ ] Topic
* [ ] Membership actions * [ ] Membership actions
* [ ] Join (accepting invites) * [ ] Join (accepting invites)
* [x] Invite * [ ] Invite
* [x] Leave * [ ] Leave
* [x] Kick/Ban/Unban * [ ] Kick/Ban/Unban
* [x] Group permissions
* [x] Typing notifications * [x] Typing notifications
* [x] Read receipts * [x] Read receipts
* [x] Delivery receipts (sent after message is bridged) * [x] Delivery receipts (sent after message is bridged)
@ -35,14 +33,13 @@
* [x] Text * [x] Text
* [x] Formatting * [x] Formatting
* [x] Mentions * [x] Mentions
* [x] Polls
* [ ] Media * [ ] Media
* [x] Images * [x] Images
* [x] Voice notes * [x] Voice notes
* [x] Files * [x] Files
* [x] Gifs * [x] Gifs
* [x] Stickers * [x] Stickers
* [x] Contacts * [ ] Contacts
* [ ] Payment messages * [ ] Payment messages
* [x] Message edits * [x] Message edits
* [x] Message reactions * [x] Message reactions
@ -55,20 +52,20 @@
* [x] Name * [x] Name
* [x] Avatar * [x] Avatar
* [x] Topic * [x] Topic
* [x] Membership actions * [ ] Membership actions
* [x] Join * [ ] Join
* [x] Invite * [ ] Invite
* [x] Request join (via invite link, requires a client that supports knocks) * [ ] Request join (via invite link, requires a client that supports knocks)
* [x] Leave * [ ] Leave
* [x] Kick/Ban/Unban * [ ] Kick/Ban/Unban
* [x] Group permissions * [ ] Group permissions
* [x] Typing notifications * [x] Typing notifications
* [x] Read receipts * [x] Read receipts
* [ ] Delivery receipts (there's no good way to bridge these) * [ ] Delivery receipts (there's no good way to bridge these)
* [x] Disappearing messages * [x] Disappearing messages
* Misc * Misc
* [x] Automatic portal creation * [ ] Automatic portal creation
* [x] After login * [ ] After login
* [x] When receiving message * [x] When receiving message
* [x] Linking as secondary device * [x] Linking as secondary device
* [ ] Registering as primary device * [ ] Registering as primary device

View file

@ -1,2 +1,4 @@
#!/bin/sh #!/bin/sh
BINARY_NAME=mautrix-signal go tool maubuild "$@" export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
export GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
go build -ldflags "$GO_LDFLAGS" -o mautrix-signal

View file

@ -1,3 +0,0 @@
#!/bin/sh
git submodule update --init
cd pkg/libsignalgo/libsignal && RUSTFLAGS="-Ctarget-feature=-crt-static" RUSTC_WRAPPER="" cargo build -p libsignal-ffi --profile=release

View file

@ -1,5 +1,4 @@
#!/bin/sh #!/bin/sh
set -e git submodule init
./build-rust.sh git submodule update
cp -f pkg/libsignalgo/libsignal/target/release/libsignal_ffi.a . make
LIBRARY_PATH=.:$LIBRARY_PATH ./build-go.sh

View file

@ -1,147 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 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 istributed 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/>.
package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/id"
)
func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request, create bool) {
login := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if login == nil {
return
}
api := login.Client.(bridgev2.IdentifierResolvingNetworkAPI)
phonenum := r.PathValue("phonenum")
resp, err := api.ResolveIdentifier(r.Context(), phonenum, create)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to resolve identifier")
JSONResponse(w, http.StatusInternalServerError, &Error{
Error: fmt.Sprintf("Failed to resolve identifier: %v", err),
ErrCode: "M_UNKNOWN",
})
return
} else if resp == nil {
JSONResponse(w, http.StatusNotFound, &Error{
ErrCode: mautrix.MNotFound.ErrCode,
Error: "User not found on Signal",
})
return
}
status := http.StatusOK
apiResp := &ResolveIdentifierResponse{
ChatID: ResolveIdentifierResponseChatID{
UUID: string(resp.UserID),
Number: phonenum,
},
}
if resp.Ghost != nil {
if resp.UserInfo != nil {
resp.Ghost.UpdateInfo(r.Context(), resp.UserInfo)
}
apiResp.OtherUser = &ResolveIdentifierResponseOtherUser{
MXID: resp.Ghost.Intent.GetMXID(),
DisplayName: resp.Ghost.Name,
AvatarURL: resp.Ghost.AvatarMXC.ParseOrIgnore(),
}
}
if resp.Chat != nil {
if resp.Chat.Portal == nil {
resp.Chat.Portal, err = m.Bridge.GetPortalByKey(r.Context(), resp.Chat.PortalKey)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get portal")
JSONResponse(w, http.StatusInternalServerError, &mautrix.RespError{
Err: "Failed to get portal",
ErrCode: "M_UNKNOWN",
})
return
}
}
if create && resp.Chat.Portal.MXID == "" {
apiResp.JustCreated = true
status = http.StatusCreated
err = resp.Chat.Portal.CreateMatrixRoom(r.Context(), login, resp.Chat.PortalInfo)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to create portal room")
JSONResponse(w, http.StatusInternalServerError, &mautrix.RespError{
Err: "Failed to create portal room",
ErrCode: "M_UNKNOWN",
})
return
}
}
apiResp.RoomID = resp.Chat.Portal.MXID
}
JSONResponse(w, status, &Response{
Success: true,
Status: "ok",
ResolveIdentifierResponse: apiResp,
})
}
func legacyProvResolveIdentifier(w http.ResponseWriter, r *http.Request) {
legacyResolveIdentifierOrStartChat(w, r, false)
}
func legacyProvPM(w http.ResponseWriter, r *http.Request) {
legacyResolveIdentifierOrStartChat(w, r, true)
}
func JSONResponse(w http.ResponseWriter, status int, response any) {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(response)
}
type Error struct {
Success bool `json:"success"`
Error string `json:"error"`
ErrCode string `json:"errcode"`
}
type Response struct {
Success bool `json:"success"`
Status string `json:"status"`
// For response in ResolveIdentifier
*ResolveIdentifierResponse
}
type ResolveIdentifierResponse struct {
RoomID id.RoomID `json:"room_id"`
ChatID ResolveIdentifierResponseChatID `json:"chat_id"`
JustCreated bool `json:"just_created"`
OtherUser *ResolveIdentifierResponseOtherUser `json:"other_user,omitempty"`
}
type ResolveIdentifierResponseChatID struct {
UUID string `json:"uuid"`
Number string `json:"number"`
}
type ResolveIdentifierResponseOtherUser struct {
MXID id.UserID `json:"mxid"`
DisplayName string `json:"displayname"`
AvatarURL id.ContentURI `json:"avatar_url"`
}

View file

@ -1,56 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 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/>.
package main
import (
"fmt"
"maunium.net/go/mautrix/bridgev2/matrix/mxmain"
"go.mau.fi/mautrix-signal/pkg/connector"
"go.mau.fi/mautrix-signal/pkg/signalmeow/web"
)
// Information to find out exactly which commit the bridge was built from.
// These are filled at build time with the -X linker flag.
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
var m = mxmain.BridgeMain{
Name: "mautrix-signal",
URL: "https://github.com/mautrix/signal",
Description: "A Matrix-Signal puppeting bridge.",
Version: "26.04",
SemCalVer: true,
Connector: &connector.SignalConnector{},
}
func main() {
web.UserAgent = fmt.Sprintf("mautrix-signal/%s %s", m.Version, web.BaseUserAgent)
m.PostStart = func() {
if m.Matrix.Provisioning != nil {
m.Matrix.Provisioning.Router.HandleFunc("GET /v2/resolve_identifier/{phonenum}", legacyProvResolveIdentifier)
m.Matrix.Provisioning.Router.HandleFunc("POST /v2/pm/{phonenum}", legacyProvPM)
}
}
m.InitVersion(Tag, Commit, BuildTime)
m.Run()
}

578
commands.go Normal file
View file

@ -0,0 +1,578 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber
//
// 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/>.
package main
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/skip2/go-qrcode"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
)
var (
HelpSectionConnectionManagement = commands.HelpSection{Name: "Connection management", Order: 11}
HelpSectionCreatingPortals = commands.HelpSection{Name: "Creating portals", Order: 15}
HelpSectionPortalManagement = commands.HelpSection{Name: "Portal management", Order: 20}
HelpSectionInvites = commands.HelpSection{Name: "Group invites", Order: 25}
HelpSectionMiscellaneous = commands.HelpSection{Name: "Miscellaneous", Order: 30}
)
type WrappedCommandEvent struct {
*commands.Event
Bridge *SignalBridge
User *User
Portal *Portal
}
func (br *SignalBridge) RegisterCommands() {
proc := br.CommandProcessor.(*commands.Processor)
proc.AddHandlers(
cmdPing,
cmdLogin,
cmdSetDeviceName,
cmdPM,
cmdSyncSpace,
cmdDeleteSession,
cmdSetRelay,
cmdUnsetRelay,
cmdDeletePortal,
cmdDeleteAllPortals,
cmdCleanupLostPortals,
)
}
func wrapCommand(handler func(*WrappedCommandEvent)) func(*commands.Event) {
return func(ce *commands.Event) {
user := ce.User.(*User)
var portal *Portal
if ce.Portal != nil {
portal = ce.Portal.(*Portal)
}
br := ce.Bridge.Child.(*SignalBridge)
handler(&WrappedCommandEvent{ce, br, user, portal})
}
}
var cmdSetRelay = &commands.FullHandler{
Func: wrapCommand(fnSetRelay),
Name: "set-relay",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Relay messages in this room through your Signal account.",
},
RequiresPortal: true,
RequiresLogin: true,
}
func fnSetRelay(ce *WrappedCommandEvent) {
if !ce.Bridge.Config.Bridge.Relay.Enabled {
ce.Reply("Relay mode is not enabled on this instance of the bridge")
} else if ce.Bridge.Config.Bridge.Relay.AdminOnly && !ce.User.Admin {
ce.Reply("Only bridge admins are allowed to enable relay mode on this instance of the bridge")
} else {
ce.Portal.RelayUserID = ce.User.MXID
ce.Portal.Update(ce.Ctx)
ce.Reply("Messages from non-logged-in users in this room will now be bridged through your Signal account")
}
}
var cmdUnsetRelay = &commands.FullHandler{
Func: wrapCommand(fnUnsetRelay),
Name: "unset-relay",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Stop relaying messages in this room.",
},
RequiresPortal: true,
}
func fnUnsetRelay(ce *WrappedCommandEvent) {
if !ce.Bridge.Config.Bridge.Relay.Enabled {
ce.Reply("Relay mode is not enabled on this instance of the bridge")
} else if ce.Bridge.Config.Bridge.Relay.AdminOnly && !ce.User.Admin {
ce.Reply("Only bridge admins are allowed to enable relay mode on this instance of the bridge")
} else {
ce.Portal.RelayUserID = ""
ce.Portal.Update(ce.Ctx)
ce.Reply("Messages from non-logged-in users will no longer be bridged in this room")
}
}
var cmdDeleteSession = &commands.FullHandler{
Func: wrapCommand(fnDeleteSession),
Name: "delete-session",
Help: commands.HelpMeta{
Section: HelpSectionConnectionManagement,
Description: "Disconnect from Signal, clearing sessions but keeping other data. Reconnect with `login`",
},
}
func fnDeleteSession(ce *WrappedCommandEvent) {
if !ce.User.IsLoggedIn() {
ce.Reply("You're not logged in")
return
}
ce.User.Client.ClearKeysAndDisconnect(ce.Ctx)
ce.Reply("Disconnected from Signal")
}
var cmdPing = &commands.FullHandler{
Func: wrapCommand(fnPing),
Name: "ping",
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Check your connection to Signal",
},
}
func fnPing(ce *WrappedCommandEvent) {
if ce.User.SignalID == uuid.Nil {
ce.Reply("You're not logged in")
} else if !ce.User.IsLoggedIn() {
ce.Reply("You were logged in at some point, but are not anymore")
} else if !ce.User.Client.IsConnected() {
ce.Reply("You're logged into Signal, but not connected to the server")
} else {
ce.Reply("You're logged into Signal and probably connected to the server")
}
}
var cmdSetDeviceName = &commands.FullHandler{
Func: wrapCommand(fnSetDeviceName),
Name: "set-device-name",
Help: commands.HelpMeta{
Section: HelpSectionConnectionManagement,
Description: "Set the name of this device in Signal",
Args: "<name>",
},
RequiresLogin: true,
}
func fnSetDeviceName(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 {
ce.Reply("**Usage:** `set-device-name <name>`")
return
}
name := strings.Join(ce.Args, " ")
err := ce.User.Client.UpdateDeviceName(ce.Ctx, name)
if err != nil {
ce.Reply("Error setting device name: %v", err)
return
}
ce.Reply("Device name updated")
}
var cmdPM = &commands.FullHandler{
Func: wrapCommand(fnPM),
Name: "pm",
Help: commands.HelpMeta{
Section: HelpSectionCreatingPortals,
Description: "Open a private chat with the given phone number.",
Args: "<_international phone number_>",
},
RequiresLogin: true,
}
func fnPM(ce *WrappedCommandEvent) {
if len(ce.Args) == 0 {
ce.Reply("**Usage:** `pm <international phone number>`")
return
}
user := ce.User
number := strings.Join(ce.Args, "")
contact, err := user.Client.ContactByE164(ce.Ctx, number)
if err != nil {
ce.Reply("Error looking up number in local contact list: %v", err)
return
}
if contact == nil {
ce.Reply("The bridge does not have the Signal ID for the number %s", number)
return
}
portal := user.GetPortalByChatID(contact.UUID.String())
if portal == nil {
ce.Reply("Error creating portal to %s", number)
ce.Log.Errorln("Error creating portal to", number)
return
}
if portal.MXID != "" {
ce.Reply("You already have a portal to %s at %s", number, portal.MXID)
return
}
if err := portal.CreateMatrixRoom(ce.Ctx, user, 0); err != nil {
ce.Reply("Error creating Matrix room for portal to %s", number)
ce.Log.Errorln("Error creating Matrix room for portal to %s: %s", number, err)
return
}
ce.Reply("Created portal room with and invited you to it.")
}
var cmdSyncSpace = &commands.FullHandler{
Func: wrapCommand(fnSyncSpace),
Name: "sync-space",
Help: commands.HelpMeta{
Section: HelpSectionMiscellaneous,
Description: "Synchronize your personal filtering space",
},
RequiresLogin: true,
}
func fnSyncSpace(ce *WrappedCommandEvent) {
if !ce.Bridge.Config.Bridge.PersonalFilteringSpaces {
ce.Reply("Personal filtering spaces are not enabled on this instance of the bridge")
return
}
ctx := ce.Ctx
dmKeys, err := ce.Bridge.DB.Portal.FindPrivateChatsNotInSpace(ctx, ce.User.SignalID)
if err != nil {
ce.ZLog.Err(err).Msg("Failed to get private chat keys")
ce.Reply("Failed to get private chat IDs from database")
return
}
count := 0
allPortals := ce.Bridge.GetAllPortalsWithMXID()
for _, portal := range allPortals {
if portal.IsPrivateChat() {
continue
}
if ce.Bridge.StateStore.IsInRoom(ctx, portal.MXID, ce.User.MXID) && portal.addToPersonalSpace(ctx, ce.User) {
count++
}
}
for _, key := range dmKeys {
portal := ce.Bridge.GetPortalByChatID(key)
portal.addToPersonalSpace(ctx, ce.User)
count++
}
plural := "s"
if count == 1 {
plural = ""
}
ce.Reply("Added %d room%s to space", count, plural)
}
var cmdLogin = &commands.FullHandler{
Func: wrapCommand(fnLogin),
Name: "login",
Help: commands.HelpMeta{
Section: commands.HelpSectionAuth,
Description: "Link the bridge to your Signal account as a web client.",
},
}
func fnLogin(ce *WrappedCommandEvent) {
//if ce.User.Session != nil {
// if ce.User.IsConnected() {
// ce.Reply("You're already logged in")
// } else {
// ce.Reply("You're already logged in. Perhaps you wanted to `reconnect`?")
// }
// return
//}
var qrEventID, msgEventID id.EventID
var signalID uuid.UUID
var signalPhone string
// First get the provisioning URL
provChan, err := ce.User.Login()
if err != nil {
ce.Log.Errorln("Failure logging in:", err)
ce.Reply("Failure logging in: %v", err)
return
}
resp := <-provChan
if resp.Err != nil || resp.State == signalmeow.StateProvisioningError {
ce.Reply("Error getting provisioning URL: %v", resp.Err)
return
}
if resp.State == signalmeow.StateProvisioningURLReceived {
qrEventID, msgEventID = ce.User.sendQR(ce, resp.ProvisioningURL, qrEventID, msgEventID)
} else {
ce.Reply("Unexpected state: %v", resp.State)
return
}
// Next, get the results of finishing registration
resp = <-provChan
_, _ = ce.Bot.RedactEvent(ce.Ctx, ce.RoomID, qrEventID)
_, _ = ce.Bot.RedactEvent(ce.Ctx, ce.RoomID, msgEventID)
if resp.Err != nil || resp.State == signalmeow.StateProvisioningError {
if resp.Err != nil && strings.HasSuffix(resp.Err.Error(), " EOF") {
ce.Reply("Logging in timed out, please try again.")
} else {
ce.Reply("Error finishing registration: %v", resp.Err)
}
return
}
if resp.State == signalmeow.StateProvisioningDataReceived {
signalID = resp.ProvisioningData.ACI
signalPhone = resp.ProvisioningData.Number
ce.Reply("Successfully logged in!")
ce.Reply("ACI: %v, Phone Number: %v", resp.ProvisioningData.ACI, resp.ProvisioningData.Number)
} else {
ce.Reply("Unexpected state: %v", resp.State)
return
}
// Finally, get the results of generating and registering prekeys
resp = <-provChan
if resp.Err != nil || resp.State == signalmeow.StateProvisioningError {
ce.Reply("Error with prekeys: %v", resp.Err)
return
}
if resp.State == signalmeow.StateProvisioningPreKeysRegistered {
ce.Reply("Successfully generated, registered and stored prekeys! 🎉")
} else {
ce.Reply("Unexpected state: %v", resp.State)
return
}
// Update user with SignalID
if signalID != uuid.Nil {
ce.User.SignalID = signalID
ce.User.SignalUsername = signalPhone
} else {
ce.Reply("Problem logging in - No SignalID received")
return
}
err = ce.User.Update(ce.Ctx)
if err != nil {
ce.ZLog.Err(err).Msg("Failed to save user to database")
}
// Connect to Signal
ce.User.Connect()
}
func (user *User) sendQR(ce *WrappedCommandEvent, code string, prevQR, prevMsg id.EventID) (qr, msg id.EventID) {
content, ok := user.uploadQR(ce, code)
if !ok {
return prevQR, prevMsg
}
if len(prevQR) != 0 {
content.SetEdit(prevQR)
}
resp, err := ce.Bot.SendMessageEvent(ce.Ctx, ce.RoomID, event.EventMessage, &content)
if err != nil {
ce.Log.Errorln("Failed to send QR code to user:", err)
} else if len(prevQR) == 0 {
prevQR = resp.EventID
}
content = event.MessageEventContent{
MsgType: event.MsgNotice,
Body: fmt.Sprintf("Raw linking URI: %s", code),
Format: event.FormatHTML,
FormattedBody: fmt.Sprintf("Raw linking URI: <code>%s</code>", code),
}
if len(prevMsg) != 0 {
content.SetEdit(prevMsg)
}
resp, err = ce.Bot.SendMessageEvent(ce.Ctx, ce.RoomID, event.EventMessage, &content)
if err != nil {
ce.Log.Errorln("Failed to send raw code to user:", err)
} else if len(prevMsg) == 0 {
prevMsg = resp.EventID
}
return prevQR, prevMsg
}
func (user *User) uploadQR(ce *WrappedCommandEvent, code string) (event.MessageEventContent, bool) {
const size = 512
qrCode, err := qrcode.Encode(code, qrcode.Low, size)
if err != nil {
ce.Log.Errorln("Failed to encode QR code:", err)
ce.Reply("Failed to encode QR code: %v", err)
return event.MessageEventContent{}, false
}
bot := user.bridge.AS.BotClient()
resp, err := bot.UploadBytes(ce.Ctx, qrCode, "image/png")
if err != nil {
ce.Log.Errorln("Failed to upload QR code:", err)
ce.Reply("Failed to upload QR code: %v", err)
return event.MessageEventContent{}, false
}
return event.MessageEventContent{
MsgType: event.MsgImage,
Info: &event.FileInfo{
MimeType: "image/png",
Width: size,
Height: size,
Size: len(qrCode),
},
Body: "qr.png",
URL: resp.ContentURI.CUString(),
}, true
}
func canDeletePortal(ctx context.Context, portal *Portal, userID id.UserID) bool {
if len(portal.MXID) == 0 {
return false
}
members, err := portal.MainIntent().JoinedMembers(ctx, portal.MXID)
if err != nil {
portal.log.Err(err).
Str("user_id", userID.String()).
Msg("Failed to get joined members to check if user can delete portal")
return false
}
for otherUser := range members.Joined {
_, isPuppet := portal.bridge.ParsePuppetMXID(otherUser)
if isPuppet || otherUser == portal.bridge.Bot.UserID || otherUser == userID {
continue
}
user := portal.bridge.GetUserByMXID(otherUser)
if user != nil && user.IsLoggedIn() {
return false
}
}
return true
}
var cmdDeletePortal = &commands.FullHandler{
Func: wrapCommand(fnDeletePortal),
Name: "delete-portal",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Delete the current portal. If the portal is used by other people, this is limited to bridge admins.",
},
RequiresPortal: true,
}
func fnDeletePortal(ce *WrappedCommandEvent) {
if !ce.User.Admin && !canDeletePortal(ce.Ctx, ce.Portal, ce.User.MXID) {
ce.Reply("Only bridge admins can delete portals with other Matrix users")
return
}
ce.Portal.log.Info().Stringer("user_id", ce.User.MXID).Msg("User requested deletion of portal")
ce.Portal.Delete()
ce.Portal.Cleanup(ce.Ctx, false)
}
var cmdDeleteAllPortals = &commands.FullHandler{
Func: wrapCommand(fnDeleteAllPortals),
Name: "delete-all-portals",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Delete all portals.",
},
}
func fnDeleteAllPortals(ce *WrappedCommandEvent) {
portals := ce.Bridge.GetAllPortalsWithMXID()
var portalsToDelete []*Portal
if ce.User.Admin {
portalsToDelete = portals
} else {
portalsToDelete = portals[:0]
for _, portal := range portals {
if canDeletePortal(ce.Ctx, portal, ce.User.MXID) {
portalsToDelete = append(portalsToDelete, portal)
}
}
}
if len(portalsToDelete) == 0 {
ce.Reply("Didn't find any portals to delete")
return
}
leave := func(portal *Portal) {
if len(portal.MXID) > 0 {
_, _ = portal.MainIntent().KickUser(ce.Ctx, portal.MXID, &mautrix.ReqKickUser{
Reason: "Deleting portal",
UserID: ce.User.MXID,
})
}
}
customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID)
if customPuppet != nil && customPuppet.CustomIntent() != nil {
intent := customPuppet.CustomIntent()
leave = func(portal *Portal) {
if len(portal.MXID) > 0 {
_, _ = intent.LeaveRoom(ce.Ctx, portal.MXID)
_, _ = intent.ForgetRoom(ce.Ctx, portal.MXID)
}
}
}
ce.Reply("Found %d portals, deleting...", len(portalsToDelete))
for _, portal := range portalsToDelete {
portal.Delete()
leave(portal)
}
ce.Reply("Finished deleting portal info. Now cleaning up rooms in background.")
backgroundCtx := context.TODO()
go func() {
for _, portal := range portalsToDelete {
portal.Cleanup(backgroundCtx, false)
}
ce.Reply("Finished background cleanup of deleted portal rooms.")
}()
}
var cmdCleanupLostPortals = &commands.FullHandler{
Func: wrapCommand(fnCleanupLostPortals),
Name: "cleanup-lost-portals",
Help: commands.HelpMeta{
Section: HelpSectionPortalManagement,
Description: "Clean up portals that were discarded due to the receiver not being logged into the bridge",
},
RequiresAdmin: true,
}
func fnCleanupLostPortals(ce *WrappedCommandEvent) {
portals, err := ce.Bridge.DB.LostPortal.GetAll(ce.Ctx)
if err != nil {
ce.Reply("Failed to get portals: %v", err)
return
} else if len(portals) == 0 {
ce.Reply("No lost portals found")
return
}
ce.Reply("Found %d lost portals, deleting...", len(portals))
for _, portal := range portals {
dmUUID, err := uuid.Parse(portal.ChatID)
intent := ce.Bot
if err == nil {
intent = ce.Bridge.GetPuppetBySignalID(dmUUID).DefaultIntent()
}
ce.Bridge.CleanupRoom(ce.Ctx, ce.ZLog, intent, portal.MXID, false)
err = portal.Delete(ce.Ctx)
if err != nil {
ce.ZLog.Err(err).Msg("Failed to delete lost portal from database after cleanup")
}
}
ce.Reply("Finished cleaning up portals")
}

233
config/bridge.go Normal file
View file

@ -0,0 +1,233 @@
// 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/>.
package config
import (
"errors"
"fmt"
"strings"
"text/template"
"time"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
type BridgeConfig struct {
UsernameTemplate string `yaml:"username_template"`
DisplaynameTemplate string `yaml:"displayname_template"`
PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"`
UseContactAvatars bool `yaml:"use_contact_avatars"`
NumberInTopic bool `yaml:"number_in_topic"`
NoteToSelfAvatar id.ContentURIString `yaml:"note_to_self_avatar"`
PortalMessageBuffer int `yaml:"portal_message_buffer"`
PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"`
BridgeNotices bool `yaml:"bridge_notices"`
DeliveryReceipts bool `yaml:"delivery_receipts"`
MessageStatusEvents bool `yaml:"message_status_events"`
MessageErrorNotices bool `yaml:"message_error_notices"`
SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
ResendBridgeInfo bool `yaml:"resend_bridge_info"`
CaptionInMessage bool `yaml:"caption_in_message"`
FederateRooms bool `yaml:"federate_rooms"`
DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"`
MessageHandlingTimeout struct {
ErrorAfterStr string `yaml:"error_after"`
DeadlineStr string `yaml:"deadline"`
ErrorAfter time.Duration `yaml:"-"`
Deadline time.Duration `yaml:"-"`
} `yaml:"message_handling_timeout"`
CommandPrefix string `yaml:"command_prefix"`
ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"`
Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"`
Provisioning struct {
Prefix string `yaml:"prefix"`
SharedSecret string `yaml:"shared_secret"`
DebugEndpoints bool `yaml:"debug_endpoints"`
} `yaml:"provisioning"`
Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`
Relay RelaybotConfig `yaml:"relay"`
usernameTemplate *template.Template `yaml:"-"`
displaynameTemplate *template.Template `yaml:"-"`
}
func (bc *BridgeConfig) GetResendBridgeInfo() bool {
return bc.ResendBridgeInfo
}
func (bc *BridgeConfig) EnableMessageStatusEvents() bool {
return bc.MessageStatusEvents
}
func (bc *BridgeConfig) EnableMessageErrorNotices() bool {
return bc.MessageErrorNotices
}
func boolToInt(val bool) int {
if val {
return 1
}
return 0
}
func (bc *BridgeConfig) Validate() error {
_, hasWildcard := bc.Permissions["*"]
_, hasExampleDomain := bc.Permissions["example.com"]
_, hasExampleUser := bc.Permissions["@admin:example.com"]
exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain)
if len(bc.Permissions) <= exampleLen {
return errors.New("bridge.permissions not configured")
}
return nil
}
type umBridgeConfig BridgeConfig
func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
err := unmarshal((*umBridgeConfig)(bc))
if err != nil {
return err
}
bc.usernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate)
if err != nil {
return err
} else if !strings.Contains(bc.FormatUsername("1234567890"), "1234567890") {
return fmt.Errorf("username template is missing user ID placeholder")
}
bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate)
if err != nil {
return err
}
return nil
}
var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil)
func (bc BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig {
return bc.DoublePuppetConfig
}
func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
return bc.Encryption
}
func (bc BridgeConfig) GetCommandPrefix() string {
return bc.CommandPrefix
}
func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts {
return bc.ManagementRoomText
}
func (bc BridgeConfig) FormatUsername(userID string) string {
var buffer strings.Builder
_ = bc.usernameTemplate.Execute(&buffer, userID)
return buffer.String()
}
type DisplaynameParams struct {
ProfileName string
ContactName string
Username string
PhoneNumber string
UUID string
AboutEmoji string
}
func (bc BridgeConfig) FormatDisplayname(contact *types.Contact) string {
var buffer strings.Builder
_ = bc.displaynameTemplate.Execute(&buffer, DisplaynameParams{
ProfileName: contact.ProfileName,
ContactName: contact.ContactName,
//Username: contact.Username,
PhoneNumber: contact.E164,
UUID: contact.UUID.String(),
AboutEmoji: contact.ProfileAboutEmoji,
})
return buffer.String()
}
type RelaybotConfig struct {
Enabled bool `yaml:"enabled"`
AdminOnly bool `yaml:"admin_only"`
MessageFormats map[event.MessageType]string `yaml:"message_formats"`
messageTemplates *template.Template `yaml:"-"`
}
type umRelaybotConfig RelaybotConfig
func (rc *RelaybotConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
err := unmarshal((*umRelaybotConfig)(rc))
if err != nil {
return err
}
rc.messageTemplates = template.New("messageTemplates")
for key, format := range rc.MessageFormats {
_, err := rc.messageTemplates.New(string(key)).Parse(format)
if err != nil {
return err
}
}
return nil
}
type Sender struct {
UserID string
event.MemberEventContent
}
type formatData struct {
Sender Sender
Message string
Content *event.MessageEventContent
}
func (rc *RelaybotConfig) FormatMessage(content *event.MessageEventContent, sender id.UserID, member event.MemberEventContent) (string, error) {
if len(member.Displayname) == 0 {
member.Displayname = sender.String()
}
member.Displayname = template.HTMLEscapeString(member.Displayname)
var output strings.Builder
err := rc.messageTemplates.ExecuteTemplate(&output, string(content.MsgType), formatData{
Sender: Sender{
UserID: template.HTMLEscapeString(sender.String()),
MemberEventContent: member,
},
Content: content,
Message: content.FormattedBody,
})
return output.String(), err
}

View file

@ -1,5 +1,5 @@
// mautrix-signal - A Matrix-Signal puppeting bridge. // mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 Tulir Asokan // Copyright (C) 2022 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU Affero General Public License as published by
@ -14,28 +14,31 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector package config
import ( import (
"maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/pkg/signalid"
) )
func (s *SignalConnector) GetDBMetaTypes() database.MetaTypes { type Config struct {
return database.MetaTypes{ *bridgeconfig.BaseConfig `yaml:",inline"`
Portal: func() any {
return &signalid.PortalMetadata{} Metrics struct {
}, Enabled bool `yaml:"enabled"`
Ghost: func() any { Listen string `yaml:"listen"`
return &signalid.GhostMetadata{} } `yaml:"metrics"`
},
Message: func() any { Signal struct {
return &signalid.MessageMetadata{} DeviceName string `yaml:"device_name"`
}, } `yaml:"signal"`
Reaction: nil,
UserLogin: func() any { Bridge BridgeConfig `yaml:"bridge"`
return &signalid.UserLoginMetadata{} }
},
} func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
_, homeserver, _ := userID.Parse()
_, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver]
return hasSecret
} }

163
config/upgrade.go Normal file
View file

@ -0,0 +1,163 @@
// 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/>.
package config
import (
"net/url"
"strings"
up "go.mau.fi/util/configupgrade"
"go.mau.fi/util/random"
"maunium.net/go/mautrix/bridge/bridgeconfig"
)
func DoUpgrade(helper *up.Helper) {
bridgeconfig.Upgrader.DoUpgrade(helper)
legacyDB, ok := helper.Get(up.Str, "appservice", "database")
if ok {
if strings.HasPrefix(legacyDB, "postgres") {
parsedDB, err := url.Parse(legacyDB)
if err != nil {
panic(err)
}
q := parsedDB.Query()
if parsedDB.Host == "" && !q.Has("host") {
q.Set("host", "/var/run/postgresql")
} else if !q.Has("sslmode") {
q.Set("sslmode", "disable")
}
parsedDB.RawQuery = q.Encode()
helper.Set(up.Str, parsedDB.String(), "appservice", "database", "uri")
helper.Set(up.Str, "postgres", "appservice", "database", "type")
} else {
dbPath := strings.TrimPrefix(strings.TrimPrefix(legacyDB, "sqlite:"), "///")
helper.Set(up.Str, dbPath, "appservice", "database", "uri")
helper.Set(up.Str, "sqlite3-fk-wal", "appservice", "database", "type")
}
}
if legacyDBMinSize, ok := helper.Get(up.Int, "appservice", "database_opts", "min_size"); ok {
helper.Set(up.Int, legacyDBMinSize, "appservice", "database", "max_idle_conns")
}
if legacyDBMaxSize, ok := helper.Get(up.Int, "appservice", "database_opts", "max_size"); ok {
helper.Set(up.Int, legacyDBMaxSize, "appservice", "database", "max_open_conns")
}
if legacyBotUsername, ok := helper.Get(up.Str, "appservice", "bot_username"); ok {
helper.Set(up.Str, legacyBotUsername, "appservice", "bot", "username")
}
if legacyBotDisplayname, ok := helper.Get(up.Str, "appservice", "bot_displayname"); ok {
helper.Set(up.Str, legacyBotDisplayname, "appservice", "bot", "displayname")
}
if legacyBotAvatar, ok := helper.Get(up.Str, "appservice", "bot_avatar"); ok {
helper.Set(up.Str, legacyBotAvatar, "appservice", "bot", "avatar")
}
helper.Copy(up.Bool, "metrics", "enabled")
helper.Copy(up.Str, "metrics", "listen")
helper.Copy(up.Str, "signal", "device_name")
if usernameTemplate, ok := helper.Get(up.Str, "bridge", "username_template"); ok && strings.Contains(usernameTemplate, "{userid}") {
helper.Set(up.Str, strings.ReplaceAll(usernameTemplate, "{userid}", "{{.}}"), "bridge", "username_template")
} else {
helper.Copy(up.Str, "bridge", "username_template")
}
if displaynameTemplate, ok := helper.Get(up.Str, "bridge", "displayname_template"); ok && strings.Contains(displaynameTemplate, "{displayname}") {
helper.Set(up.Str, strings.ReplaceAll(displaynameTemplate, "{displayname}", `{{or .ProfileName .PhoneNumber "Unknown user"}}`), "bridge", "displayname_template")
} else {
helper.Copy(up.Str, "bridge", "displayname_template")
}
helper.Copy(up.Str, "bridge", "private_chat_portal_meta")
helper.Copy(up.Bool, "bridge", "use_contact_avatars")
helper.Copy(up.Bool, "bridge", "number_in_topic")
helper.Copy(up.Str, "bridge", "note_to_self_avatar")
helper.Copy(up.Int, "bridge", "portal_message_buffer")
helper.Copy(up.Bool, "bridge", "personal_filtering_spaces")
helper.Copy(up.Bool, "bridge", "bridge_notices")
helper.Copy(up.Bool, "bridge", "delivery_receipts")
helper.Copy(up.Bool, "bridge", "message_status_events")
helper.Copy(up.Bool, "bridge", "message_error_notices")
helper.Copy(up.Bool, "bridge", "sync_direct_chat_list")
helper.Copy(up.Bool, "bridge", "resend_bridge_info")
helper.Copy(up.Bool, "bridge", "caption_in_message")
helper.Copy(up.Bool, "bridge", "federate_rooms")
helper.Copy(up.Map, "bridge", "double_puppet_server_map")
helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery")
helper.Copy(up.Map, "bridge", "login_shared_secret_map")
helper.Copy(up.Str, "bridge", "command_prefix")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_unconnected")
helper.Copy(up.Str|up.Null, "bridge", "management_room_text", "additional_help")
helper.Copy(up.Bool, "bridge", "encryption", "allow")
helper.Copy(up.Bool, "bridge", "encryption", "default")
helper.Copy(up.Bool, "bridge", "encryption", "require")
helper.Copy(up.Bool, "bridge", "encryption", "appservice")
helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "dont_store_outbound")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "ratchet_on_decrypt")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_prev_on_new_session")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_on_device_delete")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "periodically_delete_expired")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outdated_inbound")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "receive")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share")
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom")
helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds")
helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages")
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation")
helper.Copy(up.Str, "bridge", "provisioning", "prefix")
if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
sharedSecret := random.String(64)
helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret")
} else {
helper.Copy(up.Str, "bridge", "provisioning", "shared_secret")
}
helper.Copy(up.Bool, "bridge", "provisioning", "debug_endpoints")
helper.Copy(up.Map, "bridge", "permissions")
helper.Copy(up.Bool, "bridge", "relay", "enabled")
helper.Copy(up.Bool, "bridge", "relay", "admin_only")
if textRelayFormat, ok := helper.Get(up.Str, "bridge", "relay", "message_formats", "m.text"); ok && strings.Contains(textRelayFormat, "$message") && !strings.Contains(textRelayFormat, ".Message") {
// don't copy legacy message formats
} else {
helper.Copy(up.Map, "bridge", "relay", "message_formats")
}
}
var SpacedBlocks = [][]string{
{"homeserver", "software"},
{"appservice"},
{"appservice", "hostname"},
{"appservice", "database"},
{"appservice", "id"},
{"appservice", "as_token"},
{"metrics"},
{"signal"},
{"bridge"},
{"bridge", "personal_filtering_spaces"},
{"bridge", "command_prefix"},
{"bridge", "management_room_text"},
{"bridge", "encryption"},
{"bridge", "provisioning"},
{"bridge", "permissions"},
{"logging"},
}

97
custompuppet.go Normal file
View file

@ -0,0 +1,97 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 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/>.
package main
import (
"context"
"fmt"
"maunium.net/go/mautrix/id"
)
func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
puppet.CustomMXID = mxid
puppet.AccessToken = accessToken
err := puppet.Update(context.TODO())
if err != nil {
return fmt.Errorf("failed to save access token: %w", err)
}
err = puppet.StartCustomMXID(false)
if err != nil {
return err
}
// TODO leave rooms with default puppet
return nil
}
func (puppet *Puppet) ClearCustomMXID() {
save := puppet.CustomMXID != "" || puppet.AccessToken != ""
puppet.bridge.puppetsLock.Lock()
if puppet.CustomMXID != "" && puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] == puppet {
delete(puppet.bridge.puppetsByCustomMXID, puppet.CustomMXID)
}
puppet.bridge.puppetsLock.Unlock()
puppet.CustomMXID = ""
puppet.AccessToken = ""
puppet.customIntent = nil
puppet.customUser = nil
if save {
err := puppet.Update(context.TODO())
if err != nil {
puppet.log.Err(err).Msg("Failed to clear custom MXID")
}
}
}
func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error {
newIntent, newAccessToken, err := puppet.bridge.DoublePuppet.Setup(context.TODO(), puppet.CustomMXID, puppet.AccessToken, reloginOnFail)
if err != nil {
puppet.ClearCustomMXID()
return err
}
puppet.bridge.puppetsLock.Lock()
puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
puppet.bridge.puppetsLock.Unlock()
if puppet.AccessToken != newAccessToken {
puppet.AccessToken = newAccessToken
err = puppet.Update(context.TODO())
}
puppet.customIntent = newIntent
puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID)
return err
}
func (user *User) tryAutomaticDoublePuppeting() {
if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) {
return
}
user.log.Debug().Msg("Checking if double puppeting needs to be enabled")
puppet := user.bridge.GetPuppetBySignalID(user.SignalID)
if len(puppet.CustomMXID) > 0 {
user.log.Debug().Msg("User already has double-puppeting enabled")
// Custom puppet already enabled
return
}
puppet.CustomMXID = user.MXID
err := puppet.StartCustomMXID(true)
if err != nil {
user.log.Warn().Err(err).Msg("Failed to login with shared secret")
} else {
// TODO leave rooms with default puppet
user.log.Debug().Msg("Successfully automatically enabled custom puppet")
}
}

53
database/database.go Normal file
View file

@ -0,0 +1,53 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, 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/>.
package database
import (
_ "embed"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"go.mau.fi/util/dbutil"
"go.mau.fi/mautrix-signal/database/upgrades"
)
type Database struct {
*dbutil.Database
User *UserQuery
Portal *PortalQuery
LostPortal *LostPortalQuery
Puppet *PuppetQuery
Message *MessageQuery
Reaction *ReactionQuery
DisappearingMessage *DisappearingMessageQuery
}
func New(db *dbutil.Database) *Database {
db.UpgradeTable = upgrades.Table
return &Database{
Database: db,
User: &UserQuery{dbutil.MakeQueryHelper(db, newUser)},
Portal: &PortalQuery{dbutil.MakeQueryHelper(db, newPortal)},
LostPortal: &LostPortalQuery{dbutil.MakeQueryHelper(db, newLostPortal)},
Puppet: &PuppetQuery{dbutil.MakeQueryHelper(db, newPuppet)},
Message: &MessageQuery{dbutil.MakeQueryHelper(db, newMessage)},
Reaction: &ReactionQuery{dbutil.MakeQueryHelper(db, newReaction)},
DisappearingMessage: &DisappearingMessageQuery{dbutil.MakeQueryHelper(db, newDisappearingMessage)},
}
}

View file

@ -0,0 +1,125 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, 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/>.
package database
import (
"context"
"database/sql"
"time"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getUnscheduledDisappearingMessagesForRoomQuery = `
SELECT room_id, mxid, expiration_seconds, expiration_ts
FROM disappearing_message WHERE expiration_ts IS NULL AND room_id = $1
`
getExpiredDisappearingMessagesQuery = `
SELECT room_id, mxid, expiration_seconds, expiration_ts
FROM disappearing_message WHERE expiration_ts IS NOT NULL AND expiration_ts <= $1
`
getNextDisappearingMessageQuery = `
SELECT room_id, mxid, expiration_seconds, expiration_ts
FROM disappearing_message WHERE expiration_ts IS NOT NULL ORDER BY expiration_ts ASC LIMIT 1
`
insertDisappearingMessageQuery = `
INSERT INTO disappearing_message (room_id, mxid, expiration_seconds, expiration_ts) VALUES ($1, $2, $3, $4)
`
updateDisappearingMessageQuery = `
UPDATE disappearing_message SET expiration_ts=$2 WHERE mxid=$1
`
deleteDisappearingMessageQuery = `
DELETE FROM disappearing_message WHERE mxid=$1
`
)
type DisappearingMessageQuery struct {
*dbutil.QueryHelper[*DisappearingMessage]
}
type DisappearingMessage struct {
qh *dbutil.QueryHelper[*DisappearingMessage]
RoomID id.RoomID
EventID id.EventID
ExpireIn time.Duration
ExpireAt time.Time
}
func newDisappearingMessage(qh *dbutil.QueryHelper[*DisappearingMessage]) *DisappearingMessage {
return &DisappearingMessage{qh: qh}
}
func (dmq *DisappearingMessageQuery) NewWithValues(roomID id.RoomID, eventID id.EventID, expireIn time.Duration, expireAt time.Time) *DisappearingMessage {
return &DisappearingMessage{
qh: dmq.QueryHelper,
RoomID: roomID,
EventID: eventID,
ExpireIn: expireIn,
ExpireAt: expireAt,
}
}
func (dmq *DisappearingMessageQuery) GetUnscheduledForRoom(ctx context.Context, roomID id.RoomID) ([]*DisappearingMessage, error) {
return dmq.QueryMany(ctx, getUnscheduledDisappearingMessagesForRoomQuery, roomID)
}
func (dmq *DisappearingMessageQuery) GetExpiredMessages(ctx context.Context) ([]*DisappearingMessage, error) {
return dmq.QueryMany(ctx, getExpiredDisappearingMessagesQuery, time.Now().Unix()+1)
}
func (dmq *DisappearingMessageQuery) GetNextScheduledMessage(ctx context.Context) (*DisappearingMessage, error) {
return dmq.QueryOne(ctx, getNextDisappearingMessageQuery)
}
func (msg *DisappearingMessage) Scan(row dbutil.Scannable) (*DisappearingMessage, error) {
var expireIn int64
var expireAt sql.NullInt64
err := row.Scan(&msg.RoomID, &msg.EventID, &expireIn, &expireAt)
if err != nil {
return nil, err
}
msg.ExpireIn = time.Duration(expireIn) * time.Second
if expireAt.Valid {
msg.ExpireAt = time.Unix(expireAt.Int64, 0)
}
return msg, nil
}
func (msg *DisappearingMessage) sqlVariables() []any {
var expireAt sql.NullInt64
if !msg.ExpireAt.IsZero() {
expireAt.Valid = true
expireAt.Int64 = msg.ExpireAt.Unix()
}
return []any{msg.RoomID, msg.EventID, int64(msg.ExpireIn.Seconds()), expireAt}
}
func (msg *DisappearingMessage) Insert(ctx context.Context) error {
return msg.qh.Exec(ctx, insertDisappearingMessageQuery, msg.sqlVariables()...)
}
func (msg *DisappearingMessage) StartExpirationTimer(ctx context.Context) error {
msg.ExpireAt = time.Now().Add(msg.ExpireIn)
return msg.qh.Exec(ctx, updateDisappearingMessageQuery, msg.EventID, msg.ExpireAt.Unix())
}
func (msg *DisappearingMessage) Delete(ctx context.Context) error {
return msg.qh.Exec(ctx, deleteDisappearingMessageQuery, msg.EventID)
}

58
database/lostportal.go Normal file
View file

@ -0,0 +1,58 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 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/>.
package database
import (
"context"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getLostPortalsQuery = `SELECT chat_id, receiver, mxid FROM lost_portals`
deleteLostPortalQuery = `DELETE FROM lost_portals WHERE mxid=$1`
)
type LostPortalQuery struct {
*dbutil.QueryHelper[*LostPortal]
}
func (lpq *LostPortalQuery) GetAll(ctx context.Context) ([]*LostPortal, error) {
return lpq.QueryMany(ctx, getLostPortalsQuery)
}
type LostPortal struct {
qh *dbutil.QueryHelper[*LostPortal]
ChatID string
Receiver string
MXID id.RoomID
}
func newLostPortal(qh *dbutil.QueryHelper[*LostPortal]) *LostPortal {
return &LostPortal{qh: qh}
}
func (l *LostPortal) Scan(row dbutil.Scannable) (*LostPortal, error) {
err := row.Scan(&l.ChatID, &l.Receiver, &l.MXID)
return l, err
}
func (l *LostPortal) Delete(ctx context.Context) error {
return l.qh.Exec(ctx, deleteLostPortalQuery, l.MXID)
}

179
database/message.go Normal file
View file

@ -0,0 +1,179 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, 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/>.
package database
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/lib/pq"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getMessageByMXIDQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE mxid=$1
`
getMessagePartBySignalIDQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE sender=$1 AND timestamp=$2 AND part_index=$3 AND signal_receiver=$4
`
getLastMessagePartBySignalIDQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE sender=$1 AND timestamp=$2 AND signal_receiver=$3
ORDER BY part_index DESC LIMIT 1
`
getAllMessagePartsBySignalIDQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE sender=$1 AND timestamp=$2 AND signal_receiver=$3
`
getMessageLastPartBySignalIDWithUnknownReceiverQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE sender=$1 AND timestamp=$2 AND (signal_receiver=$3 OR signal_receiver='00000000-0000-0000-0000-000000000000')
ORDER BY part_index DESC LIMIT 1
`
getManyMessagesBySignalIDQueryPostgres = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE sender=$1 AND (signal_receiver=$2 OR signal_receiver=$3) AND timestamp=ANY($4)
ORDER BY timestamp DESC, part_index DESC
`
getManyMessagesBySignalIDQuerySQLite = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE sender=?1 AND (signal_receiver=?2 OR signal_receiver=?3) AND timestamp IN (?4)
ORDER BY timestamp DESC, part_index DESC
`
getFirstBeforeQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE mx_room=$1 AND timestamp <= $2
ORDER BY timestamp DESC
LIMIT 1
`
getMessagesBetweenTimeQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE signal_chat_id=$1 AND signal_receiver=$2 AND timestamp>$3 AND timestamp<=$4 AND part_index=0
ORDER BY timestamp ASC
`
insertMessageQuery = `
INSERT INTO message (sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
deleteMessageQuery = `
DELETE FROM message
WHERE sender=$1 AND timestamp=$2 AND part_index=$3 AND signal_receiver=$4
`
updateMessageTimestampQuery = `
UPDATE message SET timestamp=$4 WHERE sender=$1 AND timestamp=$2 AND signal_receiver=$3
`
)
type MessageQuery struct {
*dbutil.QueryHelper[*Message]
}
type Message struct {
qh *dbutil.QueryHelper[*Message]
Sender uuid.UUID
Timestamp uint64
PartIndex int
SignalChatID string
SignalReceiver uuid.UUID
MXID id.EventID
RoomID id.RoomID
}
func newMessage(qh *dbutil.QueryHelper[*Message]) *Message {
return &Message{qh: qh}
}
func (mq *MessageQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Message, error) {
return mq.QueryOne(ctx, getMessageByMXIDQuery, mxid)
}
func (mq *MessageQuery) GetBySignalID(ctx context.Context, sender uuid.UUID, timestamp uint64, partIndex int, receiver uuid.UUID) (*Message, error) {
return mq.QueryOne(ctx, getMessagePartBySignalIDQuery, sender, timestamp, partIndex, receiver)
}
func (mq *MessageQuery) GetLastPartBySignalID(ctx context.Context, sender uuid.UUID, timestamp uint64, receiver uuid.UUID) (*Message, error) {
return mq.QueryOne(ctx, getLastMessagePartBySignalIDQuery, sender, timestamp, receiver)
}
func (mq *MessageQuery) GetAllPartsBySignalID(ctx context.Context, sender uuid.UUID, timestamp uint64, receiver uuid.UUID) ([]*Message, error) {
return mq.QueryMany(ctx, getAllMessagePartsBySignalIDQuery, sender, timestamp, receiver)
}
func (mq *MessageQuery) GetAllBetweenTimestamps(ctx context.Context, key PortalKey, min, max uint64) ([]*Message, error) {
return mq.QueryMany(ctx, getMessagesBetweenTimeQuery, key.ChatID, key.Receiver, int64(min), int64(max))
}
func (mq *MessageQuery) GetLastPartBySignalIDWithUnknownReceiver(ctx context.Context, sender uuid.UUID, timestamp uint64, receiver uuid.UUID) (*Message, error) {
return mq.QueryOne(ctx, getMessageLastPartBySignalIDWithUnknownReceiverQuery, sender, timestamp, receiver)
}
func (mq *MessageQuery) GetManyBySignalID(ctx context.Context, sender uuid.UUID, timestamps []uint64, receiver uuid.UUID, strictReceiver bool) ([]*Message, error) {
receiver2 := uuid.Nil
if strictReceiver {
receiver2 = receiver
}
if mq.GetDB().Dialect == dbutil.Postgres {
int64Array := make([]int64, len(timestamps))
for i, timestamp := range timestamps {
int64Array[i] = int64(timestamp)
}
return mq.QueryMany(ctx, getManyMessagesBySignalIDQueryPostgres, sender, receiver, receiver2, pq.Array(int64Array))
} else {
const varargIndex = 3
arguments := make([]any, len(timestamps)+varargIndex)
placeholders := make([]string, len(timestamps))
arguments[0] = sender
arguments[1] = receiver
arguments[2] = receiver2
for i, timestamp := range timestamps {
arguments[i+varargIndex] = timestamp
placeholders[i] = fmt.Sprintf("?%d", i+varargIndex+1)
}
return mq.QueryMany(ctx, strings.Replace(getManyMessagesBySignalIDQuerySQLite, fmt.Sprintf("?%d", varargIndex+1), strings.Join(placeholders, ", "), 1), arguments...)
}
}
func (msg *Message) Scan(row dbutil.Scannable) (*Message, error) {
return dbutil.ValueOrErr(msg, row.Scan(
&msg.Sender, &msg.Timestamp, &msg.PartIndex, &msg.SignalChatID, &msg.SignalReceiver, &msg.MXID, &msg.RoomID,
))
}
func (msg *Message) sqlVariables() []any {
return []any{msg.Sender, msg.Timestamp, msg.PartIndex, msg.SignalChatID, msg.SignalReceiver, msg.MXID, msg.RoomID}
}
func (msg *Message) Insert(ctx context.Context) error {
return msg.qh.Exec(ctx, insertMessageQuery, msg.sqlVariables()...)
}
func (msg *Message) Delete(ctx context.Context) error {
return msg.qh.Exec(ctx, deleteMessageQuery, msg.Sender, msg.Timestamp, msg.PartIndex, msg.SignalReceiver)
}
func (msg *Message) SetTimestamp(ctx context.Context, editTime uint64) error {
return msg.qh.Exec(ctx, updateMessageTimestampQuery, msg.Sender, msg.Timestamp, msg.SignalReceiver, editTime)
}

200
database/portal.go Normal file
View file

@ -0,0 +1,200 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, 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/>.
package database
import (
"context"
"database/sql"
"github.com/google/uuid"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
const (
portalBaseSelect = `
SELECT chat_id, receiver, mxid, name, topic, avatar_path, avatar_hash, avatar_url,
name_set, avatar_set, topic_set, revision, encrypted, relay_user_id, expiration_time
FROM portal
`
getPortalByMXIDQuery = portalBaseSelect + `WHERE mxid=$1`
getPortalByChatIDQuery = portalBaseSelect + `WHERE chat_id=$1 AND receiver=$2`
getPortalsByReceiver = portalBaseSelect + `WHERE receiver=$1`
getPortalsByUser = portalBaseSelect + `WHERE chat_id=$1`
getAllPortalsWithMXIDQuery = portalBaseSelect + `WHERE mxid IS NOT NULL`
getChatsNotInSpaceQuery = `
SELECT chat_id FROM portal
LEFT JOIN user_portal ON portal.chat_id=user_portal.portal_chat_id AND portal.receiver=user_portal.portal_receiver
WHERE mxid<>'' AND receiver=$1 AND (user_portal.in_space=false OR user_portal.in_space IS NULL)
`
insertPortalQuery = `
INSERT INTO portal (
chat_id, receiver, mxid, name, topic, avatar_path, avatar_hash, avatar_url,
name_set, avatar_set, topic_set, revision, encrypted, relay_user_id, expiration_time
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
`
updatePortalQuery = `
UPDATE portal SET
mxid=$3, name=$4, topic=$5, avatar_path=$6, avatar_hash=$7, avatar_url=$8,
name_set=$9, avatar_set=$10, topic_set=$11, revision=$12, encrypted=$13, relay_user_id=$14, expiration_time=$15
WHERE chat_id=$1 AND receiver=$2
`
deletePortalQuery = `DELETE FROM portal WHERE chat_id=$1 AND receiver=$2`
)
type PortalQuery struct {
*dbutil.QueryHelper[*Portal]
}
type PortalKey struct {
ChatID string
Receiver uuid.UUID
}
func (pk *PortalKey) UserID() uuid.UUID {
parsed, _ := uuid.Parse(pk.ChatID)
return parsed
}
func (pk *PortalKey) GroupID() types.GroupIdentifier {
if len(pk.ChatID) == 44 {
return types.GroupIdentifier(pk.ChatID)
}
return ""
}
func NewPortalKey(chatID string, receiver uuid.UUID) PortalKey {
return PortalKey{
ChatID: chatID,
Receiver: receiver,
}
}
type Portal struct {
qh *dbutil.QueryHelper[*Portal]
PortalKey
MXID id.RoomID
Name string
Topic string
AvatarPath string
AvatarHash string
AvatarURL id.ContentURI
NameSet bool
AvatarSet bool
TopicSet bool
Revision uint32
Encrypted bool
RelayUserID id.UserID
ExpirationTime uint32
}
func newPortal(qh *dbutil.QueryHelper[*Portal]) *Portal {
return &Portal{qh: qh}
}
func (pq *PortalQuery) GetByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) {
return pq.QueryOne(ctx, getPortalByMXIDQuery, mxid)
}
func (pq *PortalQuery) GetByChatID(ctx context.Context, pk PortalKey) (*Portal, error) {
return pq.QueryOne(ctx, getPortalByChatIDQuery, pk.ChatID, pk.Receiver)
}
func (pq *PortalQuery) FindPrivateChatsWith(ctx context.Context, userID uuid.UUID) ([]*Portal, error) {
return pq.QueryMany(ctx, getPortalsByUser, userID.String())
}
func (pq *PortalQuery) FindPrivateChatsOf(ctx context.Context, receiver uuid.UUID) ([]*Portal, error) {
return pq.QueryMany(ctx, getPortalsByReceiver, receiver)
}
func (pq *PortalQuery) GetAllWithMXID(ctx context.Context) ([]*Portal, error) {
return pq.QueryMany(ctx, getAllPortalsWithMXIDQuery)
}
func (pq *PortalQuery) FindPrivateChatsNotInSpace(ctx context.Context, receiver uuid.UUID) ([]PortalKey, error) {
rows, err := pq.GetDB().Query(ctx, getChatsNotInSpaceQuery, receiver)
if err != nil {
return nil, err
}
return dbutil.NewRowIter(rows, func(rows dbutil.Scannable) (key PortalKey, err error) {
err = rows.Scan(&key.ChatID)
key.Receiver = receiver
return
}).AsList()
}
func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) {
var mxid sql.NullString
err := row.Scan(
&p.ChatID,
&p.Receiver,
&mxid,
&p.Name,
&p.Topic,
&p.AvatarPath,
&p.AvatarHash,
&p.AvatarURL,
&p.NameSet,
&p.AvatarSet,
&p.TopicSet,
&p.Revision,
&p.Encrypted,
&p.RelayUserID,
&p.ExpirationTime,
)
if err != nil {
return nil, err
}
p.MXID = id.RoomID(mxid.String)
return p, nil
}
func (p *Portal) sqlVariables() []any {
return []any{
p.ChatID,
p.Receiver,
dbutil.StrPtr(p.MXID),
p.Name,
p.Topic,
p.AvatarPath,
p.AvatarHash,
&p.AvatarURL,
p.NameSet,
p.AvatarSet,
p.TopicSet,
p.Revision,
p.Encrypted,
p.RelayUserID,
p.ExpirationTime,
}
}
func (p *Portal) Insert(ctx context.Context) error {
return p.qh.Exec(ctx, insertPortalQuery, p.sqlVariables()...)
}
func (p *Portal) Update(ctx context.Context) error {
return p.qh.Exec(ctx, updatePortalQuery, p.sqlVariables()...)
}
func (p *Portal) Delete(ctx context.Context) error {
return p.qh.Exec(ctx, deletePortalQuery, p.ChatID, p.Receiver)
}

150
database/puppet.go Normal file
View file

@ -0,0 +1,150 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, 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/>.
package database
import (
"context"
"database/sql"
"github.com/google/uuid"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
puppetBaseSelect = `
SELECT uuid, number, name, name_quality, avatar_path, avatar_hash, avatar_url, name_set, avatar_set,
contact_info_set, is_registered, custom_mxid, access_token
FROM puppet
`
getPuppetBySignalIDQuery = puppetBaseSelect + `WHERE uuid=$1`
getPuppetByNumberQuery = puppetBaseSelect + `WHERE number=$1`
getPuppetByCustomMXIDQuery = puppetBaseSelect + `WHERE custom_mxid=$1`
getPuppetsWithCustomMXID = puppetBaseSelect + `WHERE custom_mxid<>''`
updatePuppetQuery = `
UPDATE puppet SET
number=$2, name=$3, name_quality=$4, avatar_path=$5, avatar_hash=$6, avatar_url=$7,
name_set=$8, avatar_set=$9, contact_info_set=$10, is_registered=$11,
custom_mxid=$12, access_token=$13
WHERE uuid=$1
`
insertPuppetQuery = `
INSERT INTO puppet (
uuid, number, name, name_quality, avatar_path, avatar_hash, avatar_url,
name_set, avatar_set, contact_info_set, is_registered,
custom_mxid, access_token
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
)
`
)
type PuppetQuery struct {
*dbutil.QueryHelper[*Puppet]
}
type Puppet struct {
qh *dbutil.QueryHelper[*Puppet]
SignalID uuid.UUID
Number string
Name string
NameQuality int
AvatarPath string
AvatarHash string
AvatarURL id.ContentURI
NameSet bool
AvatarSet bool
IsRegistered bool
CustomMXID id.UserID
AccessToken string
ContactInfoSet bool
}
func newPuppet(qh *dbutil.QueryHelper[*Puppet]) *Puppet {
return &Puppet{qh: qh}
}
func (pq *PuppetQuery) GetBySignalID(ctx context.Context, signalID uuid.UUID) (*Puppet, error) {
return pq.QueryOne(ctx, getPuppetBySignalIDQuery, signalID)
}
func (pq *PuppetQuery) GetByNumber(ctx context.Context, number string) (*Puppet, error) {
return pq.QueryOne(ctx, getPuppetByNumberQuery, number)
}
func (pq *PuppetQuery) GetByCustomMXID(ctx context.Context, mxid id.UserID) (*Puppet, error) {
return pq.QueryOne(ctx, getPuppetByCustomMXIDQuery, mxid)
}
func (pq *PuppetQuery) GetAllWithCustomMXID(ctx context.Context) ([]*Puppet, error) {
return pq.QueryMany(ctx, getPuppetsWithCustomMXID)
}
func (p *Puppet) Scan(row dbutil.Scannable) (*Puppet, error) {
var number, customMXID sql.NullString
err := row.Scan(
&p.SignalID,
&number,
&p.Name,
&p.NameQuality,
&p.AvatarPath,
&p.AvatarHash,
&p.AvatarURL,
&p.NameSet,
&p.AvatarSet,
&p.ContactInfoSet,
&p.IsRegistered,
&customMXID,
&p.AccessToken,
)
if err != nil {
return nil, nil
}
p.Number = number.String
p.CustomMXID = id.UserID(customMXID.String)
return p, nil
}
func (p *Puppet) sqlVariables() []any {
return []any{
p.SignalID,
dbutil.StrPtr(p.Number),
p.Name,
p.NameQuality,
p.AvatarPath,
p.AvatarHash,
&p.AvatarURL,
p.NameSet,
p.AvatarSet,
p.ContactInfoSet,
p.IsRegistered,
dbutil.StrPtr(p.CustomMXID),
p.AccessToken,
}
}
func (p *Puppet) Insert(ctx context.Context) error {
return p.qh.Exec(ctx, insertPuppetQuery, p.sqlVariables()...)
}
func (p *Puppet) Update(ctx context.Context) error {
return p.qh.Exec(ctx, updatePuppetQuery, p.sqlVariables()...)
}

97
database/reaction.go Normal file
View file

@ -0,0 +1,97 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, 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/>.
package database
import (
"context"
"github.com/google/uuid"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getReactionByMXIDQuery = `SELECT msg_author, msg_timestamp, author, emoji, signal_chat_id, signal_receiver, mxid, mx_room FROM reaction WHERE mxid=$1`
getReactionBySignalIDQuery = `SELECT msg_author, msg_timestamp, author, emoji, signal_chat_id, signal_receiver, mxid, mx_room FROM reaction WHERE msg_author=$1 AND msg_timestamp=$2 AND author=$3 AND signal_receiver=$4`
insertReactionQuery = `
INSERT INTO reaction (msg_author, msg_timestamp, author, emoji, signal_chat_id, signal_receiver, mxid, mx_room)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
updateReactionQuery = `
UPDATE reaction
SET mxid=$1, emoji=$2
WHERE msg_author=$3 AND msg_timestamp=$4 AND author=$5 AND signal_receiver=$6
`
deleteReactionQuery = `
DELETE FROM reaction WHERE msg_author=$1 AND msg_timestamp=$2 AND author=$3 AND signal_receiver=$4
`
)
type ReactionQuery struct {
*dbutil.QueryHelper[*Reaction]
}
func newReaction(qh *dbutil.QueryHelper[*Reaction]) *Reaction {
return &Reaction{qh: qh}
}
type Reaction struct {
qh *dbutil.QueryHelper[*Reaction]
MsgAuthor uuid.UUID
MsgTimestamp uint64
Author uuid.UUID
Emoji string
SignalChatID string
SignalReceiver uuid.UUID
MXID id.EventID
RoomID id.RoomID
}
func (rq *ReactionQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Reaction, error) {
return rq.QueryOne(ctx, getReactionByMXIDQuery, mxid)
}
func (rq *ReactionQuery) GetBySignalID(ctx context.Context, msgAuthor uuid.UUID, msgTimestamp uint64, author, signalReceiver uuid.UUID) (*Reaction, error) {
return rq.QueryOne(ctx, getReactionBySignalIDQuery, msgAuthor, msgTimestamp, author, signalReceiver)
}
func (r *Reaction) Scan(row dbutil.Scannable) (*Reaction, error) {
return dbutil.ValueOrErr(r, row.Scan(
&r.MsgAuthor, &r.MsgTimestamp, &r.Author, &r.Emoji, &r.SignalChatID, &r.SignalReceiver, &r.MXID, &r.RoomID,
))
}
func (r *Reaction) sqlVariables() []any {
return []any{
r.MsgAuthor, r.MsgTimestamp, r.Author, r.Emoji, r.SignalChatID, r.SignalReceiver, r.MXID, r.RoomID,
}
}
func (r *Reaction) Insert(ctx context.Context) error {
return r.qh.Exec(ctx, insertReactionQuery, r.sqlVariables()...)
}
func (r *Reaction) Update(ctx context.Context) error {
return r.qh.Exec(ctx, updateReactionQuery, r.MXID, r.Emoji, r.MsgAuthor, r.MsgTimestamp, r.Author, r.SignalReceiver)
}
func (r *Reaction) Delete(ctx context.Context) error {
return r.qh.Exec(ctx, deleteReactionQuery, r.MsgAuthor, r.MsgTimestamp, r.Author, r.SignalReceiver)
}

View file

@ -0,0 +1,115 @@
-- v0 -> v19 (compatible with v17+): Latest revision
CREATE TABLE portal (
chat_id TEXT NOT NULL,
receiver uuid NOT NULL,
mxid TEXT,
name TEXT NOT NULL,
topic TEXT NOT NULL,
encrypted BOOLEAN NOT NULL DEFAULT false,
avatar_path TEXT NOT NULL DEFAULT '',
avatar_hash TEXT NOT NULL,
avatar_url TEXT NOT NULL,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar_set BOOLEAN NOT NULL DEFAULT false,
topic_set BOOLEAN NOT NULL DEFAULT false,
revision INTEGER NOT NULL DEFAULT 0,
expiration_time BIGINT NOT NULL,
relay_user_id TEXT NOT NULL,
PRIMARY KEY (chat_id, receiver),
CONSTRAINT portal_mxid_unique UNIQUE(mxid)
);
CREATE TABLE puppet (
uuid uuid PRIMARY KEY,
number TEXT UNIQUE,
name TEXT NOT NULL,
name_quality INTEGER NOT NULL,
avatar_path TEXT NOT NULL,
avatar_hash TEXT NOT NULL,
avatar_url TEXT NOT NULL,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar_set BOOLEAN NOT NULL DEFAULT false,
is_registered BOOLEAN NOT NULL DEFAULT false,
contact_info_set BOOLEAN NOT NULL DEFAULT false,
custom_mxid TEXT,
access_token TEXT NOT NULL,
CONSTRAINT puppet_custom_mxid_unique UNIQUE(custom_mxid)
);
CREATE TABLE "user" (
mxid TEXT PRIMARY KEY,
uuid uuid,
phone TEXT,
management_room TEXT,
space_room TEXT,
CONSTRAINT user_uuid_unique UNIQUE(uuid)
);
CREATE TABLE user_portal (
user_mxid TEXT,
portal_chat_id TEXT,
portal_receiver uuid,
last_read_ts BIGINT NOT NULL DEFAULT 0,
in_space BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (user_mxid, portal_chat_id, portal_receiver),
CONSTRAINT user_portal_user_fkey FOREIGN KEY (user_mxid)
REFERENCES "user"(mxid) ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT user_portal_portal_fkey FOREIGN KEY (portal_chat_id, portal_receiver)
REFERENCES portal(chat_id, receiver) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE message (
sender uuid NOT NULL,
timestamp BIGINT NOT NULL,
part_index INTEGER NOT NULL,
signal_chat_id TEXT NOT NULL,
signal_receiver uuid NOT NULL,
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
PRIMARY KEY (sender, timestamp, part_index, signal_receiver),
CONSTRAINT message_portal_fkey FOREIGN KEY (signal_chat_id, signal_receiver)
REFERENCES portal(chat_id, receiver) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (sender) REFERENCES puppet(uuid) ON DELETE CASCADE,
CONSTRAINT message_mxid_unique UNIQUE (mxid)
);
CREATE TABLE reaction (
msg_author uuid NOT NULL,
msg_timestamp BIGINT NOT NULL,
-- part_index is not used in reactions, but is required for the foreign key.
_part_index INTEGER NOT NULL DEFAULT 0,
author uuid NOT NULL,
emoji TEXT NOT NULL,
signal_chat_id TEXT NOT NULL,
signal_receiver uuid NOT NULL,
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
PRIMARY KEY (msg_author, msg_timestamp, author, signal_receiver),
CONSTRAINT reaction_message_fkey FOREIGN KEY (msg_author, msg_timestamp, _part_index, signal_receiver)
REFERENCES message (sender, timestamp, part_index, signal_receiver) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (author) REFERENCES puppet(uuid) ON DELETE CASCADE,
CONSTRAINT reaction_mxid_unique UNIQUE (mxid)
);
CREATE TABLE disappearing_message (
mxid TEXT NOT NULL PRIMARY KEY,
room_id TEXT NOT NULL,
expiration_seconds BIGINT NOT NULL,
expiration_ts BIGINT
);

View file

@ -0,0 +1,18 @@
-- v13: Switch mx_room_state from Python to Go format
ALTER TABLE mx_room_state DROP COLUMN is_encrypted;
ALTER TABLE mx_room_state DROP COLUMN has_full_member_list;
-- only: postgres for next 2 lines
ALTER TABLE mx_room_state ALTER COLUMN power_levels TYPE jsonb USING power_levels::jsonb;
ALTER TABLE mx_room_state ALTER COLUMN encryption TYPE jsonb USING encryption::jsonb;
ALTER TABLE "user" ADD COLUMN management_room TEXT;
UPDATE mx_user_profile SET displayname='' WHERE displayname IS NULL;
UPDATE mx_user_profile SET avatar_url='' WHERE avatar_url IS NULL;
CREATE TABLE mx_registrations (
user_id TEXT PRIMARY KEY
);
UPDATE mx_version SET version=5;

View file

@ -0,0 +1,3 @@
-- v14: Remove redundant notice_room column from users
UPDATE "user" SET management_room = COALESCE(management_room, notice_room);
ALTER TABLE "user" DROP COLUMN notice_room;

View file

@ -0,0 +1,3 @@
-- v15: Remove unused columns in puppet table
ALTER TABLE puppet DROP COLUMN next_batch;
ALTER TABLE puppet DROP COLUMN base_url;

View file

@ -0,0 +1,113 @@
-- v16: Refactor types (Postgres)
-- only: postgres
DROP TABLE IF EXISTS user_portal;
-- Drop constraints so we can fix timestamps.
ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
ALTER TABLE message DROP CONSTRAINT message_pkey;
-- Add part index to message and fix the hacky timestamps
ALTER TABLE message ADD COLUMN part_index INTEGER;
UPDATE message
SET timestamp=CASE WHEN timestamp > 1500000000000000 THEN timestamp / 1000 ELSE timestamp END,
part_index=CASE WHEN timestamp > 1500000000000000 THEN timestamp % 1000 ELSE 0 END;
-- If the bridge users have reacted to message parts, forget about those, not worth trying to deal with potential conflicts.
DELETE FROM reaction WHERE msg_timestamp > 1500000000000000;
ALTER TABLE message ALTER COLUMN part_index SET NOT NULL;
ALTER TABLE reaction ADD COLUMN _part_index INTEGER NOT NULL DEFAULT 0;
-- Re-add the dropped constraints (but with part index and no chat)
ALTER TABLE message ADD PRIMARY KEY (sender, timestamp, part_index, signal_receiver);
ALTER TABLE message DROP CONSTRAINT IF EXISTS message_signal_chat_id_signal_receiver_fkey;
ALTER TABLE message DROP CONSTRAINT IF EXISTS message_signal_chat_id_fkey;
ALTER TABLE message ADD CONSTRAINT message_portal_fkey
FOREIGN KEY (signal_chat_id, signal_receiver)
REFERENCES portal (chat_id, receiver)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey FOREIGN KEY (msg_author, msg_timestamp, _part_index, signal_receiver)
REFERENCES message (sender, timestamp, part_index, signal_receiver) ON DELETE CASCADE ON UPDATE CASCADE;
-- Also update the reaction primary key
ALTER TABLE reaction DROP CONSTRAINT reaction_pkey;
ALTER TABLE reaction ADD PRIMARY KEY (author, msg_author, msg_timestamp, signal_receiver);
-- Change unique constraint from (mxid, mx_room) to just mxid.
ALTER TABLE message DROP CONSTRAINT message_mxid_mx_room_key;
ALTER TABLE message ADD CONSTRAINT message_mxid_unique UNIQUE (mxid);
ALTER TABLE reaction DROP CONSTRAINT reaction_mxid_mx_room_key;
ALTER TABLE reaction ADD CONSTRAINT reaction_mxid_unique UNIQUE (mxid);
CREATE TABLE lost_portals (
mxid TEXT PRIMARY KEY,
chat_id TEXT,
receiver TEXT
);
INSERT INTO lost_portals SELECT mxid, chat_id, receiver FROM portal WHERE mxid<>'';
-- Make mxid column unique (requires using nulls for missing values)
UPDATE portal SET mxid=NULL WHERE mxid='';
ALTER TABLE portal ADD CONSTRAINT portal_mxid_unique UNIQUE(mxid);
-- Delete any portals that aren't associated with logged-in users.
DELETE FROM portal WHERE receiver<>'' AND receiver NOT IN (SELECT username FROM "user" WHERE uuid IS NOT NULL);
-- Change receiver to uuid instead of phone number, also add nil uuid for groups.
UPDATE portal SET receiver=(SELECT uuid FROM "user" WHERE username=receiver LIMIT 1) WHERE receiver<>'';
UPDATE portal SET receiver='00000000-0000-0000-0000-000000000000' WHERE receiver='';
-- Drop the foreign keys again to allow changing types (the ON UPDATE CASCADEs are needed for the above step)
ALTER TABLE message DROP CONSTRAINT message_portal_fkey;
ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
ALTER TABLE portal ALTER COLUMN receiver TYPE uuid USING receiver::uuid;
ALTER TABLE message ALTER COLUMN signal_receiver TYPE uuid USING signal_receiver::uuid;
ALTER TABLE reaction ALTER COLUMN signal_receiver TYPE uuid USING signal_receiver::uuid;
-- Re-add the dropped constraints again
ALTER TABLE message ADD CONSTRAINT message_portal_fkey
FOREIGN KEY (signal_chat_id, signal_receiver)
REFERENCES portal (chat_id, receiver)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey FOREIGN KEY (msg_author, msg_timestamp, _part_index, signal_receiver)
REFERENCES message (sender, timestamp, part_index, signal_receiver) ON DELETE CASCADE ON UPDATE CASCADE;
-- Delete group v1 portal entries
DELETE FROM portal WHERE chat_id NOT LIKE '________-____-____-____-____________' AND LENGTH(chat_id) <> 44;
DELETE FROM lost_portals WHERE mxid IN (SELECT mxid FROM portal WHERE mxid<>'');
-- Remove unnecessary nullables in portal
UPDATE portal SET name='' WHERE name IS NULL;
UPDATE portal SET topic='' WHERE topic IS NULL;
UPDATE portal SET avatar_hash='' WHERE avatar_hash IS NULL;
UPDATE portal SET avatar_url='' WHERE avatar_url IS NULL;
UPDATE portal SET expiration_time=0 WHERE expiration_time IS NULL;
UPDATE portal SET relay_user_id='' WHERE relay_user_id IS NULL;
ALTER TABLE portal ALTER COLUMN name SET NOT NULL;
ALTER TABLE portal ALTER COLUMN topic SET NOT NULL;
ALTER TABLE portal ALTER COLUMN avatar_hash SET NOT NULL;
ALTER TABLE portal ALTER COLUMN avatar_url SET NOT NULL;
ALTER TABLE portal ALTER COLUMN expiration_time SET NOT NULL;
ALTER TABLE portal ALTER COLUMN relay_user_id SET NOT NULL;
-- Add unique constraint to custom_mxid
UPDATE puppet
SET custom_mxid=NULL, access_token=''
WHERE custom_mxid<>''
AND uuid<>COALESCE((SELECT uuid FROM "user" WHERE mxid=custom_mxid), '00000000-0000-0000-0000-000000000000');
UPDATE puppet SET custom_mxid=NULL WHERE custom_mxid='';
ALTER TABLE puppet ADD CONSTRAINT puppet_custom_mxid_unique UNIQUE(custom_mxid);
-- Remove unnecessary nullables in puppet
UPDATE puppet SET name='' WHERE name IS NULL;
UPDATE puppet SET avatar_hash='' WHERE avatar_hash IS NULL;
UPDATE puppet SET avatar_url='' WHERE avatar_url IS NULL;
UPDATE puppet SET access_token='' WHERE access_token IS NULL;
ALTER TABLE puppet ALTER COLUMN name SET NOT NULL;
ALTER TABLE puppet ALTER COLUMN avatar_hash SET NOT NULL;
ALTER TABLE puppet ALTER COLUMN avatar_url SET NOT NULL;
ALTER TABLE puppet ALTER COLUMN access_token SET NOT NULL;
ALTER TABLE puppet ALTER COLUMN name_quality DROP DEFAULT;
ALTER TABLE "user" ADD CONSTRAINT user_uuid_unique UNIQUE(uuid);
ALTER TABLE "user" RENAME COLUMN username TO phone;
-- Drop room_id from disappearing message primary key
ALTER TABLE disappearing_message DROP CONSTRAINT disappearing_message_pkey;
ALTER TABLE disappearing_message ADD PRIMARY KEY (mxid);
-- Remove unnecessary nullables in disappearing_message
ALTER TABLE disappearing_message ALTER COLUMN room_id SET NOT NULL;
UPDATE disappearing_message SET expiration_seconds=0 WHERE expiration_seconds IS NULL;
ALTER TABLE disappearing_message ALTER COLUMN expiration_seconds SET NOT NULL;

View file

@ -0,0 +1,194 @@
-- v17: Refactor types (SQLite)
-- transaction: off
-- only: sqlite
-- This is separate from v16 so that postgres can run with transaction: on
-- (split upgrades by dialect don't currently allow disabling transaction in only one dialect)
DROP TABLE IF EXISTS user_portal;
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE message_new (
sender uuid NOT NULL,
timestamp BIGINT NOT NULL,
part_index INTEGER NOT NULL,
signal_chat_id TEXT NOT NULL,
signal_receiver TEXT NOT NULL,
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
PRIMARY KEY (sender, timestamp, part_index, signal_receiver),
CONSTRAINT message_portal_fkey FOREIGN KEY (signal_chat_id, signal_receiver) REFERENCES portal(chat_id, receiver) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (sender) REFERENCES puppet(uuid) ON DELETE CASCADE,
CONSTRAINT message_mxid_unique UNIQUE (mxid)
);
CREATE TABLE reaction_new (
msg_author uuid NOT NULL,
msg_timestamp BIGINT NOT NULL,
-- part_index is not used in reactions, but is required for the foreign key.
_part_index INTEGER NOT NULL DEFAULT 0,
author uuid NOT NULL,
emoji TEXT NOT NULL,
signal_chat_id TEXT NOT NULL,
signal_receiver TEXT NOT NULL,
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
PRIMARY KEY (msg_author, msg_timestamp, author, signal_receiver),
CONSTRAINT reaction_message_fkey FOREIGN KEY (msg_author, msg_timestamp, _part_index, signal_receiver)
REFERENCES message (sender, timestamp, part_index, signal_receiver) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (author) REFERENCES puppet(uuid) ON DELETE CASCADE,
CONSTRAINT reaction_mxid_unique UNIQUE (mxid)
);
INSERT INTO message_new
SELECT sender,
CASE WHEN timestamp > 1500000000000000 THEN timestamp / 1000 ELSE timestamp END,
CASE WHEN timestamp > 1500000000000000 THEN timestamp % 1000 ELSE 0 END,
COALESCE(signal_chat_id, ''),
COALESCE(signal_receiver, ''),
mxid,
mx_room
FROM message;
INSERT INTO reaction_new
SELECT msg_author,
msg_timestamp,
0, -- _part_index
author,
emoji,
COALESCE(signal_chat_id, ''),
COALESCE(signal_receiver, ''),
mxid,
mx_room
FROM reaction
WHERE msg_timestamp<1500000000000000;
DROP TABLE message;
DROP TABLE reaction;
ALTER TABLE message_new RENAME TO message;
ALTER TABLE reaction_new RENAME TO reaction;
PRAGMA foreign_key_check;
COMMIT;
PRAGMA foreign_keys = ON;
BEGIN;
CREATE TABLE lost_portals (
mxid TEXT PRIMARY KEY,
chat_id TEXT,
receiver TEXT
);
INSERT INTO lost_portals SELECT mxid, chat_id, receiver FROM portal WHERE mxid<>'';
DELETE FROM portal WHERE receiver<>'' AND receiver NOT IN (SELECT username FROM "user" WHERE uuid<>'');
UPDATE portal SET receiver=(SELECT uuid FROM "user" WHERE username=receiver LIMIT 1) WHERE receiver<>'';
UPDATE portal SET receiver='00000000-0000-0000-0000-000000000000' WHERE receiver='';
DELETE FROM portal WHERE chat_id NOT LIKE '________-____-____-____-____________' AND LENGTH(chat_id) <> 44;
DELETE FROM lost_portals WHERE mxid IN (SELECT mxid FROM portal WHERE mxid<>'');
COMMIT;
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE portal_new (
chat_id TEXT NOT NULL,
receiver uuid NOT NULL,
mxid TEXT,
name TEXT NOT NULL,
topic TEXT NOT NULL,
encrypted BOOLEAN NOT NULL DEFAULT false,
avatar_hash TEXT NOT NULL,
avatar_url TEXT NOT NULL,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar_set BOOLEAN NOT NULL DEFAULT false,
revision INTEGER NOT NULL DEFAULT 0,
expiration_time BIGINT NOT NULL,
relay_user_id TEXT NOT NULL,
PRIMARY KEY (chat_id, receiver),
CONSTRAINT portal_mxid_unique UNIQUE(mxid)
);
INSERT INTO portal_new
SELECT chat_id, receiver, CASE WHEN mxid='' THEN NULL ELSE mxid END,
COALESCE(name, ''), COALESCE(topic, ''), encrypted, COALESCE(avatar_hash, ''), COALESCE(avatar_url, ''),
name_set, avatar_set, revision, COALESCE(expiration_time, 0), COALESCE(relay_user_id, '')
FROM portal;
DROP TABLE portal;
ALTER TABLE portal_new RENAME TO portal;
CREATE TABLE puppet_new (
uuid uuid PRIMARY KEY,
number TEXT UNIQUE,
name TEXT NOT NULL,
name_quality INTEGER NOT NULL,
avatar_hash TEXT NOT NULL,
avatar_url TEXT NOT NULL,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar_set BOOLEAN NOT NULL DEFAULT false,
is_registered BOOLEAN NOT NULL DEFAULT false,
contact_info_set BOOLEAN NOT NULL DEFAULT false,
custom_mxid TEXT,
access_token TEXT NOT NULL,
CONSTRAINT puppet_custom_mxid_unique UNIQUE(custom_mxid)
);
UPDATE puppet
SET custom_mxid=NULL, access_token=''
WHERE custom_mxid<>''
AND uuid<>COALESCE((SELECT uuid FROM "user" WHERE mxid=custom_mxid), '00000000-0000-0000-0000-000000000000');
INSERT INTO puppet_new
SELECT uuid, number, COALESCE(name, ''), COALESCE(name_quality, 0), COALESCE(avatar_hash, ''),
COALESCE(avatar_url, ''), name_set, avatar_set, is_registered, contact_info_set,
CASE WHEN custom_mxid='' THEN NULL ELSE custom_mxid END, COALESCE(access_token, '')
FROM puppet;
DROP TABLE puppet;
ALTER TABLE puppet_new RENAME TO puppet;
CREATE TABLE user_new (
mxid TEXT PRIMARY KEY,
uuid uuid,
phone TEXT,
management_room TEXT,
CONSTRAINT user_uuid_unique UNIQUE(uuid)
);
INSERT INTO user_new
SELECT mxid, uuid, username, management_room
FROM user;
DROP TABLE user;
ALTER TABLE user_new RENAME TO user;
CREATE TABLE disappearing_message_new (
mxid TEXT NOT NULL PRIMARY KEY,
room_id TEXT NOT NULL,
expiration_seconds BIGINT NOT NULL,
expiration_ts BIGINT
);
INSERT INTO disappearing_message_new
SELECT mxid, room_id, COALESCE(expiration_seconds, 0), expiration_ts
FROM disappearing_message;
DROP TABLE disappearing_message;
ALTER TABLE disappearing_message_new RENAME TO disappearing_message;
PRAGMA foreign_key_check;
COMMIT;
PRAGMA foreign_keys = ON;

View file

@ -0,0 +1,17 @@
-- v18 (compatible with v17+): Add columns for personal filtering space info
ALTER TABLE "user" ADD COLUMN space_room TEXT;
DROP TABLE IF EXISTS user_portal;
CREATE TABLE user_portal (
user_mxid TEXT,
portal_chat_id TEXT,
portal_receiver uuid,
last_read_ts BIGINT NOT NULL DEFAULT 0,
in_space BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (user_mxid, portal_chat_id, portal_receiver),
CONSTRAINT user_portal_user_fkey FOREIGN KEY (user_mxid)
REFERENCES "user"(mxid) ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT user_portal_portal_fkey FOREIGN KEY (portal_chat_id, portal_receiver)
REFERENCES portal(chat_id, receiver) ON UPDATE CASCADE ON DELETE CASCADE
);

View file

@ -0,0 +1,5 @@
-- v19 (compatible with v17+): Add more metadata for portals
ALTER TABLE portal ADD COLUMN topic_set BOOLEAN NOT NULL DEFAULT false;
UPDATE portal SET topic_set=true WHERE topic<>'';
ALTER TABLE portal ADD COLUMN avatar_path TEXT NOT NULL DEFAULT '';
ALTER TABLE puppet ADD COLUMN avatar_path TEXT NOT NULL DEFAULT '';

View file

@ -1,5 +1,5 @@
// mautrix-signal - A Matrix-signal puppeting bridge. // mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2025 Tulir Asokan // Copyright (C) 2023 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU Affero General Public License as published by
@ -18,19 +18,23 @@ package upgrades
import ( import (
"context" "context"
"embed"
"errors"
"go.mau.fi/util/dbutil" "go.mau.fi/util/dbutil"
) )
var Table dbutil.UpgradeTable
//go:embed *.sql
var rawUpgrades embed.FS
func init() { func init() {
Table.Register(-1, 20, 13, "Add missing columns for backup chat table", dbutil.TxnModeOn, func(ctx context.Context, db *dbutil.Database) (err error) { Table.Register(-1, 12, 0, "Unsupported version", false, func(ctx context.Context, database *dbutil.Database) error {
var exists bool return errors.New("please upgrade to mautrix-signal v0.4.3 before upgrading to a newer version")
if exists, err = db.ColumnExists(ctx, "signalmeow_backup_chat", "latest_message_id"); err == nil && !exists {
_, err = db.Exec(ctx, `
ALTER TABLE signalmeow_backup_chat ADD COLUMN latest_message_id BIGINT;
ALTER TABLE signalmeow_backup_chat ADD COLUMN total_message_count INTEGER;
`)
}
return
}) })
Table.Register(1, 13, 0, "Jump to version 13", false, func(ctx context.Context, database *dbutil.Database) error {
return nil
})
Table.RegisterFS(rawUpgrades)
} }

115
database/user.go Normal file
View file

@ -0,0 +1,115 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, 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/>.
package database
import (
"context"
"database/sql"
"sync"
"github.com/google/uuid"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getUserByMXIDQuery = `SELECT mxid, phone, uuid, management_room, space_room FROM "user" WHERE mxid=$1`
getUserByPhoneQuery = `SELECT mxid, phone, uuid, management_room, space_room FROM "user" WHERE phone=$1`
getUserByUUIDQuery = `SELECT mxid, phone, uuid, management_room, space_room FROM "user" WHERE uuid=$1`
getAllLoggedInUsersQuery = `SELECT mxid, phone, uuid, management_room, space_room FROM "user" WHERE phone IS NOT NULL`
insertUserQuery = `INSERT INTO "user" (mxid, phone, uuid, management_room, space_room) VALUES ($1, $2, $3, $4, $5)`
updateUserQuery = `UPDATE "user" SET phone=$2, uuid=$3, management_room=$4, space_room=$5 WHERE mxid=$1`
)
type UserQuery struct {
*dbutil.QueryHelper[*User]
}
type User struct {
qh *dbutil.QueryHelper[*User]
MXID id.UserID
SignalUsername string
SignalID uuid.UUID
ManagementRoom id.RoomID
SpaceRoom id.RoomID
lastReadCache map[PortalKey]uint64
lastReadCacheLock sync.Mutex
inSpaceCache map[PortalKey]bool
inSpaceCacheLock sync.Mutex
}
func newUser(qh *dbutil.QueryHelper[*User]) *User {
return &User{
qh: qh,
lastReadCache: make(map[PortalKey]uint64),
inSpaceCache: make(map[PortalKey]bool),
}
}
func (uq *UserQuery) GetByMXID(ctx context.Context, mxid id.UserID) (*User, error) {
return uq.QueryOne(ctx, getUserByMXIDQuery, mxid)
}
func (uq *UserQuery) GetByPhone(ctx context.Context, phone string) (*User, error) {
return uq.QueryOne(ctx, getUserByPhoneQuery, phone)
}
func (uq *UserQuery) GetBySignalID(ctx context.Context, uuid uuid.UUID) (*User, error) {
return uq.QueryOne(ctx, getUserByUUIDQuery, uuid)
}
func (uq *UserQuery) GetAllLoggedIn(ctx context.Context) ([]*User, error) {
return uq.QueryMany(ctx, getAllLoggedInUsersQuery)
}
func (u *User) sqlVariables() []any {
var nu uuid.NullUUID
nu.UUID = u.SignalID
nu.Valid = u.SignalID != uuid.Nil
return []any{u.MXID, dbutil.StrPtr(u.SignalUsername), nu, dbutil.StrPtr(u.ManagementRoom), dbutil.StrPtr(u.SpaceRoom)}
}
func (u *User) Insert(ctx context.Context) error {
return u.qh.Exec(ctx, insertUserQuery, u.sqlVariables()...)
}
func (u *User) Update(ctx context.Context) error {
return u.qh.Exec(ctx, updateUserQuery, u.sqlVariables()...)
}
func (u *User) Scan(row dbutil.Scannable) (*User, error) {
var phone, managementRoom, spaceRoom sql.NullString
var signalID uuid.NullUUID
err := row.Scan(
&u.MXID,
&phone,
&signalID,
&managementRoom,
&spaceRoom,
)
if err != nil {
return nil, err
}
u.SignalUsername = phone.String
u.SignalID = signalID.UUID
u.ManagementRoom = id.RoomID(managementRoom.String)
u.SpaceRoom = id.RoomID(spaceRoom.String)
return u, nil
}

116
database/userportal.go Normal file
View file

@ -0,0 +1,116 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, 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/>.
package database
import (
"context"
"database/sql"
"errors"
"github.com/rs/zerolog"
)
const (
getLastReadTSQuery = `SELECT last_read_ts FROM user_portal WHERE user_mxid=$1 AND portal_chat_id=$2 AND portal_receiver=$3`
setLastReadTSQuery = `
INSERT INTO user_portal (user_mxid, portal_chat_id, portal_receiver, last_read_ts) VALUES ($1, $2, $3, $4)
ON CONFLICT (user_mxid, portal_chat_id, portal_receiver) DO UPDATE
SET last_read_ts=excluded.last_read_ts WHERE user_portal.last_read_ts<excluded.last_read_ts
`
getIsInSpaceQuery = `SELECT in_space FROM user_portal WHERE user_mxid=$1 AND portal_chat_id=$2 AND portal_receiver=$3`
setIsInSpaceQuery = `
INSERT INTO user_portal (user_mxid, portal_chat_id, portal_receiver, in_space) VALUES ($1, $2, $3, true)
ON CONFLICT (user_mxid, portal_chat_id, portal_receiver) DO UPDATE SET in_space=true
`
)
func (u *User) GetLastReadTS(ctx context.Context, portal PortalKey) uint64 {
u.lastReadCacheLock.Lock()
defer u.lastReadCacheLock.Unlock()
if cached, ok := u.lastReadCache[portal]; ok {
return cached
}
var ts int64
err := u.qh.GetDB().QueryRow(ctx, getLastReadTSQuery, u.MXID, portal.ChatID, portal.Receiver).Scan(&ts)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
zerolog.Ctx(ctx).Err(err).
Str("user_id", u.MXID.String()).
Any("portal_key", portal).
Msg("Failed to query last read timestamp")
return 0
}
u.lastReadCache[portal] = uint64(ts)
return uint64(ts)
}
func (u *User) SetLastReadTS(ctx context.Context, portal PortalKey, ts uint64) {
u.lastReadCacheLock.Lock()
defer u.lastReadCacheLock.Unlock()
err := u.qh.Exec(ctx, setLastReadTSQuery, u.MXID, portal.ChatID, portal.Receiver, int64(ts))
if err != nil {
zerolog.Ctx(ctx).Err(err).
Str("user_id", u.MXID.String()).
Any("portal_key", portal).
Msg("Failed to update last read timestamp")
} else {
zerolog.Ctx(ctx).Debug().
Str("user_id", u.MXID.String()).
Any("portal_key", portal).
Uint64("last_read_ts", ts).
Msg("Updated last read timestamp of portal")
u.lastReadCache[portal] = ts
}
}
func (u *User) IsInSpace(ctx context.Context, portal PortalKey) bool {
u.inSpaceCacheLock.Lock()
defer u.inSpaceCacheLock.Unlock()
if cached, ok := u.inSpaceCache[portal]; ok {
return cached
}
var inSpace bool
err := u.qh.GetDB().QueryRow(ctx, getIsInSpaceQuery, u.MXID, portal.ChatID, portal.Receiver).Scan(&inSpace)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
zerolog.Ctx(ctx).Err(err).
Str("user_id", u.MXID.String()).
Any("portal_key", portal).
Msg("Failed to query in space status")
return false
}
u.inSpaceCache[portal] = inSpace
return inSpace
}
func (u *User) MarkInSpace(ctx context.Context, portal PortalKey) {
u.inSpaceCacheLock.Lock()
defer u.inSpaceCacheLock.Unlock()
err := u.qh.Exec(ctx, setIsInSpaceQuery, u.MXID, portal.ChatID, portal.Receiver)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Str("user_id", u.MXID.String()).
Any("portal_key", portal).
Msg("Failed to update in space status")
} else {
u.inSpaceCache[portal] = true
}
}
func (u *User) RemoveInSpaceCache(key PortalKey) {
u.inSpaceCacheLock.Lock()
defer u.inSpaceCacheLock.Unlock()
delete(u.inSpaceCache, key)
}

156
disappearing.go Normal file
View file

@ -0,0 +1,156 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber
//
// 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/>.
package main
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/database"
)
type DisappearingMessagesManager struct {
DB *database.Database
Log zerolog.Logger
Bridge *SignalBridge
checkMessagesChan chan struct{}
}
func (dmm *DisappearingMessagesManager) ScheduleDisappearingForRoom(ctx context.Context, roomID id.RoomID) {
log := dmm.Log.With().Stringer("room_id", roomID).Logger()
disappearingMessages, err := dmm.DB.DisappearingMessage.GetUnscheduledForRoom(ctx, roomID)
if err != nil {
log.Err(err).Msg("Failed to get unscheduled disappearing messages")
return
}
for _, disappearingMessage := range disappearingMessages {
err = disappearingMessage.StartExpirationTimer(ctx)
if err != nil {
log.Err(err).Msg("Failed to schedule disappearing message")
} else {
log.Debug().
Str("event_id", disappearingMessage.EventID.String()).
Time("expire_at", disappearingMessage.ExpireAt).
Msg("Scheduling disappearing message")
}
}
// Tell the disappearing messages loop to check again
dmm.checkMessagesChan <- struct{}{}
}
func (dmm *DisappearingMessagesManager) StartDisappearingLoop(ctx context.Context) {
dmm.checkMessagesChan = make(chan struct{}, 1)
go func() {
log := dmm.Log.With().Str("action", "loop").Logger()
ctx = log.WithContext(ctx)
for {
dmm.redactExpiredMessages(ctx)
duration := 10 * time.Minute // Check again in 10 minutes just in case
nextMsg, err := dmm.DB.DisappearingMessage.GetNextScheduledMessage(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
log.Err(err).Msg("Failed to get next disappearing message")
continue
} else if nextMsg != nil {
duration = time.Until(nextMsg.ExpireAt)
}
select {
case <-time.After(duration):
case <-dmm.checkMessagesChan:
case <-ctx.Done():
return
}
}
}()
}
func (dmm *DisappearingMessagesManager) redactExpiredMessages(ctx context.Context) {
log := zerolog.Ctx(ctx)
expiredMessages, err := dmm.DB.DisappearingMessage.GetExpiredMessages(ctx)
if err != nil {
log.Err(err).Msg("Failed to get expired disappearing messages")
return
}
for _, msg := range expiredMessages {
portal := dmm.Bridge.GetPortalByMXID(msg.RoomID)
if portal == nil {
log.Warn().Stringer("event_id", msg.EventID).Stringer("room_id", msg.RoomID).Msg("Failed to redact message: portal not found")
err = msg.Delete(ctx)
if err != nil {
log.Err(err).
Str("event_id", msg.EventID.String()).
Msg("Failed to delete disappearing message row in database")
}
continue
}
_, err = portal.MainIntent().RedactEvent(ctx, msg.RoomID, msg.EventID, mautrix.ReqRedact{
Reason: "Message expired",
TxnID: fmt.Sprintf("mxsg_disappear_%s", msg.EventID),
})
if err != nil {
log.Err(err).
Str("event_id", msg.EventID.String()).
Str("room_id", msg.RoomID.String()).
Msg("Failed to redact message")
} else {
log.Err(err).
Str("event_id", msg.EventID.String()).
Str("room_id", msg.RoomID.String()).
Msg("Redacted message")
}
err = msg.Delete(ctx)
if err != nil {
log.Err(err).
Str("event_id", msg.EventID.String()).
Msg("Failed to delete disappearing message row in database")
}
}
}
func (dmm *DisappearingMessagesManager) AddDisappearingMessage(ctx context.Context, eventID id.EventID, roomID id.RoomID, expireIn time.Duration, startTimerNow bool) {
if expireIn == 0 {
return
}
var expireAt time.Time
if startTimerNow {
expireAt = time.Now().Add(expireIn)
}
disappearingMessage := dmm.DB.DisappearingMessage.NewWithValues(roomID, eventID, expireIn, expireAt)
err := disappearingMessage.Insert(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("event_id", eventID).
Msg("Failed to add disappearing message to database")
return
}
zerolog.Ctx(ctx).Debug().Stringer("event_id", eventID).
Msg("Added disappearing message row to database")
if startTimerNow {
// Tell the disappearing messages loop to check again
dmm.checkMessagesChan <- struct{}{}
}
}

View file

@ -4,8 +4,6 @@ if [[ -z "$GID" ]]; then
GID="$UID" GID="$UID"
fi fi
BINARY_NAME=/usr/bin/mautrix-signal
# Define functions. # Define functions.
function fixperms { function fixperms {
chown -R $UID:$GID /data chown -R $UID:$GID /data
@ -17,7 +15,7 @@ function fixperms {
} }
if [[ ! -f /data/config.yaml ]]; then if [[ ! -f /data/config.yaml ]]; then
$BINARY_NAME -c /data/config.yaml -e cp /opt/mautrix-signal/example-config.yaml /data/config.yaml
echo "Didn't find a config file." echo "Didn't find a config file."
echo "Copied default config file to /data/config.yaml" echo "Copied default config file to /data/config.yaml"
echo "Modify that config file to your liking." echo "Modify that config file to your liking."
@ -26,7 +24,7 @@ if [[ ! -f /data/config.yaml ]]; then
fi fi
if [[ ! -f /data/registration.yaml ]]; then if [[ ! -f /data/registration.yaml ]]; then
$BINARY_NAME -g -c /data/config.yaml -r /data/registration.yaml || exit $? /usr/bin/mautrix-signal -g -c /data/config.yaml -r /data/registration.yaml || exit $?
echo "Didn't find a registration file." echo "Didn't find a registration file."
echo "Generated one for you." echo "Generated one for you."
echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it." echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it."
@ -36,12 +34,13 @@ fi
cd /data cd /data
fixperms fixperms
EXE=/usr/bin/mautrix-signal
DLV=/usr/bin/dlv DLV=/usr/bin/dlv
if [ -x "$DLV" ]; then if [[ -x $DLV ]]; then
if [ "$DBGWAIT" != 1 ]; then if [[ $DBGWAIT -ne 1 ]]; then
NOWAIT=1 NOWAIT=1
fi fi
BINARY_NAME="${DLV} exec ${BINARY_NAME} ${NOWAIT:+--continue --accept-multiclient} --api-version 2 --headless -l :4040" EXE="${DLV} exec ${EXE} ${NOWAIT:+--continue --accept-multiclient} --api-version 2 --headless -l :4040"
fi fi
exec su-exec $UID:$GID $BINARY_NAME exec su-exec $UID:$GID $EXE

307
example-config.yaml Normal file
View file

@ -0,0 +1,307 @@
# 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
# What software is the homeserver running?
# Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here.
software: standard
# The URL to push real-time bridge status to.
# If set, the bridge will make POST requests to this URL whenever a user's discord 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
# Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
async_media: false
# Should the bridge use a websocket for connecting to the homeserver?
# The server side is currently not documented anywhere and is only implemented by mautrix-wsproxy,
# mautrix-asmux (deprecated), and hungryserv (proprietary).
websocket: false
# How often should the websocket be pinged? Pinging will be disabled if this is zero.
ping_interval_seconds: 0
# 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
# The hostname and port where this appservice should listen.
hostname: 0.0.0.0
port: 29328
# Database config.
database:
# The database type. "sqlite3-fk-wal" and "postgres" are supported.
type: postgres
# The database URI.
# SQLite: A raw file path is supported, but `file:<path>?_txlock=immediate` is recommended.
# https://github.com/mattn/go-sqlite3#connection-string
# Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable
# To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql
uri: postgres://user:password@host/database?sslmode=disable
# Maximum number of connections. Mostly relevant for Postgres.
max_open_conns: 20
max_idle_conns: 2
# Maximum connection idle time and lifetime before they're closed. Disabled if null.
# Parsed with https://pkg.go.dev/time#ParseDuration
max_conn_idle_time: null
max_conn_lifetime: null
# The unique ID of this appservice.
id: signal
# Appservice bot details.
bot:
# Username of the appservice 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.
displayname: Signal bridge bot
avatar: mxc://maunium.net/wPJgTQbZOtpBFmDNkiNEMDUp
# Whether or not to receive ephemeral events via appservice transactions.
# Requires MSC2409 support (i.e. Synapse 1.22+).
ephemeral_events: true
# Should incoming events be handled asynchronously?
# This may be necessary for large public instances with lots of messages going through.
# However, messages will not be guaranteed to be bridged in the same order they were sent in.
async_transactions: false
# 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 config.
metrics:
# Enable prometheus metrics?
enabled: false
# IP and port where the metrics listener should be. The path is always /metrics
listen: 127.0.0.1:8000
signal:
# Default device name that shows up in the Signal app.
device_name: mautrix-signal
# Bridge config
bridge:
# Localpart template of MXIDs for Signal users.
# {{.}} is replaced with the internal ID of the Signal user.
username_template: signal_{{.}}
# Displayname template for Signal users. This is also used as the room name in DMs if private_chat_portal_meta is enabled.
# {{.ProfileName}} - The Signal profile name set by the user.
# {{.ContactName}} - The name for the user from your phone's contact list. This is not safe on multi-user instances.
# {{.PhoneNumber}} - The phone number of the user.
# {{.UUID}} - The UUID of the Signal user.
# {{.AboutEmoji}} - The emoji set by the user in their profile.
displayname_template: '{{or .ProfileName .PhoneNumber "Unknown user"}}'
# Whether to explicitly set the avatar and room name for private chat portal rooms.
# If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
# If set to `always`, all DM rooms will have explicit names and avatars set.
# If set to `never`, DM rooms will never have names and avatars set.
private_chat_portal_meta: default
# Should avatars from the user's contact list be used? This is not safe on multi-user instances.
use_contact_avatars: false
# Should the Signal user's phone number be included in the room topic in private chat portal rooms?
number_in_topic: true
# Avatar image for the Note to Self room.
note_to_self_avatar: mxc://maunium.net/REBIVrqjZwmaWpssCZpBlmlL
portal_message_buffer: 128
# Should the bridge create a space for each logged-in user and add bridged rooms to it?
# Users who logged in before turning this on should run `!signal sync-space` to create and fill the space for the first time.
personal_filtering_spaces: false
# Should Matrix m.notice-type messages be bridged?
bridge_notices: true
# Should the bridge send a read receipt from the bridge bot when a message has been sent to Signal?
delivery_receipts: false
# Whether the bridge should send the message status as a custom com.beeper.message_send_status event.
message_status_events: false
# Whether the bridge should send error notices via m.notice events when a message fails to bridge.
message_error_notices: true
# Should the bridge 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
# 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
# Send captions in the same message as images. This will send data compatible with both MSC2530.
# This is currently not supported in most clients.
caption_in_message: false
# Whether or not created rooms should have federation enabled.
# If false, created portal rooms will never be federated.
federate_rooms: true
# Servers to always allow double puppeting from
double_puppet_server_map:
example.com: https://example.com
# Allow using double puppeting from any server with a valid client .well-known file.
double_puppet_allow_discovery: false
# Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth
#
# If set, double puppeting will be enabled automatically for local users
# instead of users having to find an access token and run `login-matrix`
# manually.
login_shared_secret_map:
example.com: foobar
# Maximum time for handling Matrix events. Duration strings formatted for https://pkg.go.dev/time#ParseDuration
# Null means there's no enforced timeout.
message_handling_timeout:
# Send an error message after this timeout, but keep waiting for the response until the deadline.
# This is counted from the origin_server_ts, so the warning time is consistent regardless of the source of delay.
# If the message is older than this when it reaches the bridge, the message won't be handled at all.
error_after: null
# Drop messages after this timeout. They may still go through if the message got sent to the servers.
# This is counted from the time the bridge starts handling the message.
deadline: 120s
# 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 `login` to log in."
# Optional extra text sent when joining a management room.
additional_help: ""
# 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
# Options for deleting megolm sessions from the bridge.
delete_keys:
# Beeper-specific: delete outbound sessions when hungryserv confirms
# that the user has uploaded the key to key backup.
delete_outbound_on_ack: false
# Don't store outbound sessions in the inbound table.
dont_store_outbound: false
# Ratchet megolm sessions forward after decrypting messages.
ratchet_on_decrypt: false
# Delete fully used keys (index >= max_messages) after decrypting messages.
delete_fully_used_on_decrypt: false
# Delete previous megolm sessions from same device when receiving a new one.
delete_prev_on_new_session: false
# Delete megolm sessions received from a device when the device is deleted.
delete_on_device_delete: false
# Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
periodically_delete_expired: false
# Delete inbound megolm sessions that don't have the received_at field used for
# automatic ratcheting and expired session deletion. This is meant as a migration
# to delete old keys prior to the bridge update.
delete_outdated_inbound: 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 Signal 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
# Disable rotating keys when a user's devices change?
# You should not enable this option unless you understand all the implications.
disable_device_change_key_rotation: false
# Settings for provisioning API
provisioning:
# Prefix for the provisioning API paths.
prefix: /_matrix/provision
# Shared secret for authentication. If set to "generate", a random secret will be generated,
# or if set to "disable", the provisioning API will be disabled.
shared_secret: generate
# Enable debug API at /debug with provisioning authentication.
debug_endpoints: false
# Permissions for using the bridge.
# Permitted values:
# relay - Talk through the relaybot (if enabled), no access otherwise
# user - Access to use the bridge to chat with a Signal account.
# admin - User level and some additional administration tools
# Permitted keys:
# * - All Matrix users
# domain - All users on that homeserver
# mxid - Specific user
permissions:
"*": relay
"example.com": user
"@admin:example.com": admin
# Settings for relay mode
relay:
# Whether relay mode should be allowed. If allowed, `!wa set-relay` can be used to turn any
# authenticated user into a relaybot for that chat.
enabled: false
# Should only admins be allowed to set themselves as relay users?
admin_only: true
# The formats to use when sending messages to Signal via the relaybot.
message_formats:
m.text: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"
m.notice: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"
m.emote: "* <b>{{ .Sender.Displayname }}</b> {{ .Message }}"
m.file: "<b>{{ .Sender.Displayname }}</b> sent a file"
m.image: "<b>{{ .Sender.Displayname }}</b> sent an image"
m.audio: "<b>{{ .Sender.Displayname }}</b> sent an audio file"
m.video: "<b>{{ .Sender.Displayname }}</b> sent a video"
m.location: "<b>{{ .Sender.Displayname }}</b> sent a location"
# Logging config. See https://github.com/tulir/zeroconfig for details.
logging:
min_level: debug
writers:
- type: stdout
format: pretty-colored
- type: file
format: json
filename: ./logs/mautrix-signal.log
max_size: 100
max_backups: 10
compress: true

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1703255338,
"narHash": "sha256-Z6wfYJQKmDN9xciTwU3cOiOk+NElxdZwy/FiHctCzjU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6df37dc6a77654682fe9f071c62b4242b5342e04",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

33
flake.nix Normal file
View file

@ -0,0 +1,33 @@
{
description = "mautrix-signal development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
(flake-utils.lib.eachDefaultSystem (system:
let pkgs = import nixpkgs { inherit system; };
in {
devShells.default = pkgs.mkShell {
LIBCLANG_PATH = "${pkgs.llvmPackages_11.libclang.lib}/lib";
buildInputs = with pkgs; [
clang
cmake
gnumake
protobuf
rust-cbindgen
rustup
olm
go_1_20
go-tools
gotools
pre-commit
];
};
}));
}

75
go.mod
View file

@ -1,52 +1,51 @@
module go.mau.fi/mautrix-signal module go.mau.fi/mautrix-signal
go 1.25.0 go 1.20
toolchain go1.26.2
tool go.mau.fi/util/cmd/maubuild
require ( require (
github.com/coder/websocket v1.8.14 github.com/beeper/libserv v0.0.0-20231231163024-8eba5b0c509d
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff github.com/google/uuid v1.5.0
github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.0
github.com/lib/pq v1.10.9
github.com/mattn/go-pointer v0.0.1 github.com/mattn/go-pointer v0.0.1
github.com/rs/zerolog v1.35.1 github.com/mattn/go-sqlite3 v1.14.19
github.com/stretchr/testify v1.11.1 github.com/prometheus/client_golang v1.18.0
github.com/tidwall/gjson v1.18.0 github.com/rs/zerolog v1.31.0
go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.50.0 github.com/stretchr/testify v1.8.4
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f github.com/tidwall/gjson v1.17.0
golang.org/x/net v0.53.0 go.mau.fi/util v0.2.2-0.20240107143939-48dfc4dc3894
golang.org/x/sync v0.20.0 golang.org/x/crypto v0.17.0
google.golang.org/protobuf v1.36.11 golang.org/x/exp v0.0.0-20231226003508-02704c960a9b
gopkg.in/yaml.v3 v3.0.1 golang.org/x/net v0.19.0
maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 google.golang.org/protobuf v1.32.0
maunium.net/go/maulogger/v2 v2.4.1
maunium.net/go/mautrix v0.16.3-0.20240107204502-25bc36bc7ae7
nhooyr.io/websocket v1.8.10
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/pretty v0.3.1 // indirect github.com/gorilla/websocket v1.5.0 // indirect
github.com/lib/pq v1.12.3 // indirect github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/prometheus/common v0.45.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/prometheus/procfs v0.12.0 // indirect
github.com/tidwall/match v1.2.0 // indirect github.com/rs/xid v1.5.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark v1.6.0 // indirect
go.mau.fi/zeroconfig v0.2.0 // indirect go.mau.fi/zeroconfig v0.1.2 // indirect
golang.org/x/mod v0.35.0 // indirect golang.org/x/sys v0.15.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect maunium.net/go/mauflag v1.0.0 // indirect
) )

139
go.sum
View file

@ -1,95 +1,98 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= github.com/DATA-DOG/go-sqlmock v1.5.1 h1:FK6RCIUSfmbnI/imIICmboyQBkOckutaa6R5YYlLZyo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/beeper/libserv v0.0.0-20231231163024-8eba5b0c509d h1:CSrg1zpAEMXK3VIUx5deRT6YMMX3Kd8jDkiUmB1uoWw=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/beeper/libserv v0.0.0-20231231163024-8eba5b0c509d/go.mod h1:b9FFm9y4mEm36G8ytVmS1vkNzJa0KepmcdVY+qf7qRU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0= github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25 h1:YPEmc+li7TF6C9AdRTcSLMb6yCHdF27/wNT7kFLIVNg= go.mau.fi/util v0.2.2-0.20240107143939-48dfc4dc3894 h1:CuR5LDSxBQLETorfwJ9vRtySeLHjMvJ7//lnCMw7Dy8=
go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25/go.mod h1:jE9FfhbgEgAwxei6lomO9v8zdCIATcquONUu4vjRwSs= go.mau.fi/util v0.2.2-0.20240107143939-48dfc4dc3894/go.mod h1:9dGsBCCbZJstx16YgnVMVi3O2bOizELoKpugLD4FoGs=
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto=
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20231226003508-02704c960a9b h1:kLiC65FbiHWFAOu+lxwNPujcsl8VYyTYYEZnsOO1WK4=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20231226003508-02704c960a9b/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 h1:zNC9eVAhw8FhKpM3AxNAh/iy75UEYX91uJUvqqAYlvo= maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4/go.mod h1:3sOGhXi3P1V6/NruTA0gujkvTypXVUraWktCuTGyDuM= maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.16.3-0.20240107204502-25bc36bc7ae7 h1:Yo1S3mSazHoT/MHNheRMuRPH74rU6/ZyVaJqTEsmaN0=
maunium.net/go/mautrix v0.16.3-0.20240107204502-25bc36bc7ae7/go.mod h1:eRQu5ED1ODsP+xq1K9l1AOD+O9FMkAhodd/RVc3Bkqg=
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=

346
main.go Normal file
View file

@ -0,0 +1,346 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber
//
// 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/>.
package main
import (
"context"
_ "embed"
"fmt"
"os"
"sync"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.mau.fi/util/configupgrade"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/bridge/commands"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/config"
"go.mau.fi/mautrix-signal/database"
"go.mau.fi/mautrix-signal/msgconv/matrixfmt"
"go.mau.fi/mautrix-signal/msgconv/signalfmt"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/store"
)
//go:embed example-config.yaml
var ExampleConfig string
// Information to find out exactly which commit the bridge was built from.
// These are filled at build time with the -X linker flag.
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
type SignalBridge struct {
bridge.Bridge
Config *config.Config
DB *database.Database
Metrics *MetricsHandler
MeowStore *store.StoreContainer
provisioning *ProvisioningAPI
usersByMXID map[id.UserID]*User
usersBySignalID map[uuid.UUID]*User
usersLock sync.Mutex
managementRooms map[id.RoomID]*User
managementRoomsLock sync.Mutex
portalsByMXID map[id.RoomID]*Portal
portalsByID map[database.PortalKey]*Portal
portalsLock sync.Mutex
puppets map[uuid.UUID]*Puppet
puppetsByCustomMXID map[id.UserID]*Puppet
puppetsLock sync.Mutex
disappearingMessagesManager *DisappearingMessagesManager
}
var _ bridge.ChildOverride = (*SignalBridge)(nil)
func (br *SignalBridge) GetExampleConfig() string {
return ExampleConfig
}
func (br *SignalBridge) GetConfigPtr() interface{} {
br.Config = &config.Config{
BaseConfig: &br.Bridge.Config,
}
br.Config.BaseConfig.Bridge = &br.Config.Bridge
return br.Config
}
func (br *SignalBridge) Init() {
br.CommandProcessor = commands.NewProcessor(&br.Bridge)
br.RegisterCommands()
signalmeow.SetLogger(br.ZLog.With().Str("component", "signalmeow").Logger())
br.DB = database.New(br.Bridge.DB)
br.MeowStore = store.NewStore(br.Bridge.DB, dbutil.ZeroLogger(br.ZLog.With().Str("db_section", "signalmeow").Logger()))
ss := br.Config.Bridge.Provisioning.SharedSecret
if len(ss) > 0 && ss != "disable" {
br.provisioning = &ProvisioningAPI{bridge: br, log: br.ZLog.With().Str("component", "provisioning").Logger()}
}
br.disappearingMessagesManager = &DisappearingMessagesManager{
DB: br.DB,
Log: br.ZLog.With().Str("component", "disappearing messages").Logger(),
Bridge: br,
}
br.Metrics = NewMetricsHandler(br.Config.Metrics.Listen, br.Log.Sub("Metrics"), br.DB)
br.MatrixHandler.TrackEventDuration = br.Metrics.TrackMatrixEvent
signalFormatParams = &signalfmt.FormatParams{
GetUserInfo: func(u uuid.UUID) signalfmt.UserInfo {
puppet := br.GetPuppetBySignalID(u)
if puppet == nil {
return signalfmt.UserInfo{}
}
user := br.GetUserBySignalID(u)
if user != nil {
return signalfmt.UserInfo{
MXID: user.MXID,
Name: puppet.Name,
}
}
return signalfmt.UserInfo{
MXID: puppet.MXID,
Name: puppet.Name,
}
},
}
matrixFormatParams = &matrixfmt.HTMLParser{
GetUUIDFromMXID: func(userID id.UserID) uuid.UUID {
parsed, ok := br.ParsePuppetMXID(userID)
if ok {
return parsed
}
user := br.GetUserByMXIDIfExists(userID)
if user != nil && user.SignalID != uuid.Nil {
return user.SignalID
}
return uuid.Nil
},
}
}
func (br *SignalBridge) logLostPortals(ctx context.Context) {
lostPortals, err := br.DB.LostPortal.GetAll(ctx)
if err != nil {
br.ZLog.Err(err).Msg("Failed to get lost portals")
return
} else if len(lostPortals) == 0 {
return
}
lostCountByReceiver := make(map[string]int)
for _, lost := range lostPortals {
lostCountByReceiver[lost.Receiver]++
}
br.ZLog.Warn().
Any("count_by_receiver", lostCountByReceiver).
Msg("Some portals were discarded due to the receiver not being logged into the bridge anymore. " +
"Use `!signal cleanup-lost-portals` to remove them from the database. " +
"Alternatively, you can re-insert the data into the portal table with the appropriate receiver column to restore the portals.")
}
func (br *SignalBridge) Start() {
go br.logLostPortals(context.TODO())
err := br.MeowStore.Upgrade(context.TODO())
if err != nil {
br.Log.Fatalln("Failed to upgrade signalmeow database: %v", err)
os.Exit(15)
}
if br.provisioning != nil {
br.Log.Debugln("Initializing provisioning API")
br.provisioning.Init()
}
go br.StartUsers()
if br.Config.Metrics.Enabled {
go br.Metrics.Start()
}
go br.disappearingMessagesManager.StartDisappearingLoop(context.TODO())
}
func (br *SignalBridge) Stop() {
br.Metrics.Stop()
for _, user := range br.usersByMXID {
br.Log.Debugln("Disconnecting", user.MXID)
user.Disconnect()
}
}
func (br *SignalBridge) GetIPortal(mxid id.RoomID) bridge.Portal {
p := br.GetPortalByMXID(mxid)
if p == nil {
return nil
}
return p
}
func (br *SignalBridge) GetIUser(mxid id.UserID, create bool) bridge.User {
p := br.GetUserByMXID(mxid)
if p == nil {
return nil
}
return p
}
func (br *SignalBridge) IsGhost(mxid id.UserID) bool {
_, isGhost := br.ParsePuppetMXID(mxid)
return isGhost
}
func (br *SignalBridge) GetIGhost(mxid id.UserID) bridge.Ghost {
p := br.GetPuppetByMXID(mxid)
if p == nil {
return nil
}
return p
}
func (br *SignalBridge) CreatePrivatePortal(roomID id.RoomID, brInviter bridge.User, brGhost bridge.Ghost) {
inviter := brInviter.(*User)
puppet := brGhost.(*Puppet)
log := br.ZLog.With().
Str("action", "create private portal").
Str("target_room_id", roomID.String()).
Str("inviter_mxid", brInviter.GetMXID().String()).
Str("invitee_uuid", puppet.SignalID.String()).
Logger()
log.Debug().Msg("Creating private chat portal")
key := database.NewPortalKey(puppet.SignalID.String(), inviter.SignalID)
portal := br.GetPortalByChatID(key)
ctx := log.WithContext(context.TODO())
if len(portal.MXID) == 0 {
br.createPrivatePortalFromInvite(ctx, roomID, inviter, puppet, portal)
return
}
log.Debug().
Str("existing_room_id", portal.MXID.String()).
Msg("Existing private chat portal found, trying to invite user")
ok := portal.ensureUserInvited(ctx, inviter)
if !ok {
log.Warn().Msg("Failed to invite user to existing private chat portal. Redirecting portal to new room")
br.createPrivatePortalFromInvite(ctx, roomID, inviter, puppet, portal)
return
}
intent := puppet.DefaultIntent()
errorMessage := fmt.Sprintf("You already have a private chat portal with me at [%[1]s](https://matrix.to/#/%[1]s)", portal.MXID)
errorContent := format.RenderMarkdown(errorMessage, true, false)
_, _ = intent.SendMessageEvent(ctx, roomID, event.EventMessage, errorContent)
log.Debug().Msg("Leaving ghost from private chat room after accepting invite because we already have a chat with the user")
_, _ = intent.LeaveRoom(ctx, roomID)
}
func (br *SignalBridge) createPrivatePortalFromInvite(ctx context.Context, roomID id.RoomID, inviter *User, puppet *Puppet, portal *Portal) {
log := zerolog.Ctx(ctx)
log.Debug().Msg("Creating private portal from invite")
// Check if room is already encrypted
var existingEncryption event.EncryptionEventContent
var encryptionEnabled bool
err := portal.MainIntent().StateEvent(ctx, roomID, event.StateEncryption, "", &existingEncryption)
if err != nil {
log.Err(err).Msg("Failed to check if encryption is enabled in private chat room")
} else {
encryptionEnabled = existingEncryption.Algorithm == id.AlgorithmMegolmV1
}
portal.MXID = roomID
br.portalsLock.Lock()
br.portalsByMXID[portal.MXID] = portal
br.portalsLock.Unlock()
intent := puppet.DefaultIntent()
if br.Config.Bridge.Encryption.Default || encryptionEnabled {
log.Debug().Msg("Adding bridge bot to new private chat portal as encryption is enabled")
_, err = intent.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: br.Bot.UserID})
if err != nil {
log.Err(err).Msg("Failed to invite bridge bot to enable e2be")
}
err = br.Bot.EnsureJoined(ctx, roomID)
if err != nil {
log.Err(err).Msg("Failed to join as bridge bot to enable e2be")
}
if !encryptionEnabled {
_, err = intent.SendStateEvent(ctx, roomID, event.StateEncryption, "", portal.getEncryptionEventContent())
if err != nil {
log.Err(err).Msg("Failed to enable e2be")
}
}
br.AS.StateStore.SetMembership(ctx, roomID, inviter.MXID, event.MembershipJoin)
br.AS.StateStore.SetMembership(ctx, roomID, puppet.MXID, event.MembershipJoin)
br.AS.StateStore.SetMembership(ctx, roomID, br.Bot.UserID, event.MembershipJoin)
portal.Encrypted = true
}
portal.UpdateDMInfo(ctx, true)
_, _ = intent.SendNotice(ctx, roomID, "Private chat portal created")
log.Info().Msg("Created private chat portal after invite")
}
func main() {
br := &SignalBridge{
usersByMXID: make(map[id.UserID]*User),
usersBySignalID: make(map[uuid.UUID]*User),
managementRooms: make(map[id.RoomID]*User),
portalsByMXID: make(map[id.RoomID]*Portal),
portalsByID: make(map[database.PortalKey]*Portal),
puppets: make(map[uuid.UUID]*Puppet),
puppetsByCustomMXID: make(map[id.UserID]*Puppet),
}
br.Bridge = bridge.Bridge{
Name: "mautrix-signal",
URL: "https://github.com/mautrix/signal",
Description: "A Matrix-Signal puppeting bridge.",
Version: "0.4.99",
ProtocolName: "Signal",
BeeperServiceName: "signal",
BeeperNetworkName: "signal",
CryptoPickleKey: "mautrix.bridge.e2ee",
ConfigUpgrader: &configupgrade.StructUpgrader{
SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade),
Blocks: config.SpacedBlocks,
Base: ExampleConfig,
},
Child: br,
}
br.InitVersion(Tag, Commit, BuildTime)
br.Main()
}

311
messagetracking.go Normal file
View file

@ -0,0 +1,311 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber
//
// 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/>.
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/msgconv"
)
var (
errUserNotConnected = errors.New("you are not connected to Signal")
errDifferentUser = errors.New("user is not the recipient of this private chat portal")
errUserNotLoggedIn = errors.New("user is not logged in and chat has no relay bot")
errRelaybotNotLoggedIn = errors.New("neither user nor relay bot of chat are logged in")
errCantRelayReactions = errors.New("user is not logged in and reactions can't be relayed")
errMNoticeDisabled = errors.New("bridging m.notice messages is disabled")
errUnexpectedParsedContentType = errors.New("unexpected parsed content type")
errRedactionTargetNotFound = errors.New("redaction target message was not found")
errRedactionTargetSentBySomeoneElse = errors.New("redaction target message was sent by someone else")
errUnreactTargetSentBySomeoneElse = errors.New("redaction target reaction was sent by someone else")
errReactionTargetNotFound = errors.New("reaction target message not found")
errEditUnknownTarget = errors.New("unknown edit target message")
errFailedToGetEditTarget = errors.New("failed to get edit target message")
errEditDifferentSender = errors.New("can't edit message sent by another user")
errEditTooOld = errors.New("message is too old to be edited")
errMessageTakingLong = errors.New("bridging the message is taking longer than usual")
errTimeoutBeforeHandling = errors.New("message timed out before handling was started")
)
func errorToStatusReason(err error) (reason event.MessageStatusReason, status event.MessageStatus, isCertain, sendNotice bool, humanMessage string) {
switch {
case errors.Is(err, errUnexpectedParsedContentType),
errors.Is(err, msgconv.ErrUnsupportedMsgType),
errors.Is(err, msgconv.ErrInvalidGeoURI):
return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, ""
case errors.Is(err, errMNoticeDisabled):
return event.MessageStatusUnsupported, event.MessageStatusFail, true, false, ""
case errors.Is(err, errEditDifferentSender),
errors.Is(err, errEditTooOld),
errors.Is(err, errEditUnknownTarget):
return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, err.Error()
case errors.Is(err, errTimeoutBeforeHandling):
return event.MessageStatusTooOld, event.MessageStatusRetriable, true, true, "the message was too old when it reached the bridge, so it was not handled"
case errors.Is(err, context.DeadlineExceeded):
return event.MessageStatusTooOld, event.MessageStatusRetriable, false, true, "handling the message took too long and was cancelled"
case errors.Is(err, errMessageTakingLong):
return event.MessageStatusTooOld, event.MessageStatusPending, false, true, err.Error()
case errors.Is(err, errRedactionTargetNotFound),
errors.Is(err, errReactionTargetNotFound),
errors.Is(err, errRedactionTargetSentBySomeoneElse),
errors.Is(err, errUnreactTargetSentBySomeoneElse):
return event.MessageStatusGenericError, event.MessageStatusFail, true, false, ""
case errors.Is(err, errUserNotConnected):
return event.MessageStatusGenericError, event.MessageStatusRetriable, true, true, ""
case errors.Is(err, errUserNotLoggedIn),
errors.Is(err, errDifferentUser),
errors.Is(err, errRelaybotNotLoggedIn):
return event.MessageStatusGenericError, event.MessageStatusRetriable, true, false, ""
default:
return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, ""
}
}
func (portal *Portal) sendErrorMessage(ctx context.Context, evt *event.Event, err error, confirmed bool, editID id.EventID) id.EventID {
if !portal.bridge.Config.Bridge.MessageErrorNotices {
return ""
}
certainty := "may not have been"
if confirmed {
certainty = "was not"
}
var msgType string
switch evt.Type {
case event.EventMessage:
msgType = "message"
case event.EventReaction:
msgType = "reaction"
case event.EventRedaction:
msgType = "redaction"
//case TypeMSC3381PollResponse, TypeMSC3381V2PollResponse:
// msgType = "poll response"
//case TypeMSC3381PollStart:
// msgType = "poll start"
default:
msgType = "unknown event"
}
msg := fmt.Sprintf("\u26a0 Your %s %s bridged: %v", msgType, certainty, err)
if errors.Is(err, errMessageTakingLong) {
msg = fmt.Sprintf("\u26a0 Bridging your %s is taking longer than usual", msgType)
}
content := &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: msg,
}
if editID != "" {
content.SetEdit(editID)
} else {
content.SetReply(evt)
}
resp, err := portal.sendMainIntentMessage(ctx, content)
if err != nil {
portal.log.Err(err).Msg("Failed to send bridging error message")
return ""
}
return resp.EventID
}
func (portal *Portal) sendStatusEvent(ctx context.Context, evtID, lastRetry id.EventID, err error, deliveredTo *[]id.UserID) {
if !portal.bridge.Config.Bridge.MessageStatusEvents {
return
}
if lastRetry == evtID {
lastRetry = ""
}
intent := portal.bridge.Bot
if !portal.Encrypted {
// Bridge bot isn't present in unencrypted DMs
intent = portal.MainIntent()
}
content := event.BeeperMessageStatusEventContent{
Network: portal.getBridgeInfoStateKey(),
RelatesTo: event.RelatesTo{
Type: event.RelReference,
EventID: evtID,
},
DeliveredToUsers: deliveredTo,
LastRetry: lastRetry,
}
if err == nil {
content.Status = event.MessageStatusSuccess
} else {
content.Reason, content.Status, _, _, content.Message = errorToStatusReason(err)
content.Error = err.Error()
}
_, err = intent.SendMessageEvent(ctx, portal.MXID, event.BeeperMessageStatus, &content)
if err != nil {
portal.log.Err(err).Msg("Failed to send message status event")
}
}
func (portal *Portal) sendDeliveryReceipt(ctx context.Context, eventID id.EventID) {
if portal.bridge.Config.Bridge.DeliveryReceipts {
err := portal.bridge.Bot.SendReceipt(ctx, portal.MXID, eventID, event.ReceiptTypeRead, nil)
if err != nil {
portal.log.Debug().Err(err).Stringer("event_id", eventID).Msg("Failed to send delivery receipt")
}
}
}
func (portal *Portal) sendMessageMetrics(ctx context.Context, evt *event.Event, err error, part string, ms *metricSender) {
log := portal.log.With().
Str("handling_step", part).
Str("event_type", evt.Type.String()).
Stringer("event_id", evt.ID).
Stringer("sender", evt.Sender).
Logger()
if evt.Type == event.EventRedaction {
log = log.With().Stringer("redacts", evt.Redacts).Logger()
}
ctx = log.WithContext(ctx)
origEvtID := evt.ID
if retryMeta := evt.Content.AsMessage().MessageSendRetry; retryMeta != nil {
origEvtID = retryMeta.OriginalEventID
}
if err != nil {
logEvt := log.Error()
if part == "Ignoring" {
logEvt = log.Debug()
}
logEvt.Err(err).Msg("Sending message metrics for event")
reason, statusCode, isCertain, sendNotice, _ := errorToStatusReason(err)
checkpointStatus := status.ReasonToCheckpointStatus(reason, statusCode)
portal.bridge.SendMessageCheckpoint(evt, status.MsgStepRemote, err, checkpointStatus, ms.getRetryNum())
if sendNotice {
ms.setNoticeID(portal.sendErrorMessage(ctx, evt, err, isCertain, ms.getNoticeID()))
}
portal.sendStatusEvent(ctx, origEvtID, evt.ID, err, nil)
} else {
log.Debug().Msg("Sending metrics for successfully handled Matrix event")
portal.sendDeliveryReceipt(ctx, evt.ID)
portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, ms.getRetryNum())
var deliveredTo *[]id.UserID
if portal.IsPrivateChat() {
deliveredTo = &[]id.UserID{}
}
portal.sendStatusEvent(ctx, origEvtID, evt.ID, nil, deliveredTo)
if prevNotice := ms.popNoticeID(); prevNotice != "" {
_, _ = portal.MainIntent().RedactEvent(ctx, portal.MXID, prevNotice, mautrix.ReqRedact{
Reason: "error resolved",
})
}
}
if ms != nil {
log.Debug().Object("timings", ms.timings).Msg("Timings for event")
}
}
type messageTimings struct {
initReceive time.Duration
decrypt time.Duration
implicitRR time.Duration
portalQueue time.Duration
totalReceive time.Duration
preproc time.Duration
convert time.Duration
totalSend time.Duration
}
func niceRound(dur time.Duration) time.Duration {
switch {
case dur < time.Millisecond:
return dur
case dur < time.Second:
return dur.Round(100 * time.Microsecond)
default:
return dur.Round(time.Millisecond)
}
}
func (mt *messageTimings) MarshalZerologObject(evt *zerolog.Event) {
evt.
Dict("bridge", zerolog.Dict().
Str("init_receive", niceRound(mt.initReceive).String()).
Str("decrypt", niceRound(mt.decrypt).String()).
Str("queue", niceRound(mt.portalQueue).String()).
Str("total_hs_to_portal", niceRound(mt.totalReceive).String())).
Dict("portal", zerolog.Dict().
Str("implicit_rr", niceRound(mt.implicitRR).String()).
Str("preproc", niceRound(mt.preproc).String()).
Str("convert", niceRound(mt.convert).String()).
Str("total_send", niceRound(mt.totalSend).String()))
}
type metricSender struct {
portal *Portal
previousNotice id.EventID
lock sync.Mutex
completed bool
retryNum int
timings *messageTimings
ctx context.Context
}
func (ms *metricSender) getRetryNum() int {
if ms != nil {
return ms.retryNum
}
return 0
}
func (ms *metricSender) getNoticeID() id.EventID {
if ms == nil {
return ""
}
return ms.previousNotice
}
func (ms *metricSender) popNoticeID() id.EventID {
if ms == nil {
return ""
}
evtID := ms.previousNotice
ms.previousNotice = ""
return evtID
}
func (ms *metricSender) setNoticeID(evtID id.EventID) {
if ms != nil && ms.previousNotice == "" {
ms.previousNotice = evtID
}
}
func (ms *metricSender) sendMessageMetrics(evt *event.Event, err error, part string, completed bool) {
ms.lock.Lock()
defer ms.lock.Unlock()
if !completed && ms.completed {
return
}
ms.portal.sendMessageMetrics(ms.ctx, evt, err, part, ms)
ms.retryNum++
ms.completed = completed
}

315
metrics.go Normal file
View file

@ -0,0 +1,315 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Element
//
// 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/>.
package main
import (
"context"
"net/http"
"runtime/debug"
"strconv"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/database"
)
type MetricsHandler struct {
db *database.Database
server *http.Server
log log.Logger
running bool
ctx context.Context
stopRecorder func()
matrixEventHandling *prometheus.HistogramVec
signalMessageAge prometheus.Histogram
signalMessageHandling *prometheus.HistogramVec
countCollection prometheus.Histogram
disconnections *prometheus.CounterVec
incomingRetryReceipts *prometheus.CounterVec
connectionFailures *prometheus.CounterVec
puppetCount prometheus.Gauge
userCount prometheus.Gauge
messageCount prometheus.Gauge
portalCount *prometheus.GaugeVec
encryptedGroupCount prometheus.Gauge
encryptedPrivateCount prometheus.Gauge
unencryptedGroupCount prometheus.Gauge
unencryptedPrivateCount prometheus.Gauge
connected prometheus.Gauge
connectedState map[string]bool
connectedStateLock sync.Mutex
loggedIn prometheus.Gauge
loggedInState map[string]bool
loggedInStateLock sync.Mutex
}
func NewMetricsHandler(address string, log log.Logger, db *database.Database) *MetricsHandler {
portalCount := promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "bridge_portals_total",
Help: "Number of portal rooms on Matrix",
}, []string{"type", "encrypted"})
return &MetricsHandler{
db: db,
server: &http.Server{Addr: address, Handler: promhttp.Handler()},
log: log,
running: false,
matrixEventHandling: promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "matrix_event",
Help: "Time spent processing Matrix events",
}, []string{"event_type"}),
signalMessageAge: promauto.NewHistogram(prometheus.HistogramOpts{
Name: "remote_event_age",
Help: "Age of messages received from Signal",
Buckets: []float64{1, 2, 3, 5, 7.5, 10, 20, 30, 60},
}),
signalMessageHandling: promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "remote_event",
Help: "Time spent processing Signal messages",
}, []string{"message_type"}),
countCollection: promauto.NewHistogram(prometheus.HistogramOpts{
Name: "bridge_count_collection",
Help: "Time spent collecting the bridge_*_total metrics",
}),
disconnections: promauto.NewCounterVec(prometheus.CounterOpts{
Name: "bridge_disconnections",
Help: "Number of times a Matrix user has been disconnected from Signal",
}, []string{"user_id"}),
connectionFailures: promauto.NewCounterVec(prometheus.CounterOpts{
Name: "bridge_connection_failures",
Help: "Number of times a connection has failed to Signal",
}, []string{"reason"}),
incomingRetryReceipts: promauto.NewCounterVec(prometheus.CounterOpts{
Name: "bridge_incoming_retry_receipts",
Help: "Number of times a remote Signal user has requested a retry from the bridge. retry_count = 5 is usually the last attempt (and very likely means a failed message)",
}, []string{"retry_count", "message_found"}),
puppetCount: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bridge_puppets_total",
Help: "Number of Signal users bridged into Matrix",
}),
userCount: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bridge_users_total",
Help: "Number of Matrix users using the bridge",
}),
messageCount: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bridge_messages_total",
Help: "Number of messages bridged",
}),
portalCount: portalCount,
encryptedGroupCount: portalCount.With(prometheus.Labels{"type": "group", "encrypted": "true"}),
encryptedPrivateCount: portalCount.With(prometheus.Labels{"type": "private", "encrypted": "true"}),
unencryptedGroupCount: portalCount.With(prometheus.Labels{"type": "group", "encrypted": "false"}),
unencryptedPrivateCount: portalCount.With(prometheus.Labels{"type": "private", "encrypted": "false"}),
loggedIn: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bridge_logged_in",
Help: "Bridge users logged into Signal",
}),
loggedInState: make(map[string]bool),
connected: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bridge_connected",
Help: "Bridge users connected to Signal",
}),
connectedState: make(map[string]bool),
}
}
func noop() {}
func (mh *MetricsHandler) TrackMatrixEvent(eventType event.Type) func() {
if !mh.running {
return noop
}
start := time.Now()
return func() {
duration := time.Since(start)
mh.matrixEventHandling.
With(prometheus.Labels{"event_type": eventType.Type}).
Observe(duration.Seconds())
}
}
func (mh *MetricsHandler) TrackSignalMessage(timestamp time.Time, messageType string) func() {
if !mh.running {
return noop
}
start := time.Now()
return func() {
duration := time.Since(start)
mh.signalMessageHandling.
With(prometheus.Labels{"message_type": messageType}).
Observe(duration.Seconds())
mh.signalMessageAge.Observe(time.Since(timestamp).Seconds())
}
}
func (mh *MetricsHandler) TrackDisconnection(userID id.UserID) {
if !mh.running {
return
}
mh.disconnections.With(prometheus.Labels{"user_id": string(userID)}).Inc()
}
func (mh *MetricsHandler) TrackConnectionFailure(reason string) {
if !mh.running {
return
}
mh.connectionFailures.With(prometheus.Labels{"reason": reason}).Inc()
}
func (mh *MetricsHandler) TrackRetryReceipt(count int, found bool) {
if !mh.running {
return
}
mh.incomingRetryReceipts.With(prometheus.Labels{
"retry_count": strconv.Itoa(count),
"message_found": strconv.FormatBool(found),
}).Inc()
}
func (mh *MetricsHandler) TrackLoginState(signalID string, loggedIn bool) {
if !mh.running {
return
}
mh.loggedInStateLock.Lock()
defer mh.loggedInStateLock.Unlock()
currentVal, ok := mh.loggedInState[signalID]
if !ok || currentVal != loggedIn {
mh.loggedInState[signalID] = loggedIn
if loggedIn {
mh.loggedIn.Inc()
} else {
mh.loggedIn.Dec()
}
}
}
func (mh *MetricsHandler) TrackConnectionState(signalID string, connected bool) {
if !mh.running {
return
}
mh.connectedStateLock.Lock()
defer mh.connectedStateLock.Unlock()
currentVal, ok := mh.connectedState[signalID]
if !ok || currentVal != connected {
mh.connectedState[signalID] = connected
if connected {
mh.connected.Inc()
} else {
mh.connected.Dec()
}
}
}
func (mh *MetricsHandler) updateStats() {
start := time.Now()
var puppetCount int
err := mh.db.QueryRow(mh.ctx, "SELECT COUNT(*) FROM puppet").Scan(&puppetCount)
if err != nil {
mh.log.Warnln("Failed to scan number of puppets:", err)
} else {
mh.puppetCount.Set(float64(puppetCount))
}
var userCount int
err = mh.db.QueryRow(mh.ctx, `SELECT COUNT(*) FROM "user"`).Scan(&userCount)
if err != nil {
mh.log.Warnln("Failed to scan number of users:", err)
} else {
mh.userCount.Set(float64(userCount))
}
var messageCount int
err = mh.db.QueryRow(mh.ctx, "SELECT COUNT(*) FROM message").Scan(&messageCount)
if err != nil {
mh.log.Warnln("Failed to scan number of messages:", err)
} else {
mh.messageCount.Set(float64(messageCount))
}
var encryptedGroupCount, encryptedPrivateCount, unencryptedGroupCount, unencryptedPrivateCount int
// TODO Use a more precise way to check if a chat_id is a UUID.
// It should also be compatible with both SQLite & Postgres.
err = mh.db.QueryRow(mh.ctx, `
SELECT
COUNT(CASE WHEN chat_id NOT LIKE '%-%-%-%-%' AND encrypted THEN 1 END) AS encrypted_group_portals,
COUNT(CASE WHEN chat_id LIKE '%-%-%-%-%' AND encrypted THEN 1 END) AS encrypted_private_portals,
COUNT(CASE WHEN chat_id NOT LIKE '%-%-%-%-%' AND NOT encrypted THEN 1 END) AS unencrypted_group_portals,
COUNT(CASE WHEN chat_id LIKE '%-%-%-%-%' AND NOT encrypted THEN 1 END) AS unencrypted_private_portals
FROM portal WHERE mxid<>''
`).Scan(&encryptedGroupCount, &encryptedPrivateCount, &unencryptedGroupCount, &unencryptedPrivateCount)
if err != nil {
mh.log.Warnln("Failed to scan number of portals:", err)
} else {
mh.encryptedGroupCount.Set(float64(encryptedGroupCount))
mh.encryptedPrivateCount.Set(float64(encryptedPrivateCount))
mh.unencryptedGroupCount.Set(float64(unencryptedGroupCount))
mh.unencryptedPrivateCount.Set(float64(encryptedPrivateCount))
}
mh.countCollection.Observe(time.Since(start).Seconds())
}
func (mh *MetricsHandler) startUpdatingStats() {
defer func() {
err := recover()
if err != nil {
mh.log.Fatalfln("Panic in metric updater: %v\n%s", err, string(debug.Stack()))
}
}()
ticker := time.Tick(10 * time.Second)
for {
mh.updateStats()
select {
case <-mh.ctx.Done():
return
case <-ticker:
}
}
}
func (mh *MetricsHandler) Start() {
mh.running = true
mh.ctx, mh.stopRecorder = context.WithCancel(context.Background())
go mh.startUpdatingStats()
err := mh.server.ListenAndServe()
mh.running = false
if err != nil && err != http.ErrServerClosed {
mh.log.Fatalln("Error in metrics listener:", err)
}
}
func (mh *MetricsHandler) Stop() {
if !mh.running {
return
}
mh.stopRecorder()
err := mh.server.Close()
if err != nil {
mh.log.Errorln("Error closing metrics listener:", err)
}
}

View file

@ -18,67 +18,50 @@ package msgconv
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "time"
"strings"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exmime" "go.mau.fi/util/exmime"
"go.mau.fi/util/ffmpeg" "go.mau.fi/util/ffmpeg"
"go.mau.fi/util/variationselector" "go.mau.fi/util/variationselector"
"golang.org/x/exp/constraints" "golang.org/x/exp/constraints"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/pkg/msgconv/matrixfmt" "go.mau.fi/mautrix-signal/msgconv/matrixfmt"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
) )
func (mc *MessageConverter) ToSignal( var (
ctx context.Context, ErrUnsupportedMsgType = errors.New("unsupported msgtype")
client *signalmeow.Client, ErrMediaDownloadFailed = errors.New("failed to download media")
portal *bridgev2.Portal, ErrMediaDecryptFailed = errors.New("failed to decrypt media")
evt *event.Event, ErrMediaConvertFailed = errors.New("failed to convert")
content *event.MessageEventContent, ErrMediaUploadFailed = errors.New("failed to upload media")
relaybotFormatted bool, ErrInvalidGeoURI = errors.New("invalid `geo:` URI in message")
replyTo *database.Message, )
) (*signalpb.DataMessage, error) {
ctx = context.WithValue(ctx, contextKeyClient, client) func (mc *MessageConverter) ToSignal(ctx context.Context, evt *event.Event, content *event.MessageEventContent, relaybotFormatted bool) (*signalpb.DataMessage, error) {
ctx = context.WithValue(ctx, contextKeyPortal, portal)
if evt.Type == event.EventSticker { if evt.Type == event.EventSticker {
content.MsgType = event.MessageType(event.EventSticker.Type) content.MsgType = event.MessageType(event.EventSticker.Type)
} }
// Matrix timestamps can be faked, but if the user is using their own Signal account, faking timestamps is their problem.
ts := uint64(evt.Timestamp)
// However, when relaying, timestamps shouldn't be trusted because anyone can send a message with any timestamp.
if relaybotFormatted {
ts = uint64(time.Now().UnixMilli())
}
dm := &signalpb.DataMessage{ dm := &signalpb.DataMessage{
Preview: mc.convertURLPreviewToSignal(ctx, content), Timestamp: &ts,
} Quote: mc.GetSignalReply(ctx, content),
if replyTo != nil { Preview: mc.convertURLPreviewToSignal(ctx, evt),
authorACI, messageID, err := signalid.ParseMessageID(replyTo.ID)
if err == nil {
dm.Quote = &signalpb.DataMessage_Quote{
Id: proto.Uint64(messageID),
AuthorAciBinary: authorACI[:],
Type: signalpb.DataMessage_Quote_NORMAL.Enum(),
}
if replyTo.Metadata.(*signalid.MessageMetadata).ContainsAttachments {
dm.Quote.Attachments = make([]*signalpb.DataMessage_Quote_QuotedAttachment, 1)
}
}
}
if content.BeeperDisappearingTimer != nil {
dm.ExpireTimer = proto.Uint32(uint32(content.BeeperDisappearingTimer.Timer.Seconds()))
} else if portal.Disappear.Timer > 0 {
dm.ExpireTimer = proto.Uint32(uint32(portal.Disappear.Timer.Seconds()))
}
if dm.ExpireTimer != nil && *dm.ExpireTimer != 0 {
timerVersion := portal.Metadata.(*signalid.PortalMetadata).ExpirationTimerVersion
if timerVersion > 0 {
dm.ExpireTimerVersion = &timerVersion
} }
if expirationTime := mc.GetData(ctx).ExpirationTime; expirationTime != 0 {
dm.ExpireTimer = proto.Uint32(uint32(expirationTime))
} }
if content.MsgType == event.MsgEmote && !relaybotFormatted { if content.MsgType == event.MsgEmote && !relaybotFormatted {
content.Body = "/me " + content.Body content.Body = "/me " + content.Body
@ -86,7 +69,7 @@ func (mc *MessageConverter) ToSignal(
content.FormattedBody = "/me " + content.FormattedBody content.FormattedBody = "/me " + content.FormattedBody
} }
} }
body, bodyRanges := matrixfmt.Parse(ctx, mc.MatrixFmtParams, content) body, bodyRanges := matrixfmt.Parse(mc.MatrixFmtParams, content)
switch content.MsgType { switch content.MsgType {
case event.MsgText, event.MsgNotice, event.MsgEmote: case event.MsgText, event.MsgNotice, event.MsgEmote:
dm.Body = proto.String(body) dm.Body = proto.String(body)
@ -110,9 +93,6 @@ func (mc *MessageConverter) ToSignal(
return nil, fmt.Errorf("failed to convert sticker: %w", err) return nil, fmt.Errorf("failed to convert sticker: %w", err)
} }
att.Flags = proto.Uint32(uint32(signalpb.AttachmentPointer_BORDERLESS)) att.Flags = proto.Uint32(uint32(signalpb.AttachmentPointer_BORDERLESS))
dm.Sticker = ParseStickerMeta(content.Info.BridgedSticker)
if dm.Sticker == nil {
var emoji *string var emoji *string
// TODO check for single grapheme cluster? // TODO check for single grapheme cluster?
if len([]rune(content.Body)) == 1 { if len([]rune(content.Body)) == 1 {
@ -124,20 +104,15 @@ func (mc *MessageConverter) ToSignal(
PackId: make([]byte, 16), PackId: make([]byte, 16),
PackKey: make([]byte, 32), PackKey: make([]byte, 32),
StickerId: proto.Uint32(0), StickerId: proto.Uint32(0),
Data: att,
Emoji: emoji, Emoji: emoji,
} }
}
dm.Sticker.Data = att
case event.MsgLocation: case event.MsgLocation:
lat, lon, err := parseGeoURI(content.GeoURI) // TODO implement
if err != nil { fallthrough
zerolog.Ctx(ctx).Err(err).Msg("Invalid geo URI")
return nil, err
}
locationString := fmt.Sprintf(mc.LocationFormat, lat, lon)
dm.Body = &locationString
default: default:
return nil, fmt.Errorf("%w %s", bridgev2.ErrUnsupportedMessageType, content.MsgType) return nil, fmt.Errorf("%w %s", ErrUnsupportedMsgType, content.MsgType)
} }
return dm, nil return dm, nil
} }
@ -151,36 +126,44 @@ func maybeInt[T constraints.Integer](v T) *T {
func (mc *MessageConverter) convertFileToSignal(ctx context.Context, evt *event.Event, content *event.MessageEventContent) (*signalpb.AttachmentPointer, error) { func (mc *MessageConverter) convertFileToSignal(ctx context.Context, evt *event.Event, content *event.MessageEventContent) (*signalpb.AttachmentPointer, error) {
log := zerolog.Ctx(ctx) log := zerolog.Ctx(ctx)
data, err := mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File) mxc := content.URL
if content.File != nil {
mxc = content.File.URL
}
data, err := mc.DownloadMatrixMedia(ctx, mxc)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err) return nil, exerrors.NewDualError(ErrMediaDownloadFailed, err)
}
if content.File != nil {
err = content.File.DecryptInPlace(data)
if err != nil {
return nil, exerrors.NewDualError(ErrMediaDecryptFailed, err)
}
} }
fileName := content.Body fileName := content.Body
if content.FileName != "" { if content.FileName != "" {
fileName = content.FileName fileName = content.FileName
} }
_, isVoice := evt.Content.Raw["org.matrix.msc3245.voice"]
mime := content.GetInfo().MimeType mime := content.GetInfo().MimeType
if mime == "" { if isVoice {
mime = http.DetectContentType(data) data, err = ffmpeg.ConvertBytes(ctx, data, ".m4a", []string{}, []string{"-c:a", "aac"}, mime)
}
if content.MSC3245Voice != nil && mime != "audio/aac" && ffmpeg.Supported() {
data, err = ffmpeg.ConvertBytes(ctx, data, ".aac", []string{}, []string{"-c:a", "aac"}, mime)
if err != nil { if err != nil {
return nil, err return nil, err
} }
mime = "audio/aac" mime = "audio/aac"
fileName += ".aac" fileName += ".m4a"
} else if evt.Type == event.EventSticker { } else if evt.Type == event.EventSticker && mime != "image/webp" && mime != "image/png" && mime != "image/apng" {
switch mime { switch mime {
case "image/webp", "image/png", "image/apng": case "image/webp", "image/png", "image/apng":
// allowed // allowed
case "image/gif": case "image/gif":
if !ffmpeg.Supported() { if !mc.ConvertGIFToAPNG {
return nil, fmt.Errorf("converting gif stickers is not supported") return nil, fmt.Errorf("converting gif stickers is not supported")
} }
data, err = ffmpeg.ConvertBytes(ctx, data, ".apng", []string{}, []string{}, mime) data, err = ffmpeg.ConvertBytes(ctx, data, ".apng", []string{}, []string{}, mime)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w (gif to apng): %w", bridgev2.ErrMediaConvertFailed, err) return nil, fmt.Errorf("%w gif to apng: %w", ErrMediaConvertFailed, err)
} }
fileName += ".apng" fileName += ".apng"
mime = "image/apng" mime = "image/apng"
@ -188,17 +171,14 @@ func (mc *MessageConverter) convertFileToSignal(ctx context.Context, evt *event.
return nil, fmt.Errorf("unsupported content type for sticker %s", mime) return nil, fmt.Errorf("unsupported content type for sticker %s", mime)
} }
} }
att, err := getClient(ctx).UploadAttachment(ctx, data) att, err := mc.GetClient(ctx).UploadAttachment(ctx, data)
if err != nil { if err != nil {
log.Err(err).Msg("Failed to upload file") log.Err(err).Msg("Failed to upload file")
return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaReuploadFailed, err) return nil, exerrors.NewDualError(ErrMediaUploadFailed, err)
} }
if content.MSC3245Voice != nil && mime == "audio/aac" { if isVoice {
att.Flags = proto.Uint32(uint32(signalpb.AttachmentPointer_VOICE_MESSAGE)) att.Flags = proto.Uint32(uint32(signalpb.AttachmentPointer_VOICE_MESSAGE))
} }
if content.Info.MauGIF {
att.Flags = proto.Uint32(uint32(signalpb.AttachmentPointer_GIF))
}
att.ContentType = proto.String(mime) att.ContentType = proto.String(mime)
att.FileName = &fileName att.FileName = &fileName
att.Height = maybeInt(uint32(content.Info.Height)) att.Height = maybeInt(uint32(content.Info.Height))
@ -210,20 +190,3 @@ func (mc *MessageConverter) convertFileToSignal(ctx context.Context, evt *event.
} }
return att, nil return att, nil
} }
func parseGeoURI(uri string) (lat, long string, err error) {
if !strings.HasPrefix(uri, "geo:") {
err = fmt.Errorf("uri doesn't have geo: prefix")
return
}
// Remove geo: prefix and anything after ;
coordinates := strings.Split(strings.TrimPrefix(uri, "geo:"), ";")[0]
splitCoordinates := strings.Split(coordinates, ",")
if len(splitCoordinates) != 2 {
err = fmt.Errorf("didn't find exactly two numbers separated by a comma")
} else {
lat = splitCoordinates[0]
long = splitCoordinates[1]
}
return
}

348
msgconv/from-signal.go Normal file
View file

@ -0,0 +1,348 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 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/>.
package msgconv
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/exfmt"
"go.mau.fi/util/exmime"
"go.mau.fi/util/ffmpeg"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/msgconv/signalfmt"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)
type ConvertedMessage struct {
Parts []*ConvertedMessagePart
Timestamp uint64
DisappearIn uint32
}
func (cm *ConvertedMessage) MergeCaption() {
if len(cm.Parts) != 2 || cm.Parts[1].Content.MsgType != event.MsgText {
return
}
switch cm.Parts[0].Content.MsgType {
case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile:
default:
return
}
mediaContent := cm.Parts[0].Content
textContent := cm.Parts[1].Content
mediaContent.FileName = mediaContent.Body
mediaContent.Body = textContent.Body
mediaContent.Format = textContent.Format
mediaContent.FormattedBody = textContent.FormattedBody
cm.Parts = cm.Parts[:1]
}
type ConvertedMessagePart struct {
Type event.Type
Content *event.MessageEventContent
Extra map[string]any
}
func calculateLength(dm *signalpb.DataMessage) int {
if dm.GetFlags()&uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE) != 0 {
return 1
}
if dm.Sticker != nil {
return 1
}
length := len(dm.Attachments) + len(dm.Contact)
if dm.Body != nil {
length++
}
if dm.Payment != nil {
length++
}
if dm.GiftBadge != nil {
length++
}
if length == 0 && dm.GetRequiredProtocolVersion() > uint32(signalpb.DataMessage_CURRENT) {
length = 1
}
return length
}
func CanConvertSignal(dm *signalpb.DataMessage) bool {
return calculateLength(dm) > 0
}
func (mc *MessageConverter) ToMatrix(ctx context.Context, dm *signalpb.DataMessage) *ConvertedMessage {
cm := &ConvertedMessage{
Timestamp: dm.GetTimestamp(),
DisappearIn: dm.GetExpireTimer(),
Parts: make([]*ConvertedMessagePart, 0, calculateLength(dm)),
}
if dm.GetFlags()&uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE) != 0 {
cm.Parts = append(cm.Parts, mc.ConvertDisappearingTimerChangeToMatrix(ctx, dm.GetExpireTimer(), true))
// Don't disappear disappearing timer changes
cm.DisappearIn = 0
// Don't allow any other parts in a disappearing timer change message
return cm
}
if dm.Sticker != nil {
cm.Parts = append(cm.Parts, mc.convertStickerToMatrix(ctx, dm.Sticker))
// Don't allow any other parts in a sticker message
return cm
}
for i, att := range dm.GetAttachments() {
cm.Parts = append(cm.Parts, mc.convertAttachmentToMatrix(ctx, i, att))
}
for _, contact := range dm.GetContact() {
cm.Parts = append(cm.Parts, mc.convertContactToMatrix(ctx, contact))
}
if dm.Payment != nil {
cm.Parts = append(cm.Parts, mc.convertPaymentToMatrix(ctx, dm.Payment))
}
if dm.GiftBadge != nil {
cm.Parts = append(cm.Parts, mc.convertGiftBadgeToMatrix(ctx, dm.GiftBadge))
}
if dm.Body != nil {
cm.Parts = append(cm.Parts, mc.convertTextToMatrix(ctx, dm))
}
if len(cm.Parts) == 0 && dm.GetRequiredProtocolVersion() > uint32(signalpb.DataMessage_CURRENT) {
cm.Parts = append(cm.Parts, &ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "The bridge does not support this message type yet.",
},
})
}
replyTo, sender := mc.GetMatrixReply(ctx, dm.Quote)
for _, part := range cm.Parts {
if part.Content.Mentions == nil {
part.Content.Mentions = &event.Mentions{}
}
if replyTo != "" {
part.Content.RelatesTo = (&event.RelatesTo{}).SetReplyTo(replyTo)
if !slices.Contains(part.Content.Mentions.UserIDs, sender) {
part.Content.Mentions.UserIDs = append(part.Content.Mentions.UserIDs, sender)
}
}
}
return cm
}
func (mc *MessageConverter) ConvertDisappearingTimerChangeToMatrix(ctx context.Context, timer uint32, updatePortal bool) *ConvertedMessagePart {
part := &ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: fmt.Sprintf("Disappearing messages set to %s", exfmt.Duration(time.Duration(timer)*time.Second)),
},
}
if timer == 0 {
part.Content.Body = "Disappearing messages disabled"
}
if updatePortal {
portal := mc.GetData(ctx)
portal.ExpirationTime = timer
err := portal.Update(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to update portal disappearing timer in database")
}
}
return part
}
func (mc *MessageConverter) convertTextToMatrix(ctx context.Context, dm *signalpb.DataMessage) *ConvertedMessagePart {
content := signalfmt.Parse(dm.GetBody(), dm.GetBodyRanges(), mc.SignalFmtParams)
extra := map[string]any{}
if len(dm.Preview) > 0 {
extra["com.beeper.linkpreviews"] = mc.convertURLPreviewsToBeeper(ctx, dm.Preview)
}
return &ConvertedMessagePart{
Type: event.EventMessage,
Content: content,
Extra: extra,
}
}
func (mc *MessageConverter) convertPaymentToMatrix(ctx context.Context, payment *signalpb.DataMessage_Payment) *ConvertedMessagePart {
return &ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Payments are not yet supported",
},
Extra: map[string]any{
"fi.mau.signal.payment": payment,
},
}
}
func (mc *MessageConverter) convertGiftBadgeToMatrix(ctx context.Context, giftBadge *signalpb.DataMessage_GiftBadge) *ConvertedMessagePart {
return &ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Gift badges are not yet supported",
},
Extra: map[string]any{
"fi.mau.signal.gift_badge": giftBadge,
},
}
}
func (mc *MessageConverter) convertContactToMatrix(ctx context.Context, contact *signalpb.DataMessage_Contact) *ConvertedMessagePart {
return &ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Contact messages are not yet supported",
},
Extra: map[string]any{
"fi.mau.signal.contact": contact,
},
}
}
func (mc *MessageConverter) convertAttachmentToMatrix(ctx context.Context, index int, att *signalpb.AttachmentPointer) *ConvertedMessagePart {
part, err := mc.reuploadAttachment(ctx, att)
if err != nil {
zerolog.Ctx(ctx).Err(err).Int("attachment_index", index).Msg("Failed to handle attachment")
return &ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: fmt.Sprintf("Failed to handle attachment %s: %v", att.GetFileName(), err),
},
}
}
return part
}
func (mc *MessageConverter) convertStickerToMatrix(ctx context.Context, sticker *signalpb.DataMessage_Sticker) *ConvertedMessagePart {
converted, err := mc.reuploadAttachment(ctx, sticker.GetData())
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to handle sticker")
return &ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: fmt.Sprintf("Failed to handle sticker: %v", err),
},
}
}
// Signal stickers are 512x512, so tell Matrix clients to render them as 256x256
if converted.Content.Info.Width == 512 && converted.Content.Info.Height == 512 {
converted.Content.Info.Width = 256
converted.Content.Info.Height = 256
}
converted.Content.Body = sticker.GetEmoji()
converted.Type = event.EventSticker
converted.Content.MsgType = ""
// TODO fetch full pack metadata like the old bridge did?
converted.Extra["fi.mau.signal.sticker"] = map[string]any{
"id": sticker.GetStickerId(),
"emoji": sticker.GetEmoji(),
"pack": map[string]any{
"id": sticker.GetPackId(),
"key": sticker.GetPackKey(),
},
}
return converted
}
func (mc *MessageConverter) reuploadAttachment(ctx context.Context, att *signalpb.AttachmentPointer) (*ConvertedMessagePart, error) {
data, err := signalmeow.DownloadAttachment(ctx, att)
if err != nil {
return nil, fmt.Errorf("failed to download attachment: %w", err)
}
mimeType := att.GetContentType()
if mimeType == "" {
mimeType = http.DetectContentType(data)
}
fileName := att.GetFileName()
extra := map[string]any{}
if mc.ConvertVoiceMessages && att.GetFlags()&uint32(signalpb.AttachmentPointer_VOICE_MESSAGE) != 0 {
data, err = ffmpeg.ConvertBytes(ctx, data, ".ogg", []string{}, []string{"-c:a", "libopus"}, mimeType)
if err != nil {
return nil, fmt.Errorf("failed to convert audio to ogg/opus: %w", err)
}
fileName += ".ogg"
mimeType = "audio/ogg"
extra["org.matrix.msc3245.voice"] = map[string]any{}
extra["org.matrix.msc1767.audio"] = map[string]any{}
}
var file *event.EncryptedFileInfo
uploadMime := mimeType
uploadFileName := fileName
if mc.GetData(ctx).Encrypted {
file = &event.EncryptedFileInfo{
EncryptedFile: *attachment.NewEncryptedFile(),
URL: "",
}
file.EncryptInPlace(data)
uploadMime = "application/octet-stream"
uploadFileName = ""
}
mxc, err := mc.UploadMatrixMedia(ctx, data, uploadFileName, uploadMime)
if err != nil {
return nil, err
}
content := &event.MessageEventContent{
Body: fileName,
Info: &event.FileInfo{
MimeType: mimeType,
Width: int(att.GetWidth()),
Height: int(att.GetHeight()),
Size: len(data),
},
}
if att.GetBlurHash() != "" {
content.Info.Blurhash = att.GetBlurHash()
content.Info.AnoaBlurhash = att.GetBlurHash()
}
switch strings.Split(mimeType, "/")[0] {
case "image":
content.MsgType = event.MsgImage
case "video":
content.MsgType = event.MsgVideo
case "audio":
content.MsgType = event.MsgAudio
default:
content.MsgType = event.MsgFile
}
if content.Body == "" {
content.Body = strings.TrimPrefix(string(content.MsgType), "m.") + exmime.ExtensionFromMimetype(mimeType)
}
if file != nil {
file.URL = mxc
content.File = file
} else {
content.URL = mxc
}
return &ConvertedMessagePart{
Type: event.EventMessage,
Content: content,
Extra: extra,
}, nil
}

View file

@ -17,20 +17,18 @@
package matrixfmt package matrixfmt
import ( import (
"context"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
) )
func Parse(ctx context.Context, parser *HTMLParser, content *event.MessageEventContent) (string, []*signalpb.BodyRange) { func Parse(parser *HTMLParser, content *event.MessageEventContent) (string, []*signalpb.BodyRange) {
if content.Format != event.FormatHTML { if content.Format != event.FormatHTML {
return content.Body, nil return content.Body, nil
} }
parseCtx := NewContext(ctx) ctx := NewContext()
parseCtx.AllowedMentions = content.Mentions ctx.AllowedMentions = content.Mentions
parsed := parser.Parse(content.FormattedBody, parseCtx) parsed := parser.Parse(content.FormattedBody, ctx)
if parsed == nil { if parsed == nil {
return "", nil return "", nil
} }

View file

@ -1,7 +1,6 @@
package matrixfmt_test package matrixfmt_test
import ( import (
"context"
"fmt" "fmt"
"testing" "testing"
@ -10,12 +9,12 @@ import (
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/pkg/msgconv/matrixfmt" "go.mau.fi/mautrix-signal/msgconv/matrixfmt"
"go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt" "go.mau.fi/mautrix-signal/msgconv/signalfmt"
) )
var formatParams = &matrixfmt.HTMLParser{ var formatParams = &matrixfmt.HTMLParser{
GetUUIDFromMXID: func(_ context.Context, id id.UserID) uuid.UUID { GetUUIDFromMXID: func(id id.UserID) uuid.UUID {
if id.Homeserver() == "signal" { if id.Homeserver() == "signal" {
return uuid.MustParse(id.Localpart()) return uuid.MustParse(id.Localpart())
} }
@ -24,7 +23,7 @@ var formatParams = &matrixfmt.HTMLParser{
} }
func TestParse_Empty(t *testing.T) { func TestParse_Empty(t *testing.T) {
text, entities := matrixfmt.Parse(context.TODO(), formatParams, &event.MessageEventContent{ text, entities := matrixfmt.Parse(formatParams, &event.MessageEventContent{
MsgType: event.MsgText, MsgType: event.MsgText,
Body: "", Body: "",
}) })
@ -33,7 +32,7 @@ func TestParse_Empty(t *testing.T) {
} }
func TestParse_EmptyHTML(t *testing.T) { func TestParse_EmptyHTML(t *testing.T) {
text, entities := matrixfmt.Parse(context.TODO(), formatParams, &event.MessageEventContent{ text, entities := matrixfmt.Parse(formatParams, &event.MessageEventContent{
MsgType: event.MsgText, MsgType: event.MsgText,
Body: "", Body: "",
Format: event.FormatHTML, Format: event.FormatHTML,
@ -44,7 +43,7 @@ func TestParse_EmptyHTML(t *testing.T) {
} }
func TestParse_Plaintext(t *testing.T) { func TestParse_Plaintext(t *testing.T) {
text, entities := matrixfmt.Parse(context.TODO(), formatParams, &event.MessageEventContent{ text, entities := matrixfmt.Parse(formatParams, &event.MessageEventContent{
MsgType: event.MsgText, MsgType: event.MsgText,
Body: "Hello world!", Body: "Hello world!",
}) })
@ -65,13 +64,6 @@ func TestParse_HTML(t *testing.T) {
Length: 5, Length: 5,
Value: signalfmt.StyleBold, Value: signalfmt.StyleBold,
}}}, }}},
{name: "UnnecessaryWhitespace", in: "<strong> Hello </strong>, World!", out: "Hello, World!", ent: signalfmt.BodyRangeList{{
Start: 0,
Length: 5,
Value: signalfmt.StyleBold,
}}},
{name: "UnnecessaryWhitespaceParagraph", in: "<p> Hello </p>", out: "Hello"},
{name: "EmptyParagraph", in: "<p>Hello</p><p> </p>", out: "Hello"},
{ {
name: "MultiBasic", name: "MultiBasic",
in: "<strong><em>Hell</em>o</strong>, <del>Wo<span data-mx-spoiler>rld</span></del><code>!</code>", in: "<strong><em>Hell</em>o</strong>, <del>Wo<span data-mx-spoiler>rld</span></del><code>!</code>",
@ -160,7 +152,7 @@ func TestParse_HTML(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
fmt.Println("--------------------------------------------------------------------------------") fmt.Println("--------------------------------------------------------------------------------")
parsed := formatParams.Parse(test.in, matrixfmt.NewContext(context.TODO())) parsed := formatParams.Parse(test.in, matrixfmt.NewContext())
assert.Equal(t, test.out, parsed.String.String()) assert.Equal(t, test.out, parsed.String.String())
assert.Equal(t, test.ent, parsed.Entities) assert.Equal(t, test.ent, parsed.Entities)
}) })

View file

@ -1,19 +1,18 @@
package matrixfmt package matrixfmt
import ( import (
"context"
"fmt" "fmt"
"math" "math"
"slices"
"strconv" "strconv"
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/exp/slices"
"golang.org/x/net/html" "golang.org/x/net/html"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt" "go.mau.fi/mautrix-signal/msgconv/signalfmt"
) )
type EntityString struct { type EntityString struct {
@ -81,26 +80,22 @@ func (es *EntityString) TrimSpace() *EntityString {
return nil return nil
} }
DebugLog("TRIMSPACE %q %+v\n", es.String, es.Entities) DebugLog("TRIMSPACE %q %+v\n", es.String, es.Entities)
cutStart := 0 var cutEnd, cutStart int
for ; cutStart < len(es.String); cutStart++ { for cutStart = 0; cutStart < len(es.String); cutStart++ {
switch es.String[cutStart] { switch es.String[cutStart] {
case '\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0: case '\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0:
continue continue
} }
break break
} }
cutEnd := len(es.String) for cutEnd = len(es.String) - 1; cutEnd >= 0; cutEnd-- {
for ; cutEnd > cutStart; cutEnd-- { switch es.String[cutEnd] {
switch es.String[cutEnd-1] {
case '\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0: case '\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0:
continue continue
} }
break break
} }
if cutEnd == cutStart { cutEnd++
DebugLog(" -> EMPTY\n")
return NewEntityString("")
}
if cutStart == 0 && cutEnd == len(es.String) { if cutStart == 0 && cutEnd == len(es.String) {
DebugLog(" -> NOOP\n") DebugLog(" -> NOOP\n")
return es return es
@ -206,15 +201,13 @@ func (ts TagStack) Has(tag string) bool {
} }
type Context struct { type Context struct {
Ctx context.Context
AllowedMentions *event.Mentions AllowedMentions *event.Mentions
TagStack TagStack TagStack TagStack
PreserveWhitespace bool PreserveWhitespace bool
} }
func NewContext(ctx context.Context) Context { func NewContext() Context {
return Context{ return Context{
Ctx: ctx,
TagStack: make(TagStack, 0, 4), TagStack: make(TagStack, 0, 4),
} }
} }
@ -231,7 +224,7 @@ func (ctx Context) WithWhitespace() Context {
// HTMLParser is a somewhat customizable Matrix HTML parser. // HTMLParser is a somewhat customizable Matrix HTML parser.
type HTMLParser struct { type HTMLParser struct {
GetUUIDFromMXID func(context.Context, id.UserID) uuid.UUID GetUUIDFromMXID func(id.UserID) uuid.UUID
} }
// TaggedString is a string that also contains a HTML tag. // TaggedString is a string that also contains a HTML tag.
@ -362,7 +355,7 @@ func (parser *HTMLParser) linkToString(node *html.Node, ctx Context) *EntityStri
// Mention not allowed, use name as-is // Mention not allowed, use name as-is
return str return str
} }
u := parser.GetUUIDFromMXID(ctx.Ctx, mxid) u := parser.GetUUIDFromMXID(mxid)
if u == uuid.Nil { if u == uuid.Nil {
// Don't include the link for mentions of non-Signal users, the name is enough // Don't include the link for mentions of non-Signal users, the name is enough
return str return str

62
msgconv/msgconv.go Normal file
View file

@ -0,0 +1,62 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2024 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/>.
package msgconv
import (
"context"
"github.com/google/uuid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/database"
"go.mau.fi/mautrix-signal/msgconv/matrixfmt"
"go.mau.fi/mautrix-signal/msgconv/signalfmt"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)
type PortalMethods interface {
UploadMatrixMedia(ctx context.Context, data []byte, fileName, contentType string) (id.ContentURIString, error)
DownloadMatrixMedia(ctx context.Context, uri id.ContentURIString) ([]byte, error)
GetMatrixReply(ctx context.Context, msg *signalpb.DataMessage_Quote) (replyTo id.EventID, replyTargetSender id.UserID)
GetSignalReply(ctx context.Context, content *event.MessageEventContent) *signalpb.DataMessage_Quote
GetClient(ctx context.Context) *signalmeow.Client
GetData(ctx context.Context) *database.Portal
}
type ExtendedPortalMethods interface {
QueueFileTransfer(ctx context.Context, msgTS uint64, fileName string, ap *signalpb.AttachmentPointer) (id.ContentURIString, error)
}
type MessageConverter struct {
PortalMethods
SignalFmtParams *signalfmt.FormatParams
MatrixFmtParams *matrixfmt.HTMLParser
ConvertVoiceMessages bool
ConvertGIFToAPNG bool
MaxFileSize int64
AsyncFiles bool
}
func (mc *MessageConverter) IsPrivateChat(ctx context.Context) bool {
return mc.GetData(ctx).UserID() != uuid.Nil
}

View file

@ -17,14 +17,12 @@
package signalfmt package signalfmt
import ( import (
"context"
"html" "html"
"slices"
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/rs/zerolog"
"golang.org/x/exp/maps" "golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@ -37,7 +35,7 @@ type UserInfo struct {
} }
type FormatParams struct { type FormatParams struct {
GetUserInfo func(ctx context.Context, uuid uuid.UUID) UserInfo GetUserInfo func(uuid uuid.UUID) UserInfo
} }
type formatContext struct { type formatContext struct {
@ -51,7 +49,7 @@ func (ctx formatContext) TextToHTML(text string) string {
return event.TextToHTML(text) return event.TextToHTML(text)
} }
func Parse(ctx context.Context, message string, ranges []*signalpb.BodyRange, params *FormatParams) *event.MessageEventContent { func Parse(message string, ranges []*signalpb.BodyRange, params *FormatParams) *event.MessageEventContent {
content := &event.MessageEventContent{ content := &event.MessageEventContent{
MsgType: event.MsgText, MsgType: event.MsgText,
Body: message, Body: message,
@ -86,27 +84,15 @@ func Parse(ctx context.Context, message string, ranges []*signalpb.BodyRange, pa
Start: int(*r.Start), Start: int(*r.Start),
Length: int(*r.Length), Length: int(*r.Length),
}.TruncateEnd(maxLength) }.TruncateEnd(maxLength)
var mentionACI uuid.UUID
switch rv := r.GetAssociatedValue().(type) { switch rv := r.GetAssociatedValue().(type) {
case *signalpb.BodyRange_Style_: case *signalpb.BodyRange_Style_:
br.Value = Style(rv.Style) br.Value = Style(rv.Style)
case *signalpb.BodyRange_MentionAci: case *signalpb.BodyRange_MentionAci:
var err error parsed, err := uuid.Parse(rv.MentionAci)
mentionACI, err = uuid.Parse(rv.MentionAci)
if err != nil { if err != nil {
continue continue
} }
case *signalpb.BodyRange_MentionAciBinary: userInfo := params.GetUserInfo(parsed)
if len(rv.MentionAciBinary) != 16 {
continue
}
mentionACI = uuid.UUID(rv.MentionAciBinary)
default:
zerolog.Ctx(ctx).Warn().Type("value_type", rv).Msg("Unsupported body range type")
continue
}
if mentionACI != uuid.Nil {
userInfo := params.GetUserInfo(ctx, mentionACI)
if userInfo.MXID == "" { if userInfo.MXID == "" {
continue continue
} }
@ -115,7 +101,7 @@ func Parse(ctx context.Context, message string, ranges []*signalpb.BodyRange, pa
// Maybe use NewUTF16String and do index replacements for the plaintext body too, // Maybe use NewUTF16String and do index replacements for the plaintext body too,
// or just replace the plaintext body by parsing the generated HTML. // or just replace the plaintext body by parsing the generated HTML.
content.Body = strings.Replace(content.Body, "\uFFFC", userInfo.Name, 1) content.Body = strings.Replace(content.Body, "\uFFFC", userInfo.Name, 1)
br.Value = Mention{UserInfo: userInfo, UUID: mentionACI} br.Value = Mention{UserInfo: userInfo, UUID: parsed}
} }
lrt.Add(br) lrt.Add(br)
} }

View file

@ -17,7 +17,6 @@
package signalfmt_test package signalfmt_test
import ( import (
"context"
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
@ -26,7 +25,7 @@ import (
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt" "go.mau.fi/mautrix-signal/msgconv/signalfmt"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf" signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
) )
@ -34,7 +33,7 @@ var realUser = uuid.New()
func TestParse(t *testing.T) { func TestParse(t *testing.T) {
formatParams := &signalfmt.FormatParams{ formatParams := &signalfmt.FormatParams{
GetUserInfo: func(ctx context.Context, uuid uuid.UUID) signalfmt.UserInfo { GetUserInfo: func(uuid uuid.UUID) signalfmt.UserInfo {
if uuid == realUser { if uuid == realUser {
return signalfmt.UserInfo{ return signalfmt.UserInfo{
MXID: "@test:example.com", MXID: "@test:example.com",
@ -169,7 +168,7 @@ func TestParse(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
parsed := signalfmt.Parse(context.TODO(), test.ins, test.ine, formatParams) parsed := signalfmt.Parse(test.ins, test.ine, formatParams)
assert.Equal(t, test.body, parsed.Body) assert.Equal(t, test.body, parsed.Body)
assert.Equal(t, test.html, parsed.FormattedBody) assert.Equal(t, test.html, parsed.FormattedBody)
}) })

View file

@ -40,8 +40,8 @@ func (m Mention) String() string {
} }
func (m Mention) Proto() signalpb.BodyRangeAssociatedValue { func (m Mention) Proto() signalpb.BodyRangeAssociatedValue {
return &signalpb.BodyRange_MentionAciBinary{ return &signalpb.BodyRange_MentionAci{
MentionAciBinary: m.UUID[:], MentionAci: m.UUID.String(),
} }
} }

141
msgconv/urlpreview.go Normal file
View file

@ -0,0 +1,141 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 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/>.
package msgconv
import (
"context"
"encoding/json"
"regexp"
"time"
"github.com/rs/zerolog"
"github.com/tidwall/gjson"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)
type BeeperLinkPreview struct {
mautrix.RespPreviewURL
MatchedURL string `json:"matched_url"`
ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"`
}
func (mc *MessageConverter) convertURLPreviewsToBeeper(ctx context.Context, preview []*signalpb.Preview) []*BeeperLinkPreview {
output := make([]*BeeperLinkPreview, len(preview))
for i, p := range preview {
output[i] = mc.convertURLPreviewToBeeper(ctx, p)
}
return output
}
func (mc *MessageConverter) convertURLPreviewToBeeper(ctx context.Context, preview *signalpb.Preview) *BeeperLinkPreview {
output := &BeeperLinkPreview{
MatchedURL: preview.GetUrl(),
RespPreviewURL: mautrix.RespPreviewURL{
CanonicalURL: preview.GetUrl(),
Title: preview.GetTitle(),
Description: preview.GetDescription(),
},
}
if preview.Image != nil {
msg, err := mc.reuploadAttachment(ctx, preview.Image)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to reupload link preview image")
} else {
output.ImageURL = msg.Content.URL
output.ImageEncryption = msg.Content.File
output.ImageType = msg.Content.Info.MimeType
output.ImageSize = msg.Content.Info.Size
output.ImageHeight = msg.Content.Info.Height
output.ImageWidth = msg.Content.Info.Width
}
}
return output
}
var URLRegex = regexp.MustCompile(`https?://[^\s/_*]+(?:/\S*)?`)
func (mc *MessageConverter) convertURLPreviewToSignal(ctx context.Context, evt *event.Event) []*signalpb.Preview {
var previews []*BeeperLinkPreview
log := zerolog.Ctx(ctx)
rawPreview := gjson.GetBytes(evt.Content.VeryRaw, `com\.beeper\.linkpreviews`)
if rawPreview.Exists() && rawPreview.IsArray() {
if err := json.Unmarshal([]byte(rawPreview.Raw), &previews); err != nil || len(previews) == 0 {
return nil
}
} /* else if portal.bridge.Config.Bridge.URLPreviews {
if matchedURL := URLRegex.FindString(evt.Content.AsMessage().Body); len(matchedURL) == 0 {
return nil
} else if parsed, err := url.Parse(matchedURL); err != nil {
return nil
} else if parsed.Host, err = idna.ToASCII(parsed.Host); err != nil {
return nil
} else if mxPreview, err := portal.MainIntent().GetURLPreview(parsed.String()); err != nil {
log.Err(err).Str("matched_url", matchedURL).Msg("Failed to fetch preview for URL found in message")
return nil
} else {
previews = []*BeeperLinkPreview{{
RespPreviewURL: *mxPreview,
MatchedURL: matchedURL,
}}
}
}*/
if len(previews) == 0 {
return nil
}
output := make([]*signalpb.Preview, len(previews))
for i, preview := range previews {
output[i] = &signalpb.Preview{
Url: proto.String(preview.MatchedURL),
Title: proto.String(preview.Title),
Description: proto.String(preview.Description),
Date: proto.Uint64(uint64(time.Now().UnixMilli())),
}
imageMXC := preview.ImageURL
if preview.ImageEncryption != nil {
imageMXC = preview.ImageEncryption.URL
}
if imageMXC != "" {
data, err := mc.DownloadMatrixMedia(ctx, imageMXC)
if err != nil {
log.Err(err).Int("preview_index", i).Msg("Failed to download URL preview image")
continue
}
if preview.ImageEncryption != nil {
err = preview.ImageEncryption.DecryptInPlace(data)
if err != nil {
log.Err(err).Int("preview_index", i).Msg("Failed to decrypt URL preview image")
continue
}
}
uploaded, err := mc.GetClient(ctx).UploadAttachment(ctx, data)
if err != nil {
log.Err(err).Int("preview_index", i).Msg("Failed to reupload URL preview image")
continue
}
uploaded.ContentType = proto.String(preview.ImageType)
uploaded.Width = proto.Uint32(uint32(preview.ImageWidth))
uploaded.Height = proto.Uint32(uint32(preview.ImageHeight))
output[i].Image = uploaded
}
}
return output
}

View file

@ -1,207 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2025 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/>.
package connector
import (
"context"
"fmt"
"slices"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-signal/pkg/msgconv"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf/backuppb"
"go.mau.fi/mautrix-signal/pkg/signalmeow/store"
)
var _ bridgev2.BackfillingNetworkAPI = (*SignalClient)(nil)
func tryCastUUID(b []byte) uuid.UUID {
if len(b) == 16 {
return uuid.UUID(b)
}
return uuid.Nil
}
func (s *SignalClient) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) {
if !s.IsLoggedIn() {
return nil, bridgev2.ErrNotLoggedIn
}
userID, groupID, err := signalid.ParsePortalID(params.Portal.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse portal ID: %w", err)
}
var chat *store.BackupChat
if groupID != "" {
chat, err = s.Client.Store.BackupStore.GetBackupChatByGroupID(ctx, groupID)
} else {
chat, err = s.Client.Store.BackupStore.GetBackupChatByUserID(ctx, userID)
}
if err != nil {
return nil, fmt.Errorf("failed to get chat: %w", err)
} else if chat == nil {
zerolog.Ctx(ctx).Debug().Msg("Chat not found, returning nil response for backfill")
return nil, nil
}
var anchorTS time.Time
if params.AnchorMessage != nil {
anchorTS = params.AnchorMessage.Timestamp
}
minTS := anchorTS
items, err := s.Client.Store.BackupStore.GetBackupChatItems(ctx, chat.Id, anchorTS, params.Forward, params.Count)
if err != nil {
return nil, fmt.Errorf("failed to get chat items: %w", err)
}
if len(items) > 0 {
minTS = time.UnixMilli(int64(items[0].DateSent))
}
// GetBackupChatItems returns in reverse chronological order, so flip the list
slices.Reverse(items)
var firstDirectionfulProcessed bool
var isRead bool
convertedMessages := make([]*bridgev2.BackfillMessage, 0, len(items))
attMap := make(msgconv.AttachmentMap)
recipientMap := make(map[uint64]*backuppb.Recipient)
getRecipientACI := func(id uint64) (uuid.UUID, error) {
recipient, ok := recipientMap[id]
if !ok {
recipient, err = s.Client.Store.BackupStore.GetBackupRecipient(ctx, id)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to get recipient %d: %w", id, err)
} else if len(recipient.GetContact().GetAci()) != 16 && recipient.GetSelf() == nil {
zerolog.Ctx(ctx).Warn().
Uint64("recipient_id", id).
Type("recipient_type", recipient.GetDestination()).
Msg("ACI not found for recipient")
}
recipientMap[id] = recipient
}
switch dest := recipient.Destination.(type) {
case *backuppb.Recipient_Self:
return s.Client.Store.ACI, nil
case *backuppb.Recipient_Contact:
if len(dest.Contact.GetAci()) == 16 {
return uuid.UUID(dest.Contact.GetAci()), nil
}
}
return uuid.Nil, nil
}
var prevStreamOrder int64
findNextStreamOrder := func(i int) int64 {
for ; i < len(items); i++ {
inc, ok := items[i].DirectionalDetails.(*backuppb.ChatItem_Incoming)
if ok {
return int64(inc.Incoming.GetDateServerSent())
}
}
return time.Now().UnixMilli()
}
for i, item := range items {
var streamOrder int64
switch dt := item.DirectionalDetails.(type) {
case *backuppb.ChatItem_Incoming:
streamOrder = int64(dt.Incoming.GetDateServerSent())
prevStreamOrder = streamOrder
if !firstDirectionfulProcessed {
firstDirectionfulProcessed = true
isRead = dt.Incoming.Read
}
case *backuppb.ChatItem_Outgoing:
streamOrder = int64(item.GetDateSent())
// Ensure stream order is higher than previous incoming item, but lower than next incoming item
streamOrder = min(streamOrder, findNextStreamOrder(i+1)-1)
streamOrder = max(streamOrder, prevStreamOrder+1)
if !firstDirectionfulProcessed {
firstDirectionfulProcessed = true
isRead = true
}
}
if len(attMap) > 0 {
clear(attMap)
}
senderACI, err := getRecipientACI(item.AuthorId)
if err != nil {
return nil, err
} else if senderACI == uuid.Nil {
continue
}
dm, reactions := msgconv.BackupToDataMessage(item, attMap)
if dm == nil {
continue
}
cm := s.Main.MsgConv.ToMatrix(ctx, s.Client, params.Portal, senderACI, s.Main.Bridge.Bot, dm, attMap)
convertedReactions := make([]*bridgev2.BackfillReaction, 0, len(reactions))
for _, reaction := range reactions {
reactionSenderACI, err := getRecipientACI(reaction.AuthorId)
if err != nil {
return nil, err
} else if reactionSenderACI == uuid.Nil {
continue
}
convertedReactions = append(convertedReactions, &bridgev2.BackfillReaction{
TargetPart: ptr.Ptr(networkid.PartID("")),
Timestamp: time.UnixMilli(int64(reaction.SentTimestamp)),
Sender: s.makeEventSender(reactionSenderACI),
Emoji: reaction.GetEmoji(),
})
}
msgID := signalid.MakeMessageID(senderACI, item.DateSent)
convertedMessages = append(convertedMessages, &bridgev2.BackfillMessage{
ConvertedMessage: cm,
Sender: s.makeEventSender(senderACI),
ID: msgID,
TxnID: networkid.TransactionID(msgID),
Timestamp: time.UnixMilli(int64(item.DateSent)),
StreamOrder: streamOrder,
Reactions: convertedReactions,
})
}
return &bridgev2.FetchMessagesResponse{
Messages: convertedMessages,
HasMore: len(items) >= params.Count,
Forward: params.Forward,
MarkRead: isRead,
ApproxTotalCount: chat.TotalMessages,
CompleteCallback: func() {
// When reaching the last backwards backfill batch, delete the chat from the backup store.
// If backwards backfilling isn't enabled, delete immediately after the first backfill request.
if (!params.Forward && len(items) < params.Count) || !s.Main.Bridge.Config.Backfill.Queue.AnyEnabled() {
err := s.Client.Store.BackupStore.DeleteBackupChat(ctx, chat.Id)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to delete chat from backup store")
} else {
zerolog.Ctx(ctx).Debug().Msg("Deleted chat from backup store as backfill seems finished")
}
} else {
err := s.Client.Store.BackupStore.DeleteBackupChatItems(ctx, chat.Id, minTS)
if err != nil {
zerolog.Ctx(ctx).Err(err).Time("min_ts", minTS).Msg("Failed to delete messages from backup store")
} else {
zerolog.Ctx(ctx).Debug().Time("min_ts", minTS).Msg("Deleted messages from backup store")
}
}
},
}, nil
}

View file

@ -1,241 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2025 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/>.
package connector
import (
"context"
"time"
"go.mau.fi/util/ffmpeg"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
)
func supportedIfFFmpeg() event.CapabilitySupportLevel {
if ffmpeg.Supported() {
return event.CapLevelPartialSupport
}
return event.CapLevelRejected
}
func capID() string {
base := "fi.mau.signal.capabilities.2026_05_12"
if ffmpeg.Supported() {
return base + "+ffmpeg"
}
return base
}
const MaxFileSize = 100 * 1024 * 1024
const MaxTextLength = 2000
var signalCaps = &event.RoomFeatures{
ID: capID(),
Formatting: map[event.FormattingFeature]event.CapabilitySupportLevel{
// Features that Signal supports natively
event.FmtBold: event.CapLevelFullySupported,
event.FmtItalic: event.CapLevelFullySupported,
event.FmtStrikethrough: event.CapLevelFullySupported,
event.FmtSpoiler: event.CapLevelFullySupported,
event.FmtInlineCode: event.CapLevelFullySupported,
event.FmtCodeBlock: event.CapLevelFullySupported,
event.FmtUserLink: event.CapLevelFullySupported,
// Features that aren't supported on Signal, but are converted into a markdown-like representation
event.FmtBlockquote: event.CapLevelPartialSupport,
event.FmtInlineLink: event.CapLevelPartialSupport,
event.FmtUnorderedList: event.CapLevelPartialSupport,
event.FmtOrderedList: event.CapLevelPartialSupport,
event.FmtListStart: event.CapLevelPartialSupport,
event.FmtHeaders: event.CapLevelPartialSupport,
},
File: map[event.CapabilityMsgType]*event.FileFeatures{
event.MsgImage: {
MimeTypes: map[string]event.CapabilitySupportLevel{
"image/gif": event.CapLevelFullySupported,
"image/png": event.CapLevelFullySupported,
"image/jpeg": event.CapLevelFullySupported,
"image/webp": event.CapLevelFullySupported,
"image/bmp": event.CapLevelFullySupported,
},
MaxWidth: 4096,
MaxHeight: 4096,
MaxSize: MaxFileSize,
Caption: event.CapLevelFullySupported,
MaxCaptionLength: MaxTextLength,
},
event.MsgVideo: {
MimeTypes: map[string]event.CapabilitySupportLevel{
"video/mp4": event.CapLevelFullySupported,
"video/ogg": event.CapLevelFullySupported,
"video/webm": event.CapLevelFullySupported,
},
MaxSize: MaxFileSize,
Caption: event.CapLevelFullySupported,
MaxCaptionLength: MaxTextLength,
},
event.MsgAudio: {
MimeTypes: map[string]event.CapabilitySupportLevel{
"audio/aac": event.CapLevelFullySupported,
"audio/mpeg": event.CapLevelFullySupported,
},
MaxSize: MaxFileSize,
},
event.MsgFile: {
MimeTypes: map[string]event.CapabilitySupportLevel{
"*/*": event.CapLevelFullySupported,
},
MaxSize: MaxFileSize,
Caption: event.CapLevelFullySupported,
MaxCaptionLength: MaxTextLength,
},
event.CapMsgSticker: {
MimeTypes: map[string]event.CapabilitySupportLevel{
// Signal clients will only render static webp, so apng is preferred
"image/webp": event.CapLevelPartialSupport,
"image/png": event.CapLevelFullySupported,
"image/apng": event.CapLevelFullySupported,
"image/gif": supportedIfFFmpeg(),
},
Caption: event.CapLevelDropped,
MaxSize: MaxFileSize,
},
event.CapMsgVoice: {
MimeTypes: map[string]event.CapabilitySupportLevel{
"audio/aac": event.CapLevelFullySupported,
"audio/ogg": supportedIfFFmpeg(),
},
Caption: event.CapLevelDropped,
MaxSize: MaxFileSize,
MaxDuration: ptr.Ptr(jsontime.S(1 * time.Hour)),
},
event.CapMsgGIF: {
MimeTypes: map[string]event.CapabilitySupportLevel{
"image/gif": event.CapLevelFullySupported,
"video/mp4": event.CapLevelFullySupported,
},
Caption: event.CapLevelFullySupported,
MaxSize: MaxFileSize,
},
},
State: event.StateFeatureMap{
event.StateRoomName.Type: {Level: event.CapLevelFullySupported},
event.StateRoomAvatar.Type: {Level: event.CapLevelFullySupported},
event.StateTopic.Type: {Level: event.CapLevelFullySupported},
event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported},
},
MemberActions: event.MemberFeatureMap{
event.MemberActionInvite: event.CapLevelFullySupported,
event.MemberActionRevokeInvite: event.CapLevelFullySupported,
event.MemberActionLeave: event.CapLevelFullySupported,
event.MemberActionBan: event.CapLevelFullySupported,
event.MemberActionKick: event.CapLevelFullySupported,
},
MaxTextLength: MaxTextLength, // TODO support arbitrary sized text messages with files
LocationMessage: event.CapLevelPartialSupport,
Poll: event.CapLevelRejected,
Thread: event.CapLevelUnsupported,
Reply: event.CapLevelFullySupported,
Edit: event.CapLevelFullySupported,
EditMaxCount: 10,
EditMaxAge: ptr.Ptr(jsontime.S(24 * time.Hour)),
Delete: event.CapLevelFullySupported,
DeleteForMe: false,
DeleteMaxAge: ptr.Ptr(jsontime.S(24 * time.Hour)),
DisappearingTimer: signalDisappearingCap,
Reaction: event.CapLevelFullySupported,
ReactionCount: 1,
AllowedReactions: nil,
CustomEmojiReactions: false,
ReadReceipts: true,
TypingNotifications: true,
DeleteChat: true,
MessageRequest: &event.MessageRequestFeatures{
AcceptWithMessage: event.CapLevelPartialSupport,
AcceptWithButton: event.CapLevelFullySupported,
},
}
var signalDisappearingCap = &event.DisappearingTimerCapability{
Types: []event.DisappearingType{event.DisappearingTypeAfterRead},
}
var signalCapsNoteToSelf *event.RoomFeatures
var signalCapsDM *event.RoomFeatures
func init() {
signalCapsDM = ptr.Clone(signalCaps)
signalCapsDM.ID = capID() + "+dm"
signalCapsDM.MemberActions = nil
signalCapsDM.State = event.StateFeatureMap{
event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported},
}
signalCapsNoteToSelf = ptr.Clone(signalCapsDM)
signalCapsNoteToSelf.EditMaxAge = nil
signalCapsNoteToSelf.DeleteMaxAge = nil
signalCapsNoteToSelf.ID = capID() + "+note_to_self"
}
func (s *SignalClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures {
if portal.Receiver == s.UserLogin.ID && portal.ID == networkid.PortalID(s.UserLogin.ID) {
return signalCapsNoteToSelf
} else if portal.RoomType == database.RoomTypeDM {
return signalCapsDM
}
return signalCaps
}
var signalGeneralCaps = &bridgev2.NetworkGeneralCapabilities{
DisappearingMessages: true,
AggressiveUpdateInfo: true,
ImplicitReadReceipts: true,
Provisioning: bridgev2.ProvisioningCapabilities{
ImagePackImport: true,
ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{
CreateDM: true,
LookupPhone: true,
LookupUsername: false, // TODO implement
ContactList: true,
},
GroupCreation: map[string]bridgev2.GroupTypeCapabilities{
"group": {
TypeDescription: "a group chat",
Name: bridgev2.GroupFieldCapability{Allowed: true, Required: true, MaxLength: 32},
Avatar: bridgev2.GroupFieldCapability{Allowed: true},
Disappear: bridgev2.GroupFieldCapability{Allowed: true, DisappearSettings: signalDisappearingCap},
Participants: bridgev2.GroupFieldCapability{Allowed: true},
},
},
},
}
func (s *SignalConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
return signalGeneralCaps
}
func (s *SignalConnector) GetBridgeInfoVersion() (info, capabilities int) {
return 1, 8
}

View file

@ -1,495 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 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/>.
package connector
import (
"context"
"crypto/sha256"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/store"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
var (
_ bridgev2.IdentifierResolvingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.GroupCreatingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.ContactListingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.GhostDMCreatingNetworkAPI = (*SignalClient)(nil)
)
var _ bridgev2.IdentifierValidatingNetwork = (*SignalConnector)(nil)
const PrivateChatTopic = "Signal private chat"
const NoteToSelfName = "Signal Note to Self"
func (s *SignalClient) GetUserInfoWithRefreshAfter(ctx context.Context, ghost *bridgev2.Ghost, refreshAfter time.Duration) (*bridgev2.UserInfo, error) {
userID, err := signalid.ParseUserIDAsServiceID(ghost.ID)
if err != nil {
return nil, err
}
if ghost.Name != "" && s.Main.Bridge.Background {
// Don't do unnecessary fetches in background mode
return nil, nil
}
var contact *types.Recipient
if userID.Type == libsignalgo.ServiceIDTypePNI {
contact, err = s.Client.Store.RecipientStore.LoadAndUpdateRecipient(ctx, uuid.Nil, userID.UUID, nil)
} else {
contact, err = s.Client.ContactByACIWithRefreshAfter(ctx, userID.UUID, refreshAfter)
}
if err != nil {
return nil, err
}
meta := ghost.Metadata.(*signalid.GhostMetadata)
if userID.Type != libsignalgo.ServiceIDTypePNI && (!s.Main.Config.UseOutdatedProfiles && meta.ProfileFetchedAt.After(contact.Profile.FetchedAt)) {
return nil, nil
}
return s.contactToUserInfo(ctx, contact)
}
func (s *SignalClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
return s.GetUserInfoWithRefreshAfter(ctx, ghost, signalmeow.DefaultProfileRefreshAfter)
}
func (s *SignalClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
userID, groupID, err := signalid.ParsePortalID(portal.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse portal id: %w", err)
}
if groupID != "" {
return s.getGroupInfo(ctx, groupID, 0, nil)
} else {
aci, pni := userID.ToACIAndPNI()
contact, err := s.Client.Store.RecipientStore.LoadAndUpdateRecipient(ctx, aci, pni, nil)
if err != nil {
return nil, err
}
return s.makeCreateDMResponse(ctx, contact, nil).PortalInfo, nil
}
}
func (s *SignalClient) contactToUserInfo(ctx context.Context, contact *types.Recipient) (*bridgev2.UserInfo, error) {
isBot := false
ui := &bridgev2.UserInfo{
IsBot: &isBot,
Identifiers: []string{},
ExtraUpdates: func(ctx context.Context, ghost *bridgev2.Ghost) (changed bool) {
meta := ghost.Metadata.(*signalid.GhostMetadata)
if meta.ProfileFetchedAt.Before(contact.Profile.FetchedAt) {
changed = meta.ProfileFetchedAt.IsZero() && !contact.Profile.FetchedAt.IsZero()
meta.ProfileFetchedAt.Time = contact.Profile.FetchedAt
}
return false
},
}
if contact.E164 != "" {
ui.Identifiers = append(ui.Identifiers, "tel:"+contact.E164)
}
name := s.Main.Config.FormatDisplayname(contact)
ui.Name = &name
if s.Main.Config.UseContactAvatars && contact.ContactAvatar.Hash != "" {
ui.Avatar = &bridgev2.Avatar{
ID: networkid.AvatarID("hash:" + contact.ContactAvatar.Hash),
Get: func(ctx context.Context) ([]byte, error) {
if contact.ContactAvatar.Image == nil {
return nil, fmt.Errorf("contact avatar not available")
}
return contact.ContactAvatar.Image, nil
},
}
} else if contact.Profile.AvatarPath == "clear" {
ui.Avatar = &bridgev2.Avatar{
ID: "",
Remove: true,
}
} else if contact.Profile.AvatarPath != "" {
ui.Avatar = &bridgev2.Avatar{
ID: makeAvatarPathID(contact.Profile.AvatarPath),
}
if s.Main.MsgConv.DirectMedia {
userID, err := signalid.ParseUserLoginID(s.UserLogin.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse user login ID: %w", err)
}
mediaID, err := signalid.DirectMediaProfileAvatar{
UserID: userID,
ContactID: contact.ACI,
ProfileAvatarPath: contact.Profile.AvatarPath,
}.AsMediaID()
if err != nil {
return nil, err
}
ui.Avatar.MXC, err = s.Main.Bridge.Matrix.GenerateContentURI(ctx, mediaID)
if err != nil {
return nil, err
}
ui.Avatar.Hash = signalid.HashMediaID(mediaID)
} else {
ui.Avatar.Get = func(ctx context.Context) ([]byte, error) {
return s.Client.DownloadUserAvatar(ctx, contact.Profile.AvatarPath, contact.Profile.Key)
}
}
}
return ui, nil
}
func (s *SignalConnector) ValidateUserID(id networkid.UserID) bool {
_, err := signalid.ParseUserIDAsServiceID(id)
return err == nil
}
func (s *SignalClient) CreateChatWithGhost(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.CreateChatResponse, error) {
parsedID, err := signalid.ParseUserIDAsServiceID(ghost.ID)
if err != nil {
return nil, err
}
resp, err := s.ResolveIdentifier(ctx, parsedID.String(), true)
if err != nil {
return nil, err
} else if resp == nil {
return nil, nil
}
resultID, err := signalid.ParseUserIDAsServiceID(resp.UserID)
if err != nil {
return nil, fmt.Errorf("failed to parse result user ID: %w", err)
}
if parsedID.Type == libsignalgo.ServiceIDTypePNI {
if resultID.Type == libsignalgo.ServiceIDTypeACI && !resultID.IsEmpty() {
resp.Chat.DMRedirectedTo = resp.UserID
} else {
resp.Chat.DMRedirectedTo = bridgev2.SpecialValueDMRedirectedToBot
}
}
return resp.Chat, nil
}
func (s *SignalClient) ResolveIdentifier(ctx context.Context, number string, _ bool) (*bridgev2.ResolveIdentifierResponse, error) {
var aci, pni uuid.UUID
var e164Number uint64
var recipient *types.Recipient
serviceID, err := signalid.ParseUserIDAsServiceID(networkid.UserID(number))
if err != nil {
number, err = bridgev2.CleanPhoneNumber(number)
if err != nil {
return nil, bridgev2.WrapRespErr(err, mautrix.MInvalidParam)
}
e164Number, err = strconv.ParseUint(strings.TrimPrefix(number, "+"), 10, 64)
if err != nil {
return nil, bridgev2.WrapRespErr(fmt.Errorf("error parsing phone number: %w", err), mautrix.MInvalidParam)
}
e164String := fmt.Sprintf("+%d", e164Number)
if recipient, err = s.Client.ContactByE164(ctx, e164String); err != nil {
return nil, fmt.Errorf("error looking up number in local contact list: %w", err)
} else if recipient != nil && (recipient.ACI == uuid.Nil || !s.Client.Store.RecipientStore.IsUnregistered(ctx, libsignalgo.NewACIServiceID(recipient.ACI))) {
aci = recipient.ACI
pni = recipient.PNI
} else if resp, err := s.Client.LookupPhone(ctx, e164Number); err != nil {
return nil, fmt.Errorf("error looking up number on server: %w", err)
} else {
aci = resp[e164Number].ACI
pni = resp[e164Number].PNI
if aci == uuid.Nil && pni == uuid.Nil {
return nil, nil
}
recipient, err = s.Client.Store.RecipientStore.UpdateRecipientE164(ctx, aci, pni, e164String)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to save recipient entry after looking up phone")
}
aci, pni = recipient.ACI, recipient.PNI
if aci != uuid.Nil {
s.Client.Store.RecipientStore.MarkUnregistered(ctx, libsignalgo.NewACIServiceID(aci), false)
}
}
} else {
aci, pni = serviceID.ToACIAndPNI()
recipient, err = s.Client.Store.RecipientStore.LoadAndUpdateRecipient(ctx, aci, pni, nil)
if err != nil {
return nil, fmt.Errorf("error loading recipient: %w", err)
}
}
zerolog.Ctx(ctx).Debug().
Uint64("e164", e164Number).
Stringer("aci", aci).
Stringer("pni", pni).
Msg("Found resolve identifier target user")
userInfo, err := s.contactToUserInfo(ctx, recipient)
if err != nil {
return nil, fmt.Errorf("failed to convert contact: %w", err)
}
var userID networkid.UserID
if aci != uuid.Nil {
userID = signalid.MakeUserID(aci)
} else {
userID = signalid.MakeUserIDFromServiceID(libsignalgo.NewPNIServiceID(pni))
}
// createChat is a no-op: chats don't need to be created, and we always return chat info
resp := &bridgev2.ResolveIdentifierResponse{
UserID: userID,
UserInfo: userInfo,
Chat: s.makeCreateDMResponse(ctx, recipient, nil),
}
resp.Ghost, err = s.Main.Bridge.GetGhostByID(ctx, resp.UserID)
if err != nil {
return nil, fmt.Errorf("failed to get ghost: %w", err)
}
return resp, nil
}
func (s *SignalClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCreateParams) (*bridgev2.CreateChatResponse, error) {
group := &signalmeow.Group{
Title: ptr.Val(params.Name).Name,
Members: make([]*signalmeow.GroupMember, 1, len(params.Participants)+1),
Description: ptr.Val(params.Topic).Topic,
AnnouncementsOnly: false,
DisappearingMessagesDuration: uint32(ptr.Val(params.Disappear).Timer.Seconds()),
AccessControl: &signalmeow.GroupAccessControl{
Members: signalmeow.AccessControl_MEMBER,
AddFromInviteLink: signalmeow.AccessControl_UNSATISFIABLE,
Attributes: signalmeow.AccessControl_ADMINISTRATOR,
},
}
var pl *event.PowerLevelsEventContent
// TODO actually get PLs
if pl != nil {
if pl.EventsDefault > pl.UsersDefault {
group.AnnouncementsOnly = true
}
if pl.Invite() > pl.UsersDefault {
group.AccessControl.Members = signalmeow.AccessControl_ADMINISTRATOR
}
if pl.GetEventLevel(event.StateRoomName) <= pl.UsersDefault {
group.AccessControl.Attributes = signalmeow.AccessControl_MEMBER
}
}
group.Members[0] = &signalmeow.GroupMember{
ACI: s.Client.Store.ACI,
Role: signalmeow.GroupMember_ADMINISTRATOR,
}
currentTS := uint64(time.Now().UnixMilli())
for _, member := range params.Participants {
userID, err := signalid.ParseUserIDAsServiceID(member)
if err != nil {
return nil, fmt.Errorf("invalid user ID %q: %w", member, err)
}
if userID.Type == libsignalgo.ServiceIDTypeACI {
group.Members = append(group.Members, &signalmeow.GroupMember{
ACI: userID.UUID,
Role: signalmeow.GroupMember_DEFAULT, // TODO set proper role from power levels
})
} else if userID.Type == libsignalgo.ServiceIDTypePNI {
// TODO check if this is correct
group.PendingMembers = append(group.PendingMembers, &signalmeow.PendingMember{
ServiceID: userID,
Role: signalmeow.GroupMember_DEFAULT,
AddedByUserID: s.Client.Store.ACI,
Timestamp: currentTS,
})
}
}
_, err := signalmeow.PrepareGroupCreation(group)
if err != nil {
return nil, fmt.Errorf("failed to prepare group creation: %w", err)
}
var avatarBytes []byte
var avatarMXC id.ContentURIString
if params.Avatar != nil && params.Avatar.URL != "" {
avatarMXC = params.Avatar.URL
avatarBytes, err = s.Main.Bridge.Bot.DownloadMedia(ctx, params.Avatar.URL, nil)
if err != nil {
return nil, fmt.Errorf("failed to download avatar: %w", err)
}
group.AvatarPath, err = s.Client.UploadGroupAvatar(ctx, avatarBytes, group.GroupIdentifier, group.GroupMasterKey)
if err != nil {
return nil, fmt.Errorf("failed to upload avatar: %w", err)
}
}
portal, err := s.Main.Bridge.GetPortalByKey(ctx, s.makePortalKey(string(group.GroupIdentifier)))
if err != nil {
return nil, fmt.Errorf("failed to get portal: %w", err)
}
if params.RoomID != "" {
err = portal.UpdateMatrixRoomID(ctx, params.RoomID, bridgev2.UpdateMatrixRoomIDParams{SyncDBMetadata: func() {
portal.Name = group.Title
portal.NameSet = true
portal.Topic = group.Description
portal.TopicSet = true
portal.AvatarHash = sha256.Sum256(avatarBytes)
portal.AvatarSet = true
portal.AvatarMXC = avatarMXC
portal.AvatarID = makeAvatarPathID(group.AvatarPath)
if group.DisappearingMessagesDuration > 0 {
portal.Disappear = database.DisappearingSetting{
Type: event.DisappearingTypeAfterRead,
Timer: time.Duration(group.DisappearingMessagesDuration) * time.Second,
}
}
}})
if err != nil {
return nil, fmt.Errorf("failed to set portal room ID: %w", err)
}
}
resp, err := s.Client.CreateGroup(ctx, group)
if err != nil {
return nil, fmt.Errorf("failed to create group: %w", err)
}
if params.RoomID != "" {
// UpdateMatrixRoomID could do this for us if we passed ChatInfoSource to it,
// but we only want to do it after the group is successfully created
portal.UpdateBridgeInfo(ctx)
portal.UpdateCapabilities(ctx, s.UserLogin, true)
}
wrappedInfo, err := s.wrapGroupInfo(ctx, resp, nil)
if err != nil {
return nil, fmt.Errorf("failed to wrap group info for sync: %w", err)
}
return &bridgev2.CreateChatResponse{
PortalKey: portal.PortalKey,
Portal: portal,
PortalInfo: wrappedInfo,
}, nil
}
func (s *SignalClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveIdentifierResponse, error) {
recipients, err := s.Client.Store.RecipientStore.LoadAllContacts(ctx)
if err != nil {
return nil, err
}
resp := make([]*bridgev2.ResolveIdentifierResponse, len(recipients))
for i, recipient := range recipients {
userInfo, err := s.contactToUserInfo(ctx, recipient)
if err != nil {
return nil, fmt.Errorf("failed to convert contact: %w", err)
}
recipientResp := &bridgev2.ResolveIdentifierResponse{
UserInfo: userInfo,
Chat: s.makeCreateDMResponse(ctx, recipient, nil),
}
if recipient.ACI != uuid.Nil {
recipientResp.UserID = signalid.MakeUserID(recipient.ACI)
ghost, err := s.Main.Bridge.GetGhostByID(ctx, recipientResp.UserID)
if err != nil {
return nil, fmt.Errorf("failed to get ghost for %s: %w", recipient.ACI, err)
}
recipientResp.Ghost = ghost
} else {
recipientResp.UserID = signalid.MakeUserIDFromServiceID(libsignalgo.NewPNIServiceID(recipient.PNI))
}
resp[i] = recipientResp
}
return resp, nil
}
func (s *SignalClient) makeCreateDMResponse(ctx context.Context, recipient *types.Recipient, backupChat *store.BackupChat) *bridgev2.CreateChatResponse {
namePtr := bridgev2.DefaultChatName
topic := PrivateChatTopic
selfUser := s.makeEventSender(s.Client.Store.ACI)
members := &bridgev2.ChatMemberList{
IsFull: true,
MemberMap: map[networkid.UserID]bridgev2.ChatMember{
selfUser.Sender: {
EventSender: selfUser,
Membership: event.MembershipJoin,
PowerLevel: &moderatorPL,
},
},
PowerLevels: &bridgev2.PowerLevelOverrides{
Events: map[event.Type]int{
event.StateRoomName: 0,
event.StateTopic: 0,
event.StateRoomAvatar: 0,
event.StateBeeperDisappearingTimer: 0,
},
},
}
if s.Main.Config.NumberInTopic && recipient.E164 != "" {
topic = fmt.Sprintf("%s with %s", PrivateChatTopic, recipient.E164)
}
var serviceID libsignalgo.ServiceID
var avatar *bridgev2.Avatar
if recipient.ACI == uuid.Nil {
namePtr = ptr.Ptr(s.Main.Config.FormatDisplayname(recipient))
serviceID = libsignalgo.NewPNIServiceID(recipient.PNI)
} else {
if backupChat == nil {
var err error
backupChat, err = s.Client.Store.BackupStore.GetBackupChatByUserID(ctx, libsignalgo.NewACIServiceID(recipient.ACI))
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get backup chat for recipient")
}
}
members.OtherUserID = signalid.MakeUserID(recipient.ACI)
if recipient.ACI == s.Client.Store.ACI {
namePtr = ptr.Ptr(NoteToSelfName)
avatar = &bridgev2.Avatar{
ID: networkid.AvatarID(s.Main.Config.NoteToSelfAvatar),
Remove: len(s.Main.Config.NoteToSelfAvatar) == 0,
MXC: s.Main.Config.NoteToSelfAvatar,
Hash: sha256.Sum256([]byte(s.Main.Config.NoteToSelfAvatar)),
}
} else {
// The other user is only present if their ACI is known
recipientUser := s.makeEventSender(recipient.ACI)
members.MemberMap[recipientUser.Sender] = bridgev2.ChatMember{
EventSender: recipientUser,
Membership: event.MembershipJoin,
PowerLevel: &moderatorPL,
}
}
serviceID = libsignalgo.NewACIServiceID(recipient.ACI)
}
return &bridgev2.CreateChatResponse{
PortalKey: s.makeDMPortalKey(serviceID),
PortalInfo: &bridgev2.ChatInfo{
Name: namePtr,
Avatar: avatar,
Topic: &topic,
Members: members,
Type: ptr.Ptr(database.RoomTypeDM),
MessageRequest: ptr.Ptr(recipient.ACI != uuid.Nil && recipient.ProbablyMessageRequest()),
CanBackfill: backupChat != nil,
ExtraUpdates: updatePortalSyncMeta,
},
}
}
func makeAvatarPathID(avatarPath string) networkid.AvatarID {
if avatarPath == "" {
return ""
}
return networkid.AvatarID("path:" + avatarPath)
}

View file

@ -1,180 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2025 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/>.
package connector
import (
"context"
"encoding/base64"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/simplevent"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf/backuppb"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
func (s *SignalClient) stopChatSync() {
if cancel := s.cancelChatSync.Swap(nil); cancel != nil {
(*cancel)()
}
}
func (s *SignalClient) syncChats(ctx context.Context, cancel context.CancelFunc) {
defer cancel()
if s.UserLogin.Metadata.(*signalid.UserLoginMetadata).ChatsSynced {
return
}
if s.Client.Store.EphemeralBackupKey != nil {
zerolog.Ctx(ctx).Info().Msg("Fetching transfer archive before syncing chats")
meta, err := s.Client.WaitForTransfer(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to request transfer archive")
return
} else if meta.Error != "" {
zerolog.Ctx(ctx).Error().Str("error_type", meta.Error).Msg("Transfer archive request was rejected")
s.UserLogin.Metadata.(*signalid.UserLoginMetadata).ChatsSynced = true
err = s.UserLogin.Save(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to save user login metadata after transfer archive request was rejected")
}
return
}
err = s.Client.FetchAndProcessTransfer(ctx, meta)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to fetch and process transfer archive")
return
}
zerolog.Ctx(ctx).Info().Msg("Transfer archive fetched and processed, syncing chats")
}
chats, err := s.Client.Store.BackupStore.GetBackupChats(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get chats from backup store")
return
}
zerolog.Ctx(ctx).Info().Int("chat_count", len(chats)).Msg("Fetched chats to sync from database")
for _, chat := range chats {
if ctx.Err() != nil {
zerolog.Ctx(ctx).Debug().
AnErr("ctx_err", ctx.Err()).
Msg("Context cancelled while syncing chats, stopping")
return
}
recipient, err := s.Client.Store.BackupStore.GetBackupRecipient(ctx, chat.RecipientId)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get recipient for chat")
continue
} else if recipient == nil {
zerolog.Ctx(ctx).Warn().
Uint64("backup_chat_id", chat.Id).
Uint64("backup_recipient_id", chat.RecipientId).
Msg("No recipient found for chat")
continue
}
resyncEvt := &simplevent.ChatResync{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatResync,
LogContext: func(c zerolog.Context) zerolog.Context {
return c.
Int("message_count", chat.TotalMessages).
Uint64("backup_chat_id", chat.Id).
Uint64("backup_recipient_id", chat.RecipientId)
},
CreatePortal: true,
},
LatestMessageTS: time.UnixMilli(int64(chat.LatestMessageID)),
}
switch dest := recipient.Destination.(type) {
case *backuppb.Recipient_Contact:
aci := tryCastUUID(dest.Contact.GetAci())
pni := tryCastUUID(dest.Contact.GetPni())
if chat.TotalMessages == 0 {
zerolog.Ctx(ctx).Debug().
Stringer("aci", aci).
Stringer("pni", pni).
Uint64("e164", dest.Contact.GetE164()).
Msg("Skipping direct chat with no messages and deleting data")
err = s.Client.Store.BackupStore.DeleteBackupChat(ctx, chat.Id)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to delete chat from backup store")
}
continue
}
processedRecipient, err := s.Client.Store.RecipientStore.LoadAndUpdateRecipient(ctx, aci, pni, nil)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get full recipient data")
continue
}
dmInfo := s.makeCreateDMResponse(ctx, processedRecipient, chat)
resyncEvt.PortalKey = dmInfo.PortalKey
resyncEvt.ChatInfo = dmInfo.PortalInfo
case *backuppb.Recipient_Self:
processedRecipient, err := s.Client.Store.RecipientStore.LoadAndUpdateRecipient(ctx, s.Client.Store.ACI, uuid.Nil, nil)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get full recipient data")
continue
}
dmInfo := s.makeCreateDMResponse(ctx, processedRecipient, chat)
resyncEvt.PortalKey = dmInfo.PortalKey
resyncEvt.ChatInfo = dmInfo.PortalInfo
case *backuppb.Recipient_Group:
if len(dest.Group.MasterKey) != libsignalgo.GroupMasterKeyLength {
continue
}
rawGroupID, err := libsignalgo.GroupMasterKey(dest.Group.MasterKey).GroupIdentifier()
if err != nil {
zerolog.Ctx(ctx).Err(err).
Uint64("recipient_id", recipient.Id).
Msg("Failed to get group identifier from master key")
continue
}
groupID := types.GroupIdentifier(base64.StdEncoding.EncodeToString(rawGroupID[:]))
groupInfo, err := s.getGroupInfo(ctx, groupID, dest.Group.GetSnapshot().GetVersion(), chat)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get full group info")
continue
}
resyncEvt.PortalKey = s.makePortalKey(string(groupID))
resyncEvt.ChatInfo = groupInfo
default:
zerolog.Ctx(ctx).Debug().
Type("destination_type", dest).
Uint64("backup_chat_id", chat.Id).
Uint64("backup_recipient_id", chat.RecipientId).
Msg("Ignoring and deleting chat with unsupported destination type")
err = s.Client.Store.BackupStore.DeleteBackupChat(ctx, chat.Id)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to delete chat from backup store")
}
continue
}
if !s.UserLogin.QueueRemoteEvent(resyncEvt).Success {
return
}
}
s.UserLogin.Metadata.(*signalid.UserLoginMetadata).ChatsSynced = true
err = s.UserLogin.Save(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to save user login metadata after syncing chats")
}
}

View file

@ -1,365 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 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/>.
package connector
import (
"context"
"fmt"
"sync/atomic"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/exsync"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/web"
)
type SignalClient struct {
Main *SignalConnector
UserLogin *bridgev2.UserLogin
Client *signalmeow.Client
Ghost *bridgev2.Ghost
queueEmptyWaiter *exsync.Event
cancelChatSync atomic.Pointer[context.CancelFunc]
}
var (
_ bridgev2.NetworkAPI = (*SignalClient)(nil)
_ bridgev2.BackgroundSyncingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.StickerImportingNetworkAPI = (*SignalClient)(nil)
)
var pushCfg = &bridgev2.PushConfig{
FCM: &bridgev2.FCMPushConfig{
// https://github.com/signalapp/Signal-Android/blob/main/app/src/main/res/values/firebase_messaging.xml#L4
SenderID: "312334754206",
},
APNs: &bridgev2.APNsPushConfig{
BundleID: "org.whispersystems.signal",
},
}
func (s *SignalClient) GetPushConfigs() *bridgev2.PushConfig {
return pushCfg
}
func (s *SignalClient) RegisterPushNotifications(ctx context.Context, pushType bridgev2.PushType, token string) error {
if s.Client == nil {
return bridgev2.ErrNotLoggedIn
}
switch pushType {
case bridgev2.PushTypeFCM:
return s.Client.RegisterFCM(ctx, token)
case bridgev2.PushTypeAPNs:
return s.Client.RegisterAPNs(ctx, token)
default:
return fmt.Errorf("unsupported push type: %s", pushType)
}
}
func (s *SignalClient) DownloadImagePack(ctx context.Context, url string) (*bridgev2.ImportedImagePack, error) {
return s.Main.MsgConv.DownloadImagePack(ctx, url)
}
func (s *SignalClient) ListImagePacks(ctx context.Context) ([]*event.ImagePackMetadata, error) {
return []*event.ImagePackMetadata{}, nil
}
func (s *SignalClient) LogoutRemote(ctx context.Context) {
if s.Client == nil {
return
}
s.stopChatSync()
err := s.Client.Unlink(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to unlink device")
}
err = s.Client.StopReceiveLoops()
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to stop receive loops for logout")
}
err = s.Main.Store.DeleteDevice(context.TODO(), &s.Client.Store.DeviceData)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to delete device from store")
}
}
func (s *SignalClient) IsThisUser(_ context.Context, userID networkid.UserID) bool {
if s.Client == nil {
return false
}
return userID == signalid.MakeUserID(s.Client.Store.ACI)
}
func (s *SignalClient) bridgeStateLoop(statusChan <-chan signalmeow.SignalConnectionStatus) {
var peekedConnectionStatus signalmeow.SignalConnectionStatus
for {
var connectionStatus signalmeow.SignalConnectionStatus
if peekedConnectionStatus.Event != signalmeow.SignalConnectionEventNone {
s.UserLogin.Log.Debug().
Stringer("peeked_connection_status_event", peekedConnectionStatus.Event).
Msg("Using peeked connectionStatus event")
connectionStatus = peekedConnectionStatus
peekedConnectionStatus = signalmeow.SignalConnectionStatus{}
} else {
var ok bool
connectionStatus, ok = <-statusChan
if !ok {
s.UserLogin.Log.Debug().Msg("statusChan channel closed")
return
}
}
err := connectionStatus.Err
switch connectionStatus.Event {
case signalmeow.SignalConnectionEventConnected:
s.UserLogin.Log.Debug().Msg("Sending Connected BridgeState")
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
case signalmeow.SignalConnectionEventDisconnected:
s.UserLogin.Log.Debug().Msg("Received SignalConnectionEventDisconnected")
// Debounce: wait 7s before sending TransientDisconnect, in case we get a reconnect
// We should wait until the next message comes in, or 7 seconds has passed.
// - If a disconnected event comes in, just loop again, unless it's been more than 7 seconds.
// - If a non-disconnected event comes in, store it in peekedConnectionStatus,
// break out of this loop and go back to the top of the goroutine to handle it in the switch.
// - If 7 seconds passes without any non-disconnect messages, send the TransientDisconnect.
// (Why 7 seconds? It was 5 at first, but websockets min retry is 5 seconds,
// so it would send TransientDisconnect right before reconnecting. 7 seems to work well.)
debounceTimer := time.NewTimer(7 * time.Second)
PeekLoop:
for {
var ok bool
select {
case peekedConnectionStatus, ok = <-statusChan:
// Handle channel closing
if !ok {
s.UserLogin.Log.Debug().Msg("connectionStatus channel closed")
return
}
// If it's another Disconnected event, just keep looping
if peekedConnectionStatus.Event == signalmeow.SignalConnectionEventDisconnected {
peekedConnectionStatus = signalmeow.SignalConnectionStatus{}
continue
}
// If it's a non-disconnect event, break out of the PeekLoop and handle it in the switch
break PeekLoop
case <-debounceTimer.C:
// Time is up, so break out of the loop and send the TransientDisconnect
break PeekLoop
}
}
// We're out of the PeekLoop, so either we got a non-disconnect event, or it's been 7 seconds (or both).
// We want to send TransientDisconnect if it's been 7 seconds, but not if the latest event was something
// other than Disconnected
if !debounceTimer.Stop() { // If the timer has already expired
// Send TransientDisconnect only if the latest event is a disconnect or no event
// (peekedConnectionStatus could be something else if the timer and the event race)
if peekedConnectionStatus.Event == signalmeow.SignalConnectionEventDisconnected ||
peekedConnectionStatus.Event == signalmeow.SignalConnectionEventNone {
s.UserLogin.Log.Debug().Msg("Sending TransientDisconnect BridgeState")
if err == nil {
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect})
} else {
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "unknown-websocket-error", Message: err.Error()})
}
}
}
case signalmeow.SignalConnectionEventLoggedOut:
s.stopChatSync()
s.UserLogin.Log.Debug().Msg("Sending BadCredentials BridgeState")
if err == nil {
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: "You have been logged out of Signal, please reconnect"})
} else {
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: err.Error()})
}
err = s.Client.ClearKeysAndDisconnect(context.TODO())
if err != nil {
s.UserLogin.Log.Error().Err(err).Msg("Failed to clear keys and disconnect")
}
case signalmeow.SignalConnectionEventError:
s.UserLogin.Log.Debug().Msg("Sending TransientDisconnect BridgeState")
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "unknown-websocket-error", Message: err.Error()})
case signalmeow.SignalConnectionEventFatalError:
s.UserLogin.Log.Debug().Msg("Sending UnknownError BridgeState")
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: "unknown-websocket-error", Message: err.Error()})
case signalmeow.SignalConnectionCleanShutdown:
if s.Client.IsLoggedIn() {
s.UserLogin.Log.Debug().Msg("Clean Shutdown - sending no BridgeState")
} else {
s.UserLogin.Log.Debug().Msg("Clean Shutdown, but logged out - Sending BadCredentials BridgeState")
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: "You have been logged out of Signal, please reconnect"})
}
}
}
}
func (s *SignalClient) Connect(ctx context.Context) {
if s.Client == nil {
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: "You're not logged into Signal"})
return
}
s.updateRemoteProfile(ctx, false)
s.tryConnect(ctx, 0, true)
}
func (s *SignalClient) ConnectBackground(ctx context.Context, _ *bridgev2.ConnectBackgroundParams) error {
s.queueEmptyWaiter.Clear()
ch, unauthCh, err := s.Client.StartWebsockets(ctx)
if err != nil {
return err
}
defer s.Disconnect()
log := zerolog.Ctx(ctx)
queueEmpty := s.queueEmptyWaiter.GetChan()
didConnect := false
for {
select {
case status := <-ch:
switch status.Event {
case web.SignalWebsocketConnectionEventConnected:
log.Info().Msg("Authed websocket connected")
didConnect = true
case web.SignalWebsocketConnectionEventDisconnected:
log.Err(status.Err).Msg("Authed websocket disconnected")
case web.SignalWebsocketConnectionEventLoggedOut:
log.Err(status.Err).Msg("Authed websocket logged out")
return fmt.Errorf("authed websocket logged out: %w", status.Err)
case web.SignalWebsocketConnectionEventError, web.SignalWebsocketConnectionEventFatalError:
log.Err(status.Err).Msg("Authed websocket error")
return fmt.Errorf("authed websocket errored: %w", status.Err)
case web.SignalWebsocketConnectionEventCleanShutdown:
log.Info().Msg("Authed websocket clean shutdown")
}
case status := <-unauthCh:
switch status.Event {
case web.SignalWebsocketConnectionEventConnected:
log.Info().Msg("Unauthed websocket connected")
case web.SignalWebsocketConnectionEventDisconnected:
log.Err(status.Err).Msg("Unauthed websocket disconnected")
case web.SignalWebsocketConnectionEventLoggedOut:
log.Err(status.Err).Msg("Unauthed websocket logged out")
case web.SignalWebsocketConnectionEventError, web.SignalWebsocketConnectionEventFatalError:
log.Err(status.Err).Msg("Unauthed websocket error")
case web.SignalWebsocketConnectionEventCleanShutdown:
log.Info().Msg("Unauthed websocket clean shutdown")
}
case <-ctx.Done():
log.Warn().Msg("Context finished before queue empty event")
if didConnect {
// Don't propagate timeout errors if the connection was successful at least once
return nil
}
return ctx.Err()
case <-queueEmpty:
log.Info().Msg("Received queue empty event")
return nil
}
}
}
func (s *SignalClient) Disconnect() {
if s.Client == nil {
return
}
s.stopChatSync()
err := s.Client.StopReceiveLoops()
if err != nil {
s.UserLogin.Log.Err(err).Msg("Failed to stop receive loops")
}
}
func (s *SignalClient) postLoginConnect() {
ctx := s.UserLogin.Log.WithContext(s.Main.Bridge.BackgroundCtx)
s.tryConnect(ctx, 0, false)
}
func (s *SignalClient) tryConnect(ctx context.Context, retryCount int, noLoginSync bool) {
if ctx.Err() != nil {
zerolog.Ctx(ctx).Debug().
Int("retry_count", retryCount).
AnErr("ctx_err", ctx.Err()).
Msg("Context is canceled, not trying to connect")
return
}
if retryCount == 0 {
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting})
}
ch, err := s.Client.StartReceiveLoops(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to start receive loops")
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "unknown-websocket-error", Message: err.Error()})
retryInSeconds := 2 << retryCount
if retryInSeconds > 150 {
retryInSeconds = 150
}
zerolog.Ctx(ctx).Debug().Int("retry_in_seconds", retryInSeconds).Msg("Sleeping and retrying connection")
select {
case <-time.After(time.Duration(retryInSeconds) * time.Second):
case <-ctx.Done():
zerolog.Ctx(ctx).Info().Msg("Context canceled, exit tryConnect")
return
}
s.tryConnect(ctx, retryCount+1, noLoginSync)
return
}
syncCtx, cancel := context.WithCancel(ctx)
if oldCancel := s.cancelChatSync.Swap(&cancel); oldCancel != nil {
(*oldCancel)()
}
go s.bridgeStateLoop(ch)
if noLoginSync {
go s.syncChats(syncCtx, cancel)
} else {
// TODO it would be more proper to only connect after syncing,
// but currently syncing will fetch group info online, so it has to be connected.
if s.Client.Store.EphemeralBackupKey != nil {
go func() {
if s.Client.Store.MasterKey != nil {
s.Client.SyncStorage(ctx)
} else {
s.UserLogin.Log.Warn().Msg("No master key for storage sync before backup sync")
}
s.syncChats(syncCtx, cancel)
}()
} else {
cancel()
if s.Client.Store.MasterKey != nil {
go s.Client.SyncStorage(ctx)
}
}
}
}
func (s *SignalClient) IsLoggedIn() bool {
if s.Client == nil {
return false
}
return s.Client.IsLoggedIn()
}

View file

@ -1,73 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2025 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/>.
package connector
import (
"errors"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/commands"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-signal/pkg/signalid"
)
var CmdDiscardSenderKey = &commands.FullHandler{
Func: fnDiscardSenderKey,
Name: "discard-sender-key",
Help: commands.HelpMeta{
Section: commands.HelpSectionChats,
Description: "Discard the Signal-side sender key in the current group",
Args: "[_login ID_]",
},
RequiresPortal: true,
RequiresLogin: true,
}
func fnDiscardSenderKey(ce *commands.Event) {
_, groupID, _ := signalid.ParsePortalID(ce.Portal.ID)
if groupID == "" {
ce.Reply("This command can only be used in group chat portals")
return
}
var login *bridgev2.UserLogin
if len(ce.Args) > 0 {
login = ce.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(ce.Args[0]))
if login == nil || login.UserMXID != ce.User.MXID {
ce.Reply("Login not found")
return
}
} else {
var err error
login, _, err = ce.Portal.FindPreferredLogin(ce.Ctx, ce.User, false)
if errors.Is(err, bridgev2.ErrNotLoggedIn) {
ce.Reply("You're not logged in in this portal")
return
} else if err != nil {
ce.Log.Err(err).Msg("Failed to find preferred login for portal")
ce.Reply("Failed to find preferred login for portal")
return
}
}
distributionID, err := login.Client.(*SignalClient).Client.ResetSenderKey(ce.Ctx, groupID)
if err != nil {
ce.Log.Err(err).Msg("Failed to reset sender key")
ce.Reply("Failed to reset sender key")
} else {
ce.Reply("Reset sender key with distribution ID %s", distributionID)
}
}

View file

@ -1,112 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 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/>.
package connector
import (
_ "embed"
"strings"
"text/template"
up "go.mau.fi/util/configupgrade"
"gopkg.in/yaml.v3"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
//go:embed example-config.yaml
var ExampleConfig string
type SignalConfig struct {
DisplaynameTemplate string `yaml:"displayname_template"`
UseContactAvatars bool `yaml:"use_contact_avatars"`
SyncContactsOnStartup bool `yaml:"sync_contacts_on_startup"`
UseOutdatedProfiles bool `yaml:"use_outdated_profiles"`
NumberInTopic bool `yaml:"number_in_topic"`
DeviceName string `yaml:"device_name"`
NoteToSelfAvatar id.ContentURIString `yaml:"note_to_self_avatar"`
LocationFormat string `yaml:"location_format"`
DisappearViewOnce bool `yaml:"disappear_view_once"`
ExtEvPolls bool `yaml:"extev_polls"`
displaynameTemplate *template.Template `yaml:"-"`
}
type umConfig SignalConfig
func (c *SignalConfig) UnmarshalYAML(node *yaml.Node) error {
err := node.Decode((*umConfig)(c))
if err != nil {
return err
}
return c.PostProcess()
}
func (c *SignalConfig) PostProcess() error {
var err error
c.displaynameTemplate, err = template.New("displayname").Parse(c.DisplaynameTemplate)
return err
}
type DisplaynameParams struct {
ProfileName string
ContactName string
Nickname string
Username string
PhoneNumber string
UUID string
ACI string
PNI string
AboutEmoji string
}
func (c *SignalConfig) FormatDisplayname(contact *types.Recipient) string {
var nameBuf strings.Builder
err := c.displaynameTemplate.Execute(&nameBuf, &DisplaynameParams{
ProfileName: contact.Profile.Name,
ContactName: contact.ContactName,
Nickname: contact.Nickname,
Username: "",
PhoneNumber: contact.E164,
UUID: contact.ACI.String(),
ACI: contact.ACI.String(),
PNI: contact.PNI.String(),
AboutEmoji: contact.Profile.AboutEmoji,
})
if err != nil {
panic(err)
}
return nameBuf.String()
}
func upgradeConfig(helper up.Helper) {
helper.Copy(up.Str, "displayname_template")
helper.Copy(up.Bool, "use_contact_avatars")
helper.Copy(up.Bool, "sync_contacts_on_startup")
helper.Copy(up.Bool, "use_outdated_profiles")
helper.Copy(up.Bool, "number_in_topic")
helper.Copy(up.Str, "device_name")
helper.Copy(up.Str, "note_to_self_avatar")
helper.Copy(up.Str, "location_format")
helper.Copy(up.Bool, "disappear_view_once")
helper.Copy(up.Bool, "extev_polls")
}
func (s *SignalConnector) GetConfig() (string, any, up.Upgrader) {
return ExampleConfig, &s.Config, up.SimpleUpgrader(upgradeConfig)
}

View file

@ -1,137 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 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/>.
package connector
import (
"context"
"fmt"
"strconv"
"time"
"github.com/google/uuid"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exhttp"
"go.mau.fi/util/exsync"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/commands"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/pkg/msgconv"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/store"
"go.mau.fi/mautrix-signal/pkg/signalmeow/web"
)
type SignalConnector struct {
MsgConv *msgconv.MessageConverter
Store *store.Container
Bridge *bridgev2.Bridge
Config SignalConfig
}
var _ bridgev2.NetworkConnector = (*SignalConnector)(nil)
var _ bridgev2.MaxFileSizeingNetwork = (*SignalConnector)(nil)
var _ bridgev2.TransactionIDGeneratingNetwork = (*SignalConnector)(nil)
func (s *SignalConnector) GetName() bridgev2.BridgeName {
return bridgev2.BridgeName{
DisplayName: "Signal",
NetworkURL: "https://signal.org",
NetworkIcon: "mxc://maunium.net/wPJgTQbZOtpBFmDNkiNEMDUp",
NetworkID: "signal",
BeeperBridgeType: "signal",
DefaultPort: 29328,
}
}
func (s *SignalConnector) Init(bridge *bridgev2.Bridge) {
s.Store = store.NewStore(bridge.DB.Database, dbutil.ZeroLogger(bridge.Log.With().Str("db_section", "signalmeow").Logger()))
s.Bridge = bridge
s.MsgConv = msgconv.NewMessageConverter(bridge)
s.MsgConv.LocationFormat = s.Config.LocationFormat
s.MsgConv.DisappearViewOnce = s.Config.DisappearViewOnce
s.MsgConv.ExtEvPolls = s.Config.ExtEvPolls
bridge.Commands.(*commands.Processor).AddHandlers(CmdDiscardSenderKey)
}
func (s *SignalConnector) SetMaxFileSize(maxSize int64) {
s.MsgConv.MaxFileSize = maxSize
}
func (s *SignalConnector) Start(ctx context.Context) error {
s.ResetHTTPTransport()
err := s.Store.Upgrade(ctx)
if err != nil {
return bridgev2.DBUpgradeError{Err: err, Section: "signalmeow"}
}
return nil
}
func (s *SignalConnector) ResetHTTPTransport() {
settings := exhttp.SensibleClientSettings
hs, ok := s.Bridge.Matrix.(bridgev2.MatrixConnectorWithHTTPSettings)
if ok {
settings = hs.GetHTTPClientSettings()
}
oldClient := web.SignalHTTPClient
web.SignalHTTPClient = settings.WithTLSConfig(web.SignalTLSConfig).Compile()
oldClient.CloseIdleConnections()
}
func (s *SignalConnector) ResetNetworkConnections() {
for _, login := range s.Bridge.GetAllCachedUserLogins() {
c := login.Client.(*SignalClient)
if c.Client != nil {
c.Client.ForceReconnect()
}
}
}
func (s *SignalConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error {
aci, err := uuid.Parse(string(login.ID))
if err != nil {
return fmt.Errorf("failed to parse user login ID: %w", err)
}
device, err := s.Store.DeviceByACI(ctx, aci)
if err != nil {
return fmt.Errorf("failed to get device from store: %w", err)
}
sc := &SignalClient{
Main: s,
UserLogin: login,
queueEmptyWaiter: exsync.NewEvent(),
}
if device != nil {
sc.Client = signalmeow.NewClient(
device,
sc.UserLogin.Log.With().Str("component", "signalmeow").Logger(),
sc.handleSignalEvent,
)
sc.Client.SyncContactsOnConnect = s.Config.SyncContactsOnStartup &&
time.Since(login.Metadata.(*signalid.UserLoginMetadata).LastContactSync.Time) > 3*24*time.Hour
}
login.Client = sc
return nil
}
func (s *SignalConnector) GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID {
return networkid.RawTransactionID(strconv.FormatInt(time.Now().UnixMilli(), 10))
}

View file

@ -1,130 +0,0 @@
package connector
import (
"context"
"encoding/base64"
"fmt"
"os"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/mediaproxy"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
var _ bridgev2.DirectMediableNetwork = (*SignalConnector)(nil)
func (s *SignalConnector) SetUseDirectMedia() {
s.MsgConv.DirectMedia = true
}
func (s *SignalConnector) Download(ctx context.Context, mediaID networkid.MediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) {
log := s.Bridge.Log.With().Str("component", "direct download").Logger()
info, err := signalid.ParseDirectMediaInfo(mediaID)
if err != nil {
return nil, fmt.Errorf("failed to parse direct media id: %w", err)
}
var rawDataResp []byte
switch info := info.(type) {
case *signalid.DirectMediaAttachment:
log.Info().
Uint64("cdn_id", info.CDNID).
Str("cdn_key", info.CDNKey).
Uint32("cdn_number", info.CDNNumber).
Int("key_len", len(info.Key)).
Int("digest_len", len(info.Digest)).
Bool("plaintext_digest", info.PlaintextDigest).
Uint32("size", info.Size).
Msg("Direct downloading attachment")
return &mediaproxy.GetMediaResponseFile{
Callback: func(w *os.File) (*mediaproxy.FileMeta, error) {
_, err := signalmeow.DownloadAttachment(
ctx, info.CDNID, info.CDNKey, info.CDNNumber, info.Key, info.Digest, info.PlaintextDigest, info.Size, w,
)
if err != nil {
return nil, err
}
return &mediaproxy.FileMeta{}, nil
},
}, nil
case *signalid.DirectMediaGroupAvatar:
log.Info().
Stringer("user_id", info.UserID).
Hex("group_id", info.GroupID[:]).
Str("group_avatar_path", info.GroupAvatarPath).
Msg("Direct downloading group avatar")
groupID := types.GroupIdentifier(base64.StdEncoding.EncodeToString(info.GroupID[:]))
userLogin, err := s.Bridge.GetExistingUserLoginByID(ctx, signalid.MakeUserLoginID(info.UserID))
if err != nil {
return nil, fmt.Errorf("failed to get user login: %w", err)
} else if userLogin == nil {
return nil, bridgev2.ErrNotLoggedIn
}
client := userLogin.Client.(*SignalClient)
groupMasterKey, err := client.Client.Store.GroupStore.MasterKeyFromGroupIdentifier(ctx, groupID)
if err != nil {
return nil, fmt.Errorf("failed to to get group master key: %w", err)
}
rawDataResp, err = client.Client.DownloadGroupAvatar(ctx, info.GroupAvatarPath, groupMasterKey)
if err != nil {
log.Err(err).Msg("Direct download failed")
return nil, err
}
case *signalid.DirectMediaProfileAvatar:
log.Info().
Stringer("user_id", info.UserID).
Stringer("contact_id", info.ContactID).
Str("profile_avatar_path", info.ProfileAvatarPath).
Msg("Direct downloading profile avatar")
userLogin, err := s.Bridge.GetExistingUserLoginByID(ctx, signalid.MakeUserLoginID(info.UserID))
if err != nil {
return nil, fmt.Errorf("failed to get user login: %w", err)
} else if userLogin == nil {
return nil, bridgev2.ErrNotLoggedIn
}
client := userLogin.Client.(*SignalClient)
profileKey, err := client.Client.Store.RecipientStore.LoadProfileKey(ctx, info.ContactID)
if err != nil {
return nil, fmt.Errorf("failed to get contact: %w", err)
} else if profileKey == nil {
return nil, fmt.Errorf("profile key not found")
}
rawDataResp, err = client.Client.DownloadUserAvatar(ctx, info.ProfileAvatarPath, *profileKey)
if err != nil {
log.Err(err).Msg("Direct download failed")
return nil, err
}
case *signalid.DirectMediaSticker:
log.Info().
Hex("pack_id", info.PackID).
Uint32("sticker_id", info.StickerID).
Msg("Direct downloading sticker")
rawDataResp, err = signalmeow.DownloadStickerPackItem(ctx, info.PackID, info.PackKey, info.StickerID)
if err != nil {
log.Err(err).Msg("Direct download failed")
return nil, err
}
default:
return nil, fmt.Errorf("no downloader for direct media type: %T", info)
}
if rawDataResp == nil {
return nil, fmt.Errorf("unexpected fallthrough with no data")
}
return mediaproxy.GetMediaResponseRawData(rawDataResp), nil
}

View file

@ -1,28 +0,0 @@
# Displayname template for Signal users.
# {{.ProfileName}} - The Signal profile name set by the user.
# {{.ContactName}} - The name for the user from your phone's contact list. This is not safe on multi-user instances.
# {{.Nickname}} - The nickname set for the user in the native Signal app. This is not safe on multi-user instances.
# {{.PhoneNumber}} - The phone number of the user.
# {{.UUID}} - The UUID of the Signal user.
# {{.AboutEmoji}} - The emoji set by the user in their profile.
displayname_template: '{{or .ProfileName .PhoneNumber "Unknown user"}}'
# Should avatars from the user's contact list be used? This is not safe on multi-user instances.
use_contact_avatars: false
# Should the bridge request the user's contact list from the phone on startup?
sync_contacts_on_startup: true
# Should the bridge sync ghost user info even if profile fetching fails? This is not safe on multi-user instances.
use_outdated_profiles: false
# Should the Signal user's phone number be included in the room topic in private chat portal rooms?
number_in_topic: true
# Default device name that shows up in the Signal app.
device_name: mautrix-signal
# Avatar image for the Note to Self room.
note_to_self_avatar: mxc://maunium.net/REBIVrqjZwmaWpssCZpBlmlL
# Format for generating URLs from location messages for sending to Signal.
# Google Maps: 'https://www.google.com/maps/place/%[1]s,%[2]s'
# OpenStreetMap: 'https://www.openstreetmap.org/?mlat=%[1]s&mlon=%[2]s'
location_format: 'https://www.google.com/maps/place/%[1]s,%[2]s'
# Should view-once messages disappear shortly after sending a read receipt on Matrix?
disappear_view_once: false
# Should polls be sent using unstable MSC3381 event types?
extev_polls: false

View file

@ -1,441 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 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/>.
package connector
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/store"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
var defaultPL = 0
var moderatorPL = 50
func roleToPL(role signalmeow.GroupMemberRole) *int {
switch role {
case signalmeow.GroupMember_ADMINISTRATOR:
return &moderatorPL
case signalmeow.GroupMember_DEFAULT:
fallthrough
default:
return &defaultPL
}
}
func applyAnnouncementsOnly(plc *bridgev2.PowerLevelOverrides, announcementsOnly bool) {
if announcementsOnly {
plc.EventsDefault = &moderatorPL
} else {
plc.EventsDefault = &defaultPL
}
}
func applyAttributesAccess(plc *bridgev2.PowerLevelOverrides, attributeAccess signalmeow.AccessControl) {
attributePL := defaultPL
if attributeAccess == signalmeow.AccessControl_ADMINISTRATOR {
attributePL = moderatorPL
}
plc.Events[event.StateRoomName] = attributePL
plc.Events[event.StateRoomAvatar] = attributePL
plc.Events[event.StateTopic] = attributePL
plc.Events[event.StateBeeperDisappearingTimer] = attributePL
}
func applyMembersAccess(plc *bridgev2.PowerLevelOverrides, memberAccess signalmeow.AccessControl) {
if memberAccess == signalmeow.AccessControl_ADMINISTRATOR {
plc.Invite = &moderatorPL
} else {
plc.Invite = &defaultPL
}
}
func inviteLinkToJoinRule(inviteLinkAccess signalmeow.AccessControl) event.JoinRule {
switch inviteLinkAccess {
case signalmeow.AccessControl_UNSATISFIABLE:
return event.JoinRuleInvite
case signalmeow.AccessControl_ADMINISTRATOR:
return event.JoinRuleKnock
case signalmeow.AccessControl_ANY:
// TODO allow public portals?
publicPortals := false
if publicPortals {
return event.JoinRulePublic
} else {
return event.JoinRuleKnock
}
default:
return event.JoinRuleInvite
}
}
func (s *SignalClient) getGroupInfo(ctx context.Context, groupID types.GroupIdentifier, minRevision uint32, backupChat *store.BackupChat) (*bridgev2.ChatInfo, error) {
groupInfo, _, err := s.Client.RetrieveGroupByID(ctx, groupID, minRevision)
if err != nil {
return nil, fmt.Errorf("failed to retrieve group by id: %w", err)
}
return s.wrapGroupInfo(ctx, groupInfo, backupChat)
}
func (s *SignalClient) wrapGroupInfo(ctx context.Context, groupInfo *signalmeow.Group, backupChat *store.BackupChat) (*bridgev2.ChatInfo, error) {
members := &bridgev2.ChatMemberList{
IsFull: true,
MemberMap: make(map[networkid.UserID]bridgev2.ChatMember, len(groupInfo.Members)+len(groupInfo.PendingMembers)+len(groupInfo.RequestingMembers)+len(groupInfo.BannedMembers)),
PowerLevels: &bridgev2.PowerLevelOverrides{
Events: map[event.Type]int{
event.StatePowerLevels: moderatorPL,
},
},
ExcludeChangesFromTimeline: true,
}
applyAnnouncementsOnly(members.PowerLevels, groupInfo.AnnouncementsOnly)
joinRule := event.JoinRuleInvite
if groupInfo.AccessControl != nil {
applyAttributesAccess(members.PowerLevels, groupInfo.AccessControl.Attributes)
applyMembersAccess(members.PowerLevels, groupInfo.AccessControl.Members)
joinRule = inviteLinkToJoinRule(groupInfo.AccessControl.AddFromInviteLink)
}
for _, member := range groupInfo.RequestingMembers {
members.MemberMap.Set(bridgev2.ChatMember{
EventSender: s.makeEventSender(member.ACI),
Membership: event.MembershipKnock,
})
}
for _, member := range groupInfo.PendingMembers {
s.addChatMemberWithACIQuery(ctx, members.MemberMap, member.ServiceID, bridgev2.ChatMember{
PowerLevel: roleToPL(member.Role),
Membership: event.MembershipInvite,
MemberSender: s.makeEventSender(member.AddedByUserID),
})
}
for _, member := range groupInfo.Members {
members.MemberMap.Set(bridgev2.ChatMember{
EventSender: s.makeEventSender(member.ACI),
PowerLevel: roleToPL(member.Role),
Membership: event.MembershipJoin,
})
}
for _, member := range groupInfo.BannedMembers {
s.addChatMemberWithACIQuery(ctx, members.MemberMap, member.ServiceID, bridgev2.ChatMember{
Membership: event.MembershipBan,
})
}
if backupChat == nil {
var err error
// TODO allow using backup chat for data too instead of asking server?
backupChat, err = s.Client.Store.BackupStore.GetBackupChatByGroupID(ctx, groupInfo.GroupIdentifier)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get backup chat for group")
}
}
avatar, err := s.makeGroupAvatar(ctx, groupInfo.GroupIdentifier, &groupInfo.AvatarPath, groupInfo.GroupMasterKey)
if err != nil {
return nil, fmt.Errorf("failed to make group avatar: %w", err)
}
return &bridgev2.ChatInfo{
Name: &groupInfo.Title,
Topic: &groupInfo.Description,
Avatar: avatar,
Disappear: &database.DisappearingSetting{
Type: event.DisappearingTypeAfterRead,
Timer: time.Duration(groupInfo.DisappearingMessagesDuration) * time.Second,
},
Members: members,
Type: ptr.Ptr(database.RoomTypeDefault),
JoinRule: &event.JoinRulesEventContent{JoinRule: joinRule},
ExtraUpdates: bridgev2.MergeExtraUpdaters(makeRevisionUpdater(groupInfo.Revision), updatePortalSyncMeta),
CanBackfill: backupChat != nil,
ExcludeChangesFromTimeline: true,
}, nil
}
func addMemberToMap(mc map[networkid.UserID]bridgev2.ChatMember, member bridgev2.ChatMember) {
mc[member.EventSender.Sender] = member
}
func updatePortalSyncMeta(ctx context.Context, portal *bridgev2.Portal) bool {
meta := portal.Metadata.(*signalid.PortalMetadata)
meta.LastSync = jsontime.UnixNow()
return true
}
func (s *SignalClient) makeGroupAvatar(ctx context.Context, groupID types.GroupIdentifier, path *string, groupMasterKey types.SerializedGroupMasterKey) (*bridgev2.Avatar, error) {
if path == nil {
return nil, nil
}
avatar := &bridgev2.Avatar{
ID: makeAvatarPathID(*path),
Remove: *path == "",
}
if s.Main.MsgConv.DirectMedia {
userID, err := signalid.ParseUserLoginID(s.UserLogin.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse user login ID: %w", err)
}
groupIDBytes, err := groupID.Bytes()
if err != nil {
return nil, fmt.Errorf("failed to get group id bytes: %w", err)
}
mediaID, err := signalid.DirectMediaGroupAvatar{
UserID: userID,
GroupID: groupIDBytes,
GroupAvatarPath: *path,
}.AsMediaID()
if err != nil {
return nil, err
}
avatar.MXC, err = s.Main.Bridge.Matrix.GenerateContentURI(ctx, mediaID)
if err != nil {
return nil, err
}
avatar.Hash = signalid.HashMediaID(mediaID)
} else {
avatar.Get = func(ctx context.Context) ([]byte, error) {
return s.Client.DownloadGroupAvatar(ctx, *path, groupMasterKey)
}
}
return avatar, nil
}
func makeRevisionUpdater(rev uint32) func(ctx context.Context, portal *bridgev2.Portal) bool {
return func(ctx context.Context, portal *bridgev2.Portal) bool {
meta := portal.Metadata.(*signalid.PortalMetadata)
if meta.Revision < rev {
meta.Revision = rev
return true
}
return false
}
}
func (s *SignalClient) groupChangeToChatInfoChange(ctx context.Context, groupID types.GroupIdentifier, rev uint32, groupChange *signalmeow.GroupChange) (*bridgev2.ChatInfoChange, error) {
avatar, err := s.makeGroupAvatar(ctx, groupID, groupChange.ModifyAvatar, groupChange.GroupMasterKey)
if err != nil {
return nil, err
}
ic := &bridgev2.ChatInfoChange{
ChatInfo: &bridgev2.ChatInfo{
ExtraUpdates: makeRevisionUpdater(rev),
Name: groupChange.ModifyTitle,
Topic: groupChange.ModifyDescription,
Avatar: avatar,
},
}
if groupChange.ModifyDisappearingMessagesDuration != nil {
ic.ChatInfo.Disappear = &database.DisappearingSetting{
Type: event.DisappearingTypeAfterRead,
Timer: time.Duration(*groupChange.ModifyDisappearingMessagesDuration) * time.Second,
}
}
var pls *bridgev2.PowerLevelOverrides
if groupChange.ModifyAnnouncementsOnly != nil ||
groupChange.ModifyAttributesAccess != nil ||
groupChange.ModifyMemberAccess != nil {
pls = &bridgev2.PowerLevelOverrides{Events: make(map[event.Type]int)}
if groupChange.ModifyAnnouncementsOnly != nil {
applyAnnouncementsOnly(pls, *groupChange.ModifyAnnouncementsOnly)
}
if groupChange.ModifyAttributesAccess != nil {
applyAttributesAccess(pls, *groupChange.ModifyAttributesAccess)
}
if groupChange.ModifyMemberAccess != nil {
applyMembersAccess(pls, *groupChange.ModifyMemberAccess)
}
}
if groupChange.ModifyAddFromInviteLinkAccess != nil {
ic.ChatInfo.JoinRule = &event.JoinRulesEventContent{
JoinRule: inviteLinkToJoinRule(*groupChange.ModifyAddFromInviteLinkAccess),
}
}
mc := make(bridgev2.ChatMemberMap)
for _, member := range groupChange.AddPendingMembers {
s.addChatMemberWithACIQuery(ctx, mc, member.ServiceID, bridgev2.ChatMember{
PowerLevel: roleToPL(member.Role),
Membership: event.MembershipInvite,
PrevMembership: event.MembershipLeave,
MemberSender: s.makeEventSender(member.AddedByUserID),
})
}
for _, member := range groupChange.AddRequestingMembers {
mc.Set(bridgev2.ChatMember{
EventSender: s.makeEventSender(member.ACI),
Membership: event.MembershipKnock,
})
}
for _, memberServiceID := range groupChange.DeletePendingMembers {
s.addChatMemberWithACIQuery(ctx, mc, *memberServiceID, bridgev2.ChatMember{
Membership: event.MembershipLeave,
PrevMembership: event.MembershipInvite,
})
}
for _, memberACI := range groupChange.DeleteRequestingMembers {
mc.Set(bridgev2.ChatMember{
EventSender: s.makeEventSender(*memberACI),
Membership: event.MembershipLeave,
PrevMembership: event.MembershipKnock,
})
}
for _, memberACI := range groupChange.DeleteMembers {
mc.Set(bridgev2.ChatMember{
EventSender: s.makeEventSender(*memberACI),
Membership: event.MembershipLeave,
PrevMembership: event.MembershipJoin,
})
}
for _, memberServiceID := range groupChange.DeleteBannedMembers {
s.addChatMemberWithACIQuery(ctx, mc, *memberServiceID, bridgev2.ChatMember{
Membership: event.MembershipLeave,
PrevMembership: event.MembershipBan,
})
}
for _, member := range groupChange.AddBannedMembers {
s.addChatMemberWithACIQuery(ctx, mc, member.ServiceID, bridgev2.ChatMember{
Membership: event.MembershipBan,
})
}
for _, member := range groupChange.PromotePendingMembers {
mc.Set(bridgev2.ChatMember{
EventSender: s.makeEventSender(member.ACI),
Membership: event.MembershipJoin,
PrevMembership: event.MembershipInvite,
})
}
for _, member := range groupChange.PromotePendingPniAciMembers {
mc.Set(bridgev2.ChatMember{
EventSender: s.makeEventSender(member.ACI),
Membership: event.MembershipJoin,
})
mc.Set(bridgev2.ChatMember{
EventSender: s.makePNIEventSender(member.PNI),
Membership: event.MembershipLeave,
PrevMembership: event.MembershipInvite,
MemberEventExtra: map[string]any{
"com.beeper.exclude_from_timeline": true,
},
})
}
for _, member := range groupChange.PromoteRequestingMembers {
mc.Set(bridgev2.ChatMember{
EventSender: s.makeEventSender(member.ACI),
Membership: event.MembershipJoin,
PrevMembership: event.MembershipKnock,
})
}
for _, member := range groupChange.AddMembers {
mc.Set(bridgev2.ChatMember{
EventSender: s.makeEventSender(member.ACI),
PowerLevel: roleToPL(member.Role),
Membership: event.MembershipJoin,
})
}
for _, member := range groupChange.ModifyMemberRoles {
mc.Set(bridgev2.ChatMember{
EventSender: s.makeEventSender(member.ACI),
PowerLevel: roleToPL(member.Role),
Membership: event.MembershipJoin,
})
}
if len(mc) > 0 || pls != nil {
ic.MemberChanges = &bridgev2.ChatMemberList{MemberMap: mc, PowerLevels: pls}
}
return ic, nil
}
func (s *SignalClient) addChatMemberWithACIQuery(
ctx context.Context, mc bridgev2.ChatMemberMap, serviceID libsignalgo.ServiceID, member bridgev2.ChatMember,
) {
member.EventSender = s.makeEventSenderFromServiceID(serviceID)
mc.Set(member)
if aci := s.tryResolvePNItoLoggedInACI(ctx, serviceID); aci != nil {
member.EventSender = s.makeEventSender(*aci)
mc.Add(member)
}
}
func (s *SignalClient) tryResolvePNItoLoggedInACI(ctx context.Context, serviceID libsignalgo.ServiceID) *uuid.UUID {
if serviceID.Type != libsignalgo.ServiceIDTypePNI {
return nil
} else if serviceID.UUID == s.Client.Store.PNI {
return &s.Client.Store.ACI
} else if s.Main.Bridge.Config.SplitPortals {
// When split portals is enabled, we don't care about anyone else's logins
return nil
} else if device, err := s.Client.Store.DeviceStore.DeviceByPNI(ctx, serviceID.UUID); err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get ACI for PNI")
return nil
} else if device == nil {
return nil
} else {
return &device.ACI
}
}
func (s *SignalClient) catchUpGroup(ctx context.Context, portal *bridgev2.Portal, fromRevision, toRevision uint32, ts uint64) {
if fromRevision >= toRevision {
return
}
log := zerolog.Ctx(ctx).With().
Str("action", "catch up group changes").
Uint32("from_revision", fromRevision).
Uint32("to_revision", toRevision).
Logger()
if fromRevision == 0 {
log.Info().Msg("Syncing full group info")
info, err := s.getGroupInfo(ctx, types.GroupIdentifier(portal.ID), toRevision, nil)
if err != nil {
log.Err(err).Msg("Failed to get group info")
} else {
portal.UpdateInfo(ctx, info, s.UserLogin, nil, time.Time{})
}
} else {
log.Info().Msg("Syncing missed group changes")
groupChanges, err := s.Client.GetGroupHistoryPage(ctx, types.GroupIdentifier(portal.ID), fromRevision, false)
if err != nil {
log.Err(err).Msg("Failed to get group history page")
s.catchUpGroup(ctx, portal, 0, toRevision, ts)
return
}
for _, gc := range groupChanges {
log.Debug().Uint32("current_rev", gc.GroupChange.Revision).Msg("Processing group change")
chatInfoChange, err := s.groupChangeToChatInfoChange(ctx, types.GroupIdentifier(portal.ID), gc.GroupChange.Revision, gc.GroupChange)
if err != nil {
log.Err(err).Msg("Failed to convert group info")
} else {
portal.ProcessChatInfoChange(ctx, s.makeEventSenderFromServiceID(gc.GroupChange.SourceServiceID), s.UserLogin, chatInfoChange, time.UnixMilli(int64(ts)))
}
if gc.GroupChange.Revision == toRevision {
break
}
}
}
}

View file

@ -1,912 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 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/>.
package connector
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"slices"
"strconv"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"go.mau.fi/util/variationselector"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)
var (
_ bridgev2.EditHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.ReactionHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.RedactionHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.ReadReceiptHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.TypingHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.RoomNameHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.RoomAvatarHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.RoomTopicHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.ChatViewingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.DisappearTimerChangingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.DeleteChatHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.PollHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.MessageRequestAcceptingNetworkAPI = (*SignalClient)(nil)
)
func (s *SignalClient) sendMessage(ctx context.Context, portalID networkid.PortalID, content *signalpb.Content) error {
userID, groupID, err := signalid.ParsePortalID(portalID)
if err != nil {
return err
}
if groupID != "" {
result, err := s.Client.SendGroupMessage(ctx, groupID, content)
if err != nil {
return err
}
totalRecipients := len(result.FailedToSendTo) + len(result.SuccessfullySentTo)
log := zerolog.Ctx(ctx).With().
Int("total_recipients", totalRecipients).
Int("failed_to_send_to_count", len(result.FailedToSendTo)).
Int("successfully_sent_to_count", len(result.SuccessfullySentTo)).
Logger()
if len(result.SuccessfullySentTo) == 0 && len(result.FailedToSendTo) == 0 {
log.Debug().Msg("No successes or failures - Probably sent to myself")
} else if len(result.SuccessfullySentTo) == 0 {
log.Error().Msg("Failed to send event to all members of Signal group")
return errors.New("failed to send to any members of Signal group")
} else if len(result.SuccessfullySentTo) < totalRecipients {
if len(result.FailedToSendTo) > 0 {
log.Warn().Msg("Failed to send event to some members of Signal group")
} else {
log.Warn().Msg("Only sent event to some members of Signal group")
}
} else {
log.Debug().Msg("Sent event to all members of Signal group")
}
return nil
} else {
res := s.Client.SendMessage(ctx, userID, content)
if !res.WasSuccessful {
return res.Error
}
return nil
}
}
func getTimestampForEvent(txnID networkid.RawTransactionID, evt *event.Event, origSender *bridgev2.OrigSender) uint64 {
if origSender != nil {
// Relaybot messages are never allowed to set the timestamp
return uint64(time.Now().UnixMilli())
}
if len(txnID) > 0 {
parsed, err := strconv.ParseUint(string(txnID), 10, 64)
if err == nil {
return parsed
}
}
return uint64(evt.Timestamp)
}
func (s *SignalClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (message *bridgev2.MatrixMessageResponse, err error) {
converted, err := s.Main.MsgConv.ToSignal(
ctx, s.Client, msg.Portal, msg.Event, msg.Content, msg.OrigSender != nil, msg.ReplyTo,
)
if err != nil {
return nil, err
}
return s.doSendMessage(ctx, msg, converted, &signalid.MessageMetadata{
ContainsAttachments: len(converted.Attachments) > 0,
})
}
func (s *SignalClient) doSendMessage(
ctx context.Context,
msg *bridgev2.MatrixMessage,
converted *signalpb.DataMessage,
meta *signalid.MessageMetadata,
) (*bridgev2.MatrixMessageResponse, error) {
ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)
converted.Timestamp = &ts
if meta == nil {
meta = &signalid.MessageMetadata{}
}
msgID := signalid.MakeMessageID(s.Client.Store.ACI, ts)
msg.AddPendingToIgnore(networkid.TransactionID(msgID))
err := s.sendMessage(ctx, msg.Portal.ID, signalmeow.WrapDataMessage(converted))
if err != nil {
return nil, bridgev2.WrapErrorInStatus(err).WithSendNotice(true)
}
dbMsg := &database.Message{
ID: msgID,
SenderID: signalid.MakeUserID(s.Client.Store.ACI),
Timestamp: time.UnixMilli(int64(ts)),
Metadata: meta,
}
return &bridgev2.MatrixMessageResponse{
DB: dbMsg,
RemovePending: networkid.TransactionID(msgID),
}, nil
}
func (s *SignalClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
_, targetSentTimestamp, err := signalid.ParseMessageID(msg.EditTarget.ID)
if err != nil {
return fmt.Errorf("failed to parse target message ID: %w", err)
} else if msg.EditTarget.SenderID != signalid.MakeUserID(s.Client.Store.ACI) {
return fmt.Errorf("cannot edit other people's messages")
}
var replyTo *database.Message
if msg.EditTarget.ReplyTo.MessageID != "" {
replyTo, err = s.Main.Bridge.DB.Message.GetFirstOrSpecificPartByID(ctx, msg.Portal.Receiver, msg.EditTarget.ReplyTo)
if err != nil {
return fmt.Errorf("failed to get message reply target: %w", err)
}
}
converted, err := s.Main.MsgConv.ToSignal(ctx, s.Client, msg.Portal, msg.Event, msg.Content, msg.OrigSender != nil, replyTo)
if err != nil {
return err
}
ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)
converted.Timestamp = &ts
err = s.sendMessage(ctx, msg.Portal.ID, signalmeow.WrapEditMessage(&signalpb.EditMessage{
TargetSentTimestamp: proto.Uint64(targetSentTimestamp),
DataMessage: converted,
}))
if err != nil {
return bridgev2.WrapErrorInStatus(err).WithSendNotice(true)
}
msg.EditTarget.ID = signalid.MakeMessageID(s.Client.Store.ACI, ts)
msg.EditTarget.Metadata = &signalid.MessageMetadata{ContainsAttachments: len(converted.Attachments) > 0}
msg.EditTarget.EditCount++
return nil
}
func (s *SignalClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
return bridgev2.MatrixReactionPreResponse{
SenderID: signalid.MakeUserID(s.Client.Store.ACI),
EmojiID: "",
Emoji: variationselector.FullyQualify(msg.Content.RelatesTo.Key),
}, nil
}
func (s *SignalClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (reaction *database.Reaction, err error) {
targetAuthorACI, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetMessage.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse target message ID: %w", err)
}
ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)
err = s.sendMessage(ctx, msg.Portal.ID, signalmeow.WrapDataMessage(&signalpb.DataMessage{
Timestamp: proto.Uint64(ts),
RequiredProtocolVersion: proto.Uint32(uint32(signalpb.DataMessage_REACTIONS)),
Reaction: &signalpb.DataMessage_Reaction{
Emoji: proto.String(msg.PreHandleResp.Emoji),
Remove: proto.Bool(false),
TargetAuthorAciBinary: targetAuthorACI[:],
TargetSentTimestamp: proto.Uint64(targetSentTimestamp),
},
}))
if err != nil {
return nil, err
}
return &database.Reaction{}, nil
}
func (s *SignalClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error {
targetAuthorACI, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetReaction.MessageID)
if err != nil {
return fmt.Errorf("failed to parse target message ID: %w", err)
}
ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)
err = s.sendMessage(ctx, msg.Portal.ID, signalmeow.WrapDataMessage(&signalpb.DataMessage{
Timestamp: proto.Uint64(ts),
RequiredProtocolVersion: proto.Uint32(uint32(signalpb.DataMessage_REACTIONS)),
Reaction: &signalpb.DataMessage_Reaction{
Emoji: proto.String(msg.TargetReaction.Emoji),
Remove: proto.Bool(true),
TargetAuthorAciBinary: targetAuthorACI[:],
TargetSentTimestamp: proto.Uint64(targetSentTimestamp),
},
}))
if err != nil {
return err
}
return nil
}
func (s *SignalClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error {
_, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetMessage.ID)
if err != nil {
return fmt.Errorf("failed to parse target message ID: %w", err)
} else if msg.TargetMessage.SenderID != signalid.MakeUserID(s.Client.Store.ACI) {
return fmt.Errorf("cannot delete other people's messages")
}
ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)
err = s.sendMessage(ctx, msg.Portal.ID, signalmeow.WrapDataMessage(&signalpb.DataMessage{
Timestamp: proto.Uint64(ts),
Delete: &signalpb.DataMessage_Delete{
TargetSentTimestamp: proto.Uint64(targetSentTimestamp),
},
}))
if err != nil {
return err
}
return nil
}
func (s *SignalClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bridgev2.MatrixReadReceipt) error {
if !receipt.ReadUpTo.After(receipt.LastRead) {
return nil
}
if receipt.LastRead.IsZero() {
receipt.LastRead = receipt.ReadUpTo.Add(-5 * time.Second)
}
dbMessages, err := s.Main.Bridge.DB.Message.GetMessagesBetweenTimeQuery(ctx, receipt.Portal.PortalKey, receipt.LastRead, receipt.ReadUpTo)
if err != nil {
return fmt.Errorf("failed to get messages to mark as read: %w", err)
} else if len(dbMessages) == 0 {
return nil
}
messagesToRead := map[uuid.UUID][]uint64{}
for _, msg := range dbMessages {
userID, timestamp, err := signalid.ParseMessageID(msg.ID)
if err != nil {
continue
}
messagesToRead[userID] = append(messagesToRead[userID], timestamp)
}
zerolog.Ctx(ctx).Debug().
Any("targets", messagesToRead).
Msg("Collected read receipt target messages")
// TODO send sync message manually containing all read receipts instead of a separate message for each recipient
for destination, messages := range messagesToRead {
// Don't send read receipts for own messages
if destination == s.Client.Store.ACI {
continue
}
// Don't use portal.sendSignalMessage because we're sending this straight to
// who sent the original message, not the portal's ChatID
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
result := s.Client.SendMessage(ctx, libsignalgo.NewACIServiceID(destination), signalmeow.ReadReceptMessageForTimestamps(messages))
cancel()
if !result.WasSuccessful {
zerolog.Ctx(ctx).Err(result.Error).
Stringer("destination", destination).
Uints64("message_ids", messages).
Msg("Failed to send read receipt to Signal")
} else {
zerolog.Ctx(ctx).Debug().
Stringer("destination", destination).
Uints64("message_ids", messages).
Msg("Sent read receipt to Signal")
}
}
return nil
}
func (s *SignalClient) HandleMatrixTyping(ctx context.Context, typing *bridgev2.MatrixTyping) error {
userID, groupID, err := signalid.ParsePortalID(typing.Portal.ID)
if err != nil {
return err
}
typingMessage := signalmeow.TypingMessage(typing.IsTyping)
if !userID.IsEmpty() && userID.Type == libsignalgo.ServiceIDTypeACI {
result := s.Client.SendMessage(ctx, userID, typingMessage)
if !result.WasSuccessful {
return result.Error
}
} else if groupID != "" {
_, err = s.Client.SendGroupMessage(ctx, groupID, typingMessage)
if err != nil {
return err
}
}
return nil
}
func (s *SignalClient) handleMatrixRoomMeta(ctx context.Context, portal *bridgev2.Portal, gc *signalmeow.GroupChange, postUpdatePortal func()) (bool, error) {
_, groupID, err := signalid.ParsePortalID(portal.ID)
if err != nil || groupID == "" {
return false, err
}
gc.Revision = portal.Metadata.(*signalid.PortalMetadata).Revision + 1
revision, err := s.Client.UpdateGroup(ctx, gc, groupID)
if err != nil {
return false, err
}
if gc.ModifyTitle != nil {
portal.Name = *gc.ModifyTitle
portal.NameSet = true
}
if gc.ModifyDescription != nil {
portal.Topic = *gc.ModifyDescription
portal.TopicSet = true
}
if gc.ModifyAvatar != nil {
portal.AvatarID = makeAvatarPathID(*gc.ModifyAvatar)
portal.AvatarSet = true
}
if postUpdatePortal != nil {
postUpdatePortal()
}
portal.Metadata.(*signalid.PortalMetadata).Revision = revision
return true, nil
}
func (s *SignalClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error) {
return s.handleMatrixRoomMeta(ctx, msg.Portal, &signalmeow.GroupChange{
ModifyTitle: &msg.Content.Name,
}, nil)
}
func (s *SignalClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridgev2.MatrixRoomAvatar) (bool, error) {
_, groupID, err := signalid.ParsePortalID(msg.Portal.ID)
if err != nil || groupID == "" {
return false, err
}
var avatarPath string
var avatarHash [32]byte
if msg.Content.URL != "" {
data, err := s.Main.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, nil)
if err != nil {
return false, fmt.Errorf("failed to download avatar: %w", err)
}
avatarHash = sha256.Sum256(data)
avatarPath, err = s.Client.UploadGroupAvatar(ctx, data, groupID, "")
if err != nil {
return false, fmt.Errorf("failed to reupload avatar: %w", err)
}
}
return s.handleMatrixRoomMeta(ctx, msg.Portal, &signalmeow.GroupChange{
ModifyAvatar: &avatarPath,
}, func() {
msg.Portal.AvatarMXC = msg.Content.URL
msg.Portal.AvatarHash = avatarHash
})
}
func (s *SignalClient) HandleMatrixRoomTopic(ctx context.Context, msg *bridgev2.MatrixRoomTopic) (bool, error) {
return s.handleMatrixRoomMeta(ctx, msg.Portal, &signalmeow.GroupChange{
ModifyDescription: &msg.Content.Topic,
}, nil)
}
func (s *SignalClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2.MatrixMembershipChange) (*bridgev2.MatrixMembershipResult, error) {
if msg.Type.IsSelf && msg.OrigSender != nil {
return nil, nil
}
var targetIntent bridgev2.MatrixAPI
var targetSignalID libsignalgo.ServiceID
var err error
if msg.Portal.RoomType == database.RoomTypeDM {
switch msg.Type {
case bridgev2.Invite:
return nil, fmt.Errorf("cannot invite additional user to dm")
default:
return nil, nil
}
}
targetSignalID, err = signalid.ParseGhostOrUserLoginID(msg.Target)
if err != nil {
return nil, fmt.Errorf("failed to parse target signal id: %w", err)
}
switch target := msg.Target.(type) {
case *bridgev2.Ghost:
targetIntent = target.Intent
case *bridgev2.UserLogin:
targetIntent = target.User.DoublePuppet(ctx)
if targetIntent == nil {
ghost, err := s.Main.Bridge.GetGhostByID(ctx, networkid.UserID(target.ID))
if err != nil {
return nil, fmt.Errorf("failed to get ghost for user: %w", err)
}
targetIntent = ghost.Intent
}
default:
return nil, fmt.Errorf("cannot get target intent: unknown type: %T", target)
}
log := zerolog.Ctx(ctx).With().
Str("From Membership", string(msg.Type.From)).
Str("To Membership", string(msg.Type.To)).
Logger()
gc := &signalmeow.GroupChange{}
role := signalmeow.GroupMember_DEFAULT
if msg.Type.To == event.MembershipInvite || msg.Type == bridgev2.AcceptKnock {
levels, err := msg.Portal.Bridge.Matrix.GetPowerLevels(ctx, msg.Portal.MXID)
if err != nil {
log.Err(err).Msg("Couldn't get power levels")
if levels.GetUserLevel(targetIntent.GetMXID()) >= moderatorPL {
role = signalmeow.GroupMember_ADMINISTRATOR
}
}
}
switch msg.Type {
case bridgev2.AcceptInvite:
if targetSignalID.Type != libsignalgo.ServiceIDTypeACI {
return nil, fmt.Errorf("can't accept invite for non-ACI service ID")
}
gc.PromotePendingMembers = []*signalmeow.PromotePendingMember{{
ACI: targetSignalID.UUID,
}}
case bridgev2.RevokeInvite, bridgev2.RejectInvite:
gc.DeletePendingMembers = []*libsignalgo.ServiceID{&targetSignalID}
case bridgev2.Leave, bridgev2.Kick:
if targetSignalID.Type != libsignalgo.ServiceIDTypeACI {
return nil, fmt.Errorf("can't kick non-ACI service ID")
}
gc.DeleteMembers = []*uuid.UUID{&targetSignalID.UUID}
case bridgev2.Invite:
if targetSignalID.Type == libsignalgo.ServiceIDTypeACI {
gc.AddMembers = []*signalmeow.AddMember{{
GroupMember: signalmeow.GroupMember{
ACI: targetSignalID.UUID,
Role: role,
},
}}
} else {
gc.AddPendingMembers = []*signalmeow.PendingMember{{
ServiceID: targetSignalID,
Role: role,
AddedByUserID: s.Client.Store.ACI,
Timestamp: uint64(msg.Event.Timestamp),
}}
}
// TODO: joining and knocking requires a way to obtain the invite link
// because the joining/knocking member doesn't have the GroupMasterKey yet
// case bridgev2.Join:
// gc.AddMembers = []*signalmeow.AddMember{{
// GroupMember: signalmeow.GroupMember{
// ACI: targetSignalID,
// Role: role,
// },
// JoinFromInviteLink: true,
// }}
// case bridgev2.Knock:
// gc.AddRequestingMembers = []*signalmeow.RequestingMember{{
// ACI: targetSignalID,
// Timestamp: uint64(time.Now().UnixMilli()),
// }}
case bridgev2.AcceptKnock:
if targetSignalID.Type != libsignalgo.ServiceIDTypeACI {
return nil, fmt.Errorf("can't accept knock from non-ACI service ID")
}
gc.PromoteRequestingMembers = []*signalmeow.RoleMember{{
ACI: targetSignalID.UUID,
Role: role,
}}
case bridgev2.RetractKnock, bridgev2.RejectKnock:
if targetSignalID.Type != libsignalgo.ServiceIDTypeACI {
return nil, fmt.Errorf("can't reject knock from non-ACI service ID")
}
gc.DeleteRequestingMembers = []*uuid.UUID{&targetSignalID.UUID}
case bridgev2.BanKnocked, bridgev2.BanInvited, bridgev2.BanJoined, bridgev2.BanLeft:
gc.AddBannedMembers = []*signalmeow.BannedMember{{
ServiceID: targetSignalID,
Timestamp: uint64(time.Now().UnixMilli()),
}}
switch msg.Type {
case bridgev2.BanJoined:
if targetSignalID.Type != libsignalgo.ServiceIDTypeACI {
return nil, fmt.Errorf("can't ban joined non-ACI service ID")
}
gc.DeleteMembers = []*uuid.UUID{&targetSignalID.UUID}
case bridgev2.BanInvited:
gc.DeletePendingMembers = []*libsignalgo.ServiceID{&targetSignalID}
case bridgev2.BanKnocked:
if targetSignalID.Type != libsignalgo.ServiceIDTypeACI {
return nil, fmt.Errorf("can't ban knocked non-ACI service ID")
}
gc.DeleteRequestingMembers = []*uuid.UUID{&targetSignalID.UUID}
}
case bridgev2.Unban:
gc.DeleteBannedMembers = []*libsignalgo.ServiceID{&targetSignalID}
default:
return nil, fmt.Errorf("unsupported membership change: %s -> %s", msg.Type.From, msg.Type.To)
}
_, groupID, err := signalid.ParsePortalID(msg.Portal.ID)
if err != nil || groupID == "" {
return nil, err
}
gc.Revision = msg.Portal.Metadata.(*signalid.PortalMetadata).Revision + 1
revision, err := s.Client.UpdateGroup(ctx, gc, groupID)
if err != nil {
return nil, err
}
if msg.Type == bridgev2.Invite && targetSignalID.Type != libsignalgo.ServiceIDTypePNI {
err = targetIntent.EnsureJoined(ctx, msg.Portal.MXID)
if err != nil {
return nil, err
}
}
msg.Portal.Metadata.(*signalid.PortalMetadata).Revision = revision
return nil, nil
}
func plToRole(pl int) signalmeow.GroupMemberRole {
if pl >= moderatorPL {
return signalmeow.GroupMember_ADMINISTRATOR
} else {
return signalmeow.GroupMember_DEFAULT
}
}
func plToAccessControl(pl int) *signalmeow.AccessControl {
var accessControl signalmeow.AccessControl
if pl >= moderatorPL {
accessControl = signalmeow.AccessControl_ADMINISTRATOR
} else {
accessControl = signalmeow.AccessControl_MEMBER
}
return &accessControl
}
func hasAdminChanged(plc *bridgev2.SinglePowerLevelChange) bool {
if plc == nil {
return false
}
return (plc.NewLevel < moderatorPL) != (plc.OrigLevel < moderatorPL)
}
func (s *SignalClient) HandleMatrixPowerLevels(ctx context.Context, msg *bridgev2.MatrixPowerLevelChange) (bool, error) {
if msg.Portal.RoomType == database.RoomTypeDM {
return false, nil
}
gc := &signalmeow.GroupChange{}
for _, plc := range msg.Users {
if !hasAdminChanged(&plc.SinglePowerLevelChange) {
continue
}
serviceID, err := signalid.ParseGhostOrUserLoginID(plc.Target)
if err != nil || serviceID.Type != libsignalgo.ServiceIDTypeACI {
continue
}
gc.ModifyMemberRoles = append(gc.ModifyMemberRoles, &signalmeow.RoleMember{
ACI: serviceID.UUID,
Role: plToRole(plc.NewLevel),
})
}
if hasAdminChanged(msg.EventsDefault) {
announcementsOnly := msg.EventsDefault.NewLevel >= moderatorPL
gc.ModifyAnnouncementsOnly = &announcementsOnly
}
if hasAdminChanged(msg.StateDefault) {
gc.ModifyAttributesAccess = plToAccessControl(msg.StateDefault.NewLevel)
}
if hasAdminChanged(msg.Invite) {
gc.ModifyMemberAccess = plToAccessControl(msg.Invite.NewLevel)
}
_, groupID, err := signalid.ParsePortalID(msg.Portal.ID)
if err != nil || groupID == "" {
return false, err
}
revision, err := s.Client.UpdateGroup(ctx, gc, groupID)
if err != nil {
return false, err
}
msg.Portal.Metadata.(*signalid.PortalMetadata).Revision = revision
return true, nil
}
func (s *SignalClient) HandleMatrixViewingChat(ctx context.Context, msg *bridgev2.MatrixViewingChat) error {
if msg.Portal == nil {
return nil
}
// Sync the other users ghost in DMs
if msg.Portal.OtherUserID != "" {
ghost, err := s.Main.Bridge.GetExistingGhostByID(ctx, msg.Portal.OtherUserID)
if err != nil {
return fmt.Errorf("failed to get ghost for sync: %w", err)
} else if ghost == nil {
zerolog.Ctx(ctx).Warn().
Str("other_user_id", string(msg.Portal.OtherUserID)).
Msg("No ghost found for other user in portal")
} else {
meta := ghost.Metadata.(*signalid.GhostMetadata)
if meta.ProfileFetchedAt.Time.Add(5 * time.Minute).Before(time.Now()) {
// Reset, but don't save, portal last sync time for immediate sync now
meta.ProfileFetchedAt.Time = time.Time{}
info, err := s.GetUserInfoWithRefreshAfter(ctx, ghost, 5*time.Minute)
if err != nil {
return fmt.Errorf("failed to get user info: %w", err)
}
ghost.UpdateInfo(ctx, info)
}
}
}
// always resync the portal if its stale
portalMeta := msg.Portal.Metadata.(*signalid.PortalMetadata)
if portalMeta.LastSync.Add(24 * time.Hour).Before(time.Now()) {
s.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatResync,
PortalKey: msg.Portal.PortalKey,
},
GetChatInfoFunc: s.GetChatInfo,
})
}
return nil
}
func (s *SignalClient) HandleMatrixDisappearingTimer(ctx context.Context, msg *bridgev2.MatrixDisappearingTimer) (bool, error) {
if msg.Content.Type != event.DisappearingTypeAfterRead && msg.Content.Timer.Duration != 0 {
return false, fmt.Errorf("unsupported disappearing timer type: %s", msg.Content.Type)
}
userID, groupID, err := signalid.ParsePortalID(msg.Portal.ID)
if err != nil {
return false, err
}
newSetting := database.DisappearingSetting{
Type: event.DisappearingTypeAfterRead,
Timer: msg.Content.Timer.Duration,
}
if newSetting.Timer == 0 {
newSetting.Type = event.DisappearingTypeNone
}
if groupID != "" {
return s.handleMatrixRoomMeta(ctx, msg.Portal, &signalmeow.GroupChange{
ModifyDisappearingMessagesDuration: ptr.Ptr(uint32(msg.Content.Timer.Seconds())),
}, func() {
msg.Portal.Disappear = newSetting
})
} else {
ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)
res := s.Client.SendMessage(ctx, userID, signalmeow.WrapDataMessage(&signalpb.DataMessage{
Timestamp: ptr.Ptr(ts),
Flags: ptr.Ptr(uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE)),
ExpireTimer: ptr.Ptr(uint32(msg.Content.Timer.Seconds())),
}))
if !res.WasSuccessful {
return false, res.Error
}
msg.Portal.Disappear = newSetting
return true, nil
}
}
func (s *SignalClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2.MatrixDeleteChat) error {
userID, groupID, err := signalid.ParsePortalID(msg.Portal.ID)
if err != nil {
return fmt.Errorf("failed to parse portal ID: %w", err)
}
if msg.Content.FromMessageRequest {
// TODO block and delete support?
err = s.syncMessageRequestResponse(ctx, msg.Portal, signalpb.SyncMessage_MessageRequestResponse_DELETE)
if err != nil {
return fmt.Errorf("failed to send message request delete sync: %w", err)
}
}
// Build ConversationIdentifier based on portal type
var conversationID *signalpb.ConversationIdentifier
if groupID == "" {
conversationID = &signalpb.ConversationIdentifier{
Identifier: &signalpb.ConversationIdentifier_ThreadServiceIdBinary{
ThreadServiceIdBinary: userID.Bytes(),
},
}
} else {
gid, err := groupID.Bytes()
if err != nil {
return fmt.Errorf("failed to parse group ID: %w", err)
}
conversationID = &signalpb.ConversationIdentifier{
Identifier: &signalpb.ConversationIdentifier_ThreadGroupId{
ThreadGroupId: gid[:],
},
}
}
// Retrieve most recent messages from the portal
var mostRecentMessages []*signalpb.AddressableMessage
dbMessages, err := s.Main.Bridge.DB.Message.GetMessagesBetweenTimeQuery(
ctx,
msg.Portal.PortalKey,
time.Now().Add(-30*24*time.Hour), // Last 30 days
time.Now(),
)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get recent messages for conversation delete")
} else if len(dbMessages) > 0 {
// Limit to the 5 most recent messages overall
limit := 5
startIdx := 0
if len(dbMessages) > limit {
startIdx = len(dbMessages) - limit
}
// Create AddressableMessage for most recent messages
for _, dbMsg := range dbMessages[startIdx:] {
senderACI, timestamp, err := signalid.ParseMessageID(dbMsg.ID)
if err != nil {
continue
}
mostRecentMessages = append(mostRecentMessages, &signalpb.AddressableMessage{
Author: &signalpb.AddressableMessage_AuthorServiceIdBinary{
AuthorServiceIdBinary: senderACI[:],
},
SentTimestamp: proto.Uint64(timestamp),
})
}
}
recipientID := s.Client.Store.ACIServiceID()
// Send DeleteForMe sync message to self
result := s.Client.SendMessage(ctx, recipientID, signalmeow.WrapSyncMessage(&signalpb.SyncMessage{
Content: &signalpb.SyncMessage_DeleteForMe_{
DeleteForMe: &signalpb.SyncMessage_DeleteForMe{
ConversationDeletes: []*signalpb.SyncMessage_DeleteForMe_ConversationDelete{{
Conversation: conversationID,
MostRecentMessages: mostRecentMessages,
IsFullDelete: proto.Bool(true),
}},
},
},
}))
zerolog.Ctx(ctx).Debug().
Str("portal_id", string(msg.Portal.ID)).
Int("recent_messages_count", len(mostRecentMessages)).
Msg("Sent conversation deletion to Signal")
if !result.WasSuccessful {
return fmt.Errorf("failed to send delete conversation sync message: %w %s %s", result.Error, userID, groupID)
}
return nil
}
func (s *SignalClient) HandleMatrixPollStart(ctx context.Context, msg *bridgev2.MatrixPollStart) (*bridgev2.MatrixMessageResponse, error) {
optionNames := make([]string, len(msg.Content.PollStart.Answers))
optionIDs := make([]string, len(msg.Content.PollStart.Answers))
for i, option := range msg.Content.PollStart.Answers {
optionNames[i] = option.Text
optionIDs[i] = option.ID
}
converted := &signalpb.DataMessage{
PollCreate: &signalpb.DataMessage_PollCreate{
Question: ptr.Ptr(msg.Content.PollStart.Question.Text),
AllowMultiple: ptr.Ptr(msg.Content.PollStart.MaxSelections != 1),
Options: optionNames,
},
RequiredProtocolVersion: ptr.Ptr(uint32(signalpb.DataMessage_POLLS)),
}
return s.doSendMessage(ctx, &msg.MatrixMessage, converted, &signalid.MessageMetadata{
MatrixPollOptionIDs: optionIDs,
})
}
func (s *SignalClient) HandleMatrixPollVote(ctx context.Context, msg *bridgev2.MatrixPollVote) (*bridgev2.MatrixMessageResponse, error) {
senderACI, msgTS, err := signalid.ParseMessageID(msg.VoteTo.ID)
if err != nil {
return nil, err
}
mxOptions := msg.VoteTo.Metadata.(*signalid.MessageMetadata).MatrixPollOptionIDs
optionIndexes := make([]uint32, len(msg.Content.Response.Answers))
for i, answer := range msg.Content.Response.Answers {
if idx := slices.Index(mxOptions, answer); idx >= 0 {
optionIndexes[i] = uint32(idx)
} else if idx, err = strconv.Atoi(answer); err == nil && idx >= 0 {
optionIndexes[i] = uint32(idx)
} else {
return nil, fmt.Errorf("unknown poll answer ID: %s", answer)
}
}
converted := &signalpb.DataMessage{
PollVote: &signalpb.DataMessage_PollVote{
TargetAuthorAciBinary: senderACI[:],
TargetSentTimestamp: &msgTS,
OptionIndexes: optionIndexes,
VoteCount: proto.Uint32(1), // TODO
},
RequiredProtocolVersion: proto.Uint32(0),
}
return s.doSendMessage(ctx, &msg.MatrixMessage, converted, nil)
}
func (s *SignalClient) syncMessageRequestResponse(
ctx context.Context,
portal *bridgev2.Portal,
respType signalpb.SyncMessage_MessageRequestResponse_Type,
) error {
userID, groupID, err := signalid.ParsePortalID(portal.ID)
if err != nil {
return err
}
accept := &signalpb.SyncMessage_MessageRequestResponse{
Type: respType.Enum(),
}
if groupID != "" {
gidBytes, err := groupID.Bytes()
if err != nil {
return fmt.Errorf("failed to parse group ID: %w", err)
}
accept.GroupId = gidBytes[:]
} else if userID.Type == libsignalgo.ServiceIDTypeACI {
accept.ThreadAciBinary = userID.UUID[:]
} else {
return fmt.Errorf("invalid portal ID for message request response: %s", portal.ID)
}
res := s.Client.SendMessage(ctx, libsignalgo.NewACIServiceID(s.Client.Store.ACI), signalmeow.WrapSyncMessage(&signalpb.SyncMessage{
Content: &signalpb.SyncMessage_MessageRequestResponse_{
MessageRequestResponse: accept,
},
}))
if !res.WasSuccessful {
return res.Error
}
return nil
}
func (s *SignalClient) HandleMatrixAcceptMessageRequest(ctx context.Context, msg *bridgev2.MatrixAcceptMessageRequest) error {
userID, _, err := signalid.ParsePortalID(msg.Portal.ID)
if err != nil {
return err
}
err = s.syncMessageRequestResponse(ctx, msg.Portal, signalpb.SyncMessage_MessageRequestResponse_ACCEPT)
if err != nil {
return fmt.Errorf("failed to sync message request acceptance: %w", err)
}
if userID.Type == libsignalgo.ServiceIDTypeACI {
profileKey, err := s.Client.ProfileKeyForSignalID(ctx, s.Client.Store.ACI)
if err != nil {
return fmt.Errorf("failed to get own profile key: %w", err)
}
var pniSig *signalpb.PniSignatureMessage
if s.Client.Store.AccountRecord.GetPhoneNumberSharingMode() == signalpb.AccountRecord_EVERYBODY {
sig, err := s.Client.Store.PNIIdentityKeyPair.SignAlternateIdentity(s.Client.Store.ACIIdentityKeyPair.GetIdentityKey())
if err != nil {
return fmt.Errorf("failed to generate PNI signature: %w", err)
}
pniSig = &signalpb.PniSignatureMessage{
Pni: s.Client.Store.PNI[:],
Signature: sig,
}
}
res := s.Client.SendMessage(ctx, userID, &signalpb.Content{
Content: &signalpb.Content_DataMessage{DataMessage: &signalpb.DataMessage{
Flags: proto.Uint32(uint32(signalpb.DataMessage_PROFILE_KEY_UPDATE)),
ProfileKey: profileKey.Slice(),
Timestamp: proto.Uint64(getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)),
RequiredProtocolVersion: proto.Uint32(0),
}},
PniSignatureMessage: pniSig,
})
if !res.WasSuccessful {
return fmt.Errorf("failed to share profile key to accept message request: %w", res.Error)
}
// TODO send read receipts too?
}
return nil
}

View file

@ -1,809 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 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/>.
package connector
import (
"context"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.mau.fi/util/exzerolog"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/events"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
func (s *SignalClient) handleSignalEvent(rawEvt events.SignalEvent) bool {
switch evt := rawEvt.(type) {
case *events.ChatEvent:
return s.Main.Bridge.QueueRemoteEvent(s.UserLogin, &Bv2ChatEvent{ChatEvent: evt, s: s}).Success
case *events.DecryptionError:
return s.Main.Bridge.QueueRemoteEvent(s.UserLogin, s.wrapDecryptionError(evt)).Success
case *events.Receipt:
return s.handleSignalReceipt(evt)
case *events.ReadSelf:
return s.handleSignalReadSelf(evt)
case *events.DeleteForMe:
return s.handleSignalDeleteForMe(evt)
case *events.MessageRequestResponse:
return s.handleSignalMessageRequestResponse(evt)
case *events.Call:
return s.Main.Bridge.QueueRemoteEvent(s.UserLogin, s.wrapCallEvent(evt)).Success
case *events.ContactList:
s.handleSignalContactList(evt)
case *events.ACIFound:
s.handleSignalACIFound(evt)
case *events.QueueEmpty:
s.queueEmptyWaiter.Set()
case *events.LoggedOut:
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: evt.Error.Error()})
default:
s.UserLogin.Log.Warn().Type("event_type", evt).Msg("Unrecognized signalmeow event type")
}
return true
}
func (s *SignalClient) wrapCallEvent(evt *events.Call) bridgev2.RemoteMessage {
return &simplevent.Message[*events.Call]{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventMessage,
LogContext: func(c zerolog.Context) zerolog.Context {
c = c.Stringer("sender_id", evt.Info.Sender)
c = c.Uint64("message_ts", evt.Timestamp)
return c
},
PortalKey: s.makePortalKey(evt.Info.ChatID),
CreatePortal: true,
Sender: s.makeEventSender(evt.Info.Sender),
Timestamp: time.UnixMilli(int64(evt.Timestamp)),
},
Data: evt,
ID: signalid.MakeMessageID(evt.Info.Sender, evt.Timestamp),
ConvertMessageFunc: convertCallEvent,
}
}
func convertCallEvent(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data *events.Call) (*bridgev2.ConvertedMessage, error) {
content := &event.MessageEventContent{
MsgType: event.MsgNotice,
}
if data.IsRinging {
content.Body = "Incoming call"
if userID, _, _ := signalid.ParsePortalID(portal.ID); !userID.IsEmpty() {
content.MsgType = event.MsgText
}
content.BeeperActionMessage = &event.BeeperActionMessage{
Type: event.BeeperActionMessageCall,
}
} else {
content.Body = "Call ended"
}
return &bridgev2.ConvertedMessage{
Parts: []*bridgev2.ConvertedMessagePart{{
Type: event.EventMessage,
Content: content,
}},
}, nil
}
func (s *SignalClient) wrapDecryptionError(evt *events.DecryptionError) bridgev2.RemoteMessage {
return &simplevent.Message[*events.DecryptionError]{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventMessage,
LogContext: func(c zerolog.Context) zerolog.Context {
c = c.Stringer("sender_id", evt.Sender)
c = c.Uint64("message_ts", evt.Timestamp)
return c
},
PortalKey: s.makePortalKey(evt.Sender.String()),
CreatePortal: true,
Sender: s.makeEventSender(evt.Sender),
Timestamp: time.UnixMilli(int64(evt.Timestamp)),
StreamOrder: int64(evt.Timestamp),
},
Data: evt,
// TODO use main message id and edit it if it later becomes decryptable?
ID: "decrypterr|" + signalid.MakeMessageID(evt.Sender, evt.Timestamp),
ConvertMessageFunc: convertDecryptionError,
}
}
func convertDecryptionError(_ context.Context, _ *bridgev2.Portal, _ bridgev2.MatrixAPI, _ *events.DecryptionError) (*bridgev2.ConvertedMessage, error) {
return &bridgev2.ConvertedMessage{
Parts: []*bridgev2.ConvertedMessagePart{{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Message couldn't be decrypted. It may have been in this chat or a group chat. Please check your Signal app.",
},
}},
}, nil
}
type Bv2ChatEvent struct {
*events.ChatEvent
s *SignalClient
}
var (
_ bridgev2.RemoteMessage = (*Bv2ChatEvent)(nil)
_ bridgev2.RemoteEdit = (*Bv2ChatEvent)(nil)
_ bridgev2.RemoteEventWithTimestamp = (*Bv2ChatEvent)(nil)
_ bridgev2.RemoteReaction = (*Bv2ChatEvent)(nil)
_ bridgev2.RemoteReactionRemove = (*Bv2ChatEvent)(nil)
_ bridgev2.RemoteMessageRemove = (*Bv2ChatEvent)(nil)
_ bridgev2.RemoteTyping = (*Bv2ChatEvent)(nil)
_ bridgev2.RemotePreHandler = (*Bv2ChatEvent)(nil)
_ bridgev2.RemoteChatInfoChange = (*Bv2ChatEvent)(nil)
_ bridgev2.RemoteEventWithStreamOrder = (*Bv2ChatEvent)(nil)
)
func (evt *Bv2ChatEvent) GetType() bridgev2.RemoteEventType {
switch innerEvt := evt.Event.(type) {
case *signalpb.DataMessage:
switch {
case innerEvt.Body != nil, innerEvt.Attachments != nil, innerEvt.Contact != nil, innerEvt.Sticker != nil,
innerEvt.Payment != nil, innerEvt.GiftBadge != nil, innerEvt.PollCreate != nil, innerEvt.PollVote != nil,
innerEvt.GetRequiredProtocolVersion() > uint32(signalpb.DataMessage_CURRENT),
innerEvt.GetFlags()&uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE) != 0:
return bridgev2.RemoteEventMessage
case innerEvt.Reaction != nil:
if innerEvt.Reaction.GetRemove() {
return bridgev2.RemoteEventReactionRemove
}
return bridgev2.RemoteEventReaction
case innerEvt.Delete != nil, innerEvt.AdminDelete != nil:
return bridgev2.RemoteEventMessageRemove
case innerEvt.GetGroupV2().GetGroupChange() != nil:
return bridgev2.RemoteEventChatInfoChange
}
case *signalpb.EditMessage:
return bridgev2.RemoteEventEdit
case *signalpb.TypingMessage:
return bridgev2.RemoteEventTyping
}
return bridgev2.RemoteEventUnknown
}
func (evt *Bv2ChatEvent) GetChatInfoChange(ctx context.Context) (*bridgev2.ChatInfoChange, error) {
dm, _ := evt.Event.(*signalpb.DataMessage)
gv2 := dm.GetGroupV2()
if gv2 == nil || gv2.GroupChange == nil {
return nil, fmt.Errorf("GetChatInfoChange() called for non-GroupChange event")
}
groupChange, err := evt.s.Client.DecryptGroupChange(ctx, gv2)
if err != nil {
return nil, fmt.Errorf("failed to decrypt group change: %w", err)
}
// XXX: is this ID compatible with types.GroupIdentifier?
return evt.s.groupChangeToChatInfoChange(ctx, types.GroupIdentifier(evt.Info.ChatID), gv2.GetRevision(), groupChange)
}
func (evt *Bv2ChatEvent) PreHandle(ctx context.Context, portal *bridgev2.Portal) {
dataMsg, ok := evt.Event.(*signalpb.DataMessage)
if !ok || dataMsg.GroupV2 == nil {
return
}
portalRev := portal.Metadata.(*signalid.PortalMetadata).Revision
if evt.Info.GroupRevision > portalRev {
toRevision := evt.Info.GroupRevision
if dataMsg.GetGroupV2().GetGroupChange() != nil {
toRevision--
}
evt.s.catchUpGroup(ctx, portal, portalRev, toRevision, dataMsg.GetTimestamp())
}
}
func (evt *Bv2ChatEvent) GetTimeout() time.Duration {
if evt.Event.(*signalpb.TypingMessage).GetAction() == signalpb.TypingMessage_STARTED {
return 15 * time.Second
} else {
return 0
}
}
func (evt *Bv2ChatEvent) GetPortalKey() networkid.PortalKey {
return evt.s.makePortalKey(evt.Info.ChatID)
}
func (evt *Bv2ChatEvent) ShouldCreatePortal() bool {
return evt.GetType() == bridgev2.RemoteEventMessage || evt.GetType() == bridgev2.RemoteEventChatInfoChange
}
func (evt *Bv2ChatEvent) AddLogContext(c zerolog.Context) zerolog.Context {
c = c.Stringer("sender_id", evt.Info.Sender)
switch innerEvt := evt.Event.(type) {
case *signalpb.DataMessage:
c = c.Uint64("message_ts", innerEvt.GetTimestamp())
switch {
case innerEvt.Reaction != nil:
c = c.Uint64("reaction_target_ts", innerEvt.Reaction.GetTargetSentTimestamp())
case innerEvt.Delete != nil:
c = c.Uint64("delete_target_ts", innerEvt.Delete.GetTargetSentTimestamp())
}
case *signalpb.EditMessage:
c = c.
Uint64("edit_target_ts", innerEvt.GetTargetSentTimestamp()).
Uint64("edit_ts", innerEvt.GetDataMessage().GetTimestamp())
}
return c
}
func (evt *Bv2ChatEvent) GetSender() bridgev2.EventSender {
return evt.s.makeEventSender(evt.Info.Sender)
}
func (evt *Bv2ChatEvent) GetID() networkid.MessageID {
ts := evt.getDataMsgTimestamp()
if ts == 0 {
return ""
}
return signalid.MakeMessageID(evt.Info.Sender, ts)
}
func (evt *Bv2ChatEvent) getDataMsgTimestamp() uint64 {
switch innerEvt := evt.Event.(type) {
case *signalpb.DataMessage:
return innerEvt.GetTimestamp()
case *signalpb.EditMessage:
return innerEvt.GetDataMessage().GetTimestamp()
default:
return 0
}
}
func (evt *Bv2ChatEvent) GetTimestamp() time.Time {
ts := evt.getDataMsgTimestamp()
if ts == 0 {
return time.Now()
}
return time.UnixMilli(int64(ts))
}
func (evt *Bv2ChatEvent) GetTargetMessage() networkid.MessageID {
var targetAuthorACI uuid.UUID
var targetSentTS uint64
switch innerEvt := evt.Event.(type) {
case *signalpb.DataMessage:
switch {
case innerEvt.Reaction != nil:
targetAuthorACI, _ = signalmeow.ParseStringOrBinaryUUID(innerEvt.Reaction.GetTargetAuthorAci(), innerEvt.Reaction.GetTargetAuthorAciBinary())
targetSentTS = innerEvt.Reaction.GetTargetSentTimestamp()
case innerEvt.Delete != nil:
targetSentTS = innerEvt.Delete.GetTargetSentTimestamp()
case innerEvt.AdminDelete != nil:
if len(innerEvt.AdminDelete.GetTargetAuthorAciBinary()) == 16 {
targetAuthorACI = uuid.UUID(innerEvt.AdminDelete.GetTargetAuthorAciBinary())
}
targetSentTS = innerEvt.AdminDelete.GetTargetSentTimestamp()
default:
return ""
}
case *signalpb.EditMessage:
targetSentTS = innerEvt.GetTargetSentTimestamp()
default:
return ""
}
if targetAuthorACI == uuid.Nil {
targetAuthorACI = evt.Info.Sender
}
return signalid.MakeMessageID(targetAuthorACI, targetSentTS)
}
func (evt *Bv2ChatEvent) GetReactionEmoji() (string, networkid.EmojiID) {
dataMsg, ok := evt.Event.(*signalpb.DataMessage)
if !ok || dataMsg.Reaction == nil {
panic(fmt.Errorf("GetReactionEmoji() called for non-reaction event"))
}
return dataMsg.GetReaction().GetEmoji(), ""
}
func (evt *Bv2ChatEvent) GetRemovedEmojiID() networkid.EmojiID {
return ""
}
func (evt *Bv2ChatEvent) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
dataMsg, ok := evt.Event.(*signalpb.DataMessage)
if !ok {
return nil, fmt.Errorf("ConvertMessage() called for non-DataMessage event")
}
converted := evt.s.Main.MsgConv.ToMatrix(ctx, evt.s.Client, portal, evt.Info.Sender, intent, dataMsg, nil)
if converted.Disappear.Type != "" {
evtTS := evt.GetTimestamp()
if !dataMsg.GetIsViewOnce() {
portal.UpdateDisappearingSetting(ctx, converted.Disappear, bridgev2.UpdateDisappearingSettingOpts{
Sender: intent,
Timestamp: evtTS,
Implicit: true,
Save: true,
SendNotice: true,
})
}
if evt.Info.Sender == evt.s.Client.Store.ACI {
converted.Disappear.DisappearAt = evtTS.Add(converted.Disappear.Timer)
}
}
return converted, nil
}
func (evt *Bv2ChatEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (*bridgev2.ConvertedEdit, error) {
editMsg, ok := evt.Event.(*signalpb.EditMessage)
if !ok {
return nil, fmt.Errorf("ConvertEdit() called for non-EditMessage event")
}
// TODO tell converter about existing parts to avoid reupload?
converted := evt.s.Main.MsgConv.ToMatrix(ctx, evt.s.Client, portal, evt.Info.Sender, intent, editMsg.GetDataMessage(), nil)
// TODO can anything other than the text be edited?
editPart := converted.Parts[len(converted.Parts)-1].ToEditPart(existing[len(existing)-1])
editPart.Part.EditCount++
editPart.Part.ID = signalid.MakeMessageID(evt.Info.Sender, editMsg.GetDataMessage().GetTimestamp())
return &bridgev2.ConvertedEdit{
ModifiedParts: []*bridgev2.ConvertedEditPart{editPart},
}, nil
}
func (evt *Bv2ChatEvent) GetStreamOrder() int64 {
return int64(evt.Info.ServerTimestamp)
}
type Bv2Receipt struct {
Type signalpb.ReceiptMessage_Type
Chat networkid.PortalKey
Sender bridgev2.EventSender
LastTS time.Time
LastID networkid.MessageID
IDs []networkid.MessageID
}
func (b *Bv2Receipt) GetType() bridgev2.RemoteEventType {
switch b.Type {
case signalpb.ReceiptMessage_READ:
return bridgev2.RemoteEventReadReceipt
case signalpb.ReceiptMessage_DELIVERY:
return bridgev2.RemoteEventDeliveryReceipt
default:
return bridgev2.RemoteEventUnknown
}
}
func (b *Bv2Receipt) GetPortalKey() networkid.PortalKey {
return b.Chat
}
func (b *Bv2Receipt) AddLogContext(c zerolog.Context) zerolog.Context {
return c.
Str("sender_id", string(b.Sender.Sender)).
Stringer("receipt_type", b.Type).
Array("message_ids", exzerolog.ArrayOfStrs(b.IDs))
}
func (b *Bv2Receipt) GetSender() bridgev2.EventSender {
return b.Sender
}
func (b *Bv2Receipt) GetLastReceiptTarget() networkid.MessageID {
return b.LastID
}
func (b *Bv2Receipt) GetReceiptTargets() []networkid.MessageID {
return b.IDs
}
func (b *Bv2Receipt) GetReadUpTo() time.Time {
return time.Time{}
}
var _ bridgev2.RemoteReadReceipt = (*Bv2Receipt)(nil)
func convertReceipts[T any](ctx context.Context, input []T, getMessageFunc func(ctx context.Context, msgID T) (*database.Message, error)) map[networkid.PortalKey]*Bv2Receipt {
log := zerolog.Ctx(ctx)
receipts := make(map[networkid.PortalKey]*Bv2Receipt)
for _, msgID := range input {
msg, err := getMessageFunc(ctx, msgID)
if err != nil {
log.Err(err).Any("message_id", msgID).Msg("Failed to get target message for receipt")
} else if msg == nil {
log.Debug().Any("message_id", msgID).Msg("Got receipt for unknown message")
} else {
receiptEvt, ok := receipts[msg.Room]
if !ok {
receiptEvt = &Bv2Receipt{Chat: msg.Room}
receipts[msg.Room] = receiptEvt
}
receiptEvt.IDs = append(receiptEvt.IDs, msg.ID)
if receiptEvt.LastTS.Before(msg.Timestamp) {
receiptEvt.LastTS = msg.Timestamp
receiptEvt.LastID = msg.ID
}
}
}
return receipts
}
func (s *SignalClient) dispatchReceipts(sender uuid.UUID, receiptType signalpb.ReceiptMessage_Type, receipts map[networkid.PortalKey]*Bv2Receipt) bool {
evtSender := s.makeEventSender(sender)
for chat, receiptEvt := range receipts {
receiptEvt.Chat = chat
receiptEvt.Sender = evtSender
receiptEvt.Type = receiptType
if !s.Main.Bridge.QueueRemoteEvent(s.UserLogin, receiptEvt).Success {
return false
}
}
return true
}
func (s *SignalClient) handleSignalReceipt(evt *events.Receipt) bool {
log := s.UserLogin.Log.With().
Str("action", "handle signal receipt").
Stringer("sender_id", evt.Sender).
Stringer("receipt_type", evt.Content.GetType()).
Logger()
ctx := log.WithContext(s.Main.Bridge.BackgroundCtx)
receipts := convertReceipts(ctx, evt.Content.Timestamp, func(ctx context.Context, msgTS uint64) (*database.Message, error) {
return s.Main.Bridge.DB.Message.GetFirstPartByID(ctx, s.UserLogin.ID, signalid.MakeMessageID(s.Client.Store.ACI, msgTS))
})
return s.dispatchReceipts(evt.Sender, evt.Content.GetType(), receipts)
}
func (s *SignalClient) handleSignalReadSelf(evt *events.ReadSelf) bool {
log := s.UserLogin.Log.With().
Str("action", "handle signal read self").
Logger()
ctx := log.WithContext(s.Main.Bridge.BackgroundCtx)
receipts := convertReceipts(ctx, evt.Messages, func(ctx context.Context, msgInfo *signalpb.SyncMessage_Read) (*database.Message, error) {
aciUUID, err := signalmeow.ParseStringOrBinaryUUID(msgInfo.GetSenderAci(), msgInfo.GetSenderAciBinary())
if err != nil {
return nil, err
}
return s.Main.Bridge.DB.Message.GetFirstPartByID(ctx, s.UserLogin.ID, signalid.MakeMessageID(aciUUID, msgInfo.GetTimestamp()))
})
return s.dispatchReceipts(s.Client.Store.ACI, signalpb.ReceiptMessage_READ, receipts)
}
func (s *SignalClient) conversationIDToPortalKey(ctx context.Context, cid *signalpb.ConversationIdentifier) (networkid.PortalKey, bool) {
log := zerolog.Ctx(ctx)
switch ident := cid.GetIdentifier().(type) {
case *signalpb.ConversationIdentifier_ThreadServiceId:
serviceID, err := libsignalgo.ServiceIDFromString(ident.ThreadServiceId)
if err != nil {
log.Err(err).Str("chat_id", ident.ThreadServiceId).Msg("Failed to parse delete for me conversation ID")
return networkid.PortalKey{}, false
}
return s.makeDMPortalKey(serviceID), true
case *signalpb.ConversationIdentifier_ThreadServiceIdBinary:
serviceID, err := libsignalgo.ServiceIDFromBytes(ident.ThreadServiceIdBinary)
if err != nil {
log.Err(err).Hex("chat_id", ident.ThreadServiceIdBinary).Msg("Failed to parse delete for me conversation ID")
return networkid.PortalKey{}, false
}
return s.makeDMPortalKey(serviceID), true
case *signalpb.ConversationIdentifier_ThreadGroupId:
if len(ident.ThreadGroupId) != libsignalgo.GroupIdentifierLength {
log.Error().
Str("chat_id", base64.StdEncoding.EncodeToString(ident.ThreadGroupId)).
Msg("Invalid group ID length in delete for me conversation")
return networkid.PortalKey{}, false
}
return s.makePortalKey((*libsignalgo.GroupIdentifier)(ident.ThreadGroupId).String()), true
case *signalpb.ConversationIdentifier_ThreadE164:
log.Warn().Str("chat_id", ident.ThreadE164).Msg("Unsupported E164 conversation ID in delete for me")
return networkid.PortalKey{}, false
default:
log.Warn().
Type("chat_id_type", ident).
Msg("Unsupported conversation ID protobuf type in delete for me")
return networkid.PortalKey{}, false
}
}
func (s *SignalClient) addressableMessageToID(ctx context.Context, portalKey networkid.PortalKey, am *signalpb.AddressableMessage) networkid.MessageID {
log := zerolog.Ctx(ctx)
switch typedAuthor := am.GetAuthor().(type) {
case *signalpb.AddressableMessage_AuthorServiceId:
serviceID, err := libsignalgo.ServiceIDFromString(typedAuthor.AuthorServiceId)
if err != nil {
log.Err(err).
Object("portal_key", portalKey).
Str("author_service_id", typedAuthor.AuthorServiceId).
Msg("Failed to parse delete for me message author service ID")
return ""
} else if serviceID.Type != libsignalgo.ServiceIDTypeACI {
log.Warn().
Object("portal_key", portalKey).
Str("author_service_id", typedAuthor.AuthorServiceId).
Msg("Dropping delete for me message with unsupported service ID type")
return ""
}
return signalid.MakeMessageID(serviceID.UUID, am.GetSentTimestamp())
case *signalpb.AddressableMessage_AuthorServiceIdBinary:
serviceID, err := libsignalgo.ServiceIDFromBytes(typedAuthor.AuthorServiceIdBinary)
if err != nil {
log.Err(err).
Object("portal_key", portalKey).
Hex("author_service_id_binary", typedAuthor.AuthorServiceIdBinary).
Msg("Failed to parse delete for me message author service ID")
return ""
} else if serviceID.Type != libsignalgo.ServiceIDTypeACI {
log.Warn().
Object("portal_key", portalKey).
Hex("author_service_id_binary", typedAuthor.AuthorServiceIdBinary).
Msg("Dropping delete for me message with unsupported service ID type")
return ""
}
return signalid.MakeMessageID(serviceID.UUID, am.GetSentTimestamp())
case *signalpb.AddressableMessage_AuthorE164:
log.Warn().
Object("portal_key", portalKey).
Str("author_e164", typedAuthor.AuthorE164).
Msg("Dropping delete for me message with unsupported E164 author")
return ""
default:
log.Warn().
Object("portal_key", portalKey).
Type("author_type", typedAuthor).
Msg("Dropping delete for me message with unrecognized author protobuf type")
return ""
}
}
func (s *SignalClient) handleSignalDeleteForMe(evt *events.DeleteForMe) bool {
log := s.UserLogin.Log.With().
Str("action", "handle signal delete for me").
Logger()
ctx := log.WithContext(s.Main.Bridge.BackgroundCtx)
for _, conv := range evt.GetConversationDeletes() {
if !conv.GetIsFullDelete() {
// Non-full deletes might mean clearing chats?
continue
}
portalKey, ok := s.conversationIDToPortalKey(ctx, conv.GetConversation())
if !ok {
continue
}
res := s.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatDelete,
PortalKey: portalKey,
Timestamp: time.UnixMilli(int64(evt.Timestamp)),
StreamOrder: int64(evt.Timestamp),
},
OnlyForMe: true,
})
if !res.Success {
return false
}
}
for _, conv := range evt.GetLocalOnlyConversationDeletes() {
portalKey, ok := s.conversationIDToPortalKey(ctx, conv.GetConversation())
if !ok {
continue
}
res := s.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatDelete,
PortalKey: portalKey,
Timestamp: time.UnixMilli(int64(evt.Timestamp)),
StreamOrder: int64(evt.Timestamp),
},
OnlyForMe: true,
})
if !res.Success {
return false
}
}
for _, conv := range evt.GetMessageDeletes() {
portalKey, ok := s.conversationIDToPortalKey(ctx, conv.GetConversation())
if !ok {
continue
}
for _, msg := range conv.GetMessages() {
msgID := s.addressableMessageToID(ctx, portalKey, msg)
if msgID == "" {
continue
}
res := s.UserLogin.QueueRemoteEvent(&simplevent.MessageRemove{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventMessageRemove,
PortalKey: portalKey,
Timestamp: time.UnixMilli(int64(evt.Timestamp)),
StreamOrder: int64(evt.Timestamp),
},
OnlyForMe: true,
TargetMessage: msgID,
})
if !res.Success {
return false
}
}
}
return true
}
func (s *SignalClient) handleSignalMessageRequestResponse(evt *events.MessageRequestResponse) bool {
if evt.Type != signalpb.SyncMessage_MessageRequestResponse_ACCEPT {
// TODO do we need to do anything with blocks/deletes here or are they sent as normal delete events?
return true
}
var portalKey networkid.PortalKey
if evt.GroupID != nil {
portalKey = s.makePortalKey(evt.GroupID.String())
} else if evt.ThreadACI != uuid.Nil {
portalKey = s.makeDMPortalKey(libsignalgo.NewACIServiceID(evt.ThreadACI))
} else {
return true
}
res := s.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatInfoChange,
PortalKey: portalKey,
Timestamp: time.UnixMilli(int64(evt.Timestamp)),
StreamOrder: int64(evt.Timestamp),
LogContext: func(c zerolog.Context) zerolog.Context {
return c.Str("action", "unmark message request").Str("source", "sync message")
},
},
ChatInfoChange: &bridgev2.ChatInfoChange{
ChatInfo: &bridgev2.ChatInfo{
MessageRequest: ptr.Ptr(false),
},
},
})
return res.Success
}
func (s *SignalClient) handleSignalACIFound(evt *events.ACIFound) {
log := s.UserLogin.Log.With().
Str("action", "handle aci found").
Stringer("aci", evt.ACI).
Stringer("pni", evt.PNI).
Logger()
ctx := log.WithContext(s.Main.Bridge.BackgroundCtx)
pniPortalKey := s.makeDMPortalKey(evt.PNI)
aciPortalKey := s.makeDMPortalKey(evt.ACI)
result, portal, err := s.Main.Bridge.ReIDPortal(ctx, pniPortalKey, aciPortalKey)
if err != nil {
log.Err(err).Msg("Failed to re-ID portal")
} else if result == bridgev2.ReIDResultSourceReIDd || result == bridgev2.ReIDResultTargetDeletedAndSourceReIDd {
// If the source portal is re-ID'd, we need to sync metadata and participants.
// If the source is deleted, then it doesn't matter, any existing target will already be correct
info, err := s.GetChatInfo(ctx, portal)
if err != nil {
log.Err(err).Msg("Failed to get chat info to update portal after re-ID")
} else {
portal.UpdateInfo(ctx, info, s.UserLogin, nil, time.Time{})
}
}
}
func (s *SignalClient) handleSignalContactList(evt *events.ContactList) {
log := s.UserLogin.Log.With().Str("action", "handle contact list").Logger()
ctx := log.WithContext(s.Main.Bridge.BackgroundCtx)
for _, contact := range evt.Contacts {
if contact.ACI == uuid.Nil {
continue
}
if !evt.IsFromDB {
fullContact, err := s.Client.ContactByACI(ctx, contact.ACI)
if err != nil {
log.Err(err).Msg("Failed to get full contact info from store")
continue
}
fullContact.ContactAvatar = contact.ContactAvatar
contact = fullContact
}
ghost, err := s.Main.Bridge.GetGhostByID(ctx, signalid.MakeUserID(contact.ACI))
if err != nil {
log.Err(err).Msg("Failed to get ghost to update contact info")
continue
}
userInfo, err := s.contactToUserInfo(ctx, contact)
if err != nil {
log.Err(err).Msg("Failed to convert contact info")
continue
}
ghost.UpdateInfo(ctx, userInfo)
if contact.ACI == s.Client.Store.ACI {
s.updateRemoteProfile(ctx, true)
}
if ptr.Val(contact.Whitelisted) {
portal, err := s.Main.Bridge.GetExistingPortalByKey(ctx, s.makeDMPortalKey(libsignalgo.NewACIServiceID(contact.ACI)))
if err != nil {
log.Err(err).Msg("Failed to get existing portal to update contact info")
continue
} else if portal != nil && portal.MessageRequest {
s.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatInfoChange,
LogContext: func(c zerolog.Context) zerolog.Context {
return c.Str("action", "unmark message request").Str("source", "contact list")
},
PortalKey: portal.PortalKey,
},
ChatInfoChange: &bridgev2.ChatInfoChange{
ChatInfo: &bridgev2.ChatInfo{
MessageRequest: ptr.Ptr(false),
},
},
})
}
}
}
s.UserLogin.Metadata.(*signalid.UserLoginMetadata).LastContactSync = jsontime.UnixMilliNow()
err := s.UserLogin.Save(ctx)
if err != nil {
log.Err(err).Msg("Failed to update last contact sync time")
}
}
func (s *SignalClient) updateRemoteProfile(ctx context.Context, resendState bool) {
var err error
if s.Ghost == nil {
s.Ghost, err = s.Main.Bridge.GetGhostByID(ctx, signalid.MakeUserID(s.Client.Store.ACI))
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost for remote profile update")
return
}
}
changed := false
if s.UserLogin.RemoteProfile.Name != s.Ghost.Name {
s.UserLogin.RemoteProfile.Name = s.Ghost.Name
changed = true
}
if s.UserLogin.RemoteProfile.Avatar != s.Ghost.AvatarMXC {
s.UserLogin.RemoteProfile.Avatar = s.Ghost.AvatarMXC
changed = true
}
if len(s.Ghost.Identifiers) > 0 && strings.HasPrefix(s.Ghost.Identifiers[0], "tel:") {
phone := strings.TrimPrefix(s.Ghost.Identifiers[0], "tel:")
if s.UserLogin.RemoteProfile.Phone != phone {
s.UserLogin.RemoteProfile.Phone = phone
changed = true
}
}
if changed {
err = s.UserLogin.Save(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to save updated remote profile")
}
if resendState {
// TODO this has potential race conditions
s.UserLogin.BridgeState.Send(s.UserLogin.BridgeState.GetPrevUnsent())
}
}
}

View file

@ -1,69 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 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/>.
package connector
import (
"fmt"
"github.com/google/uuid"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalid"
)
func (s *SignalClient) makePortalKey(chatID string) networkid.PortalKey {
key := networkid.PortalKey{ID: networkid.PortalID(chatID)}
// For non-group chats, add receiver
if s.Main.Bridge.Config.SplitPortals || len(chatID) != 44 {
key.Receiver = s.UserLogin.ID
}
return key
}
func (s *SignalClient) makeDMPortalKey(serviceID libsignalgo.ServiceID) networkid.PortalKey {
return networkid.PortalKey{
ID: signalid.MakeDMPortalID(serviceID),
Receiver: s.UserLogin.ID,
}
}
func (s *SignalClient) makeEventSender(sender uuid.UUID) bridgev2.EventSender {
return bridgev2.EventSender{
IsFromMe: sender == s.Client.Store.ACI,
SenderLogin: signalid.MakeUserLoginID(sender),
Sender: signalid.MakeUserID(sender),
}
}
func (s *SignalClient) makePNIEventSender(sender uuid.UUID) bridgev2.EventSender {
return bridgev2.EventSender{
Sender: signalid.MakeUserIDFromServiceID(libsignalgo.NewPNIServiceID(sender)),
}
}
func (s *SignalClient) makeEventSenderFromServiceID(serviceID libsignalgo.ServiceID) bridgev2.EventSender {
switch serviceID.Type {
case libsignalgo.ServiceIDTypeACI:
return s.makeEventSender(serviceID.UUID)
case libsignalgo.ServiceIDTypePNI:
return s.makePNIEventSender(serviceID.UUID)
default:
panic(fmt.Errorf("invalid service ID type %d", serviceID.Type))
}
}

View file

@ -1,168 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 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/>.
package connector
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/status"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/store"
)
func (s *SignalConnector) GetLoginFlows() []bridgev2.LoginFlow {
return []bridgev2.LoginFlow{{
Name: "QR",
Description: "Scan a QR code to pair the bridge to your Signal app",
ID: "qr",
}}
}
func (s *SignalConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
if flowID != "qr" {
return nil, fmt.Errorf("invalid login flow ID")
}
return &QRLogin{User: user, Main: s}, nil
}
type QRLogin struct {
User *bridgev2.User
Main *SignalConnector
cancelChan context.CancelFunc
ProvChan chan signalmeow.ProvisioningResponse
newQRCount int
}
var _ bridgev2.LoginProcessDisplayAndWait = (*QRLogin)(nil)
func (qr *QRLogin) Cancel() {
qr.cancelChan()
go func() {
for range qr.ProvChan {
}
}()
}
const (
LoginStepQR = "fi.mau.signal.login.qr"
LoginStepProcess = "fi.mau.signal.login.processing"
LoginStepComplete = "fi.mau.signal.login.complete"
)
func (qr *QRLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
log := qr.Main.Bridge.Log.With().
Str("action", "login").
Stringer("user_id", qr.User.MXID).
Logger()
provCtx, cancel := context.WithCancel(log.WithContext(qr.Main.Bridge.BackgroundCtx))
qr.cancelChan = cancel
// Don't use the start context here: the channel will outlive the start request.
qr.ProvChan = signalmeow.PerformProvisioning(
provCtx, qr.Main.Store, qr.Main.Config.DeviceName, qr.Main.Bridge.Config.Backfill.Enabled,
)
var resp signalmeow.ProvisioningResponse
select {
case resp = <-qr.ProvChan:
if resp.Err != nil {
return nil, resp.Err
} else if resp.State != signalmeow.StateProvisioningURLReceived {
return nil, fmt.Errorf("unexpected state %v", resp.State)
}
case <-ctx.Done():
cancel()
return nil, ctx.Err()
// TODO separate timeout here?
}
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeDisplayAndWait,
StepID: LoginStepQR,
Instructions: "Scan the QR code on your Signal app to log in",
DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{
Type: bridgev2.LoginDisplayTypeQR,
Data: resp.ProvisioningURL,
},
}, nil
}
func (qr *QRLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
if qr.ProvChan == nil {
return nil, fmt.Errorf("login not started")
}
select {
case resp := <-qr.ProvChan:
if resp.Err != nil {
qr.cancelChan()
return nil, resp.Err
} else if resp.State != signalmeow.StateProvisioningDataReceived {
qr.cancelChan()
return nil, fmt.Errorf("unexpected state %v", resp.State)
} else if resp.ProvisioningData.ACI == uuid.Nil {
qr.cancelChan()
return nil, fmt.Errorf("no signal account ID received")
}
return qr.loginComplete(ctx, resp.ProvisioningData)
// Server will timeout the request after 60 seconds, but Signal Desktop opens
// a new socket and gets a new QR code after 45 seconds. We should do the same.
case <-time.After(45 * time.Second):
qr.cancelChan()
qr.newQRCount++
if qr.newQRCount >= 6 {
return nil, fmt.Errorf("too many QR code refreshes")
}
return qr.Start(ctx)
case <-ctx.Done():
qr.cancelChan()
return nil, ctx.Err()
}
}
func (qr *QRLogin) loginComplete(ctx context.Context, provData *store.DeviceData) (*bridgev2.LoginStep, error) {
defer qr.cancelChan()
ul, err := qr.User.NewLogin(ctx, &database.UserLogin{
ID: signalid.MakeUserLoginID(provData.ACI),
RemoteName: provData.Number,
RemoteProfile: status.RemoteProfile{
Phone: provData.Number,
},
Metadata: &signalid.UserLoginMetadata{},
}, &bridgev2.NewLoginParams{
DeleteOnConflict: true,
})
if err != nil {
return nil, fmt.Errorf("failed to create user login: %w", err)
}
ul.Client.(*SignalClient).postLoginConnect()
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeComplete,
StepID: LoginStepComplete,
Instructions: fmt.Sprintf("Successfully logged in as %s / %s", provData.Number, provData.ACI),
CompleteParams: &bridgev2.LoginCompleteParams{
UserLoginID: ul.ID,
UserLogin: ul,
},
}, nil
}

View file

@ -1,6 +1,5 @@
// mautrix-signal - A Matrix-signal puppeting bridge. // mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Sumner Evans // Copyright (C) 2023 Sumner Evans
// Copyright (C) 2025 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU Affero General Public License as published by
@ -18,11 +17,14 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
*/ */
import "C" import "C"
import ( import (
"runtime" "runtime"
"github.com/google/uuid"
) )
type Address struct { type Address struct {
@ -36,44 +38,45 @@ func wrapAddress(ptr *C.SignalProtocolAddress) *Address {
return address return address
} }
func NewUUIDAddress(u uuid.UUID, deviceID uint) (*Address, error) {
return newAddress(u.String(), deviceID)
}
func NewUUIDAddressFromString(uuidStr string, deviceID uint) (*Address, error) { func NewUUIDAddressFromString(uuidStr string, deviceID uint) (*Address, error) {
serviceID, err := ServiceIDFromString(uuidStr) parsed, err := uuid.Parse(uuidStr)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return serviceID.Address(deviceID) return NewUUIDAddress(parsed, deviceID)
}
// Deprecated: phone addresses are not used anymore
func NewPhoneAddress(phone string, deviceID uint) (*Address, error) {
return newAddress(phone, deviceID)
} }
func newAddress(name string, deviceID uint) (*Address, error) { func newAddress(name string, deviceID uint) (*Address, error) {
var pa C.SignalMutPointerProtocolAddress var pa *C.SignalProtocolAddress
signalFfiError := C.signal_address_new(&pa, C.CString(name), C.uint(deviceID)) signalFfiError := C.signal_address_new(&pa, C.CString(name), C.uint(deviceID))
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return wrapAddress(pa.raw), nil return wrapAddress(pa), nil
}
func (pa *Address) mutPtr() C.SignalMutPointerProtocolAddress {
return C.SignalMutPointerProtocolAddress{pa.ptr}
}
func (pa *Address) constPtr() C.SignalConstPointerProtocolAddress {
return C.SignalConstPointerProtocolAddress{pa.ptr}
} }
func (pa *Address) Clone() (*Address, error) { func (pa *Address) Clone() (*Address, error) {
var cloned C.SignalMutPointerProtocolAddress var cloned *C.SignalProtocolAddress
signalFfiError := C.signal_address_clone(&cloned, pa.constPtr()) signalFfiError := C.signal_address_clone(&cloned, pa.ptr)
runtime.KeepAlive(pa) runtime.KeepAlive(pa)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return wrapAddress(cloned.raw), nil return wrapAddress(cloned), nil
} }
func (pa *Address) Destroy() error { func (pa *Address) Destroy() error {
pa.CancelFinalizer() pa.CancelFinalizer()
return wrapError(C.signal_address_destroy(pa.mutPtr())) return wrapError(C.signal_address_destroy(pa.ptr))
} }
func (pa *Address) CancelFinalizer() { func (pa *Address) CancelFinalizer() {
@ -82,7 +85,7 @@ func (pa *Address) CancelFinalizer() {
func (pa *Address) Name() (string, error) { func (pa *Address) Name() (string, error) {
var name *C.char var name *C.char
signalFfiError := C.signal_address_get_name(&name, pa.constPtr()) signalFfiError := C.signal_address_get_name(&name, pa.ptr)
runtime.KeepAlive(pa) runtime.KeepAlive(pa)
if signalFfiError != nil { if signalFfiError != nil {
return "", wrapError(signalFfiError) return "", wrapError(signalFfiError)
@ -90,17 +93,17 @@ func (pa *Address) Name() (string, error) {
return CopyCStringToString(name), nil return CopyCStringToString(name), nil
} }
func (pa *Address) NameServiceID() (ServiceID, error) { func (pa *Address) NameUUID() (uuid.UUID, error) {
name, err := pa.Name() name, err := pa.Name()
if err != nil { if err != nil {
return ServiceID{}, err return uuid.Nil, err
} }
return ServiceIDFromString(name) return uuid.Parse(name)
} }
func (pa *Address) DeviceID() (uint, error) { func (pa *Address) DeviceID() (uint, error) {
var deviceID C.uint var deviceID C.uint
signalFfiError := C.signal_address_get_device_id(&deviceID, pa.constPtr()) signalFfiError := C.signal_address_get_device_id(&deviceID, pa.ptr)
runtime.KeepAlive(pa) runtime.KeepAlive(pa)
if signalFfiError != nil { if signalFfiError != nil {
return 0, wrapError(signalFfiError) return 0, wrapError(signalFfiError)

View file

@ -19,7 +19,6 @@ package libsignalgo_test
import ( import (
"testing" "testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"go.mau.fi/mautrix-signal/pkg/libsignalgo" "go.mau.fi/mautrix-signal/pkg/libsignalgo"
@ -29,14 +28,12 @@ import (
func TestAddress(t *testing.T) { func TestAddress(t *testing.T) {
setupLogging() setupLogging()
testUUID := uuid.New() addr, err := libsignalgo.NewPhoneAddress("addr1", 5)
addr, err := libsignalgo.NewPNIServiceID(testUUID).Address(5)
assert.NoError(t, err) assert.NoError(t, err)
name, err := addr.Name() name, err := addr.Name()
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "PNI:"+testUUID.String(), name) assert.Equal(t, "addr1", name)
deviceID, err := addr.DeviceID() deviceID, err := addr.DeviceID()
assert.NoError(t, err) assert.NoError(t, err)

View file

@ -1,6 +1,5 @@
// mautrix-signal - A Matrix-signal puppeting bridge. // mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Sumner Evans // Copyright (C) 2023 Sumner Evans
// Copyright (C) 2025 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU Affero General Public License as published by
@ -18,6 +17,7 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
*/ */
import "C" import "C"
@ -35,37 +35,23 @@ func wrapAES256_GCM_SIV(ptr *C.SignalAes256GcmSiv) *AES256_GCM_SIV {
} }
func NewAES256_GCM_SIV(key []byte) (*AES256_GCM_SIV, error) { func NewAES256_GCM_SIV(key []byte) (*AES256_GCM_SIV, error) {
var aes C.SignalMutPointerAes256GcmSiv var aes *C.SignalAes256GcmSiv
signalFfiError := C.signal_aes256_gcm_siv_new(&aes, BytesToBuffer(key)) signalFfiError := C.signal_aes256_gcm_siv_new(&aes, BytesToBuffer(key))
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return wrapAES256_GCM_SIV(aes.raw), nil return wrapAES256_GCM_SIV(aes), nil
}
func (aes *AES256_GCM_SIV) mutPtr() C.SignalMutPointerAes256GcmSiv {
return C.SignalMutPointerAes256GcmSiv{aes.ptr}
}
func (aes *AES256_GCM_SIV) constPtr() C.SignalConstPointerAes256GcmSiv {
return C.SignalConstPointerAes256GcmSiv{aes.ptr}
} }
func (aes *AES256_GCM_SIV) Destroy() error { func (aes *AES256_GCM_SIV) Destroy() error {
runtime.SetFinalizer(aes, nil) runtime.SetFinalizer(aes, nil)
return wrapError(C.signal_aes256_gcm_siv_destroy(C.SignalMutPointerAes256GcmSiv{raw: aes.ptr})) return wrapError(C.signal_aes256_gcm_siv_destroy(aes.ptr))
} }
func (aes *AES256_GCM_SIV) Encrypt(plaintext, nonce, associatedData []byte) ([]byte, error) { func (aes *AES256_GCM_SIV) Encrypt(plaintext, nonce, associatedData []byte) ([]byte, error) {
var encrypted C.SignalOwnedBuffer = C.SignalOwnedBuffer{} var encrypted C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_aes256_gcm_siv_encrypt( signalFfiError := C.signal_aes256_gcm_siv_encrypt(&encrypted, aes.ptr, BytesToBuffer(plaintext), BytesToBuffer(nonce), BytesToBuffer(associatedData))
&encrypted,
C.SignalConstPointerAes256GcmSiv{raw: aes.ptr},
BytesToBuffer(plaintext),
BytesToBuffer(nonce),
BytesToBuffer(associatedData),
)
runtime.KeepAlive(aes) runtime.KeepAlive(aes)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
@ -75,13 +61,7 @@ func (aes *AES256_GCM_SIV) Encrypt(plaintext, nonce, associatedData []byte) ([]b
func (aes *AES256_GCM_SIV) Decrypt(ciphertext, nonce, associatedData []byte) ([]byte, error) { func (aes *AES256_GCM_SIV) Decrypt(ciphertext, nonce, associatedData []byte) ([]byte, error) {
var decrypted C.SignalOwnedBuffer = C.SignalOwnedBuffer{} var decrypted C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_aes256_gcm_siv_decrypt( signalFfiError := C.signal_aes256_gcm_siv_decrypt(&decrypted, aes.ptr, BytesToBuffer(ciphertext), BytesToBuffer(nonce), BytesToBuffer(associatedData))
&decrypted,
C.SignalConstPointerAes256GcmSiv{raw: aes.ptr},
BytesToBuffer(ciphertext),
BytesToBuffer(nonce),
BytesToBuffer(associatedData),
)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }

View file

@ -1,6 +1,5 @@
// mautrix-signal - A Matrix-signal puppeting bridge. // mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber // Copyright (C) 2023 Scott Weber
// Copyright (C) 2025 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU Affero General Public License as published by
@ -18,12 +17,12 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
#include <stdlib.h> #include <stdlib.h>
*/ */
import "C" import "C"
import ( import (
"fmt"
"unsafe" "unsafe"
"github.com/google/uuid" "github.com/google/uuid"
@ -40,30 +39,37 @@ func (ac *AuthCredentialWithPni) Slice() []byte {
} }
func ReceiveAuthCredentialWithPni( func ReceiveAuthCredentialWithPni(
serverPublicParams *ServerPublicParams, serverPublicParams ServerPublicParams,
aci uuid.UUID, aci uuid.UUID,
pni uuid.UUID, pni uuid.UUID,
redemptionTime uint64, redemptionTime uint64,
authCredResponse AuthCredentialWithPniResponse, authCredResponse AuthCredentialWithPniResponse,
) (*AuthCredentialWithPni, error) { ) (*AuthCredentialWithPni, error) {
var c_result C.SignalOwnedBuffer = C.SignalOwnedBuffer{} c_result := [C.SignalAUTH_CREDENTIAL_WITH_PNI_LEN]C.uchar{}
c_serverPublicParams := (*[C.SignalSERVER_PUBLIC_PARAMS_LEN]C.uchar)(unsafe.Pointer(&serverPublicParams[0]))
c_aci, err := SignalServiceIDFromUUID(aci)
if err != nil {
return nil, err
}
c_pni, err := SignalPNIServiceIDFromUUID(pni)
if err != nil {
return nil, err
}
c_authCredResponse := (*[C.SignalAUTH_CREDENTIAL_WITH_PNI_RESPONSE_LEN]C.uchar)(unsafe.Pointer(&authCredResponse[0]))
signalFfiError := C.signal_server_public_params_receive_auth_credential_with_pni_as_service_id( signalFfiError := C.signal_server_public_params_receive_auth_credential_with_pni_as_aci(
&c_result, &c_result,
C.SignalConstPointerServerPublicParams{serverPublicParams}, c_serverPublicParams,
NewACIServiceID(aci).CFixedBytes(), c_aci,
NewPNIServiceID(pni).CFixedBytes(), c_pni,
C.uint64_t(redemptionTime), C.uint64_t(redemptionTime),
BytesToBuffer(authCredResponse[:]), c_authCredResponse,
) )
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
resultBytes := CopySignalOwnedBufferToBytes(c_result) result := AuthCredentialWithPni(C.GoBytes(unsafe.Pointer(&c_result), C.int(C.SignalAUTH_CREDENTIAL_WITH_PNI_LEN)))
if len(resultBytes) != C.SignalAUTH_CREDENTIAL_WITH_PNI_LEN { return &result, nil
return nil, fmt.Errorf("invalid response length %d (expected %d)", len(resultBytes), C.SignalAUTH_CREDENTIAL_WITH_PNI_LEN)
}
return (*AuthCredentialWithPni)(resultBytes), nil
} }
func NewAuthCredentialWithPniResponse(b []byte) (*AuthCredentialWithPniResponse, error) { func NewAuthCredentialWithPniResponse(b []byte) (*AuthCredentialWithPniResponse, error) {
@ -77,21 +83,23 @@ func NewAuthCredentialWithPniResponse(b []byte) (*AuthCredentialWithPniResponse,
} }
func CreateAuthCredentialWithPniPresentation( func CreateAuthCredentialWithPniPresentation(
serverPublicParams *ServerPublicParams, serverPublicParams ServerPublicParams,
randomness Randomness, randomness Randomness,
groupSecretParams GroupSecretParams, groupSecretParams GroupSecretParams,
authCredWithPni AuthCredentialWithPni, authCredWithPni AuthCredentialWithPni,
) (*AuthCredentialPresentation, error) { ) (*AuthCredentialPresentation, error) {
var c_result C.SignalOwnedBuffer = C.SignalOwnedBuffer{} var c_result C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
c_serverPublicParams := (*[C.SignalSERVER_PUBLIC_PARAMS_LEN]C.uchar)(unsafe.Pointer(&serverPublicParams[0]))
c_randomness := (*[C.SignalRANDOMNESS_LEN]C.uchar)(unsafe.Pointer(&randomness[0])) c_randomness := (*[C.SignalRANDOMNESS_LEN]C.uchar)(unsafe.Pointer(&randomness[0]))
c_groupSecretParams := (*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uchar)(unsafe.Pointer(&groupSecretParams[0])) c_groupSecretParams := (*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uchar)(unsafe.Pointer(&groupSecretParams[0]))
c_authCredWithPni := (*[C.SignalAUTH_CREDENTIAL_WITH_PNI_LEN]C.uchar)(unsafe.Pointer(&authCredWithPni[0]))
signalFfiError := C.signal_server_public_params_create_auth_credential_with_pni_presentation_deterministic( signalFfiError := C.signal_server_public_params_create_auth_credential_with_pni_presentation_deterministic(
&c_result, &c_result,
C.SignalConstPointerServerPublicParams{serverPublicParams}, c_serverPublicParams,
c_randomness, c_randomness,
c_groupSecretParams, c_groupSecretParams,
BytesToBuffer(authCredWithPni[:]), c_authCredWithPni,
) )
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)

View file

@ -1,140 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2025 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/>.
package libsignalgo
/*
#include "./libsignal-ffi.h"
*/
import "C"
import (
"runtime"
"unsafe"
"go.mau.fi/util/random"
)
type BackupKey [C.SignalBACKUP_KEY_LEN]byte
func (bk *BackupKey) Slice() []byte {
if bk == nil {
return nil
}
return bk[:]
}
const BackupIDLength = 16
type BackupID [BackupIDLength]byte
type BackupMetadataKey [C.SignalLOCAL_BACKUP_METADATA_KEY_LEN]byte
type BackupMediaID [C.SignalMEDIA_ID_LEN]byte
type BackupMediaKey [C.SignalMEDIA_ENCRYPTION_KEY_LEN]byte
func GenerateRandomBackupKey() *BackupKey {
return (*BackupKey)(random.Bytes(C.SignalBACKUP_KEY_LEN))
}
func BytesToBackupKey(bytes []byte) *BackupKey {
if len(bytes) != C.SignalBACKUP_KEY_LEN {
return nil
}
return (*BackupKey)(bytes)
}
func (bk *BackupKey) DeriveBackupID(aci ServiceID) (*BackupID, error) {
var out BackupID
signalFfiError := C.signal_backup_key_derive_backup_id(
(*[BackupIDLength]C.uint8_t)(unsafe.Pointer(&out)),
(*[C.SignalBACKUP_KEY_LEN]C.uint8_t)(unsafe.Pointer(bk)),
aci.CFixedBytes(),
)
runtime.KeepAlive(bk)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
return &out, nil
}
func (bk *BackupKey) DeriveECKey(aci ServiceID) (*PrivateKey, error) {
var out C.SignalMutPointerPrivateKey
signalFfiError := C.signal_backup_key_derive_ec_key(
&out,
(*[C.SignalBACKUP_KEY_LEN]C.uint8_t)(unsafe.Pointer(&bk)),
aci.CFixedBytes(),
)
runtime.KeepAlive(bk)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
return wrapPrivateKey(out.raw), nil
}
func (bk *BackupKey) DeriveLocalBackupMetadataKey() (*BackupMetadataKey, error) {
var out BackupMetadataKey
signalFfiError := C.signal_backup_key_derive_local_backup_metadata_key(
(*[C.SignalLOCAL_BACKUP_METADATA_KEY_LEN]C.uint8_t)(unsafe.Pointer(&out)),
(*[C.SignalBACKUP_KEY_LEN]C.uint8_t)(unsafe.Pointer(bk)),
)
runtime.KeepAlive(bk)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
return &out, nil
}
func (bk *BackupKey) DeriveMediaID(mediaName string) (*BackupMediaID, error) {
var out BackupMediaID
signalFfiError := C.signal_backup_key_derive_media_id(
(*[C.SignalMEDIA_ID_LEN]C.uint8_t)(unsafe.Pointer(&out)),
(*[C.SignalBACKUP_KEY_LEN]C.uint8_t)(unsafe.Pointer(bk)),
C.CString(mediaName),
)
runtime.KeepAlive(bk)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
return &out, nil
}
func (bk *BackupKey) DeriveMediaEncryptionKey(mediaID *BackupMediaID) (*BackupMediaKey, error) {
var out BackupMediaKey
signalFfiError := C.signal_backup_key_derive_media_encryption_key(
(*[C.SignalMEDIA_ENCRYPTION_KEY_LEN]C.uint8_t)(unsafe.Pointer(&out)),
(*[C.SignalBACKUP_KEY_LEN]C.uint8_t)(unsafe.Pointer(bk)),
(*[C.SignalMEDIA_ID_LEN]C.uint8_t)(unsafe.Pointer(mediaID)),
)
runtime.KeepAlive(bk)
runtime.KeepAlive(mediaID)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
return &out, nil
}
func (bk *BackupKey) DeriveThumbnailTransitEncryptionKey(mediaID *BackupMediaID) (*BackupMediaKey, error) {
var out BackupMediaKey
signalFfiError := C.signal_backup_key_derive_thumbnail_transit_encryption_key(
(*[C.SignalMEDIA_ENCRYPTION_KEY_LEN]C.uint8_t)(unsafe.Pointer(&out)),
(*[C.SignalBACKUP_KEY_LEN]C.uint8_t)(unsafe.Pointer(bk)),
(*[C.SignalMEDIA_ID_LEN]C.uint8_t)(unsafe.Pointer(mediaID)),
)
runtime.KeepAlive(bk)
runtime.KeepAlive(mediaID)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
return &out, nil
}

View file

@ -17,12 +17,11 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
*/ */
import "C" import "C"
import ( import (
"fmt"
"runtime"
"unsafe" "unsafe"
) )
@ -44,22 +43,6 @@ func BytesToBuffer(data []byte) C.SignalBorrowedBuffer {
return buf return buf
} }
func ManyBytesToBuffer[T ~[]byte](datas []T) (C.SignalBorrowedSliceOfBuffers, func()) {
buffers := make([]C.SignalBorrowedBuffer, len(datas))
var pinner runtime.Pinner
for i, data := range datas {
if len(data) == 0 {
panic(fmt.Errorf("empty slice passed to ManyBytesToBuffer at index %d", i))
}
pinner.Pin(&data[0])
buffers[i] = BytesToBuffer(data)
}
return C.SignalBorrowedSliceOfBuffers{
base: unsafe.SliceData(buffers),
length: C.size_t(len(buffers)),
}, pinner.Unpin
}
func EmptyBorrowedBuffer() C.SignalBorrowedBuffer { func EmptyBorrowedBuffer() C.SignalBorrowedBuffer {
return C.SignalBorrowedBuffer{} return C.SignalBorrowedBuffer{}
} }

View file

@ -1,6 +0,0 @@
package libsignalgo
/*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm -lz -lstdc++
*/
import "C"

View file

@ -1,6 +1,5 @@
// mautrix-signal - A Matrix-signal puppeting bridge. // mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Sumner Evans // Copyright (C) 2023 Sumner Evans
// Copyright (C) 2025 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU Affero General Public License as published by
@ -18,6 +17,7 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
*/ */
import "C" import "C"
@ -44,28 +44,17 @@ func wrapCiphertextMessage(ptr *C.SignalCiphertextMessage) *CiphertextMessage {
} }
func NewCiphertextMessage(plaintext *PlaintextContent) (*CiphertextMessage, error) { func NewCiphertextMessage(plaintext *PlaintextContent) (*CiphertextMessage, error) {
var ciphertextMessage C.SignalMutPointerCiphertextMessage var ciphertextMessage *C.SignalCiphertextMessage
signalFfiError := C.signal_ciphertext_message_from_plaintext_content( signalFfiError := C.signal_ciphertext_message_from_plaintext_content(&ciphertextMessage, plaintext.ptr)
&ciphertextMessage,
plaintext.constPtr(),
)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return wrapCiphertextMessage(ciphertextMessage.raw), nil return wrapCiphertextMessage(ciphertextMessage), nil
}
func (c *CiphertextMessage) mutPtr() C.SignalMutPointerCiphertextMessage {
return C.SignalMutPointerCiphertextMessage{c.ptr}
}
func (c *CiphertextMessage) constPtr() C.SignalConstPointerCiphertextMessage {
return C.SignalConstPointerCiphertextMessage{c.ptr}
} }
func (c *CiphertextMessage) Destroy() error { func (c *CiphertextMessage) Destroy() error {
c.CancelFinalizer() c.CancelFinalizer()
return wrapError(C.signal_ciphertext_message_destroy(c.mutPtr())) return wrapError(C.signal_ciphertext_message_destroy(c.ptr))
} }
func (c *CiphertextMessage) CancelFinalizer() { func (c *CiphertextMessage) CancelFinalizer() {
@ -74,7 +63,7 @@ func (c *CiphertextMessage) CancelFinalizer() {
func (c *CiphertextMessage) Serialize() ([]byte, error) { func (c *CiphertextMessage) Serialize() ([]byte, error) {
var serialized C.SignalOwnedBuffer = C.SignalOwnedBuffer{} var serialized C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_ciphertext_message_serialize(&serialized, c.constPtr()) signalFfiError := C.signal_ciphertext_message_serialize(&serialized, c.ptr)
runtime.KeepAlive(c) runtime.KeepAlive(c)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
@ -84,7 +73,7 @@ func (c *CiphertextMessage) Serialize() ([]byte, error) {
func (c *CiphertextMessage) MessageType() (CiphertextMessageType, error) { func (c *CiphertextMessage) MessageType() (CiphertextMessageType, error) {
var messageType C.uint8_t var messageType C.uint8_t
signalFfiError := C.signal_ciphertext_message_type(&messageType, c.constPtr()) signalFfiError := C.signal_ciphertext_message_type(&messageType, c.ptr)
runtime.KeepAlive(c) runtime.KeepAlive(c)
if signalFfiError != nil { if signalFfiError != nil {
return 0, wrapError(signalFfiError) return 0, wrapError(signalFfiError)

View file

@ -17,6 +17,7 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
*/ */
import "C" import "C"
@ -39,17 +40,3 @@ func CopySignalOwnedBufferToBytes(buffer C.SignalOwnedBuffer) (b []byte) {
C.signal_free_buffer(buffer.base, buffer.length) C.signal_free_buffer(buffer.base, buffer.length)
return return
} }
func CopySignalBytestringArray[T ~[]byte](buffer C.SignalBytestringArray) (b []T) {
concatted := C.GoBytes(unsafe.Pointer(buffer.bytes.base), C.int(buffer.bytes.length))
b = make([]T, int(buffer.lengths.length))
sizeTSize := unsafe.Sizeof(C.size_t(0))
offset := 0
for i := 0; i < int(buffer.lengths.length); i++ {
length := int(*(*C.size_t)(unsafe.Add(unsafe.Pointer(buffer.lengths.base), uintptr(i)*sizeTSize)))
b[i] = concatted[offset : offset+length]
offset += length
}
C.signal_free_bytestring_array(buffer)
return
}

View file

@ -1,6 +1,5 @@
// mautrix-signal - A Matrix-signal puppeting bridge. // mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Sumner Evans // Copyright (C) 2023 Sumner Evans
// Copyright (C) 2025 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU Affero General Public License as published by
@ -18,11 +17,13 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
*/ */
import "C" import "C"
import ( import (
"runtime" "runtime"
"time"
) )
type DecryptionErrorMessage struct { type DecryptionErrorMessage struct {
@ -37,64 +38,47 @@ func wrapDecryptionErrorMessage(ptr *C.SignalDecryptionErrorMessage) *Decryption
} }
func DeserializeDecryptionErrorMessage(messageBytes []byte) (*DecryptionErrorMessage, error) { func DeserializeDecryptionErrorMessage(messageBytes []byte) (*DecryptionErrorMessage, error) {
var dem C.SignalMutPointerDecryptionErrorMessage var dem *C.SignalDecryptionErrorMessage
signalFfiError := C.signal_decryption_error_message_deserialize( signalFfiError := C.signal_decryption_error_message_deserialize(&dem, BytesToBuffer(messageBytes))
&dem,
BytesToBuffer(messageBytes),
)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return wrapDecryptionErrorMessage(dem.raw), nil return wrapDecryptionErrorMessage(dem), nil
} }
func DecryptionErrorMessageForOriginalMessage(originalBytes []byte, originalType CiphertextMessageType, originalTs uint64, originalSenderDeviceID uint) (*DecryptionErrorMessage, error) { func DecryptionErrorMessageForOriginalMessage(originalBytes []byte, originalType uint8, originalTs uint64, originalSenderDeviceID uint) (*DecryptionErrorMessage, error) {
var dem C.SignalMutPointerDecryptionErrorMessage var dem *C.SignalDecryptionErrorMessage
signalFfiError := C.signal_decryption_error_message_for_original_message( signalFfiError := C.signal_decryption_error_message_for_original_message(&dem, BytesToBuffer(originalBytes), C.uint8_t(originalType), C.uint64_t(originalTs), C.uint32_t(originalSenderDeviceID))
&dem,
BytesToBuffer(originalBytes),
C.uint8_t(originalType),
C.uint64_t(originalTs),
C.uint32_t(originalSenderDeviceID),
)
runtime.KeepAlive(originalBytes) runtime.KeepAlive(originalBytes)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return wrapDecryptionErrorMessage(dem.raw), nil return wrapDecryptionErrorMessage(dem), nil
} }
func DecryptionErrorMessageFromSerializedContent(serialized []byte) (*DecryptionErrorMessage, error) { func DecryptionErrorMessageFromSerializedContent(serialized []byte) (*DecryptionErrorMessage, error) {
var dem C.SignalMutPointerDecryptionErrorMessage var dem *C.SignalDecryptionErrorMessage
signalFfiError := C.signal_decryption_error_message_extract_from_serialized_content(&dem, BytesToBuffer(serialized)) signalFfiError := C.signal_decryption_error_message_extract_from_serialized_content(&dem, BytesToBuffer(serialized))
runtime.KeepAlive(serialized) runtime.KeepAlive(serialized)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return wrapDecryptionErrorMessage(dem.raw), nil return wrapDecryptionErrorMessage(dem), nil
}
func (dem *DecryptionErrorMessage) mutPtr() C.SignalMutPointerDecryptionErrorMessage {
return C.SignalMutPointerDecryptionErrorMessage{dem.ptr}
}
func (dem *DecryptionErrorMessage) constPtr() C.SignalConstPointerDecryptionErrorMessage {
return C.SignalConstPointerDecryptionErrorMessage{dem.ptr}
} }
func (dem *DecryptionErrorMessage) Clone() (*DecryptionErrorMessage, error) { func (dem *DecryptionErrorMessage) Clone() (*DecryptionErrorMessage, error) {
var cloned C.SignalMutPointerDecryptionErrorMessage var cloned *C.SignalDecryptionErrorMessage
signalFfiError := C.signal_decryption_error_message_clone(&cloned, dem.constPtr()) signalFfiError := C.signal_decryption_error_message_clone(&cloned, dem.ptr)
runtime.KeepAlive(dem) runtime.KeepAlive(dem)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return wrapDecryptionErrorMessage(cloned.raw), nil return wrapDecryptionErrorMessage(cloned), nil
} }
func (dem *DecryptionErrorMessage) Destroy() error { func (dem *DecryptionErrorMessage) Destroy() error {
dem.CancelFinalizer() dem.CancelFinalizer()
return wrapError(C.signal_decryption_error_message_destroy(dem.mutPtr())) return wrapError(C.signal_decryption_error_message_destroy(dem.ptr))
} }
func (dem *DecryptionErrorMessage) CancelFinalizer() { func (dem *DecryptionErrorMessage) CancelFinalizer() {
@ -103,7 +87,7 @@ func (dem *DecryptionErrorMessage) CancelFinalizer() {
func (dem *DecryptionErrorMessage) Serialize() ([]byte, error) { func (dem *DecryptionErrorMessage) Serialize() ([]byte, error) {
var serialized C.SignalOwnedBuffer = C.SignalOwnedBuffer{} var serialized C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_decryption_error_message_serialize(&serialized, dem.constPtr()) signalFfiError := C.signal_decryption_error_message_serialize(&serialized, dem.ptr)
runtime.KeepAlive(dem) runtime.KeepAlive(dem)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
@ -111,19 +95,19 @@ func (dem *DecryptionErrorMessage) Serialize() ([]byte, error) {
return CopySignalOwnedBufferToBytes(serialized), nil return CopySignalOwnedBufferToBytes(serialized), nil
} }
func (dem *DecryptionErrorMessage) GetTimestamp() (uint64, error) { func (dem *DecryptionErrorMessage) GetTimestamp() (time.Time, error) {
var ts C.uint64_t var ts C.uint64_t
signalFfiError := C.signal_decryption_error_message_get_timestamp(&ts, dem.constPtr()) signalFfiError := C.signal_decryption_error_message_get_timestamp(&ts, dem.ptr)
runtime.KeepAlive(dem) runtime.KeepAlive(dem)
if signalFfiError != nil { if signalFfiError != nil {
return 0, wrapError(signalFfiError) return time.Time{}, wrapError(signalFfiError)
} }
return uint64(ts), nil return time.UnixMilli(int64(ts)), nil
} }
func (dem *DecryptionErrorMessage) GetDeviceID() (uint32, error) { func (dem *DecryptionErrorMessage) GetDeviceID() (uint32, error) {
var deviceID C.uint32_t var deviceID C.uint32_t
signalFfiError := C.signal_decryption_error_message_get_device_id(&deviceID, dem.constPtr()) signalFfiError := C.signal_decryption_error_message_get_device_id(&deviceID, dem.ptr)
runtime.KeepAlive(dem) runtime.KeepAlive(dem)
if signalFfiError != nil { if signalFfiError != nil {
return 0, wrapError(signalFfiError) return 0, wrapError(signalFfiError)
@ -132,11 +116,11 @@ func (dem *DecryptionErrorMessage) GetDeviceID() (uint32, error) {
} }
func (dem *DecryptionErrorMessage) GetRatchetKey() (*PublicKey, error) { func (dem *DecryptionErrorMessage) GetRatchetKey() (*PublicKey, error) {
var pk C.SignalMutPointerPublicKey var pk *C.SignalPublicKey
signalFfiError := C.signal_decryption_error_message_get_ratchet_key(&pk, dem.constPtr()) signalFfiError := C.signal_decryption_error_message_get_ratchet_key(&pk, dem.ptr)
runtime.KeepAlive(dem) runtime.KeepAlive(dem)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return wrapPublicKey(pk.raw), nil return wrapPublicKey(pk), nil
} }

View file

@ -17,6 +17,7 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
*/ */
import "C" import "C"

View file

@ -17,6 +17,7 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
*/ */
import "C" import "C"
@ -26,10 +27,6 @@ import (
type ErrorCode int type ErrorCode int
func (e ErrorCode) Error() string {
return fmt.Sprintf("libsignalgo.ErrorCode(%d)", int(e))
}
const ( const (
ErrorCodeUnknownError ErrorCode = 1 ErrorCodeUnknownError ErrorCode = 1
ErrorCodeInvalidState ErrorCode = 2 ErrorCodeInvalidState ErrorCode = 2
@ -38,7 +35,6 @@ const (
ErrorCodeInvalidArgument ErrorCode = 5 ErrorCodeInvalidArgument ErrorCode = 5
ErrorCodeInvalidType ErrorCode = 6 ErrorCodeInvalidType ErrorCode = 6
ErrorCodeInvalidUtf8String ErrorCode = 7 ErrorCodeInvalidUtf8String ErrorCode = 7
ErrorCodeCancelled ErrorCode = 8
ErrorCodeProtobufError ErrorCode = 10 ErrorCodeProtobufError ErrorCode = 10
ErrorCodeLegacyCiphertextVersion ErrorCode = 21 ErrorCodeLegacyCiphertextVersion ErrorCode = 21
ErrorCodeUnknownCiphertextVersion ErrorCode = 22 ErrorCodeUnknownCiphertextVersion ErrorCode = 22
@ -56,61 +52,9 @@ const (
ErrorCodeInvalidRegistrationId ErrorCode = 81 ErrorCodeInvalidRegistrationId ErrorCode = 81
ErrorCodeInvalidSession ErrorCode = 82 ErrorCodeInvalidSession ErrorCode = 82
ErrorCodeInvalidSenderKeySession ErrorCode = 83 ErrorCodeInvalidSenderKeySession ErrorCode = 83
ErrorCodeInvalidProtocolAddress ErrorCode = 84
ErrorCodeDuplicatedMessage ErrorCode = 90 ErrorCodeDuplicatedMessage ErrorCode = 90
ErrorCodeCallbackError ErrorCode = 100 ErrorCodeCallbackError ErrorCode = 100
ErrorCodeVerificationFailure ErrorCode = 110 ErrorCodeVerificationFailure ErrorCode = 110
ErrorCodeUsernameCannotBeEmpty ErrorCode = 120
ErrorCodeUsernameCannotStartWithDigit ErrorCode = 121
ErrorCodeUsernameMissingSeparator ErrorCode = 122
ErrorCodeUsernameBadDiscriminatorCharacter ErrorCode = 123
ErrorCodeUsernameBadNicknameCharacter ErrorCode = 124
ErrorCodeUsernameTooShort ErrorCode = 125
ErrorCodeUsernameTooLong ErrorCode = 126
ErrorCodeUsernameLinkInvalidEntropyDataLength ErrorCode = 127
ErrorCodeUsernameLinkInvalid ErrorCode = 128
ErrorCodeUsernameDiscriminatorCannotBeEmpty ErrorCode = 130
ErrorCodeUsernameDiscriminatorCannotBeZero ErrorCode = 131
ErrorCodeUsernameDiscriminatorCannotBeSingleDigit ErrorCode = 132
ErrorCodeUsernameDiscriminatorCannotHaveLeadingZeros ErrorCode = 133
ErrorCodeUsernameDiscriminatorTooLarge ErrorCode = 134
ErrorCodeIoError ErrorCode = 140
ErrorCodeInvalidMediaInput ErrorCode = 141
ErrorCodeUnsupportedMediaInput ErrorCode = 142
ErrorCodeConnectionTimedOut ErrorCode = 143
ErrorCodeNetworkProtocol ErrorCode = 144
ErrorCodeRateLimited ErrorCode = 145
ErrorCodeWebSocket ErrorCode = 146
ErrorCodeCdsiInvalidToken ErrorCode = 147
ErrorCodeConnectionFailed ErrorCode = 148
ErrorCodeChatServiceInactive ErrorCode = 149
ErrorCodeRequestTimedOut ErrorCode = 150
ErrorCodeRateLimitChallenge ErrorCode = 151
ErrorCodePossibleCaptiveNetwork ErrorCode = 152
ErrorCodeSvrDataMissing ErrorCode = 160
ErrorCodeSvrRestoreFailed ErrorCode = 161
ErrorCodeSvrRotationMachineTooManySteps ErrorCode = 162
ErrorCodeSvrRequestFailed ErrorCode = 163
ErrorCodeAppExpired ErrorCode = 170
ErrorCodeDeviceDeregistered ErrorCode = 171
ErrorCodeConnectionInvalidated ErrorCode = 172
ErrorCodeConnectedElsewhere ErrorCode = 173
ErrorCodeBackupValidation ErrorCode = 180
ErrorCodeRegistrationInvalidSessionId ErrorCode = 190
ErrorCodeRegistrationUnknown ErrorCode = 192
ErrorCodeRegistrationSessionNotFound ErrorCode = 193
ErrorCodeRegistrationNotReadyForVerification ErrorCode = 194
ErrorCodeRegistrationSendVerificationCodeFailed ErrorCode = 195
ErrorCodeRegistrationCodeNotDeliverable ErrorCode = 196
ErrorCodeRegistrationSessionUpdateRejected ErrorCode = 197
ErrorCodeRegistrationCredentialsCouldNotBeParsed ErrorCode = 198
ErrorCodeRegistrationDeviceTransferPossible ErrorCode = 199
ErrorCodeRegistrationRecoveryVerificationFailed ErrorCode = 200
ErrorCodeRegistrationLock ErrorCode = 201
ErrorCodeKeyTransparencyError ErrorCode = 210
ErrorCodeKeyTransparencyVerificationFailed ErrorCode = 211
ErrorCodeRequestUnauthorized ErrorCode = 220
ErrorCodeMismatchedDevices ErrorCode = 221
) )
type SignalError struct { type SignalError struct {
@ -122,10 +66,6 @@ func (e *SignalError) Error() string {
return fmt.Sprintf("%d: %s", e.Code, e.Message) return fmt.Sprintf("%d: %s", e.Code, e.Message)
} }
func (e *SignalError) Unwrap() error {
return e.Code
}
func (ctx *CallbackContext) wrapError(signalError *C.SignalFfiError) error { func (ctx *CallbackContext) wrapError(signalError *C.SignalFfiError) error {
if signalError == nil { if signalError == nil {
return nil return nil
@ -153,7 +93,7 @@ func wrapError(signalError *C.SignalFfiError) error {
func wrapSignalError(signalError *C.SignalFfiError, errorType C.uint32_t) error { func wrapSignalError(signalError *C.SignalFfiError, errorType C.uint32_t) error {
var messageBytes *C.char var messageBytes *C.char
getMessageError := C.signal_error_get_message(&messageBytes, signalError) getMessageError := C.signal_error_get_message(signalError, &messageBytes)
if getMessageError != nil { if getMessageError != nil {
// Ignore any errors from this, it will just end up being an empty string. // Ignore any errors from this, it will just end up being an empty string.
C.signal_error_free(getMessageError) C.signal_error_free(getMessageError)

View file

@ -1,6 +1,5 @@
// mautrix-signal - A Matrix-signal puppeting bridge. // mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Sumner Evans // Copyright (C) 2023 Sumner Evans
// Copyright (C) 2025 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU Affero General Public License as published by
@ -18,6 +17,7 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
*/ */
import "C" import "C"
@ -42,48 +42,32 @@ func wrapFingerprint(ptr *C.SignalFingerprint) *Fingerprint {
} }
func NewFingerprint(iterations, version FingerprintVersion, localIdentifier []byte, localKey *PublicKey, remoteIdentifier []byte, remoteKey *PublicKey) (*Fingerprint, error) { func NewFingerprint(iterations, version FingerprintVersion, localIdentifier []byte, localKey *PublicKey, remoteIdentifier []byte, remoteKey *PublicKey) (*Fingerprint, error) {
var pa C.SignalMutPointerFingerprint var pa *C.SignalFingerprint
signalFfiError := C.signal_fingerprint_new( signalFfiError := C.signal_fingerprint_new(&pa, C.uint32_t(iterations), C.uint32_t(version), BytesToBuffer(localIdentifier), localKey.ptr, BytesToBuffer(remoteIdentifier), remoteKey.ptr)
&pa,
C.uint32_t(iterations),
C.uint32_t(version),
BytesToBuffer(localIdentifier),
localKey.constPtr(),
BytesToBuffer(remoteIdentifier),
remoteKey.constPtr(),
)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return wrapFingerprint(pa.raw), nil return wrapFingerprint(pa), nil
}
func (f *Fingerprint) mutPtr() C.SignalMutPointerFingerprint {
return C.SignalMutPointerFingerprint{f.ptr}
}
func (f *Fingerprint) constPtr() C.SignalConstPointerFingerprint {
return C.SignalConstPointerFingerprint{f.ptr}
} }
func (f *Fingerprint) Clone() (*Fingerprint, error) { func (f *Fingerprint) Clone() (*Fingerprint, error) {
var cloned C.SignalMutPointerFingerprint var cloned *C.SignalFingerprint
signalFfiError := C.signal_fingerprint_clone(&cloned, f.constPtr()) signalFfiError := C.signal_fingerprint_clone(&cloned, f.ptr)
runtime.KeepAlive(f) runtime.KeepAlive(f)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return wrapFingerprint(cloned.raw), nil return wrapFingerprint(cloned), nil
} }
func (f *Fingerprint) Destroy() error { func (f *Fingerprint) Destroy() error {
runtime.SetFinalizer(f, nil) runtime.SetFinalizer(f, nil)
return wrapError(C.signal_fingerprint_destroy(f.mutPtr())) return wrapError(C.signal_fingerprint_destroy(f.ptr))
} }
func (f *Fingerprint) ScannableEncoding() ([]byte, error) { func (f *Fingerprint) ScannableEncoding() ([]byte, error) {
var scannableEncoding C.SignalOwnedBuffer = C.SignalOwnedBuffer{} var scannableEncoding C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_fingerprint_scannable_encoding(&scannableEncoding, f.constPtr()) signalFfiError := C.signal_fingerprint_scannable_encoding(&scannableEncoding, f.ptr)
runtime.KeepAlive(f) runtime.KeepAlive(f)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
@ -93,7 +77,7 @@ func (f *Fingerprint) ScannableEncoding() ([]byte, error) {
func (f *Fingerprint) DisplayString() (string, error) { func (f *Fingerprint) DisplayString() (string, error) {
var displayString *C.char var displayString *C.char
signalFfiError := C.signal_fingerprint_display_string(&displayString, f.constPtr()) signalFfiError := C.signal_fingerprint_display_string(&displayString, f.ptr)
runtime.KeepAlive(f) runtime.KeepAlive(f)
if signalFfiError != nil { if signalFfiError != nil {
return "", wrapError(signalFfiError) return "", wrapError(signalFfiError)

View file

@ -1,6 +1,5 @@
// mautrix-signal - A Matrix-signal puppeting bridge. // mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Sumner Evans // Copyright (C) 2023 Sumner Evans
// Copyright (C) 2025 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU Affero General Public License as published by
@ -18,6 +17,7 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
*/ */
import "C" import "C"
@ -32,11 +32,11 @@ import (
func GroupEncrypt(ctx context.Context, ptext []byte, sender *Address, distributionID uuid.UUID, store SenderKeyStore) (*CiphertextMessage, error) { func GroupEncrypt(ctx context.Context, ptext []byte, sender *Address, distributionID uuid.UUID, store SenderKeyStore) (*CiphertextMessage, error) {
callbackCtx := NewCallbackContext(ctx) callbackCtx := NewCallbackContext(ctx)
defer callbackCtx.Unref() defer callbackCtx.Unref()
var ciphertextMessage C.SignalMutPointerCiphertextMessage var ciphertextMessage *C.SignalCiphertextMessage
signalFfiError := C.signal_group_encrypt_message( signalFfiError := C.signal_group_encrypt_message(
&ciphertextMessage, &ciphertextMessage,
sender.constPtr(), sender.ptr,
*(*C.SignalUuid)(unsafe.Pointer(&distributionID)), (*[C.SignalUUID_LEN]C.uchar)(unsafe.Pointer(&distributionID)),
BytesToBuffer(ptext), BytesToBuffer(ptext),
callbackCtx.wrapSenderKeyStore(store)) callbackCtx.wrapSenderKeyStore(store))
runtime.KeepAlive(ptext) runtime.KeepAlive(ptext)
@ -44,7 +44,7 @@ func GroupEncrypt(ctx context.Context, ptext []byte, sender *Address, distributi
if signalFfiError != nil { if signalFfiError != nil {
return nil, callbackCtx.wrapError(signalFfiError) return nil, callbackCtx.wrapError(signalFfiError)
} }
return wrapCiphertextMessage(ciphertextMessage.raw), nil return wrapCiphertextMessage(ciphertextMessage), nil
} }
func GroupDecrypt(ctx context.Context, ctext []byte, sender *Address, store SenderKeyStore) ([]byte, error) { func GroupDecrypt(ctx context.Context, ctext []byte, sender *Address, store SenderKeyStore) ([]byte, error) {
@ -53,7 +53,7 @@ func GroupDecrypt(ctx context.Context, ctext []byte, sender *Address, store Send
var resp C.SignalOwnedBuffer = C.SignalOwnedBuffer{} var resp C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_group_decrypt_message( signalFfiError := C.signal_group_decrypt_message(
&resp, &resp,
sender.constPtr(), sender.ptr,
BytesToBuffer(ctext), BytesToBuffer(ctext),
callbackCtx.wrapSenderKeyStore(store)) callbackCtx.wrapSenderKeyStore(store))
runtime.KeepAlive(ctext) runtime.KeepAlive(ctext)

View file

@ -30,7 +30,7 @@ import (
func TestGroupCipher(t *testing.T) { func TestGroupCipher(t *testing.T) {
ctx := context.TODO() ctx := context.TODO()
sender, err := libsignalgo.NewACIServiceID(uuid.New()).Address(4) sender, err := libsignalgo.NewPhoneAddress("+14159999111", 4)
assert.NoError(t, err) assert.NoError(t, err)
distributionID, err := uuid.Parse("d1d1d1d1-7000-11eb-b32a-33b8a8a487a6") distributionID, err := uuid.Parse("d1d1d1d1-7000-11eb-b32a-33b8a8a487a6")

View file

@ -1,6 +1,5 @@
// mautrix-signal - A Matrix-signal puppeting bridge. // mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Sumner Evans // Copyright (C) 2023 Sumner Evans
// Copyright (C) 2025 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU Affero General Public License as published by
@ -18,13 +17,12 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
*/ */
import "C" import "C"
import ( import (
"crypto/rand" "crypto/rand"
"encoding/base64"
"fmt"
"runtime" "runtime"
"unsafe" "unsafe"
@ -42,20 +40,10 @@ func GenerateRandomness() Randomness {
return randomness return randomness
} }
const GroupMasterKeyLength = C.SignalGROUP_MASTER_KEY_LEN type GroupMasterKey [C.SignalGROUP_MASTER_KEY_LEN]byte
const GroupIdentifierLength = C.SignalGROUP_IDENTIFIER_LEN
type GroupMasterKey [GroupMasterKeyLength]byte
type GroupSecretParams [C.SignalGROUP_SECRET_PARAMS_LEN]byte type GroupSecretParams [C.SignalGROUP_SECRET_PARAMS_LEN]byte
type GroupPublicParams [C.SignalGROUP_PUBLIC_PARAMS_LEN]byte type GroupPublicParams [C.SignalGROUP_PUBLIC_PARAMS_LEN]byte
type GroupIdentifier [GroupIdentifierLength]byte type GroupIdentifier [C.SignalGROUP_IDENTIFIER_LEN]byte
func (gid *GroupIdentifier) String() string {
if gid == nil {
return ""
}
return base64.StdEncoding.EncodeToString(gid[:])
}
type UUIDCiphertext [C.SignalUUID_CIPHERTEXT_LEN]byte type UUIDCiphertext [C.SignalUUID_CIPHERTEXT_LEN]byte
type ProfileKeyCiphertext [C.SignalPROFILE_KEY_CIPHERTEXT_LEN]byte type ProfileKeyCiphertext [C.SignalPROFILE_KEY_CIPHERTEXT_LEN]byte
@ -64,22 +52,6 @@ func GenerateGroupSecretParams() (GroupSecretParams, error) {
return GenerateGroupSecretParamsWithRandomness(GenerateRandomness()) return GenerateGroupSecretParamsWithRandomness(GenerateRandomness())
} }
func (gmk GroupMasterKey) GroupIdentifier() (*GroupIdentifier, error) {
if groupSecretParams, err := DeriveGroupSecretParamsFromMasterKey(gmk); err != nil {
return nil, fmt.Errorf("DeriveGroupSecretParamsFromMasterKey error: %w", err)
} else if groupPublicParams, err := groupSecretParams.GetPublicParams(); err != nil {
return nil, fmt.Errorf("GetPublicParams error: %w", err)
} else if groupIdentifier, err := GetGroupIdentifier(*groupPublicParams); err != nil {
return nil, fmt.Errorf("GetGroupIdentifier error: %w", err)
} else {
return groupIdentifier, nil
}
}
func (gmk GroupMasterKey) SecretParams() (GroupSecretParams, error) {
return DeriveGroupSecretParamsFromMasterKey(gmk)
}
func GenerateGroupSecretParamsWithRandomness(randomness Randomness) (GroupSecretParams, error) { func GenerateGroupSecretParamsWithRandomness(randomness Randomness) (GroupSecretParams, error) {
var params [C.SignalGROUP_SECRET_PARAMS_LEN]C.uchar var params [C.SignalGROUP_SECRET_PARAMS_LEN]C.uchar
signalFfiError := C.signal_group_secret_params_generate_deterministic(&params, (*[C.SignalRANDOMNESS_LEN]C.uint8_t)(unsafe.Pointer(&randomness))) signalFfiError := C.signal_group_secret_params_generate_deterministic(&params, (*[C.SignalRANDOMNESS_LEN]C.uint8_t)(unsafe.Pointer(&randomness)))
@ -144,70 +116,40 @@ func (gsp *GroupSecretParams) DecryptBlobWithPadding(blob []byte) ([]byte, error
return CopySignalOwnedBufferToBytes(plaintext), nil return CopySignalOwnedBufferToBytes(plaintext), nil
} }
func (gsp *GroupSecretParams) EncryptBlobWithPaddingDeterministic(randomness Randomness, plaintext []byte, padding_len uint32) ([]byte, error) { func (gsp *GroupSecretParams) DecryptUUID(ciphertextUUID UUIDCiphertext) (uuid.UUID, error) {
var ciphertext C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
borrowedPlaintext := BytesToBuffer(plaintext)
signalFfiError := C.signal_group_secret_params_encrypt_blob_with_padding_deterministic(
&ciphertext,
(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)),
(*[C.SignalRANDOMNESS_LEN]C.uint8_t)(unsafe.Pointer(&randomness)),
borrowedPlaintext,
(C.uint32_t)(padding_len),
)
runtime.KeepAlive(randomness)
runtime.KeepAlive(gsp)
runtime.KeepAlive(plaintext)
runtime.KeepAlive(padding_len)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
return CopySignalOwnedBufferToBytes(ciphertext), nil
}
func (gsp *GroupSecretParams) DecryptServiceID(ciphertextServiceID UUIDCiphertext) (ServiceID, error) {
u := C.SignalServiceIdFixedWidthBinaryBytes{} u := C.SignalServiceIdFixedWidthBinaryBytes{}
signalFfiError := C.signal_group_secret_params_decrypt_service_id( signalFfiError := C.signal_group_secret_params_decrypt_service_id(
&u, &u,
(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)), (*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)),
(*[C.SignalUUID_CIPHERTEXT_LEN]C.uint8_t)(unsafe.Pointer(&ciphertextServiceID)), (*[C.SignalUUID_CIPHERTEXT_LEN]C.uint8_t)(unsafe.Pointer(&ciphertextUUID)),
) )
runtime.KeepAlive(gsp) runtime.KeepAlive(gsp)
runtime.KeepAlive(ciphertextServiceID) runtime.KeepAlive(ciphertextUUID)
if signalFfiError != nil { if signalFfiError != nil {
return EmptyServiceID, wrapError(signalFfiError) return uuid.Nil, wrapError(signalFfiError)
} }
serviceID := ServiceIDFromCFixedBytes(&u) result, err := SignalServiceIDToUUID(&u)
return serviceID, nil if err != nil {
} return uuid.Nil, err
func (gsp *GroupSecretParams) EncryptServiceID(serviceID ServiceID) (*UUIDCiphertext, error) {
var cipherTextServiceID [C.SignalUUID_CIPHERTEXT_LEN]C.uchar
signalFfiError := C.signal_group_secret_params_encrypt_service_id(
&cipherTextServiceID,
(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)),
serviceID.CFixedBytes(),
)
runtime.KeepAlive(gsp)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
} }
var result UUIDCiphertext return result, nil
copy(result[:], C.GoBytes(unsafe.Pointer(&cipherTextServiceID), C.int(C.SignalUUID_CIPHERTEXT_LEN)))
return &result, nil
} }
func (gsp *GroupSecretParams) DecryptProfileKey(ciphertextProfileKey ProfileKeyCiphertext, u uuid.UUID) (*ProfileKey, error) { func (gsp *GroupSecretParams) DecryptProfileKey(ciphertextProfileKey ProfileKeyCiphertext, u uuid.UUID) (*ProfileKey, error) {
profileKey := [C.SignalPROFILE_KEY_LEN]C.uchar{} profileKey := [C.SignalPROFILE_KEY_LEN]C.uchar{}
serviceId, err := SignalServiceIDFromUUID(u)
if err != nil {
return nil, err
}
signalFfiError := C.signal_group_secret_params_decrypt_profile_key( signalFfiError := C.signal_group_secret_params_decrypt_profile_key(
&profileKey, &profileKey,
(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)), (*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)),
(*[C.SignalPROFILE_KEY_CIPHERTEXT_LEN]C.uint8_t)(unsafe.Pointer(&ciphertextProfileKey)), (*[C.SignalPROFILE_KEY_CIPHERTEXT_LEN]C.uint8_t)(unsafe.Pointer(&ciphertextProfileKey)),
NewACIServiceID(u).CFixedBytes(), serviceId,
) )
runtime.KeepAlive(gsp) runtime.KeepAlive(gsp)
runtime.KeepAlive(ciphertextProfileKey) runtime.KeepAlive(ciphertextProfileKey)
runtime.KeepAlive(u)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
@ -215,57 +157,3 @@ func (gsp *GroupSecretParams) DecryptProfileKey(ciphertextProfileKey ProfileKeyC
copy(result[:], C.GoBytes(unsafe.Pointer(&profileKey), C.int(C.SignalPROFILE_KEY_LEN))) copy(result[:], C.GoBytes(unsafe.Pointer(&profileKey), C.int(C.SignalPROFILE_KEY_LEN)))
return &result, nil return &result, nil
} }
func (gsp *GroupSecretParams) EncryptProfileKey(profileKey ProfileKey, u uuid.UUID) (*ProfileKeyCiphertext, error) {
ciphertextProfileKey := [C.SignalPROFILE_KEY_CIPHERTEXT_LEN]C.uchar{}
signalFfiError := C.signal_group_secret_params_encrypt_profile_key(
&ciphertextProfileKey,
(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(gsp)),
(*[C.SignalPROFILE_KEY_LEN]C.uint8_t)(unsafe.Pointer(&profileKey)),
NewACIServiceID(u).CFixedBytes(),
)
runtime.KeepAlive(gsp)
runtime.KeepAlive(profileKey)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
var result ProfileKeyCiphertext
copy(result[:], C.GoBytes(unsafe.Pointer(&ciphertextProfileKey), C.int(C.SignalPROFILE_KEY_CIPHERTEXT_LEN)))
return &result, nil
}
func (gsp *GroupSecretParams) CreateExpiringProfileKeyCredentialPresentation(spp *ServerPublicParams, credential ExpiringProfileKeyCredential) (*ProfileKeyCredentialPresentation, error) {
var out C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
randomness := GenerateRandomness()
signalFfiError := C.signal_server_public_params_create_expiring_profile_key_credential_presentation_deterministic(
&out,
C.SignalConstPointerServerPublicParams{spp},
(*[C.SignalRANDOMNESS_LEN]C.uint8_t)(unsafe.Pointer(&randomness)),
(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uchar)(unsafe.Pointer(gsp)),
(*[C.SignalEXPIRING_PROFILE_KEY_CREDENTIAL_LEN]C.uchar)(unsafe.Pointer(&credential)),
)
runtime.KeepAlive(gsp)
runtime.KeepAlive(credential)
runtime.KeepAlive(randomness)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
presentationBytes := CopySignalOwnedBufferToBytes(out)
presentation := ProfileKeyCredentialPresentation(presentationBytes)
return &presentation, nil
}
func (gsp *GroupSecretParams) GetMasterKey() (*GroupMasterKey, error) {
masterKeyBytes := [C.SignalGROUP_MASTER_KEY_LEN]C.uchar{}
signalFfiError := C.signal_group_secret_params_get_master_key(
&masterKeyBytes,
(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uchar)(unsafe.Pointer(gsp)),
)
runtime.KeepAlive(gsp)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
var groupMasterKey GroupMasterKey
copy(groupMasterKey[:], C.GoBytes(unsafe.Pointer(&masterKeyBytes), C.int(C.SignalGROUP_MASTER_KEY_LEN)))
return &groupMasterKey, nil
}

View file

@ -1,215 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2025 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/>.
package libsignalgo
/*
#include "./libsignal-ffi.h"
*/
import "C"
import (
"encoding/base64"
"runtime"
"time"
"unsafe"
)
type GroupSendFullToken []byte
func (gsft GroupSendFullToken) String() string {
return base64.StdEncoding.EncodeToString(gsft)
}
func (gsft GroupSendFullToken) CheckValidContents() error {
signalFfiError := C.signal_group_send_full_token_check_valid_contents(
BytesToBuffer(gsft),
)
runtime.KeepAlive(gsft)
if signalFfiError != nil {
return wrapError(signalFfiError)
}
return nil
}
func (gsft GroupSendFullToken) GetExpiration() (time.Time, error) {
var expiration C.uint64_t
signalFfiError := C.signal_group_send_full_token_get_expiration(
&expiration,
BytesToBuffer(gsft),
)
runtime.KeepAlive(gsft)
if signalFfiError != nil {
return time.Time{}, wrapError(signalFfiError)
}
return time.Unix(int64(expiration), 0), nil
}
type GroupSendToken []byte
func (gst GroupSendToken) CheckValidContents() error {
signalFfiError := C.signal_group_send_token_check_valid_contents(
BytesToBuffer(gst),
)
runtime.KeepAlive(gst)
if signalFfiError != nil {
return wrapError(signalFfiError)
}
return nil
}
func (gst GroupSendToken) ToFullToken(expiration time.Time) (GroupSendFullToken, error) {
var fullToken C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_group_send_token_to_full_token(
&fullToken,
BytesToBuffer(gst),
C.uint64_t(expiration.Unix()),
)
runtime.KeepAlive(gst)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
return CopySignalOwnedBufferToBytes(fullToken), nil
}
type GroupSendEndorsement []byte
func (gse GroupSendEndorsement) ToToken(groupSecretParams *GroupSecretParams) (GroupSendToken, error) {
var token C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_group_send_endorsement_to_token(
&token,
BytesToBuffer(gse),
(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(groupSecretParams)),
)
runtime.KeepAlive(gse)
runtime.KeepAlive(groupSecretParams)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
return CopySignalOwnedBufferToBytes(token), nil
}
func (gse GroupSendEndorsement) ToFullToken(params *GroupSecretParams, expiration time.Time) (GroupSendFullToken, error) {
token, err := gse.ToToken(params)
if err != nil {
return nil, err
}
return token.ToFullToken(expiration)
}
func (gse GroupSendEndorsement) CheckValidContents() error {
signalFfiError := C.signal_group_send_endorsement_check_valid_contents(
BytesToBuffer(gse),
)
runtime.KeepAlive(gse)
if signalFfiError != nil {
return wrapError(signalFfiError)
}
return nil
}
func (gse GroupSendEndorsement) Remove(other GroupSendEndorsement) (GroupSendEndorsement, error) {
var result C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_group_send_endorsement_remove(
&result,
BytesToBuffer(gse),
BytesToBuffer(other),
)
runtime.KeepAlive(gse)
runtime.KeepAlive(other)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
return CopySignalOwnedBufferToBytes(result), nil
}
func GroupSendEndorsementCombine(endorsements ...GroupSendEndorsement) (GroupSendEndorsement, error) {
var result C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
cEndorsements, unpin := ManyBytesToBuffer(endorsements)
defer unpin()
signalFfiError := C.signal_group_send_endorsement_combine(
&result,
cEndorsements,
)
runtime.KeepAlive(endorsements)
if signalFfiError != nil {
return nil, wrapError(signalFfiError)
}
return CopySignalOwnedBufferToBytes(result), nil
}
type GroupSendEndorsementsResponse []byte
func (gser GroupSendEndorsementsResponse) GetExpiration() (time.Time, error) {
var expiration C.uint64_t
signalFfiError := C.signal_group_send_endorsements_response_get_expiration(
&expiration,
BytesToBuffer(gser),
)
runtime.KeepAlive(gser)
if signalFfiError != nil {
return time.Time{}, wrapError(signalFfiError)
}
return time.Unix(int64(expiration), 0), nil
}
func (gser GroupSendEndorsementsResponse) CheckValidContents() error {
signalFfiError := C.signal_group_send_endorsements_response_check_valid_contents(
BytesToBuffer(gser),
)
runtime.KeepAlive(gser)
if signalFfiError != nil {
return wrapError(signalFfiError)
}
return nil
}
func (gser GroupSendEndorsementsResponse) ReceiveWithServiceIDs(
groupMembers []ServiceID, localUser ServiceID, params *GroupSecretParams, spp *ServerPublicParams,
) (GroupSendEndorsement, map[ServiceID]GroupSendEndorsement, error) {
var out C.SignalBytestringArray = C.SignalBytestringArray{}
concatenatedMembers := make([]byte, len(groupMembers)*17)
for i, member := range groupMembers {
copy(concatenatedMembers[i*17:(i+1)*17], member.FixedBytes()[:])
}
signalFfiError := C.signal_group_send_endorsements_response_receive_and_combine_with_service_ids(
&out,
BytesToBuffer(gser),
BytesToBuffer(concatenatedMembers),
localUser.CFixedBytes(),
C.uint64_t(time.Now().Unix()),
(*[C.SignalGROUP_SECRET_PARAMS_LEN]C.uint8_t)(unsafe.Pointer(params)),
C.SignalConstPointerServerPublicParams{spp},
)
runtime.KeepAlive(gser)
runtime.KeepAlive(concatenatedMembers)
runtime.KeepAlive(params)
runtime.KeepAlive(spp)
if signalFfiError != nil {
return nil, nil, wrapError(signalFfiError)
}
endorsements := CopySignalBytestringArray[GroupSendEndorsement](out)
memberEndorsements := make(map[ServiceID]GroupSendEndorsement, len(groupMembers))
for i, member := range groupMembers {
if len(endorsements) > i && len(endorsements[i]) > 0 {
memberEndorsements[member] = endorsements[i]
}
}
combined, err := GroupSendEndorsementCombine(endorsements...)
if err != nil {
return nil, memberEndorsements, err
}
return combined, memberEndorsements, nil
}

View file

@ -1,6 +1,5 @@
// mautrix-signal - A Matrix-signal puppeting bridge. // mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Sumner Evans // Copyright (C) 2023 Sumner Evans
// Copyright (C) 2025 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU Affero General Public License as published by
@ -18,6 +17,7 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
*/ */
import "C" import "C"
@ -35,34 +35,22 @@ func wrapHSMEnclaveClient(ptr *C.SignalHsmEnclaveClient) *HSMEnclaveClient {
} }
func NewHSMEnclaveClient(trustedPublicKey, trustedCodeHashes []byte) (*HSMEnclaveClient, error) { func NewHSMEnclaveClient(trustedPublicKey, trustedCodeHashes []byte) (*HSMEnclaveClient, error) {
var cds C.SignalMutPointerHsmEnclaveClient var cds *C.SignalHsmEnclaveClient
signalFfiError := C.signal_hsm_enclave_client_new( signalFfiError := C.signal_hsm_enclave_client_new(&cds, BytesToBuffer(trustedPublicKey), BytesToBuffer(trustedCodeHashes))
&cds,
BytesToBuffer(trustedPublicKey),
BytesToBuffer(trustedCodeHashes),
)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return wrapHSMEnclaveClient(cds.raw), nil return wrapHSMEnclaveClient(cds), nil
}
func (hsm *HSMEnclaveClient) mutPtr() C.SignalMutPointerHsmEnclaveClient {
return C.SignalMutPointerHsmEnclaveClient{hsm.ptr}
}
func (hsm *HSMEnclaveClient) constPtr() C.SignalConstPointerHsmEnclaveClient {
return C.SignalConstPointerHsmEnclaveClient{hsm.ptr}
} }
func (hsm *HSMEnclaveClient) Destroy() error { func (hsm *HSMEnclaveClient) Destroy() error {
runtime.SetFinalizer(hsm, nil) runtime.SetFinalizer(hsm, nil)
return wrapError(C.signal_hsm_enclave_client_destroy(hsm.mutPtr())) return wrapError(C.signal_hsm_enclave_client_destroy(hsm.ptr))
} }
func (hsm *HSMEnclaveClient) InitialRequest() ([]byte, error) { func (hsm *HSMEnclaveClient) InitialRequest() ([]byte, error) {
var resp C.SignalOwnedBuffer = C.SignalOwnedBuffer{} var resp C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_hsm_enclave_client_initial_request(&resp, hsm.constPtr()) signalFfiError := C.signal_hsm_enclave_client_initial_request(&resp, hsm.ptr)
runtime.KeepAlive(hsm) runtime.KeepAlive(hsm)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
@ -71,7 +59,7 @@ func (hsm *HSMEnclaveClient) InitialRequest() ([]byte, error) {
} }
func (hsm *HSMEnclaveClient) CompleteHandshake(handshakeReceived []byte) error { func (hsm *HSMEnclaveClient) CompleteHandshake(handshakeReceived []byte) error {
signalFfiError := C.signal_hsm_enclave_client_complete_handshake(hsm.mutPtr(), BytesToBuffer(handshakeReceived)) signalFfiError := C.signal_hsm_enclave_client_complete_handshake(hsm.ptr, BytesToBuffer(handshakeReceived))
runtime.KeepAlive(hsm) runtime.KeepAlive(hsm)
runtime.KeepAlive(handshakeReceived) runtime.KeepAlive(handshakeReceived)
return wrapError(signalFfiError) return wrapError(signalFfiError)
@ -79,7 +67,7 @@ func (hsm *HSMEnclaveClient) CompleteHandshake(handshakeReceived []byte) error {
func (hsm *HSMEnclaveClient) EstablishedSend(plaintext []byte) ([]byte, error) { func (hsm *HSMEnclaveClient) EstablishedSend(plaintext []byte) ([]byte, error) {
var resp C.SignalOwnedBuffer = C.SignalOwnedBuffer{} var resp C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_hsm_enclave_client_established_send(&resp, hsm.mutPtr(), BytesToBuffer(plaintext)) signalFfiError := C.signal_hsm_enclave_client_established_send(&resp, hsm.ptr, BytesToBuffer(plaintext))
runtime.KeepAlive(hsm) runtime.KeepAlive(hsm)
runtime.KeepAlive(plaintext) runtime.KeepAlive(plaintext)
if signalFfiError != nil { if signalFfiError != nil {
@ -90,7 +78,7 @@ func (hsm *HSMEnclaveClient) EstablishedSend(plaintext []byte) ([]byte, error) {
func (hsm *HSMEnclaveClient) EstablishedReceive(ciphertext []byte) ([]byte, error) { func (hsm *HSMEnclaveClient) EstablishedReceive(ciphertext []byte) ([]byte, error) {
var resp C.SignalOwnedBuffer = C.SignalOwnedBuffer{} var resp C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_hsm_enclave_client_established_recv(&resp, hsm.mutPtr(), BytesToBuffer(ciphertext)) signalFfiError := C.signal_hsm_enclave_client_established_recv(&resp, hsm.ptr, BytesToBuffer(ciphertext))
runtime.KeepAlive(hsm) runtime.KeepAlive(hsm)
runtime.KeepAlive(ciphertext) runtime.KeepAlive(ciphertext)
if signalFfiError != nil { if signalFfiError != nil {

View file

@ -1,6 +1,5 @@
// mautrix-signal - A Matrix-signal puppeting bridge. // mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Sumner Evans // Copyright (C) 2023 Sumner Evans
// Copyright (C) 2025 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU Affero General Public License as published by
@ -18,6 +17,7 @@
package libsignalgo package libsignalgo
/* /*
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
#include "./libsignal-ffi.h" #include "./libsignal-ffi.h"
*/ */
import "C" import "C"
@ -41,39 +41,23 @@ func NewIdentityKeyFromBytes(bytes []byte) (*IdentityKey, error) {
return &IdentityKey{publicKey: publicKey}, nil return &IdentityKey{publicKey: publicKey}, nil
} }
func (i *IdentityKey) TrySerialize() []byte {
if i == nil {
return nil
}
serialized, err := i.Serialize()
if err != nil {
return nil
}
return serialized
}
func (i *IdentityKey) Serialize() ([]byte, error) { func (i *IdentityKey) Serialize() ([]byte, error) {
return i.publicKey.Serialize() return i.publicKey.Serialize()
} }
func DeserializeIdentityKey(bytes []byte) (*IdentityKey, error) { func DeserializeIdentityKey(bytes []byte) (*IdentityKey, error) {
var publicKey C.SignalMutPointerPublicKey var publicKey *C.SignalPublicKey
signalFfiError := C.signal_publickey_deserialize(&publicKey, BytesToBuffer(bytes)) signalFfiError := C.signal_publickey_deserialize(&publicKey, BytesToBuffer(bytes))
runtime.KeepAlive(bytes) runtime.KeepAlive(bytes)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return &IdentityKey{publicKey: wrapPublicKey(publicKey.raw)}, nil return &IdentityKey{publicKey: wrapPublicKey(publicKey)}, nil
} }
func (i *IdentityKey) VerifyAlternateIdentity(other *IdentityKey, signature []byte) (bool, error) { func (i *IdentityKey) VerifyAlternateIdentity(other *IdentityKey, signature []byte) (bool, error) {
var verify C.bool var verify C.bool
signalFfiError := C.signal_identitykey_verify_alternate_identity( signalFfiError := C.signal_identitykey_verify_alternate_identity(&verify, i.publicKey.ptr, other.publicKey.ptr, BytesToBuffer(signature))
&verify,
i.publicKey.constPtr(),
other.publicKey.constPtr(),
BytesToBuffer(signature),
)
runtime.KeepAlive(i) runtime.KeepAlive(i)
runtime.KeepAlive(other) runtime.KeepAlive(other)
runtime.KeepAlive(signature) runtime.KeepAlive(signature)
@ -84,7 +68,8 @@ func (i *IdentityKey) VerifyAlternateIdentity(other *IdentityKey, signature []by
} }
func (i *IdentityKey) Equal(other *IdentityKey) (bool, error) { func (i *IdentityKey) Equal(other *IdentityKey) (bool, error) {
return i.publicKey.Equal(other.publicKey) result, err := i.publicKey.Compare(other.publicKey)
return result == 0, err
} }
type IdentityKeyPair struct { type IdentityKeyPair struct {
@ -113,13 +98,14 @@ func GenerateIdentityKeyPair() (*IdentityKeyPair, error) {
} }
func DeserializeIdentityKeyPair(bytes []byte) (*IdentityKeyPair, error) { func DeserializeIdentityKeyPair(bytes []byte) (*IdentityKeyPair, error) {
var keys C.SignalPairOfMutPointerPublicKeyMutPointerPrivateKey var privateKey *C.SignalPrivateKey
signalFfiError := C.signal_identitykeypair_deserialize(&keys, BytesToBuffer(bytes)) var publicKey *C.SignalPublicKey
signalFfiError := C.signal_identitykeypair_deserialize(&privateKey, &publicKey, BytesToBuffer(bytes))
runtime.KeepAlive(bytes) runtime.KeepAlive(bytes)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
} }
return &IdentityKeyPair{publicKey: wrapPublicKey(keys.first.raw), privateKey: wrapPrivateKey(keys.second.raw)}, nil return &IdentityKeyPair{publicKey: wrapPublicKey(publicKey), privateKey: wrapPrivateKey(privateKey)}, nil
} }
func NewIdentityKeyPair(publicKey *PublicKey, privateKey *PrivateKey) (*IdentityKeyPair, error) { func NewIdentityKeyPair(publicKey *PublicKey, privateKey *PrivateKey) (*IdentityKeyPair, error) {
@ -128,11 +114,7 @@ func NewIdentityKeyPair(publicKey *PublicKey, privateKey *PrivateKey) (*Identity
func (i *IdentityKeyPair) Serialize() ([]byte, error) { func (i *IdentityKeyPair) Serialize() ([]byte, error) {
var serialized C.SignalOwnedBuffer = C.SignalOwnedBuffer{} var serialized C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_identitykeypair_serialize( signalFfiError := C.signal_identitykeypair_serialize(&serialized, i.publicKey.ptr, i.privateKey.ptr)
&serialized,
i.publicKey.constPtr(),
i.privateKey.constPtr(),
)
runtime.KeepAlive(i) runtime.KeepAlive(i)
if signalFfiError != nil { if signalFfiError != nil {
return nil, wrapError(signalFfiError) return nil, wrapError(signalFfiError)
@ -146,12 +128,7 @@ func (i *IdentityKeyPair) GetIdentityKey() *IdentityKey {
func (i *IdentityKeyPair) SignAlternateIdentity(other *IdentityKey) ([]byte, error) { func (i *IdentityKeyPair) SignAlternateIdentity(other *IdentityKey) ([]byte, error) {
var signature C.SignalOwnedBuffer = C.SignalOwnedBuffer{} var signature C.SignalOwnedBuffer = C.SignalOwnedBuffer{}
signalFfiError := C.signal_identitykeypair_sign_alternate_identity( signalFfiError := C.signal_identitykeypair_sign_alternate_identity(&signature, i.publicKey.ptr, i.privateKey.ptr, other.publicKey.ptr)
&signature,
i.publicKey.constPtr(),
i.privateKey.constPtr(),
other.publicKey.constPtr(),
)
runtime.KeepAlive(i) runtime.KeepAlive(i)
runtime.KeepAlive(other) runtime.KeepAlive(other)
if signalFfiError != nil { if signalFfiError != nil {

Some files were not shown because too many files have changed in this diff Show more