mirror of
https://github.com/mautrix/signal.git
synced 2026-05-15 05:36:53 -04:00
Compare commits
1 commit
main
...
tulir/uint
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3cb6c3791 |
254 changed files with 21813 additions and 49258 deletions
8
.envrc
Normal file
8
.envrc
Normal 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
2
.gitattributes
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*.pb.go linguist-generated=true
|
|
||||||
*.pb.raw binary linguist-generated=true
|
|
||||||
15
.github/ISSUE_TEMPLATE/bug.md
vendored
15
.github/ISSUE_TEMPLATE/bug.md
vendored
|
|
@ -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: ``
|
|
||||||
|
|
|
||||||
2
.github/ISSUE_TEMPLATE/enhancement.md
vendored
2
.github/ISSUE_TEMPLATE/enhancement.md
vendored
|
|
@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
34
.github/workflows/go.yml
vendored
34
.github/workflows/go.yml
vendored
|
|
@ -3,24 +3,18 @@ name: Go
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GOTOOLCHAIN: local
|
LIBSIGNAL_REF: "v0.36.1"
|
||||||
|
|
||||||
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 +27,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 +61,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 ./...
|
|
||||||
|
|
|
||||||
29
.github/workflows/stale.yml
vendored
29
.github/workflows/stale.yml
vendored
|
|
@ -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
3
.gitignore
vendored
|
|
@ -6,7 +6,6 @@
|
||||||
*.log*
|
*.log*
|
||||||
|
|
||||||
/mautrix-signal
|
/mautrix-signal
|
||||||
|
/mautrix-signalgo
|
||||||
/start
|
/start
|
||||||
/libsignal_ffi.a
|
/libsignal_ffi.a
|
||||||
|
|
||||||
.idea
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,15 @@ 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-use-stringer
|
- id: zerolog-use-stringer
|
||||||
|
|
|
||||||
280
CHANGELOG.md
280
CHANGELOG.md
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
28
Dockerfile
28
Dockerfile
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
32
Makefile
Normal 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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
43
ROADMAP.md
43
ROADMAP.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
7
build.sh
7
build.sh
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
554
commands.go
Normal file
554
commands.go
Normal file
|
|
@ -0,0 +1,554 @@
|
||||||
|
// 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"
|
||||||
|
"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(context.TODO())
|
||||||
|
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(context.TODO())
|
||||||
|
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.SignalDevice.IsDeviceLoggedIn() {
|
||||||
|
ce.Reply("You're not logged in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ce.User.SignalDevice.ClearKeysAndDisconnect(context.TODO())
|
||||||
|
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.SignalDevice.IsDeviceLoggedIn() {
|
||||||
|
ce.Reply("You were logged in at some point, but are not anymore")
|
||||||
|
} else if !ce.User.SignalDevice.Connection.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.SignalDevice.UpdateDeviceName(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.SignalDevice.ContactByE164(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(user, nil); 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.ZLog.WithContext(context.TODO())
|
||||||
|
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(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 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 = ce.User.sendQR(ce, resp.ProvisioningURL, qrEventID)
|
||||||
|
} else {
|
||||||
|
ce.Reply("Unexpected state: %v", resp.State)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next, get the results of finishing registration
|
||||||
|
resp = <-provChan
|
||||||
|
_, _ = ce.Bot.RedactEvent(ce.RoomID, qrEventID)
|
||||||
|
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(context.TODO())
|
||||||
|
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, prevEvent id.EventID) id.EventID {
|
||||||
|
url, ok := user.uploadQR(ce, code)
|
||||||
|
if !ok {
|
||||||
|
return prevEvent
|
||||||
|
}
|
||||||
|
content := event.MessageEventContent{
|
||||||
|
MsgType: event.MsgImage,
|
||||||
|
Body: code,
|
||||||
|
URL: url.CUString(),
|
||||||
|
}
|
||||||
|
if len(prevEvent) != 0 {
|
||||||
|
content.SetEdit(prevEvent)
|
||||||
|
}
|
||||||
|
resp, err := ce.Bot.SendMessageEvent(ce.RoomID, event.EventMessage, &content)
|
||||||
|
if err != nil {
|
||||||
|
ce.Log.Errorln("Failed to send QR code to user:", err)
|
||||||
|
} else if len(prevEvent) == 0 {
|
||||||
|
prevEvent = resp.EventID
|
||||||
|
}
|
||||||
|
return prevEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (user *User) uploadQR(ce *WrappedCommandEvent, code string) (id.ContentURI, bool) {
|
||||||
|
qrCode, err := qrcode.Encode(code, qrcode.Low, 256)
|
||||||
|
if err != nil {
|
||||||
|
ce.Log.Errorln("Failed to encode QR code:", err)
|
||||||
|
ce.Reply("Failed to encode QR code: %v", err)
|
||||||
|
return id.ContentURI{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
bot := user.bridge.AS.BotClient()
|
||||||
|
|
||||||
|
resp, err := bot.UploadBytes(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 id.ContentURI{}, false
|
||||||
|
}
|
||||||
|
return resp.ContentURI, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func canDeletePortal(portal *Portal, userID id.UserID) bool {
|
||||||
|
if len(portal.MXID) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := portal.MainIntent().JoinedMembers(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.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(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(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(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(portal.MXID)
|
||||||
|
_, _ = intent.ForgetRoom(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.")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for _, portal := range portalsToDelete {
|
||||||
|
portal.Cleanup(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(context.TODO())
|
||||||
|
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.ZLog, intent, portal.MXID, false)
|
||||||
|
err = portal.Delete(context.TODO())
|
||||||
|
if err != nil {
|
||||||
|
ce.ZLog.Err(err).Msg("Failed to delete lost portal from database after cleanup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ce.Reply("Finished cleaning up portals")
|
||||||
|
}
|
||||||
230
config/bridge.go
Normal file
230
config/bridge.go
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
// 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"`
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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) 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,23 +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 upgrades
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"maunium.net/go/mautrix/bridge/bridgeconfig"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
"go.mau.fi/util/dbutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
type Config struct {
|
||||||
Table.Register(-1, 20, 13, "Add missing columns for backup chat table", dbutil.TxnModeOn, func(ctx context.Context, db *dbutil.Database) (err error) {
|
*bridgeconfig.BaseConfig `yaml:",inline"`
|
||||||
var exists bool
|
|
||||||
if exists, err = db.ColumnExists(ctx, "signalmeow_backup_chat", "latest_message_id"); err == nil && !exists {
|
Metrics struct {
|
||||||
_, err = db.Exec(ctx, `
|
Enabled bool `yaml:"enabled"`
|
||||||
ALTER TABLE signalmeow_backup_chat ADD COLUMN latest_message_id BIGINT;
|
Listen string `yaml:"listen"`
|
||||||
ALTER TABLE signalmeow_backup_chat ADD COLUMN total_message_count INTEGER;
|
} `yaml:"metrics"`
|
||||||
`)
|
|
||||||
}
|
Signal struct {
|
||||||
return
|
DeviceName string `yaml:"device_name"`
|
||||||
})
|
} `yaml:"signal"`
|
||||||
|
|
||||||
|
Bridge BridgeConfig `yaml:"bridge"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
|
||||||
|
_, homeserver, _ := userID.Parse()
|
||||||
|
_, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver]
|
||||||
|
|
||||||
|
return hasSecret
|
||||||
}
|
}
|
||||||
161
config/upgrade.go
Normal file
161
config/upgrade.go
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
// 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.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
97
custompuppet.go
Normal 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(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
53
database/database.go
Normal 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)},
|
||||||
|
}
|
||||||
|
}
|
||||||
125
database/disappearingmessage.go
Normal file
125
database/disappearingmessage.go
Normal 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
58
database/lostportal.go
Normal 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
179
database/message.go
Normal 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)
|
||||||
|
}
|
||||||
190
database/portal.go
Normal file
190
database/portal.go
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
// 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_hash, avatar_url, name_set, avatar_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`
|
||||||
|
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_hash, avatar_url, name_set, avatar_set,
|
||||||
|
revision, encrypted, relay_user_id, expiration_time
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
`
|
||||||
|
updatePortalQuery = `
|
||||||
|
UPDATE portal SET
|
||||||
|
mxid=$3, name=$4, topic=$5, avatar_hash=$6, avatar_url=$7, name_set=$8,
|
||||||
|
avatar_set=$9, revision=$10, encrypted=$11, relay_user_id=$12,
|
||||||
|
expiration_time=$13
|
||||||
|
WHERE chat_id=$1 AND receiver=$2
|
||||||
|
`
|
||||||
|
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
|
||||||
|
AvatarHash string
|
||||||
|
AvatarURL id.ContentURI
|
||||||
|
NameSet bool
|
||||||
|
AvatarSet bool
|
||||||
|
Revision int
|
||||||
|
Encrypted bool
|
||||||
|
RelayUserID id.UserID
|
||||||
|
ExpirationTime int
|
||||||
|
}
|
||||||
|
|
||||||
|
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) 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().QueryContext(ctx, getChatsNotInSpaceQuery, receiver)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return dbutil.NewRowIter(rows, func(rows dbutil.Rows) (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.AvatarHash,
|
||||||
|
&p.AvatarURL,
|
||||||
|
&p.NameSet,
|
||||||
|
&p.AvatarSet,
|
||||||
|
&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.AvatarHash,
|
||||||
|
&p.AvatarURL,
|
||||||
|
p.NameSet,
|
||||||
|
p.AvatarSet,
|
||||||
|
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)
|
||||||
|
}
|
||||||
147
database/puppet.go
Normal file
147
database/puppet.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
// 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_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_hash=$5, avatar_url=$6,
|
||||||
|
name_set=$7, avatar_set=$8, contact_info_set=$9, is_registered=$10,
|
||||||
|
custom_mxid=$11, access_token=$12
|
||||||
|
WHERE uuid=$1
|
||||||
|
`
|
||||||
|
insertPuppetQuery = `
|
||||||
|
INSERT INTO puppet (
|
||||||
|
uuid, number, name, name_quality, 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
|
||||||
|
)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
type PuppetQuery struct {
|
||||||
|
*dbutil.QueryHelper[*Puppet]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Puppet struct {
|
||||||
|
qh *dbutil.QueryHelper[*Puppet]
|
||||||
|
|
||||||
|
SignalID uuid.UUID
|
||||||
|
Number string
|
||||||
|
Name string
|
||||||
|
NameQuality int
|
||||||
|
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.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.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
97
database/reaction.go
Normal 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)
|
||||||
|
}
|
||||||
112
database/upgrades/00-latest.sql
Normal file
112
database/upgrades/00-latest.sql
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
-- v0 -> v18 (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_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)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE puppet (
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
18
database/upgrades/13-upgrade-mx-state-store.sql
Normal file
18
database/upgrades/13-upgrade-mx-state-store.sql
Normal 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;
|
||||||
3
database/upgrades/14-remove-notice-room.sql
Normal file
3
database/upgrades/14-remove-notice-room.sql
Normal 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;
|
||||||
3
database/upgrades/15-remove-unused-puppet-columns.sql
Normal file
3
database/upgrades/15-remove-unused-puppet-columns.sql
Normal 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;
|
||||||
109
database/upgrades/16-refactor-postgres.sql
Normal file
109
database/upgrades/16-refactor-postgres.sql
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
-- 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 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;
|
||||||
190
database/upgrades/17-refactor-sqlite.sql
Normal file
190
database/upgrades/17-refactor-sqlite.sql
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
-- 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)
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
17
database/upgrades/18-spaces.sql
Normal file
17
database/upgrades/18-spaces.sql
Normal 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
|
||||||
|
);
|
||||||
|
|
@ -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) 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
|
||||||
|
|
@ -14,28 +14,26 @@
|
||||||
// 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 upgrades
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maunium.net/go/mautrix/bridgev2/database"
|
"embed"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"go.mau.fi/mautrix-signal/pkg/signalid"
|
"go.mau.fi/util/dbutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *SignalConnector) GetDBMetaTypes() database.MetaTypes {
|
var Table dbutil.UpgradeTable
|
||||||
return database.MetaTypes{
|
|
||||||
Portal: func() any {
|
//go:embed *.sql
|
||||||
return &signalid.PortalMetadata{}
|
var rawUpgrades embed.FS
|
||||||
},
|
|
||||||
Ghost: func() any {
|
func init() {
|
||||||
return &signalid.GhostMetadata{}
|
Table.Register(-1, 12, 0, "Unsupported version", false, func(tx dbutil.Execable, database *dbutil.Database) error {
|
||||||
},
|
return errors.New("please upgrade to mautrix-signal v0.4.3 before upgrading to a newer version")
|
||||||
Message: func() any {
|
})
|
||||||
return &signalid.MessageMetadata{}
|
Table.Register(1, 13, 0, "Jump to version 13", false, func(tx dbutil.Execable, database *dbutil.Database) error {
|
||||||
},
|
return nil
|
||||||
Reaction: nil,
|
})
|
||||||
UserLogin: func() any {
|
Table.RegisterFS(rawUpgrades)
|
||||||
return &signalid.UserLoginMetadata{}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
115
database/user.go
Normal file
115
database/user.go
Normal 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
116
database/userportal.go
Normal 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().QueryRowContext(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().QueryRowContext(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)
|
||||||
|
}
|
||||||
150
disappearing.go
Normal file
150
disappearing.go
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
// 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 = nextMsg.ExpireAt.Sub(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err = portal.MainIntent().RedactEvent(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{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
303
example-config.yaml
Normal file
303
example-config.yaml
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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
61
flake.lock
generated
Normal 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
33
flake.nix
Normal 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
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}
|
||||||
76
go.mod
76
go.mod
|
|
@ -1,52 +1,50 @@
|
||||||
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/mattn/go-pointer v0.0.1
|
github.com/lib/pq v1.10.9
|
||||||
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.20231229201527-e01ca03301e9
|
||||||
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.20240103125335-7c45a3d28be2
|
||||||
|
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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
141
go.sum
141
go.sum
|
|
@ -1,95 +1,96 @@
|
||||||
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-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
|
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
|
||||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
|
||||||
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.20231229201527-e01ca03301e9 h1:sYi2qn5XYnWyHjzBj04/ZeyqMMK31qPM1l2v7aWeiA0=
|
||||||
go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25/go.mod h1:jE9FfhbgEgAwxei6lomO9v8zdCIATcquONUu4vjRwSs=
|
go.mau.fi/util v0.2.2-0.20231229201527-e01ca03301e9/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.20240103125335-7c45a3d28be2 h1:Tgdv1P3hl6I4BDzHNl4kZ+2VrdpkBJ1F3WOyzFbyHlU=
|
||||||
|
maunium.net/go/mautrix v0.16.3-0.20240103125335-7c45a3d28be2/go.mod h1:gCgLw/4c1a8QsiOWTdUdXlt5cYdE0rJ9wLeZQKPD58Q=
|
||||||
|
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
|
||||||
|
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||||
|
|
|
||||||
364
main.go
Normal file
364
main.go
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
// 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: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 *signalmeow.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
|
||||||
|
puppetsByNumber map[string]*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 = signalmeow.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
|
||||||
|
}
|
||||||
|
// TODO only get if exists
|
||||||
|
user := br.GetUserByMXID(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()
|
||||||
|
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(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(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(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(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(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(roomID)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to join as bridge bot to enable e2be")
|
||||||
|
}
|
||||||
|
if !encryptionEnabled {
|
||||||
|
_, err = intent.SendStateEvent(roomID, event.StateEncryption, "", portal.getEncryptionEventContent())
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to enable e2be")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
br.AS.StateStore.SetMembership(roomID, inviter.MXID, event.MembershipJoin)
|
||||||
|
br.AS.StateStore.SetMembership(roomID, puppet.MXID, event.MembershipJoin)
|
||||||
|
br.AS.StateStore.SetMembership(roomID, br.Bot.UserID, event.MembershipJoin)
|
||||||
|
portal.Encrypted = true
|
||||||
|
}
|
||||||
|
//portal.Topic = PrivateChatTopic
|
||||||
|
_, _ = portal.MainIntent().SetRoomTopic(portal.MXID, portal.Topic)
|
||||||
|
if portal.shouldSetDMRoomMetadata() {
|
||||||
|
portal.Name = puppet.Name
|
||||||
|
portal.AvatarURL = puppet.AvatarURL
|
||||||
|
portal.AvatarHash = puppet.AvatarHash
|
||||||
|
portal.AvatarSet = puppet.AvatarSet
|
||||||
|
_, err = portal.MainIntent().SetRoomName(portal.MXID, portal.Name)
|
||||||
|
portal.NameSet = err == nil
|
||||||
|
_, err = portal.MainIntent().SetRoomAvatar(portal.MXID, portal.AvatarURL)
|
||||||
|
portal.AvatarSet = err == nil
|
||||||
|
}
|
||||||
|
err = portal.Update(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to update portal in database")
|
||||||
|
}
|
||||||
|
portal.UpdateBridgeInfo()
|
||||||
|
_, _ = intent.SendNotice(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),
|
||||||
|
puppetsByNumber: make(map[string]*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()
|
||||||
|
}
|
||||||
308
messagetracking.go
Normal file
308
messagetracking.go
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
// 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")
|
||||||
|
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(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(content)
|
||||||
|
if err != nil {
|
||||||
|
portal.log.Err(err).Msg("Failed to send bridging error message")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return resp.EventID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (portal *Portal) sendStatusEvent(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(portal.MXID, event.BeeperMessageStatus, &content)
|
||||||
|
if err != nil {
|
||||||
|
portal.log.Err(err).Msg("Failed to send message status event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (portal *Portal) sendDeliveryReceipt(eventID id.EventID) {
|
||||||
|
if portal.bridge.Config.Bridge.DeliveryReceipts {
|
||||||
|
err := portal.bridge.Bot.SendReceipt(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(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()
|
||||||
|
}
|
||||||
|
|
||||||
|
origEvtID := evt.ID
|
||||||
|
if retryMeta := evt.Content.AsMessage().MessageSendRetry; retryMeta != nil {
|
||||||
|
origEvtID = retryMeta.OriginalEventID
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logEvt := portal.log.Error()
|
||||||
|
if part == "Ignoring" {
|
||||||
|
logEvt = portal.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(evt, err, isCertain, ms.getNoticeID()))
|
||||||
|
}
|
||||||
|
portal.sendStatusEvent(origEvtID, evt.ID, err, nil)
|
||||||
|
} else {
|
||||||
|
portal.log.Debug().Msg("Sending metrics for successfully handled Matrix event")
|
||||||
|
portal.sendDeliveryReceipt(evt.ID)
|
||||||
|
portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, ms.getRetryNum())
|
||||||
|
var deliveredTo *[]id.UserID
|
||||||
|
if portal.IsPrivateChat() {
|
||||||
|
deliveredTo = &[]id.UserID{}
|
||||||
|
}
|
||||||
|
portal.sendStatusEvent(origEvtID, evt.ID, nil, deliveredTo)
|
||||||
|
if prevNotice := ms.popNoticeID(); prevNotice != "" {
|
||||||
|
_, _ = portal.MainIntent().RedactEvent(portal.MXID, prevNotice, mautrix.ReqRedact{
|
||||||
|
Reason: "error resolved",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ms != nil {
|
||||||
|
portal.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
|
||||||
|
}
|
||||||
|
|
||||||
|
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(evt, err, part, ms)
|
||||||
|
ms.retryNum++
|
||||||
|
ms.completed = completed
|
||||||
|
}
|
||||||
315
metrics.go
Normal file
315
metrics.go
Normal 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.Now().Sub(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.Now().Sub(start)
|
||||||
|
mh.signalMessageHandling.
|
||||||
|
With(prometheus.Labels{"message_type": messageType}).
|
||||||
|
Observe(duration.Seconds())
|
||||||
|
mh.signalMessageAge.Observe(time.Now().Sub(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.QueryRowContext(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.QueryRowContext(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.QueryRowContext(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.QueryRowContext(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.Now().Sub(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,67 +18,51 @@ 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"
|
"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),
|
||||||
|
Preview: mc.convertURLPreviewToSignal(ctx, evt),
|
||||||
}
|
}
|
||||||
if replyTo != nil {
|
if expirationTime := mc.GetData(ctx).ExpirationTime; expirationTime != 0 {
|
||||||
authorACI, messageID, err := signalid.ParseMessageID(replyTo.ID)
|
dm.ExpireTimer = proto.Uint32(uint32(expirationTime))
|
||||||
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 content.MsgType == event.MsgEmote && !relaybotFormatted {
|
if content.MsgType == event.MsgEmote && !relaybotFormatted {
|
||||||
content.Body = "/me " + content.Body
|
content.Body = "/me " + content.Body
|
||||||
|
|
@ -86,7 +70,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,34 +94,26 @@ 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))
|
||||||
|
var emoji *string
|
||||||
|
// TODO check for single grapheme cluster?
|
||||||
|
if len([]rune(content.Body)) == 1 {
|
||||||
|
emoji = proto.String(variationselector.Remove(content.Body))
|
||||||
|
}
|
||||||
|
dm.Sticker = &signalpb.DataMessage_Sticker{
|
||||||
|
// Signal iOS validates that pack id/key are of the correct length.
|
||||||
|
// Android is fine with any non-nil values (like a zero-length byte string).
|
||||||
|
PackId: make([]byte, 16),
|
||||||
|
PackKey: make([]byte, 32),
|
||||||
|
StickerId: proto.Uint32(0),
|
||||||
|
|
||||||
dm.Sticker = ParseStickerMeta(content.Info.BridgedSticker)
|
Data: att,
|
||||||
if dm.Sticker == nil {
|
Emoji: emoji,
|
||||||
var emoji *string
|
|
||||||
// TODO check for single grapheme cluster?
|
|
||||||
if len([]rune(content.Body)) == 1 {
|
|
||||||
emoji = proto.String(variationselector.Remove(content.Body))
|
|
||||||
}
|
|
||||||
dm.Sticker = &signalpb.DataMessage_Sticker{
|
|
||||||
// Signal iOS validates that pack id/key are of the correct length.
|
|
||||||
// Android is fine with any non-nil values (like a zero-length byte string).
|
|
||||||
PackId: make([]byte, 16),
|
|
||||||
PackKey: make([]byte, 32),
|
|
||||||
StickerId: proto.Uint32(0),
|
|
||||||
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 +127,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 +172,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 := signalmeow.UploadAttachment(ctx, mc.GetClient(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 +191,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
|
|
||||||
}
|
|
||||||
346
msgconv/from-signal.go
Normal file
346
msgconv/from-signal.go
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
// 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))
|
||||||
|
// 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, dm *signalpb.DataMessage) *ConvertedMessagePart {
|
||||||
|
part := &ConvertedMessagePart{
|
||||||
|
Type: event.EventMessage,
|
||||||
|
Content: &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgNotice,
|
||||||
|
Body: fmt.Sprintf("Disappearing messages set to %s", exfmt.Duration(time.Duration(dm.GetExpireTimer())*time.Second)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if dm.GetExpireTimer() == 0 {
|
||||||
|
part.Content.Body = "Disappearing messages disabled"
|
||||||
|
}
|
||||||
|
portal := mc.GetData(ctx)
|
||||||
|
portal.ExpirationTime = int(dm.GetExpireTimer())
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -285,6 +278,7 @@ func (parser *HTMLParser) listToString(node *html.Node, ctx Context) *EntityStri
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var prefix string
|
var prefix string
|
||||||
|
// TODO make bullets and numbering configurable
|
||||||
if ordered {
|
if ordered {
|
||||||
indexPadding := indentLength - Digits(counter)
|
indexPadding := indentLength - Digits(counter)
|
||||||
if indexPadding < 0 {
|
if indexPadding < 0 {
|
||||||
|
|
@ -362,7 +356,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
62
msgconv/msgconv.go
Normal 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.Device
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
143
msgconv/urlpreview.go
Normal file
143
msgconv/urlpreview.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// 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"
|
||||||
|
|
||||||
|
"go.mau.fi/mautrix-signal/pkg/signalmeow"
|
||||||
|
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 := signalmeow.UploadAttachment(ctx, mc.GetClient(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
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -30,13 +29,13 @@ func BorrowedMutableBuffer(length int) C.SignalBorrowedMutableBuffer {
|
||||||
data := make([]byte, length)
|
data := make([]byte, length)
|
||||||
return C.SignalBorrowedMutableBuffer{
|
return C.SignalBorrowedMutableBuffer{
|
||||||
base: (*C.uchar)(unsafe.Pointer(&data[0])),
|
base: (*C.uchar)(unsafe.Pointer(&data[0])),
|
||||||
length: C.size_t(len(data)),
|
length: C.uintptr_t(len(data)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func BytesToBuffer(data []byte) C.SignalBorrowedBuffer {
|
func BytesToBuffer(data []byte) C.SignalBorrowedBuffer {
|
||||||
buf := C.SignalBorrowedBuffer{
|
buf := C.SignalBorrowedBuffer{
|
||||||
length: C.size_t(len(data)),
|
length: C.uintptr_t(len(data)),
|
||||||
}
|
}
|
||||||
if len(data) > 0 {
|
if len(data) > 0 {
|
||||||
buf.base = (*C.uchar)(unsafe.Pointer(&data[0]))
|
buf.base = (*C.uchar)(unsafe.Pointer(&data[0]))
|
||||||
|
|
@ -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{}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
package libsignalgo
|
|
||||||
|
|
||||||
/*
|
|
||||||
#cgo LDFLAGS: -lsignal_ffi -ldl -lm -lz -lstdc++
|
|
||||||
*/
|
|
||||||
import "C"
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,91 +27,34 @@ 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
|
||||||
ErrorCodeInternalError ErrorCode = 3
|
ErrorCodeInternalError ErrorCode = 3
|
||||||
ErrorCodeNullParameter ErrorCode = 4
|
ErrorCodeNullParameter ErrorCode = 4
|
||||||
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
|
ErrorCodeUnrecognizedMessageVersion ErrorCode = 23
|
||||||
ErrorCodeUnrecognizedMessageVersion ErrorCode = 23
|
ErrorCodeInvalidMessage ErrorCode = 30
|
||||||
ErrorCodeInvalidMessage ErrorCode = 30
|
ErrorCodeSealedSenderSelfSend ErrorCode = 31
|
||||||
ErrorCodeSealedSenderSelfSend ErrorCode = 31
|
ErrorCodeInvalidKey ErrorCode = 40
|
||||||
ErrorCodeInvalidKey ErrorCode = 40
|
ErrorCodeInvalidSignature ErrorCode = 41
|
||||||
ErrorCodeInvalidSignature ErrorCode = 41
|
ErrorCodeInvalidAttestationData ErrorCode = 42
|
||||||
ErrorCodeInvalidAttestationData ErrorCode = 42
|
ErrorCodeFingerprintVersionMismatch ErrorCode = 51
|
||||||
ErrorCodeFingerprintVersionMismatch ErrorCode = 51
|
ErrorCodeFingerprintParsingError ErrorCode = 52
|
||||||
ErrorCodeFingerprintParsingError ErrorCode = 52
|
ErrorCodeUntrustedIdentity ErrorCode = 60
|
||||||
ErrorCodeUntrustedIdentity ErrorCode = 60
|
ErrorCodeInvalidKeyIdentifier ErrorCode = 70
|
||||||
ErrorCodeInvalidKeyIdentifier ErrorCode = 70
|
ErrorCodeSessionNotFound ErrorCode = 80
|
||||||
ErrorCodeSessionNotFound ErrorCode = 80
|
ErrorCodeInvalidRegistrationId ErrorCode = 81
|
||||||
ErrorCodeInvalidRegistrationId ErrorCode = 81
|
ErrorCodeInvalidSession ErrorCode = 82
|
||||||
ErrorCodeInvalidSession ErrorCode = 82
|
ErrorCodeInvalidSenderKeySession ErrorCode = 83
|
||||||
ErrorCodeInvalidSenderKeySession ErrorCode = 83
|
ErrorCodeDuplicatedMessage ErrorCode = 90
|
||||||
ErrorCodeInvalidProtocolAddress ErrorCode = 84
|
ErrorCodeCallbackError ErrorCode = 100
|
||||||
ErrorCodeDuplicatedMessage ErrorCode = 90
|
ErrorCodeVerificationFailure ErrorCode = 110
|
||||||
ErrorCodeCallbackError ErrorCode = 100
|
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
@ -33,51 +31,26 @@ import (
|
||||||
|
|
||||||
type Randomness [C.SignalRANDOMNESS_LEN]byte
|
type Randomness [C.SignalRANDOMNESS_LEN]byte
|
||||||
|
|
||||||
func GenerateRandomness() Randomness {
|
func GenerateRandomness() (Randomness, error) {
|
||||||
var randomness Randomness
|
var randomness Randomness
|
||||||
_, err := rand.Read(randomness[:])
|
_, err := rand.Read(randomness[:])
|
||||||
if err != nil {
|
return randomness, err
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
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
|
||||||
|
|
||||||
func GenerateGroupSecretParams() (GroupSecretParams, error) {
|
func GenerateGroupSecretParams() (GroupSecretParams, error) {
|
||||||
return GenerateGroupSecretParamsWithRandomness(GenerateRandomness())
|
randomness, err := GenerateRandomness()
|
||||||
}
|
if err != nil {
|
||||||
|
return GroupSecretParams{}, err
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
return GenerateGroupSecretParamsWithRandomness(randomness)
|
||||||
|
|
||||||
func (gmk GroupMasterKey) SecretParams() (GroupSecretParams, error) {
|
|
||||||
return DeriveGroupSecretParamsFromMasterKey(gmk)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateGroupSecretParamsWithRandomness(randomness Randomness) (GroupSecretParams, error) {
|
func GenerateGroupSecretParamsWithRandomness(randomness Randomness) (GroupSecretParams, error) {
|
||||||
|
|
@ -144,70 +117,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 +158,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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,14 +17,17 @@
|
||||||
package libsignalgo
|
package libsignalgo
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
#cgo LDFLAGS: -lsignal_ffi -ldl -lm
|
||||||
#include "./libsignal-ffi.h"
|
#include "./libsignal-ffi.h"
|
||||||
|
|
||||||
extern int signal_get_identity_key_pair_callback(void *store_ctx, SignalPairOfMutPointerPrivateKeyMutPointerPublicKey *keyp);
|
typedef const SignalProtocolAddress const_address;
|
||||||
extern int signal_get_local_registration_id_callback(void *store_ctx, uint32_t *idp);
|
typedef const SignalPublicKey const_public_key;
|
||||||
extern int signal_save_identity_key_callback(void *store_ctx, uint8_t *out, SignalMutPointerProtocolAddress address, SignalMutPointerPublicKey public_key);
|
|
||||||
extern int signal_get_identity_key_callback(void *store_ctx, SignalMutPointerPublicKey *public_keyp, SignalMutPointerProtocolAddress address);
|
extern int signal_get_identity_key_pair_callback(uintptr_t store_ctx, SignalPrivateKey **keyp);
|
||||||
extern int signal_is_trusted_identity_callback(void *store_ctx, bool *out, SignalMutPointerProtocolAddress address, SignalMutPointerPublicKey public_key, uint32_t direction);
|
extern int signal_get_local_registration_id_callback(uintptr_t store_ctx, uint32_t *idp);
|
||||||
extern void signal_destroy_identity_key_store_callback(void *store_ctx);
|
extern int signal_save_identity_key_callback(uintptr_t store_ctx, const_address *address, const_public_key *public_key);
|
||||||
|
extern int signal_get_identity_key_callback(uintptr_t store_ctx, SignalPublicKey **public_keyp, const_address *address);
|
||||||
|
extern int signal_is_trusted_identity_callback(uintptr_t store_ctx, const_address *address, const_public_key *public_key, unsigned int direction);
|
||||||
*/
|
*/
|
||||||
import "C"
|
import "C"
|
||||||
import (
|
import (
|
||||||
|
|
@ -43,41 +45,34 @@ const (
|
||||||
type IdentityKeyStore interface {
|
type IdentityKeyStore interface {
|
||||||
GetIdentityKeyPair(ctx context.Context) (*IdentityKeyPair, error)
|
GetIdentityKeyPair(ctx context.Context) (*IdentityKeyPair, error)
|
||||||
GetLocalRegistrationID(ctx context.Context) (uint32, error)
|
GetLocalRegistrationID(ctx context.Context) (uint32, error)
|
||||||
SaveIdentityKey(ctx context.Context, theirServiceID ServiceID, identityKey *IdentityKey) (bool, error)
|
SaveIdentityKey(ctx context.Context, address *Address, identityKey *IdentityKey) (bool, error)
|
||||||
GetIdentityKey(ctx context.Context, theirServiceID ServiceID) (*IdentityKey, error)
|
GetIdentityKey(ctx context.Context, address *Address) (*IdentityKey, error)
|
||||||
IsTrustedIdentity(ctx context.Context, theirServiceID ServiceID, identityKey *IdentityKey, direction SignalDirection) (bool, error)
|
IsTrustedIdentity(ctx context.Context, address *Address, identityKey *IdentityKey, direction SignalDirection) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
//export signal_get_identity_key_pair_callback
|
//export signal_get_identity_key_pair_callback
|
||||||
func signal_get_identity_key_pair_callback(storeCtx unsafe.Pointer, keyp *C.SignalPairOfMutPointerPrivateKeyMutPointerPublicKey) C.int {
|
func signal_get_identity_key_pair_callback(storeCtx uintptr, keyp **C.SignalPrivateKey) C.int {
|
||||||
return wrapStoreCallback(storeCtx, func(store IdentityKeyStore, ctx context.Context) error {
|
return wrapStoreCallback(storeCtx, func(store IdentityKeyStore, ctx context.Context) error {
|
||||||
key, err := store.GetIdentityKeyPair(ctx)
|
key, err := store.GetIdentityKeyPair(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if key == nil {
|
if key == nil {
|
||||||
keyp.first.raw = nil
|
*keyp = nil
|
||||||
keyp.second.raw = nil
|
} else {
|
||||||
return nil
|
clone, err := key.privateKey.Clone()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clone.CancelFinalizer()
|
||||||
|
*keyp = clone.ptr
|
||||||
}
|
}
|
||||||
privClone, err := key.privateKey.Clone()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
pubClone, err := key.publicKey.Clone()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
privClone.CancelFinalizer()
|
|
||||||
pubClone.CancelFinalizer()
|
|
||||||
keyp.first.raw = privClone.ptr
|
|
||||||
keyp.second.raw = pubClone.ptr
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
//export signal_get_local_registration_id_callback
|
//export signal_get_local_registration_id_callback
|
||||||
func signal_get_local_registration_id_callback(storeCtx unsafe.Pointer, idp *C.uint32_t) C.int {
|
func signal_get_local_registration_id_callback(storeCtx uintptr, idp *C.uint32_t) C.int {
|
||||||
return wrapStoreCallback(storeCtx, func(store IdentityKeyStore, ctx context.Context) error {
|
return wrapStoreCallback(storeCtx, func(store IdentityKeyStore, ctx context.Context) error {
|
||||||
registrationID, err := store.GetLocalRegistrationID(ctx)
|
registrationID, err := store.GetLocalRegistrationID(ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -88,82 +83,63 @@ func signal_get_local_registration_id_callback(storeCtx unsafe.Pointer, idp *C.u
|
||||||
}
|
}
|
||||||
|
|
||||||
//export signal_save_identity_key_callback
|
//export signal_save_identity_key_callback
|
||||||
func signal_save_identity_key_callback(storeCtx unsafe.Pointer, out *C.uint8_t, address C.SignalMutPointerProtocolAddress, publicKey C.SignalMutPointerPublicKey) C.int {
|
func signal_save_identity_key_callback(storeCtx uintptr, address *C.const_address, publicKey *C.const_public_key) C.int {
|
||||||
return wrapStoreCallback(storeCtx, func(store IdentityKeyStore, ctx context.Context) error {
|
return wrapStoreCallbackCustomReturn(storeCtx, func(store IdentityKeyStore, ctx context.Context) (int, error) {
|
||||||
publicKeyStruct := PublicKey{ptr: publicKey.raw}
|
publicKeyStruct := PublicKey{ptr: (*C.SignalPublicKey)(unsafe.Pointer(publicKey))}
|
||||||
cloned, err := publicKeyStruct.Clone()
|
cloned, err := publicKeyStruct.Clone()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return -1, err
|
||||||
}
|
|
||||||
addr := &Address{ptr: address.raw}
|
|
||||||
theirServiceID, err := addr.NameServiceID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
replaced, err := store.SaveIdentityKey(
|
replaced, err := store.SaveIdentityKey(
|
||||||
ctx,
|
ctx,
|
||||||
theirServiceID,
|
&Address{ptr: (*C.SignalProtocolAddress)(unsafe.Pointer(address))},
|
||||||
&IdentityKey{cloned},
|
&IdentityKey{cloned},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return -1, err
|
||||||
}
|
}
|
||||||
if replaced {
|
if replaced {
|
||||||
*out = 1
|
return 1, nil
|
||||||
} else {
|
} else {
|
||||||
*out = 0
|
return 0, nil
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
//export signal_get_identity_key_callback
|
//export signal_get_identity_key_callback
|
||||||
func signal_get_identity_key_callback(storeCtx unsafe.Pointer, public_keyp *C.SignalMutPointerPublicKey, address C.SignalMutPointerProtocolAddress) C.int {
|
func signal_get_identity_key_callback(storeCtx uintptr, public_keyp **C.SignalPublicKey, address *C.const_address) C.int {
|
||||||
return wrapStoreCallback(storeCtx, func(store IdentityKeyStore, ctx context.Context) error {
|
return wrapStoreCallback(storeCtx, func(store IdentityKeyStore, ctx context.Context) error {
|
||||||
addr := &Address{ptr: address.raw}
|
key, err := store.GetIdentityKey(ctx, &Address{ptr: (*C.SignalProtocolAddress)(unsafe.Pointer(address))})
|
||||||
theirServiceID, err := addr.NameServiceID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
key, err := store.GetIdentityKey(ctx, theirServiceID)
|
|
||||||
if err == nil && key != nil {
|
if err == nil && key != nil {
|
||||||
key.publicKey.CancelFinalizer()
|
key.publicKey.CancelFinalizer()
|
||||||
public_keyp.raw = key.publicKey.ptr
|
*public_keyp = key.publicKey.ptr
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
//export signal_is_trusted_identity_callback
|
//export signal_is_trusted_identity_callback
|
||||||
func signal_is_trusted_identity_callback(storeCtx unsafe.Pointer, out *C.bool, address C.SignalMutPointerProtocolAddress, public_key C.SignalMutPointerPublicKey, direction C.uint32_t) C.int {
|
func signal_is_trusted_identity_callback(storeCtx uintptr, address *C.const_address, public_key *C.const_public_key, direction C.uint) C.int {
|
||||||
return wrapStoreCallback(storeCtx, func(store IdentityKeyStore, ctx context.Context) error {
|
return wrapStoreCallbackCustomReturn(storeCtx, func(store IdentityKeyStore, ctx context.Context) (int, error) {
|
||||||
addr := &Address{ptr: address.raw}
|
trusted, err := store.IsTrustedIdentity(ctx, &Address{ptr: (*C.SignalProtocolAddress)(unsafe.Pointer(address))}, &IdentityKey{&PublicKey{ptr: (*C.SignalPublicKey)(unsafe.Pointer(public_key))}}, SignalDirection(direction))
|
||||||
theirServiceID, err := addr.NameServiceID()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return -1, err
|
||||||
}
|
}
|
||||||
trusted, err := store.IsTrustedIdentity(ctx, theirServiceID, &IdentityKey{&PublicKey{ptr: public_key.raw}}, SignalDirection(direction))
|
if trusted {
|
||||||
if err != nil {
|
return 1, nil
|
||||||
return err
|
} else {
|
||||||
|
return 0, nil
|
||||||
}
|
}
|
||||||
*out = C.bool(trusted)
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
//export signal_destroy_identity_key_store_callback
|
func (ctx *CallbackContext) wrapIdentityKeyStore(store IdentityKeyStore) *C.SignalIdentityKeyStore {
|
||||||
func signal_destroy_identity_key_store_callback(storeCtx unsafe.Pointer) {
|
return &C.SignalIdentityKeyStore{
|
||||||
// No-op: Go's garbage collector handles cleanup
|
ctx: wrapStore(ctx, store),
|
||||||
}
|
get_identity_key_pair: C.SignalGetIdentityKeyPair(C.signal_get_identity_key_pair_callback),
|
||||||
|
get_local_registration_id: C.SignalGetLocalRegistrationId(C.signal_get_local_registration_id_callback),
|
||||||
func (ctx *CallbackContext) wrapIdentityKeyStore(store IdentityKeyStore) C.SignalConstPointerFfiIdentityKeyStoreStruct {
|
save_identity: C.SignalSaveIdentityKey(C.signal_save_identity_key_callback),
|
||||||
return C.SignalConstPointerFfiIdentityKeyStoreStruct{&C.SignalIdentityKeyStore{
|
get_identity: C.SignalGetIdentityKey(C.signal_get_identity_key_callback),
|
||||||
ctx: wrapStore(ctx, store),
|
is_trusted_identity: C.SignalIsTrustedIdentity(C.signal_is_trusted_identity_callback),
|
||||||
get_local_identity_key_pair: C.SignalFfiIdentityKeyStoreGetLocalIdentityKeyPair(C.signal_get_identity_key_pair_callback),
|
}
|
||||||
get_local_registration_id: C.SignalFfiIdentityKeyStoreGetLocalRegistrationId(C.signal_get_local_registration_id_callback),
|
|
||||||
get_identity_key: C.SignalFfiIdentityKeyStoreGetIdentityKey(C.signal_get_identity_key_callback),
|
|
||||||
save_identity_key: C.SignalFfiIdentityKeyStoreSaveIdentityKey(C.signal_save_identity_key_callback),
|
|
||||||
is_trusted_identity: C.SignalFfiIdentityKeyStoreIsTrustedIdentity(C.signal_is_trusted_identity_callback),
|
|
||||||
destroy: C.SignalFfiIdentityKeyStoreDestroy(C.signal_destroy_identity_key_store_callback),
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue