Watch
1
0
Fork
You've already forked mautrix-signal
0
mirror of https://github.com/mautrix/signal.git synced 2026-11-13 01:29:17 -05:00

Compare commits

..
Author SHA1 Message Date
indent[bot]
01626c1428
connector: warn when Signal reports the primary device as idle (#715)
Co-authored-by: Indent <noreply@indent.com>
2026-11-12 07:34:57 -07:00
indent[bot]
b91e565505
groupinfo: keep poll vote level when groups become announcement-only (#730)
Co-authored-by: Indent <noreply@indent.com>
2026-11-12 06:54:17 -07:00
indent[bot]
8761e85a5b
groupinfo: keep reaction and redaction levels when groups become announcement-only (#728)
Co-authored-by: Indent <noreply@indent.com>
2026-11-12 06:05:53 -07:00
Gerardo Rodriguez
a96f214609
groupinfo,signalmeow: make ended groups read-only (#725) 2026-11-11 02:36:47 +00:00
Gerardo Rodriguez
561064e8f5
handlematrix: send admin deletes for other members' messages in groups (#722) 2026-11-11 02:36:43 +00:00
Gerardo Rodriguez
03c8d29ae7
signalmeow/receiving: fetch PNI identity from the primary device when verifying PNI signatures (#720) 2026-11-11 02:36:40 +00:00
Gerardo Rodriguez
59a4ff2afc
msgconv/from-signal: send notice when a message is pinned/unpinned (#724) 2026-11-10 23:18:34 +00:00
Gerardo Rodriguez
27e3fc2582
signalmeow/storageservice: fix unnecessarily marking recipients as changed (#712) 2026-11-10 23:18:23 +00:00
Gerardo Rodriguez
15674cc755
signalmeow/groupcache: keep send endorsements for group changes without a response (#714) 2026-11-10 23:18:13 +00:00
Gerardo Rodriguez
fa622888a9
client: don't connect logins that are already logged out (#713) 2026-11-10 23:18:04 +00:00
Gerardo Rodriguez
d59791e7d3
signalmeow: send typing messages as online-only (#716) 2026-11-10 23:18:01 +00:00
Gerardo Rodriguez
cd8bc557d6
signalmeow/storageservice: ignore record types the bridge doesn't use (#717) 2026-11-10 23:17:56 +00:00
Gerardo Rodriguez
5c740406db
signalmeow: handle server delivery receipts as delivery receipts (#718) 2026-11-10 23:17:50 +00:00
Gerardo Rodriguez
c6abc27db6
signalmeow/storageservice: only fetch new records after the first sync (#719) 2026-11-10 23:17:39 +00:00
Gerardo Rodriguez
0a4f1d81c8
signalmeow/web: don't return an error when closing an already closed websocket (#721) 2026-11-10 23:17:31 +00:00
Gerardo Rodriguez
74b4c70ee4
handlesignal: send a notice in DMs when a safety number changes (#723) 2026-11-10 23:16:58 +00:00
Gerardo Rodriguez
8cb696dcfc
signalmeow/groups: decode announcements-only flag in full group state (#727) 2026-11-10 23:04:39 +00:00
indent[bot]
d451962ba2
msgconv: download stickers from their pack when the attachment is gone (#708)
Co-authored-by: Indent <noreply@indent.com>
2026-11-09 17:42:33 +00:00
indent[bot]
c827c97adb
chatinfo: fall back to profile avatar when contact avatar isn't loaded (#710)
Co-authored-by: Indent <noreply@indent.com>
2026-11-09 17:41:17 +00:00
indent[bot]
c820321548
signalmeow/websocket: log alerts sent by the server when connecting (#709)
Co-authored-by: Indent <noreply@indent.com>
2026-11-09 17:34:19 +00:00
indent[bot]
34b4187e07
chatinfo: stop listing contacts after request cancellation (#705)
Co-authored-by: Indent <noreply@indent.com>
2026-11-05 15:33:19 +00:00
Nick Mills-Barrett
a630835d70
pre-commit: install bridgelint with the CI Go toolchain (#706) 2026-11-05 13:09:30 +00:00
indent[bot]
7501c40cba
handlesignal: resync group info once for portals synced before reaction overrides (#702)
Co-authored-by: Indent <noreply@indent.com>
2026-11-04 19:17:03 -05:00
Gerardo Rodriguez
e2e994f99f
signalmeow/attachments: send uploadLength when requesting upload form (#703) 2026-11-05 00:28:39 +02:00
Nick Mills-Barrett
b0346e6698
pre-commit: add bridgelint hook (#701) 2026-11-03 21:36:55 +00:00
Tulir Asokan
020738bc24 changelog: update 2026-11-02 21:59:48 +02:00
Tulir Asokan
403c050d3b libsignal: update to v0.106.1 2026-11-02 21:59:48 +02:00
indent[bot]
4b5f4f74ca
groupinfo: allow reactions in announcement-only groups (#692)
Co-authored-by: Indent <noreply@indent.com>
2026-11-02 09:28:08 -05:00
indent[bot]
a82a7f38a4
signalmeow/web: use a longer response header timeout for attachment uploads (#695)
Co-authored-by: Indent <noreply@indent.com>
2026-11-01 13:16:23 -05:00
indent[bot]
d3e8d560ba
chatsync: stop waiting for transfer archive after it times out (#698)
Co-authored-by: Indent <noreply@indent.com>
2026-11-01 13:12:33 -05:00
indent[bot]
9dbbb6e4ea
signalmeow/keys: upload at most 100 prekeys of each type (#699)
Co-authored-by: Indent <noreply@indent.com>
2026-11-01 13:11:32 -05:00
indent[bot]
ac200339c5
signalmeow/receiving: use ciphertext message type for retry requests (#697)
Co-authored-by: Indent <noreply@indent.com>
2026-11-01 13:06:19 -05:00
Gerardo Rodriguez
880ea0dceb
signalmeow/receiving: ignore hangups for calls accepted on another device (#700) 2026-11-01 07:47:23 -05:00
Tulir Asokan
1cc623ed01 signalmeow/web: only log error responses by default
Closes #696
2026-10-30 14:17:10 +02:00
Highest
572a190db2
msgconv: clarify outgoing view-once placeholders (#679) 2026-10-29 11:40:25 -04:00
Nick Mills-Barrett
e2319d6cde
signalmeow: handle closed websocket status channels (#691) 2026-10-29 13:27:39 +00:00
Nick Mills-Barrett
357eb58a44
directmedia: map upstream download failures to matrix errors (#694)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:22:11 +00:00
Nick Mills-Barrett
c2b2792f0b
client,signalmeow/web: log out on upgrade 401 and retry other upgrade errors (#693)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:59:59 +00:00
Gerardo Rodriguez
6dc9ac3e0c
signalmeow/web: make request counter atomic (#687) 2026-10-27 08:28:43 -04:00
Gerardo Rodriguez
6c5841d1b7
backfill: mark read from the newest item and pass ReadUpTo (#689) 2026-10-26 12:22:45 -04:00
Gerardo Rodriguez
45d6a02f12
matrixfmt: don't add trailing separator when joining entity strings (#688) 2026-10-26 09:54:39 -04:00
Tulir Asokan
9f44495d18 msgconv/from-signal: add support for encrypted direct media 2026-10-26 14:18:13 +02:00
Gerardo Rodriguez
ccb1792120
signalmeow/senderkey: retry multi-recipient sends on server errors (#684) 2026-10-26 06:23:57 -04:00
Gerardo Rodriguez
7e65e8ecc9
signalmeow/web: don't block read loop exit on full request queue (#685) 2026-10-24 06:38:11 -04:00
Nick Mills-Barrett
2ef3d65686
connector: map errors to message statuses, resperrors and bridge states (#683) 2026-10-22 22:10:10 +02:00
Nick Mills-Barrett
887a0d1c33
capabilities: allow m4a and wav audio files (#678) 2026-10-21 23:40:25 +02:00
Toni Spets
46aff24f80
libsignalgo: free records and addresses owned by store callbacks (#682) 2026-10-19 17:51:23 +03:00
Tulir Asokan
82299a169e libsignalgo: remove gcc const pointer hack by using typedefs from libsignal headers 2026-10-17 13:53:01 +03:00
Gerardo Rodriguez
0915c775cf
msgconv/from-matrix: send bodies over 2 KiB as long text attachments (#680) 2026-10-16 18:42:29 -04:00
Tulir Asokan
d129f0ecc6 Bump version to v26.10 2026-10-16 18:03:24 +03:00
Tulir Asokan
d737b89d3d libsignalgo: add custom build tag to opt into clang mode 2026-10-16 12:18:10 +03:00
Gerardo Rodriguez
cbd299ee64
connector: don't generate duplicate transaction IDs (#677) 2026-10-14 06:59:36 -04:00
Gerardo Rodriguez
20d192a7ac
handlematrix: leave groups when deleting them (#675) 2026-10-13 09:23:52 -04:00
Gerardo Rodriguez
bb1cb86343
handlesignal: don't count storage service updates as contact syncs (#676) 2026-10-13 08:29:32 -04:00
Tulir Asokan
2901b2a60a signalmeow/provisioning: clean up PerformProvisioning flow 2026-10-13 14:35:00 +03:00
Tulir Asokan
4772cdf8b2 msgconv/from-signal: use old view-once media behavior if view limited is disabled 2026-10-12 16:34:49 +03:00
Tulir Asokan
d1f74685c8 signalmeow/senderkey: reset shared with list on device removals 2026-10-12 15:11:10 +03:00
Tulir Asokan
4d3b54d3f9 signalmeow/store: dispatch events for identity changes 2026-10-12 14:53:08 +03:00
Tulir Asokan
11b854b7d5 handlesignal: don't fail group info changes entirely 2026-10-12 14:02:54 +03:00
Tulir Asokan
2ed2d00694 signalmeow/receiving: add various length checks and other fixes 2026-10-12 14:02:54 +03:00
Tulir Asokan
462e5c615a signalmeow/receiving: validate sender certificates properly 2026-10-12 13:39:29 +03:00
Tulir Asokan
772b384061 libsignalgo: fix time precision in some calls
Fixes #674
2026-10-10 13:15:27 +03:00
Tulir Asokan
e73dca83e7 signalmeow/provisioning: handle binary aci/pni fields 2026-10-09 16:25:40 +03:00
Tulir Asokan
fc893ce0a3 startchat: ensure phone number lookup doesn't use stale cached response 2026-10-08 18:09:40 +03:00
Tulir Asokan
2dfa90bf19 signalmeow/username: allow uppercase letters 2026-10-08 18:08:01 +03:00
Tulir Asokan
2bde52712c signalmeow/provisioning: copy auth credential salt on login 2026-10-08 18:07:39 +03:00
Tulir Asokan
97a5f2d0af userinfo: store resolved usernames 2026-10-08 17:44:04 +03:00
Tulir Asokan
e831a77e42 signalmeow/web: fix grpc target 2026-10-08 17:34:41 +03:00
Tulir Asokan
18f645a05d startchat,signalmeow,libsignalgo: add support for looking up usernames 2026-10-08 17:33:31 +03:00
Tulir Asokan
d9ace4c552 signalmeow: add support for phonenumberless accounts 2026-10-08 16:54:29 +03:00
Tulir Asokan
e3b168e1d1 libsignal: update to v0.105.0 2026-10-08 15:19:45 +03:00
Tulir Asokan
241d56187d signalmeow: update protobufs 2026-10-08 15:18:56 +03:00
Highest
6b4c22586c
handle*,signalmeow: implement view-once media with new interface (#672) 2026-10-06 15:24:08 -04:00
Nick Mills-Barrett
e04d4c941e
signalmeow: reconcile self recipient during provisioning (#673)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2026-10-06 09:39:50 +00:00
Tulir Asokan
e131b72209 handle*,signalmeow: add support for blocking DMs 2026-10-05 17:52:08 +03:00
Tulir Asokan
feb07b93a9 signalmeow: use context for attachment download requests 2026-10-01 18:14:00 +03:00
Tulir Asokan
c2099f55d5 signalmeow: update protobufs 2026-10-01 18:14:00 +03:00
Tulir Asokan
b82e0ca560 libsignal: update to v0.103.1 2026-10-01 18:14:00 +03:00
Tulir Asokan
43eae9f91d legacyprovision: remove last bits 2026-10-01 18:14:00 +03:00
Nick Mills-Barrett
da84491279
signalmeow/storageservice: serialize storage syncs and coalesce fetch latest notices (#670) 2026-09-25 20:54:08 +01:00
Tulir Asokan
32b9e0ca68 signalmeow/retry: fix handling retry receipts with no sender key info 2026-09-20 17:12:13 +03:00
Toni Spets
7667ed1d4b
signalmeow: fix stop deadlock in key check loop (#669)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2026-09-18 11:56:29 +03:00
Tulir Asokan
8771d18686 msgconv/imagepack: optimize creating unique shortcodes 2026-09-16 15:34:00 +03:00
Tulir Asokan
317b1687ec changelog: fix typo 2026-09-16 15:15:15 +03:00
119 changed files with 8162 additions and 4054 deletions

View file

@ -30,6 +30,7 @@ jobs:
run: | run: |
go install golang.org/x/tools/cmd/goimports@latest go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest go install honnef.co/go/tools/cmd/staticcheck@latest
go install github.com/beeper/pre-commit-go/bridgelint@latest
export PATH="$HOME/go/bin:$PATH" export PATH="$HOME/go/bin:$PATH"
- name: Run pre-commit - name: Run pre-commit

View file

@ -22,7 +22,10 @@ repos:
# 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.5.0
hooks: hooks:
- id: zerolog-ban-msgf - id: zerolog-ban-msgf
- id: zerolog-use-stringer - id: zerolog-use-stringer
- id: bridgelint
language: system
verbose: true

View file

@ -1,7 +1,38 @@
# v26.11 (unreleased)
* Updated libsignal to v0.106.1.
* Added support for sending more than 2048 characters of text to Signal by
uploading the rest of the text as an attachment, like official clients do.
* Added automatic retrying of group message sends if the server returns a 500
or 503 error.
* Added support for encrypting direct media with FLOE ([MSC4571]).
* Fixed memory leak in libsignal store implementation.
[MSC4571]: https://github.com/matrix-org/matrix-spec-proposals/pull/4571
# v26.10
* Updated libsignal to v0.105.0.
* Added support for phonenumberless accounts.
* Added support for starting chats with usernames and username links.
* Added support for blocking and unblocking users on Signal.
* Added support for new view-limited media interface in bridgev2.
* Added proper sender certificate validation.
* Changed storage sync to only sync once if repeated fetch latest notices are
received.
* Fixed contact syncs not happening even when `sync_contacts_on_startup` was
set to true.
* Fixed Signal encryption using wrong timestamp precision for session expiry.
* Fixed sender key sharing not handling removed users properly.
* Fixed handling retry receipts with no sender key info.
* Fixed deadlock in prekey upload loop if the client gets logged out.
* Fixed infinite loop when importing sticker packs with duplicate emoji
shortcuts.
# v26.09 # v26.09
* Bumped minimum Go version to 1.26. * Bumped minimum Go version to 1.26.
* Updated libsignal to v1.102.2. * Updated libsignal to v0.102.2.
* Fixed handling messages with invalid values in formatting body ranges. * Fixed handling messages with invalid values in formatting body ranges.
* Fixed websocket request retrying not working correctly if the first attempt * Fixed websocket request retrying not working correctly if the first attempt
times out. times out.

View file

@ -1,147 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is istributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/id"
)
func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request, create bool) {
login := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if login == nil {
return
}
api := login.Client.(bridgev2.IdentifierResolvingNetworkAPI)
phonenum := r.PathValue("phonenum")
resp, err := api.ResolveIdentifier(r.Context(), phonenum, create)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to resolve identifier")
JSONResponse(w, http.StatusInternalServerError, &Error{
Error: fmt.Sprintf("Failed to resolve identifier: %v", err),
ErrCode: "M_UNKNOWN",
})
return
} else if resp == nil {
JSONResponse(w, http.StatusNotFound, &Error{
ErrCode: mautrix.MNotFound.ErrCode,
Error: "User not found on Signal",
})
return
}
status := http.StatusOK
apiResp := &ResolveIdentifierResponse{
ChatID: ResolveIdentifierResponseChatID{
UUID: string(resp.UserID),
Number: phonenum,
},
}
if resp.Ghost != nil {
if resp.UserInfo != nil {
resp.Ghost.UpdateInfo(r.Context(), resp.UserInfo)
}
apiResp.OtherUser = &ResolveIdentifierResponseOtherUser{
MXID: resp.Ghost.Intent.GetMXID(),
DisplayName: resp.Ghost.Name,
AvatarURL: resp.Ghost.AvatarMXC.ParseOrIgnore(),
}
}
if resp.Chat != nil {
if resp.Chat.Portal == nil {
resp.Chat.Portal, err = m.Bridge.GetPortalByKey(r.Context(), resp.Chat.PortalKey)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to get portal")
JSONResponse(w, http.StatusInternalServerError, &mautrix.RespError{
Err: "Failed to get portal",
ErrCode: "M_UNKNOWN",
})
return
}
}
if create && resp.Chat.Portal.MXID == "" {
apiResp.JustCreated = true
status = http.StatusCreated
err = resp.Chat.Portal.CreateMatrixRoom(r.Context(), login, resp.Chat.PortalInfo)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to create portal room")
JSONResponse(w, http.StatusInternalServerError, &mautrix.RespError{
Err: "Failed to create portal room",
ErrCode: "M_UNKNOWN",
})
return
}
}
apiResp.RoomID = resp.Chat.Portal.MXID
}
JSONResponse(w, status, &Response{
Success: true,
Status: "ok",
ResolveIdentifierResponse: apiResp,
})
}
func legacyProvResolveIdentifier(w http.ResponseWriter, r *http.Request) {
legacyResolveIdentifierOrStartChat(w, r, false)
}
func legacyProvPM(w http.ResponseWriter, r *http.Request) {
legacyResolveIdentifierOrStartChat(w, r, true)
}
func JSONResponse(w http.ResponseWriter, status int, response any) {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(response)
}
type Error struct {
Success bool `json:"success"`
Error string `json:"error"`
ErrCode string `json:"errcode"`
}
type Response struct {
Success bool `json:"success"`
Status string `json:"status"`
// For response in ResolveIdentifier
*ResolveIdentifierResponse
}
type ResolveIdentifierResponse struct {
RoomID id.RoomID `json:"room_id"`
ChatID ResolveIdentifierResponseChatID `json:"chat_id"`
JustCreated bool `json:"just_created"`
OtherUser *ResolveIdentifierResponseOtherUser `json:"other_user,omitempty"`
}
type ResolveIdentifierResponseChatID struct {
UUID string `json:"uuid"`
Number string `json:"number"`
}
type ResolveIdentifierResponseOtherUser struct {
MXID id.UserID `json:"mxid"`
DisplayName string `json:"displayname"`
AvatarURL id.ContentURI `json:"avatar_url"`
}

View file

@ -37,7 +37,7 @@ var m = mxmain.BridgeMain{
Name: "mautrix-signal", Name: "mautrix-signal",
URL: "https://github.com/mautrix/signal", URL: "https://github.com/mautrix/signal",
Description: "A Matrix-Signal puppeting bridge.", Description: "A Matrix-Signal puppeting bridge.",
Version: "26.09", Version: "26.10",
SemCalVer: true, SemCalVer: true,
Connector: &connector.SignalConnector{}, Connector: &connector.SignalConnector{},
@ -45,12 +45,6 @@ var m = mxmain.BridgeMain{
func main() { func main() {
web.UserAgent = fmt.Sprintf("mautrix-signal/%s %s", m.Version, web.BaseUserAgent) 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.InitVersion(Tag, Commit, BuildTime)
m.Run() m.Run()
} }

28
go.mod
View file

@ -2,7 +2,7 @@ module go.mau.fi/mautrix-signal
go 1.26.0 go 1.26.0
toolchain go1.27.1 toolchain go1.27.2
tool go.mau.fi/util/cmd/maubuild tool go.mau.fi/util/cmd/maubuild
@ -13,16 +13,16 @@ require (
github.com/mattn/go-pointer v0.0.1 github.com/mattn/go-pointer v0.0.1
github.com/rs/zerolog v1.35.1 github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.12.0 github.com/stretchr/testify v1.12.0
github.com/tidwall/gjson v1.19.0 github.com/tidwall/gjson v1.20.0
go.mau.fi/util v0.10.1 go.mau.fi/util v0.11.0
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.58.0
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e
golang.org/x/net v0.59.0 golang.org/x/net v0.61.0
golang.org/x/sync v0.23.0 golang.org/x/sync v0.24.0
google.golang.org/grpc v1.83.2 google.golang.org/grpc v1.84.0
google.golang.org/protobuf v1.36.12 google.golang.org/protobuf v1.36.12
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.31.0 maunium.net/go/mautrix v0.32.1-0.20261026145030-920bc073ba8d
) )
require ( require (
@ -33,7 +33,7 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.52 // indirect github.com/mattn/go-sqlite3 v1.14.52 // indirect
github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
@ -42,10 +42,10 @@ require (
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.8.6 // indirect github.com/yuin/goldmark v1.8.6 // indirect
go.mau.fi/zeroconfig v0.2.0 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect
golang.org/x/mod v0.41.0 // indirect golang.org/x/mod v0.42.0 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.43.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // 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
maunium.net/go/mauflag v1.0.0 // indirect maunium.net/go/mauflag v1.0.0 // indirect

70
go.sum
View file

@ -2,8 +2,6 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
@ -11,10 +9,6 @@ github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7
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/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg= github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg=
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@ -38,8 +32,8 @@ github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E= github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b h1:OzNsuVdSWGwXvWKTtChx9ve89k4cFJ6NxYM4Aw4/f+E=
github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
@ -53,8 +47,8 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDq
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
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.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.20.0 h1:+agJ3rEzKcCXKDKo0ml26UROcpcAlnZyjq+TjbY5Fto=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/gjson v1.20.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
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/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
@ -65,43 +59,31 @@ 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.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mau.fi/util v0.10.1 h1:1oSqb4TwzLA0cUDY0aomyBPFKkZ3J5fqCmrXV3VH3GQ= go.mau.fi/util v0.11.0 h1:JNEpkVRxfTdJUC0DIGKiRlLKzl4G/qLX/eVzoP7QuwY=
go.mau.fi/util v0.10.1/go.mod h1:40TDo7/ekSeOjgr8KAmX31Yf4zrOF94j83WQB+u5ZPc= go.mau.fi/util v0.11.0/go.mod h1:04AEFVsc4zNdIkDC1gkdqbg/nZFolHY50pyXh2DSy7s=
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
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.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.12/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=
@ -113,5 +95,5 @@ 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.31.0 h1:x6XNBSa0kOaBgcleI+ZBLkGvBdHIE0qdeY6TZ4wWUeI= maunium.net/go/mautrix v0.32.1-0.20261026145030-920bc073ba8d h1:DsMn0i7cBKcQZ0AVxJm9hfWAu6xJdjRrf457EtXxyNY=
maunium.net/go/mautrix v0.31.0/go.mod h1:vfPYtoGAjlTnIF+W8tl+rjX3yB4PEfqYR2sDXvQo4Co= maunium.net/go/mautrix v0.32.1-0.20261026145030-920bc073ba8d/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU=

View file

@ -77,8 +77,8 @@ func (s *SignalClient) FetchMessages(ctx context.Context, params bridgev2.FetchM
} }
// GetBackupChatItems returns in reverse chronological order, so flip the list // GetBackupChatItems returns in reverse chronological order, so flip the list
slices.Reverse(items) slices.Reverse(items)
var firstDirectionfulProcessed bool
var isRead bool var isRead bool
var readUpTo time.Time
convertedMessages := make([]*bridgev2.BackfillMessage, 0, len(items)) convertedMessages := make([]*bridgev2.BackfillMessage, 0, len(items))
attMap := make(msgconv.AttachmentMap) attMap := make(msgconv.AttachmentMap)
recipientMap := make(map[uint64]*backuppb.Recipient) recipientMap := make(map[uint64]*backuppb.Recipient)
@ -123,20 +123,17 @@ func (s *SignalClient) FetchMessages(ctx context.Context, params bridgev2.FetchM
case *backuppb.ChatItem_Incoming: case *backuppb.ChatItem_Incoming:
streamOrder = int64(dt.Incoming.GetDateServerSent()) streamOrder = int64(dt.Incoming.GetDateServerSent())
prevStreamOrder = streamOrder prevStreamOrder = streamOrder
if !firstDirectionfulProcessed { isRead = dt.Incoming.Read
firstDirectionfulProcessed = true if isRead {
isRead = dt.Incoming.Read readUpTo = time.UnixMilli(int64(item.DateSent))
} }
case *backuppb.ChatItem_Outgoing: case *backuppb.ChatItem_Outgoing:
streamOrder = int64(item.GetDateSent()) streamOrder = int64(item.GetDateSent())
// Ensure stream order is higher than previous incoming item, but lower than next incoming item // Ensure stream order is higher than previous incoming item, but lower than next incoming item
streamOrder = min(streamOrder, findNextStreamOrder(i+1)-1) streamOrder = min(streamOrder, findNextStreamOrder(i+1)-1)
streamOrder = max(streamOrder, prevStreamOrder+1) streamOrder = max(streamOrder, prevStreamOrder+1)
isRead = true
if !firstDirectionfulProcessed { readUpTo = time.UnixMilli(int64(item.DateSent))
firstDirectionfulProcessed = true
isRead = true
}
} }
if len(attMap) > 0 { if len(attMap) > 0 {
clear(attMap) clear(attMap)
@ -178,11 +175,15 @@ func (s *SignalClient) FetchMessages(ctx context.Context, params bridgev2.FetchM
Reactions: convertedReactions, Reactions: convertedReactions,
}) })
} }
if isRead {
readUpTo = time.Time{}
}
return &bridgev2.FetchMessagesResponse{ return &bridgev2.FetchMessagesResponse{
Messages: convertedMessages, Messages: convertedMessages,
HasMore: len(items) >= params.Count, HasMore: len(items) >= params.Count,
Forward: params.Forward, Forward: params.Forward,
MarkRead: isRead, MarkRead: isRead,
ReadUpTo: readUpTo,
ApproxTotalCount: chat.TotalMessages, ApproxTotalCount: chat.TotalMessages,
CompleteCallback: func() { CompleteCallback: func() {
// When reaching the last backwards backfill batch, delete the chat from the backup store. // When reaching the last backwards backfill batch, delete the chat from the backup store.

View file

@ -23,11 +23,13 @@ import (
"go.mau.fi/util/ffmpeg" "go.mau.fi/util/ffmpeg"
"go.mau.fi/util/jsontime" "go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalid"
) )
func supportedIfFFmpeg() event.CapabilitySupportLevel { func supportedIfFFmpeg() event.CapabilitySupportLevel {
@ -38,7 +40,7 @@ func supportedIfFFmpeg() event.CapabilitySupportLevel {
} }
func capID() string { func capID() string {
base := "fi.mau.signal.capabilities.2026_07_22" base := "fi.mau.signal.capabilities.2026_10_21"
if ffmpeg.Supported() { if ffmpeg.Supported() {
return base + "+ffmpeg" return base + "+ffmpeg"
} }
@ -46,7 +48,7 @@ func capID() string {
} }
const MaxFileSize = 100 * 1024 * 1024 const MaxFileSize = 100 * 1024 * 1024
const MaxTextLength = 2000 const MaxTextLength = 64 * 1024
var signalCaps = &event.RoomFeatures{ var signalCaps = &event.RoomFeatures{
ID: capID(), ID: capID(),
@ -81,6 +83,7 @@ var signalCaps = &event.RoomFeatures{
}, },
MaxWidth: 4096, MaxWidth: 4096,
MaxHeight: 4096, MaxHeight: 4096,
ViewLimitedTypes: []*event.BeeperViewLimitedMedia{{Type: "count", Count: 1}},
MaxSize: MaxFileSize, MaxSize: MaxFileSize,
Caption: event.CapLevelFullySupported, Caption: event.CapLevelFullySupported,
MaxCaptionLength: MaxTextLength, MaxCaptionLength: MaxTextLength,
@ -91,6 +94,7 @@ var signalCaps = &event.RoomFeatures{
"video/ogg": event.CapLevelFullySupported, "video/ogg": event.CapLevelFullySupported,
"video/webm": event.CapLevelFullySupported, "video/webm": event.CapLevelFullySupported,
}, },
ViewLimitedTypes: []*event.BeeperViewLimitedMedia{{Type: "count", Count: 1}},
MaxSize: MaxFileSize, MaxSize: MaxFileSize,
Caption: event.CapLevelFullySupported, Caption: event.CapLevelFullySupported,
MaxCaptionLength: MaxTextLength, MaxCaptionLength: MaxTextLength,
@ -101,6 +105,8 @@ var signalCaps = &event.RoomFeatures{
"audio/mpeg": event.CapLevelFullySupported, "audio/mpeg": event.CapLevelFullySupported,
"audio/mp3": event.CapLevelFullySupported, "audio/mp3": event.CapLevelFullySupported,
"audio/flac": event.CapLevelFullySupported, "audio/flac": event.CapLevelFullySupported,
"audio/mp4": event.CapLevelFullySupported,
"audio/wav": event.CapLevelFullySupported,
}, },
MaxSize: MaxFileSize, MaxSize: MaxFileSize,
}, },
@ -154,7 +160,7 @@ var signalCaps = &event.RoomFeatures{
event.MemberActionBan: event.CapLevelFullySupported, event.MemberActionBan: event.CapLevelFullySupported,
event.MemberActionKick: event.CapLevelFullySupported, event.MemberActionKick: event.CapLevelFullySupported,
}, },
MaxTextLength: MaxTextLength, // TODO support arbitrary sized text messages with files MaxTextLength: MaxTextLength,
LocationMessage: event.CapLevelPartialSupport, LocationMessage: event.CapLevelPartialSupport,
Poll: event.CapLevelFullySupported, Poll: event.CapLevelFullySupported,
PollEnd: event.CapLevelUnsupported, PollEnd: event.CapLevelUnsupported,
@ -192,6 +198,7 @@ var signalDisappearingCap = &event.DisappearingTimerCapability{
var signalCapsNoteToSelf *event.RoomFeatures var signalCapsNoteToSelf *event.RoomFeatures
var signalCapsDM *event.RoomFeatures var signalCapsDM *event.RoomFeatures
var signalCapsPNIDM *event.RoomFeatures
func init() { func init() {
signalCapsDM = ptr.Clone(signalCaps) signalCapsDM = ptr.Clone(signalCaps)
@ -200,7 +207,16 @@ func init() {
signalCapsDM.State = event.StateFeatureMap{ signalCapsDM.State = event.StateFeatureMap{
event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported}, event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported},
} }
signalCapsPNIDM = ptr.Clone(signalCapsDM)
signalCapsPNIDM.ID += "+pni"
signalCapsDM.BlockUser = true
signalCapsDM.ReportSpam = true
signalCapsNoteToSelf = ptr.Clone(signalCapsDM) signalCapsNoteToSelf = ptr.Clone(signalCapsDM)
signalCapsNoteToSelf.BlockUser = false
signalCapsNoteToSelf.ReportSpam = false
signalCapsNoteToSelf.EditMaxAge = nil signalCapsNoteToSelf.EditMaxAge = nil
signalCapsNoteToSelf.DeleteMaxAge = nil signalCapsNoteToSelf.DeleteMaxAge = nil
signalCapsNoteToSelf.ID = capID() + "+note_to_self" signalCapsNoteToSelf.ID = capID() + "+note_to_self"
@ -210,6 +226,10 @@ func (s *SignalClient) GetCapabilities(ctx context.Context, portal *bridgev2.Por
if portal.Receiver == s.UserLogin.ID && portal.ID == networkid.PortalID(s.UserLogin.ID) { if portal.Receiver == s.UserLogin.ID && portal.ID == networkid.PortalID(s.UserLogin.ID) {
return signalCapsNoteToSelf return signalCapsNoteToSelf
} else if portal.RoomType == database.RoomTypeDM { } else if portal.RoomType == database.RoomTypeDM {
userID, _, _ := signalid.ParsePortalID(portal.ID)
if userID.Type == libsignalgo.ServiceIDTypePNI {
return signalCapsPNIDM
}
return signalCapsDM return signalCapsDM
} }
return signalCaps return signalCaps
@ -224,7 +244,7 @@ var signalGeneralCaps = &bridgev2.NetworkGeneralCapabilities{
ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{ ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{
CreateDM: true, CreateDM: true,
LookupPhone: true, LookupPhone: true,
LookupUsername: false, // TODO implement LookupUsername: true,
ContactList: true, ContactList: true,
}, },
GroupCreation: map[string]bridgev2.GroupTypeCapabilities{ GroupCreation: map[string]bridgev2.GroupTypeCapabilities{
@ -245,5 +265,5 @@ func (s *SignalConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities
} }
func (s *SignalConnector) GetBridgeInfoVersion() (info, capabilities int) { func (s *SignalConnector) GetBridgeInfoVersion() (info, capabilities int) {
return 1, 11 return 1, 15
} }

View file

@ -75,7 +75,7 @@ func (s *SignalClient) GetUserInfoWithRefreshAfter(ctx context.Context, ghost *b
if userID.Type != libsignalgo.ServiceIDTypePNI && (!s.Main.Config.UseOutdatedProfiles && meta.ProfileFetchedAt.After(contact.Profile.FetchedAt)) { if userID.Type != libsignalgo.ServiceIDTypePNI && (!s.Main.Config.UseOutdatedProfiles && meta.ProfileFetchedAt.After(contact.Profile.FetchedAt)) {
return nil, nil return nil, nil
} }
return s.contactToUserInfo(ctx, contact) return s.contactToUserInfo(ctx, contact, meta.Username, false)
} }
func (s *SignalClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { func (s *SignalClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
@ -99,7 +99,7 @@ func (s *SignalClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal)
} }
} }
func (s *SignalClient) contactToUserInfo(ctx context.Context, contact *types.Recipient) (*bridgev2.UserInfo, error) { func (s *SignalClient) contactToUserInfo(ctx context.Context, contact *types.Recipient, username string, saveUsername bool) (*bridgev2.UserInfo, error) {
isBot := false isBot := false
ui := &bridgev2.UserInfo{ ui := &bridgev2.UserInfo{
IsBot: &isBot, IsBot: &isBot,
@ -110,17 +110,25 @@ func (s *SignalClient) contactToUserInfo(ctx context.Context, contact *types.Rec
changed = meta.ProfileFetchedAt.IsZero() && !contact.Profile.FetchedAt.IsZero() changed = meta.ProfileFetchedAt.IsZero() && !contact.Profile.FetchedAt.IsZero()
meta.ProfileFetchedAt.Time = contact.Profile.FetchedAt meta.ProfileFetchedAt.Time = contact.Profile.FetchedAt
} }
return false if saveUsername && username != "" && meta.Username != username {
meta.Username = username
changed = true
}
return
}, },
} }
if contact.E164 != "" { if contact.E164 != "" {
ui.Identifiers = append(ui.Identifiers, "tel:"+contact.E164) ui.Identifiers = append(ui.Identifiers, "tel:"+contact.E164)
} }
name := s.Main.Config.FormatDisplayname(contact) if username != "" {
ui.Identifiers = append(ui.Identifiers, "signal:"+username)
}
name := s.Main.Config.FormatDisplayname(contact, username)
ui.Name = &name ui.Name = &name
if s.Main.Config.UseContactAvatars && contact.ContactAvatar.Hash != "" { contactAvatarID := networkid.AvatarID("hash:" + contact.ContactAvatar.Hash)
if s.Main.Config.UseContactAvatars && contact.ContactAvatar.Hash != "" && s.canUseContactAvatar(ctx, contact, contactAvatarID) {
ui.Avatar = &bridgev2.Avatar{ ui.Avatar = &bridgev2.Avatar{
ID: networkid.AvatarID("hash:" + contact.ContactAvatar.Hash), ID: contactAvatarID,
Get: func(ctx context.Context) ([]byte, error) { Get: func(ctx context.Context) ([]byte, error) {
if contact.ContactAvatar.Image == nil { if contact.ContactAvatar.Image == nil {
return nil, fmt.Errorf("contact avatar not available") return nil, fmt.Errorf("contact avatar not available")
@ -165,6 +173,20 @@ func (s *SignalClient) contactToUserInfo(ctx context.Context, contact *types.Rec
return ui, nil return ui, nil
} }
func (s *SignalClient) canUseContactAvatar(ctx context.Context, contact *types.Recipient, avatarID networkid.AvatarID) bool {
if contact.ContactAvatar.Image != nil {
return true
} else if contact.ACI == uuid.Nil {
return false
}
ghost, err := s.Main.Bridge.GetExistingGhostByID(ctx, signalid.MakeUserID(contact.ACI))
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost to check contact avatar")
return true
}
return ghost != nil && ghost.AvatarID == avatarID && ghost.AvatarMXC != "" && ghost.AvatarSet
}
func (s *SignalConnector) ValidateUserID(id networkid.UserID) bool { func (s *SignalConnector) ValidateUserID(id networkid.UserID) bool {
_, err := signalid.ParseUserIDAsServiceID(id) _, err := signalid.ParseUserIDAsServiceID(id)
return err == nil return err == nil
@ -195,45 +217,93 @@ func (s *SignalClient) CreateChatWithGhost(ctx context.Context, ghost *bridgev2.
return resp.Chat, nil return resp.Chat, nil
} }
func (s *SignalClient) resolvePhone(ctx context.Context, number string) (aci, pni uuid.UUID, e164Number uint64, recipient *types.Recipient, err error) {
number, err = bridgev2.CleanPhoneNumber(number)
if err != nil {
err = bridgev2.WrapRespErr(err, mautrix.MInvalidParam)
return
}
e164Number, err = strconv.ParseUint(strings.TrimPrefix(number, "+"), 10, 64)
if err != nil {
err = bridgev2.WrapRespErr(fmt.Errorf("error parsing phone number: %w", err), mautrix.MInvalidParam)
return
}
e164String := fmt.Sprintf("+%d", e164Number)
if recipient, err = s.Client.ContactByE164(ctx, e164String); err != nil {
err = 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, lookupErr := s.Client.LookupPhone(ctx, e164Number); lookupErr != nil {
err = wrapRespError(fmt.Errorf("error looking up number on server: %w", lookupErr))
} else {
aci = resp[e164Number].ACI
pni = resp[e164Number].PNI
if aci == uuid.Nil && pni == uuid.Nil {
recipient = nil
return
}
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)
}
}
return
}
func (s *SignalClient) resolveUsername(ctx context.Context, username string) (aci, pni uuid.UUID, err error) {
serviceID, err := s.Client.ResolveUsername(ctx, username)
if err != nil {
err = wrapRespError(fmt.Errorf("error looking up username on server: %w", err))
return
}
aci, pni = serviceID.ToACIAndPNI()
return
}
const signalUserLinkPrefix = "https://signal.me/#eu/"
func (s *SignalClient) resolveLink(ctx context.Context, linkHandle string) (aci, pni uuid.UUID, username string, err error) {
username, err = s.Client.ResolveUsernameLink(ctx, linkHandle)
if err != nil {
err = bridgev2.WrapRespErr(err, mautrix.MInvalidParam)
return
} else if username == "" {
return
}
aci, pni, err = s.resolveUsername(ctx, username)
return
}
func (s *SignalClient) ResolveIdentifier(ctx context.Context, number string, _ bool) (*bridgev2.ResolveIdentifierResponse, error) { func (s *SignalClient) ResolveIdentifier(ctx context.Context, number string, _ bool) (*bridgev2.ResolveIdentifierResponse, error) {
var aci, pni uuid.UUID var aci, pni uuid.UUID
var e164Number uint64 var e164Number uint64
var recipient *types.Recipient var recipient *types.Recipient
var username string
serviceID, err := signalid.ParseUserIDAsServiceID(networkid.UserID(number)) serviceID, err := signalid.ParseUserIDAsServiceID(networkid.UserID(number))
if err != nil { 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() aci, pni = serviceID.ToACIAndPNI()
} else if strings.HasPrefix(number, "+") {
aci, pni, e164Number, recipient, err = s.resolvePhone(ctx, number)
} else if signalmeow.SignalUsernameRegex.MatchString(number) {
username = strings.TrimPrefix(number, "@")
aci, pni, err = s.resolveUsername(ctx, number)
} else if handle, ok := strings.CutPrefix(number, signalUserLinkPrefix); ok {
aci, pni, username, err = s.resolveLink(ctx, handle)
} else {
err = bridgev2.RespError(mautrix.MInvalidParam.WithMessage("Input must be an international phone number, username, link or user ID"))
}
if err != nil {
return nil, err
}
if recipient == nil {
if aci == uuid.Nil && pni == uuid.Nil {
return nil, nil
}
recipient, err = s.Client.Store.RecipientStore.LoadAndUpdateRecipient(ctx, aci, pni, nil) recipient, err = s.Client.Store.RecipientStore.LoadAndUpdateRecipient(ctx, aci, pni, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("error loading recipient: %w", err) return nil, fmt.Errorf("error loading recipient: %w", err)
@ -245,7 +315,7 @@ func (s *SignalClient) ResolveIdentifier(ctx context.Context, number string, _ b
Stringer("pni", pni). Stringer("pni", pni).
Msg("Found resolve identifier target user") Msg("Found resolve identifier target user")
userInfo, err := s.contactToUserInfo(ctx, recipient) userInfo, err := s.contactToUserInfo(ctx, recipient, username, true)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to convert contact: %w", err) return nil, fmt.Errorf("failed to convert contact: %w", err)
} }
@ -334,7 +404,7 @@ func (s *SignalClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCr
} }
group.AvatarPath, err = s.Client.UploadGroupAvatar(ctx, avatarBytes, group.GroupIdentifier, group.GroupMasterKey) group.AvatarPath, err = s.Client.UploadGroupAvatar(ctx, avatarBytes, group.GroupIdentifier, group.GroupMasterKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to upload avatar: %w", err) return nil, wrapRespError(fmt.Errorf("failed to upload avatar: %w", err))
} }
} }
portal, err := s.Main.Bridge.GetPortalByKey(ctx, s.makePortalKey(string(group.GroupIdentifier))) portal, err := s.Main.Bridge.GetPortalByKey(ctx, s.makePortalKey(string(group.GroupIdentifier)))
@ -364,7 +434,7 @@ func (s *SignalClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCr
} }
resp, err := s.Client.CreateGroup(ctx, group) resp, err := s.Client.CreateGroup(ctx, group)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create group: %w", err) return nil, wrapRespError(fmt.Errorf("failed to create group: %w", err))
} }
if params.RoomID != "" { if params.RoomID != "" {
// UpdateMatrixRoomID could do this for us if we passed ChatInfoSource to it, // UpdateMatrixRoomID could do this for us if we passed ChatInfoSource to it,
@ -390,23 +460,32 @@ func (s *SignalClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveI
} }
resp := make([]*bridgev2.ResolveIdentifierResponse, len(recipients)) resp := make([]*bridgev2.ResolveIdentifierResponse, len(recipients))
for i, recipient := range recipients { for i, recipient := range recipients {
userInfo, err := s.contactToUserInfo(ctx, recipient) if err = ctx.Err(); err != nil {
if err != nil { return nil, err
return nil, fmt.Errorf("failed to convert contact: %w", err)
}
recipientResp := &bridgev2.ResolveIdentifierResponse{
UserInfo: userInfo,
Chat: s.makeCreateDMResponse(ctx, recipient, nil),
} }
recipientResp := &bridgev2.ResolveIdentifierResponse{}
var username string
if recipient.ACI != uuid.Nil { if recipient.ACI != uuid.Nil {
recipientResp.Chat = &bridgev2.CreateChatResponse{
PortalKey: s.makeDMPortalKey(libsignalgo.NewACIServiceID(recipient.ACI)),
}
recipientResp.UserID = signalid.MakeUserID(recipient.ACI) recipientResp.UserID = signalid.MakeUserID(recipient.ACI)
ghost, err := s.Main.Bridge.GetGhostByID(ctx, recipientResp.UserID) ghost, err := s.Main.Bridge.GetGhostByID(ctx, recipientResp.UserID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ghost for %s: %w", recipient.ACI, err) return nil, fmt.Errorf("failed to get ghost for %s: %w", recipient.ACI, err)
} }
recipientResp.Ghost = ghost recipientResp.Ghost = ghost
username = ghost.Metadata.(*signalid.GhostMetadata).Username
} else { } else {
recipientResp.UserID = signalid.MakeUserIDFromServiceID(libsignalgo.NewPNIServiceID(recipient.PNI)) pniServiceID := libsignalgo.NewPNIServiceID(recipient.PNI)
recipientResp.Chat = &bridgev2.CreateChatResponse{
PortalKey: s.makeDMPortalKey(pniServiceID),
}
recipientResp.UserID = signalid.MakeUserIDFromServiceID(pniServiceID)
}
recipientResp.UserInfo, err = s.contactToUserInfo(ctx, recipient, username, false)
if err != nil {
return nil, fmt.Errorf("failed to convert contact: %w", err)
} }
resp[i] = recipientResp resp[i] = recipientResp
} }
@ -441,7 +520,7 @@ func (s *SignalClient) makeCreateDMResponse(ctx context.Context, recipient *type
var serviceID libsignalgo.ServiceID var serviceID libsignalgo.ServiceID
var avatar *bridgev2.Avatar var avatar *bridgev2.Avatar
if recipient.ACI == uuid.Nil { if recipient.ACI == uuid.Nil {
namePtr = ptr.Ptr(s.Main.Config.FormatDisplayname(recipient)) namePtr = ptr.Ptr(s.Main.Config.FormatDisplayname(recipient, ""))
serviceID = libsignalgo.NewPNIServiceID(recipient.PNI) serviceID = libsignalgo.NewPNIServiceID(recipient.PNI)
} else { } else {
if backupChat == nil { if backupChat == nil {
@ -480,6 +559,7 @@ func (s *SignalClient) makeCreateDMResponse(ctx context.Context, recipient *type
Members: members, Members: members,
Type: ptr.Ptr(database.RoomTypeDM), Type: ptr.Ptr(database.RoomTypeDM),
UserBlocked: ptr.Ptr(recipient.Blocked),
MessageRequest: ptr.Ptr(recipient.ACI != uuid.Nil && recipient.ProbablyMessageRequest()), MessageRequest: ptr.Ptr(recipient.ACI != uuid.Nil && recipient.ProbablyMessageRequest()),
CanBackfill: backupChat != nil, CanBackfill: backupChat != nil,
ExtraUpdates: updatePortalSyncMeta, ExtraUpdates: updatePortalSyncMeta,

View file

@ -19,6 +19,7 @@ package connector
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"errors"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@ -28,6 +29,7 @@ import (
"go.mau.fi/mautrix-signal/pkg/libsignalgo" "go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalid" "go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf/backuppb" "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf/backuppb"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types" "go.mau.fi/mautrix-signal/pkg/signalmeow/types"
) )
@ -48,7 +50,15 @@ func (s *SignalClient) syncChats(ctx context.Context, cancel context.CancelFunc)
if s.Client.Store.EphemeralBackupKey != nil { if s.Client.Store.EphemeralBackupKey != nil {
zerolog.Ctx(ctx).Info().Msg("Fetching transfer archive before syncing chats") zerolog.Ctx(ctx).Info().Msg("Fetching transfer archive before syncing chats")
meta, err := s.Client.WaitForTransfer(ctx) meta, err := s.Client.WaitForTransfer(ctx)
if err != nil { if errors.Is(err, signalmeow.ErrTransferTimedOut) {
zerolog.Ctx(ctx).Warn().Msg("Transfer archive wasn't uploaded in time, giving up")
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 timed out")
}
return
} else if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to request transfer archive") zerolog.Ctx(ctx).Err(err).Msg("Failed to request transfer archive")
return return
} else if meta.Error != "" { } else if meta.Error != "" {

View file

@ -40,8 +40,9 @@ type SignalClient struct {
Client *signalmeow.Client Client *signalmeow.Client
Ghost *bridgev2.Ghost Ghost *bridgev2.Ghost
queueEmptyWaiter *exsync.Event recentMessageRooms *exsync.RingBuffer[networkid.MessageID, networkid.PortalKey]
cancelChatSync atomic.Pointer[context.CancelFunc] queueEmptyWaiter *exsync.Event
cancelChatSync atomic.Pointer[context.CancelFunc]
} }
var ( var (
@ -181,9 +182,9 @@ func (s *SignalClient) bridgeStateLoop(statusChan <-chan signalmeow.SignalConnec
peekedConnectionStatus.Event == signalmeow.SignalConnectionEventNone { peekedConnectionStatus.Event == signalmeow.SignalConnectionEventNone {
s.UserLogin.Log.Debug().Msg("Sending TransientDisconnect BridgeState") s.UserLogin.Log.Debug().Msg("Sending TransientDisconnect BridgeState")
if err == nil { if err == nil {
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect}) s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "signal-disconnected"})
} else { } else {
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "unknown-websocket-error", Message: err.Error()}) s.UserLogin.BridgeState.Send(connectionErrorState(err))
} }
} }
} }
@ -191,11 +192,7 @@ func (s *SignalClient) bridgeStateLoop(statusChan <-chan signalmeow.SignalConnec
case signalmeow.SignalConnectionEventLoggedOut: case signalmeow.SignalConnectionEventLoggedOut:
s.stopChatSync() s.stopChatSync()
s.UserLogin.Log.Debug().Msg("Sending BadCredentials BridgeState") s.UserLogin.Log.Debug().Msg("Sending BadCredentials BridgeState")
if err == nil { s.sendLoggedOutState(err)
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()) err = s.Client.ClearKeysAndDisconnect(context.TODO())
if err != nil { if err != nil {
s.UserLogin.Log.Error().Err(err).Msg("Failed to clear keys and disconnect") s.UserLogin.Log.Error().Err(err).Msg("Failed to clear keys and disconnect")
@ -203,18 +200,18 @@ func (s *SignalClient) bridgeStateLoop(statusChan <-chan signalmeow.SignalConnec
case signalmeow.SignalConnectionEventError: case signalmeow.SignalConnectionEventError:
s.UserLogin.Log.Debug().Msg("Sending TransientDisconnect BridgeState") s.UserLogin.Log.Debug().Msg("Sending TransientDisconnect BridgeState")
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "unknown-websocket-error", Message: err.Error()}) s.UserLogin.BridgeState.Send(connectionErrorState(err))
case signalmeow.SignalConnectionEventFatalError: case signalmeow.SignalConnectionEventFatalError:
s.UserLogin.Log.Debug().Msg("Sending UnknownError BridgeState") s.UserLogin.Log.Debug().Msg("Sending UnknownError BridgeState")
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: "unknown-websocket-error", Message: err.Error()}) s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: "signal-connection-failed", Info: map[string]any{"go_error": err.Error()}})
case signalmeow.SignalConnectionCleanShutdown: case signalmeow.SignalConnectionCleanShutdown:
if s.Client.IsLoggedIn() { if s.Client.IsLoggedIn() {
s.UserLogin.Log.Debug().Msg("Clean Shutdown - sending no BridgeState") s.UserLogin.Log.Debug().Msg("Clean Shutdown - sending no BridgeState")
} else { } else {
s.UserLogin.Log.Debug().Msg("Clean Shutdown, but logged out - Sending BadCredentials BridgeState") 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"}) s.sendLoggedOutState(nil)
} }
} }
} }
@ -222,7 +219,10 @@ func (s *SignalClient) bridgeStateLoop(statusChan <-chan signalmeow.SignalConnec
func (s *SignalClient) Connect(ctx context.Context) { func (s *SignalClient) Connect(ctx context.Context) {
if s.Client == nil { if s.Client == nil {
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: "You're not logged into Signal"}) s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: "signal-not-logged-in", UserAction: status.UserActionRelogin})
return
} else if !s.Client.IsLoggedIn() {
s.sendLoggedOutState(nil)
return return
} }
s.updateRemoteProfile(ctx, false) s.updateRemoteProfile(ctx, false)
@ -230,6 +230,9 @@ func (s *SignalClient) Connect(ctx context.Context) {
} }
func (s *SignalClient) ConnectBackground(ctx context.Context, _ *bridgev2.ConnectBackgroundParams) error { func (s *SignalClient) ConnectBackground(ctx context.Context, _ *bridgev2.ConnectBackgroundParams) error {
if !s.IsLoggedIn() {
return bridgev2.ErrNotLoggedIn
}
s.queueEmptyWaiter.Clear() s.queueEmptyWaiter.Clear()
ch, unauthCh, err := s.Client.StartWebsockets(ctx) ch, unauthCh, err := s.Client.StartWebsockets(ctx)
if err != nil { if err != nil {
@ -250,6 +253,11 @@ func (s *SignalClient) ConnectBackground(ctx context.Context, _ *bridgev2.Connec
log.Err(status.Err).Msg("Authed websocket disconnected") log.Err(status.Err).Msg("Authed websocket disconnected")
case web.SignalWebsocketConnectionEventLoggedOut: case web.SignalWebsocketConnectionEventLoggedOut:
log.Err(status.Err).Msg("Authed websocket logged out") log.Err(status.Err).Msg("Authed websocket logged out")
s.sendLoggedOutState(status.Err)
err = s.Client.ClearKeysAndDisconnect(ctx)
if err != nil {
log.Err(err).Msg("Failed to clear keys and disconnect")
}
return fmt.Errorf("authed websocket logged out: %w", status.Err) return fmt.Errorf("authed websocket logged out: %w", status.Err)
case web.SignalWebsocketConnectionEventError, web.SignalWebsocketConnectionEventFatalError: case web.SignalWebsocketConnectionEventError, web.SignalWebsocketConnectionEventFatalError:
log.Err(status.Err).Msg("Authed websocket error") log.Err(status.Err).Msg("Authed websocket error")
@ -314,7 +322,7 @@ func (s *SignalClient) tryConnect(ctx context.Context, retryCount int, noLoginSy
ch, err := s.Client.StartReceiveLoops(ctx) ch, err := s.Client.StartReceiveLoops(ctx)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to start receive loops") 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()}) s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: "signal-connect-failed", Info: map[string]any{"go_error": err.Error()}})
retryInSeconds := 2 << retryCount retryInSeconds := 2 << retryCount
if retryInSeconds > 150 { if retryInSeconds > 150 {
retryInSeconds = 150 retryInSeconds = 150
@ -342,7 +350,7 @@ func (s *SignalClient) tryConnect(ctx context.Context, retryCount int, noLoginSy
if s.Client.Store.EphemeralBackupKey != nil { if s.Client.Store.EphemeralBackupKey != nil {
go func() { go func() {
if s.Client.Store.MasterKey != nil { if s.Client.Store.MasterKey != nil {
s.Client.SyncStorage(ctx) s.Client.StorageSync(ctx)
} else { } else {
s.UserLogin.Log.Warn().Msg("No master key for storage sync before backup sync") s.UserLogin.Log.Warn().Msg("No master key for storage sync before backup sync")
} }
@ -351,7 +359,7 @@ func (s *SignalClient) tryConnect(ctx context.Context, retryCount int, noLoginSy
} else { } else {
cancel() cancel()
if s.Client.Store.MasterKey != nil { if s.Client.Store.MasterKey != nil {
go s.Client.SyncStorage(ctx) s.Client.QueueStorageSync(ctx)
} }
} }
} }

View file

@ -41,8 +41,8 @@ type SignalConfig struct {
DeviceName string `yaml:"device_name"` DeviceName string `yaml:"device_name"`
NoteToSelfAvatar id.ContentURIString `yaml:"note_to_self_avatar"` NoteToSelfAvatar id.ContentURIString `yaml:"note_to_self_avatar"`
LocationFormat string `yaml:"location_format"` LocationFormat string `yaml:"location_format"`
DisappearViewOnce bool `yaml:"disappear_view_once"`
ExtEvPolls bool `yaml:"extev_polls"` ExtEvPolls bool `yaml:"extev_polls"`
IdentityChangeNotices bool `yaml:"identity_change_notices"`
displaynameTemplate *template.Template `yaml:"-"` displaynameTemplate *template.Template `yaml:"-"`
} }
@ -75,13 +75,16 @@ type DisplaynameParams struct {
AboutEmoji string AboutEmoji string
} }
func (c *SignalConfig) FormatDisplayname(contact *types.Recipient) string { func (c *SignalConfig) FormatDisplayname(contact *types.Recipient, username string) string {
if username != "" && !strings.HasPrefix(username, "@") {
username = "@" + username
}
var nameBuf strings.Builder var nameBuf strings.Builder
err := c.displaynameTemplate.Execute(&nameBuf, &DisplaynameParams{ err := c.displaynameTemplate.Execute(&nameBuf, &DisplaynameParams{
ProfileName: contact.Profile.Name, ProfileName: contact.Profile.Name,
ContactName: contact.ContactName, ContactName: contact.ContactName,
Nickname: contact.Nickname, Nickname: contact.Nickname,
Username: "", Username: username,
PhoneNumber: contact.E164, PhoneNumber: contact.E164,
UUID: contact.ACI.String(), UUID: contact.ACI.String(),
ACI: contact.ACI.String(), ACI: contact.ACI.String(),
@ -103,8 +106,8 @@ func upgradeConfig(helper up.Helper) {
helper.Copy(up.Str, "device_name") helper.Copy(up.Str, "device_name")
helper.Copy(up.Str, "note_to_self_avatar") helper.Copy(up.Str, "note_to_self_avatar")
helper.Copy(up.Str, "location_format") helper.Copy(up.Str, "location_format")
helper.Copy(up.Bool, "disappear_view_once")
helper.Copy(up.Bool, "extev_polls") helper.Copy(up.Bool, "extev_polls")
helper.Copy(up.Bool, "identity_change_notices")
} }
func (s *SignalConnector) GetConfig() (string, any, up.Upgrader) { func (s *SignalConnector) GetConfig() (string, any, up.Upgrader) {

View file

@ -20,6 +20,7 @@ import (
"context" "context"
"fmt" "fmt"
"strconv" "strconv"
"sync"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@ -44,6 +45,9 @@ type SignalConnector struct {
Store *store.Container Store *store.Container
Bridge *bridgev2.Bridge Bridge *bridgev2.Bridge
Config SignalConfig Config SignalConfig
lastTxnTS int64
lastTxnTSLock sync.Mutex
} }
var _ bridgev2.NetworkConnector = (*SignalConnector)(nil) var _ bridgev2.NetworkConnector = (*SignalConnector)(nil)
@ -66,7 +70,6 @@ func (s *SignalConnector) Init(bridge *bridgev2.Bridge) {
s.Bridge = bridge s.Bridge = bridge
s.MsgConv = msgconv.NewMessageConverter(bridge) s.MsgConv = msgconv.NewMessageConverter(bridge)
s.MsgConv.LocationFormat = s.Config.LocationFormat s.MsgConv.LocationFormat = s.Config.LocationFormat
s.MsgConv.DisappearViewOnce = s.Config.DisappearViewOnce
s.MsgConv.ExtEvPolls = s.Config.ExtEvPolls s.MsgConv.ExtEvPolls = s.Config.ExtEvPolls
bridge.Commands.(*commands.Processor).AddHandlers(CmdDiscardSenderKey) bridge.Commands.(*commands.Processor).AddHandlers(CmdDiscardSenderKey)
} }
@ -91,8 +94,11 @@ func (s *SignalConnector) ResetHTTPTransport() {
settings = hs.GetHTTPClientSettings() settings = hs.GetHTTPClientSettings()
} }
oldClient := web.SignalHTTPClient oldClient := web.SignalHTTPClient
oldUploadClient := web.SignalUploadHTTPClient
web.SignalHTTPClient = settings.WithTLSConfig(web.SignalTLSConfig).Compile() web.SignalHTTPClient = settings.WithTLSConfig(web.SignalTLSConfig).Compile()
web.SignalUploadHTTPClient = settings.WithTLSConfig(web.SignalTLSConfig).WithResponseHeaderTimeout(2 * time.Minute).Compile()
oldClient.CloseIdleConnections() oldClient.CloseIdleConnections()
oldUploadClient.CloseIdleConnections()
} }
func (s *SignalConnector) ResetNetworkConnections() { func (s *SignalConnector) ResetNetworkConnections() {
@ -117,7 +123,8 @@ func (s *SignalConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Use
Main: s, Main: s,
UserLogin: login, UserLogin: login,
queueEmptyWaiter: exsync.NewEvent(), recentMessageRooms: exsync.NewRingBuffer[networkid.MessageID, networkid.PortalKey](32),
queueEmptyWaiter: exsync.NewEvent(),
} }
if device != nil { if device != nil {
sc.Client = signalmeow.NewClient( sc.Client = signalmeow.NewClient(
@ -133,5 +140,8 @@ func (s *SignalConnector) LoadUserLogin(ctx context.Context, login *bridgev2.Use
} }
func (s *SignalConnector) GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID { func (s *SignalConnector) GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID {
return networkid.RawTransactionID(strconv.FormatInt(time.Now().UnixMilli(), 10)) s.lastTxnTSLock.Lock()
defer s.lastTxnTSLock.Unlock()
s.lastTxnTS = max(time.Now().UnixMilli(), s.lastTxnTS+1)
return networkid.RawTransactionID(strconv.FormatInt(s.lastTxnTS, 10))
} }

View file

@ -79,7 +79,7 @@ func (s *SignalConnector) Download(ctx context.Context, mediaID networkid.MediaI
rawDataResp, err = client.Client.DownloadGroupAvatar(ctx, info.GroupAvatarPath, groupMasterKey) rawDataResp, err = client.Client.DownloadGroupAvatar(ctx, info.GroupAvatarPath, groupMasterKey)
if err != nil { if err != nil {
log.Err(err).Msg("Direct download failed") log.Err(err).Msg("Direct download failed")
return nil, err return nil, wrapDirectMediaError(err)
} }
case *signalid.DirectMediaProfileAvatar: case *signalid.DirectMediaProfileAvatar:
log.Info(). log.Info().
@ -107,7 +107,7 @@ func (s *SignalConnector) Download(ctx context.Context, mediaID networkid.MediaI
rawDataResp, err = client.Client.DownloadUserAvatar(ctx, info.ProfileAvatarPath, *profileKey) rawDataResp, err = client.Client.DownloadUserAvatar(ctx, info.ProfileAvatarPath, *profileKey)
if err != nil { if err != nil {
log.Err(err).Msg("Direct download failed") log.Err(err).Msg("Direct download failed")
return nil, err return nil, wrapDirectMediaError(err)
} }
case *signalid.DirectMediaSticker: case *signalid.DirectMediaSticker:
log.Info(). log.Info().
@ -118,7 +118,7 @@ func (s *SignalConnector) Download(ctx context.Context, mediaID networkid.MediaI
rawDataResp, err = signalmeow.DownloadStickerPackItem(ctx, info.PackID, info.PackKey, info.StickerID) rawDataResp, err = signalmeow.DownloadStickerPackItem(ctx, info.PackID, info.PackKey, info.StickerID)
if err != nil { if err != nil {
log.Err(err).Msg("Direct download failed") log.Err(err).Msg("Direct download failed")
return nil, err return nil, wrapDirectMediaError(err)
} }
default: default:
return nil, fmt.Errorf("no downloader for direct media type: %T", info) return nil, fmt.Errorf("no downloader for direct media type: %T", info)

196
pkg/connector/errors.go Normal file
View file

@ -0,0 +1,196 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2026 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"
"fmt"
"net/http"
"google.golang.org/grpc/codes"
grpcstatus "google.golang.org/grpc/status"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/web"
)
func init() {
status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{
"signal-logged-out": "You have been logged out of Signal, please reconnect",
"signal-not-logged-in": "You're not logged into Signal",
"signal-disconnected": "Disconnected from Signal, reconnecting",
"signal-connection-error": "Connection to Signal failed, reconnecting",
"signal-server-error": "Signal server error, reconnecting",
"signal-rate-limited": "Rate limited by Signal, reconnecting",
"signal-connect-failed": "Failed to connect to Signal, retrying",
"signal-connection-failed": "Connection to Signal failed",
"signal-idle-primary-device": "Signal hasn't seen your phone in a long time. The bridge is still connected, " +
"but Signal will unlink it if you don't open Signal on your phone soon.",
})
}
var _ status.BridgeStateFiller = (*SignalClient)(nil)
func (s *SignalClient) FillBridgeState(state status.BridgeState) status.BridgeState {
if state.StateEvent == status.StateConnected && s.Client != nil && s.Client.AuthedWS.PrimaryDeviceIdle() {
state.StateEvent = status.StateTransientDisconnect
state.Error = "signal-idle-primary-device"
state.UserAction = status.UserActionOpenNative
}
return state
}
var (
errEditOthersMessage = bridgev2.WrapErrorInStatus(errors.New("you can only edit your own messages")).
WithErrorAsMessage().
WithErrorReason(event.MessageStatusNoPermission).
WithStatus(event.MessageStatusFail).
WithIsCertain(true).
WithSendNotice(true)
errDeleteOthersMessage = bridgev2.WrapErrorInStatus(errors.New("you can only delete your own messages")).
WithErrorAsMessage().
WithErrorReason(event.MessageStatusNoPermission).
WithStatus(event.MessageStatusFail).
WithIsCertain(true).
WithSendNotice(true)
errInviteToDM = bridgev2.WrapErrorInStatus(errors.New("can't invite more users to Signal DMs")).
WithErrorAsMessage().
WithErrorReason(event.MessageStatusUnsupported).
WithStatus(event.MessageStatusFail).
WithIsCertain(true).
WithSendNotice(true)
errUnsupportedDisappearingType = bridgev2.WrapErrorInStatus(errors.New("only disappearing timers that start after reading are supported on Signal")).
WithErrorAsMessage().
WithErrorReason(event.MessageStatusUnsupported).
WithStatus(event.MessageStatusFail).
WithIsCertain(true).
WithSendNotice(true)
)
func wrapConversionError(err error) error {
if err == nil || errors.As(err, &bridgev2.MessageStatus{}) {
return err
}
return bridgev2.WrapErrorInStatus(err).
WithMessage("failed to convert message for Signal").
WithIsCertain(true).
WithSendNotice(true)
}
func wrapSendError(err error) error {
if err == nil || errors.As(err, &bridgev2.MessageStatus{}) {
return err
}
switch {
case errors.Is(err, signalmeow.ErrUnregisteredUser):
return bridgev2.WrapErrorInStatus(err).
WithMessage("recipient isn't registered on Signal").
WithErrorReason(event.MessageStatusNetworkError).
WithStatus(event.MessageStatusFail).
WithIsCertain(true).
WithSendNotice(true)
case errors.Is(err, signalmeow.ErrRateLimited), errors.Is(err, signalmeow.RateLimitError):
return bridgev2.WrapErrorInStatus(err).
WithMessage("rate limited by Signal, try again later").
WithErrorReason(event.MessageStatusNetworkError).
WithIsCertain(true).
WithSendNotice(true)
case errors.Is(err, signalmeow.AuthorizationFailedError):
return bridgev2.WrapErrorInStatus(err).
WithMessage("you don't have permission to do that in this Signal group").
WithErrorReason(event.MessageStatusNoPermission).
WithStatus(event.MessageStatusFail).
WithIsCertain(true).
WithSendNotice(true)
case errors.Is(err, signalmeow.GroupPatchNotAcceptedError):
return bridgev2.WrapErrorInStatus(err).
WithMessage("Signal rejected the group change").
WithErrorReason(event.MessageStatusNetworkError).
WithStatus(event.MessageStatusFail).
WithIsCertain(true).
WithSendNotice(true)
default:
return bridgev2.WrapErrorInStatus(err).
WithMessage("failed to send to Signal").
WithSendNotice(true)
}
}
func wrapRespError(err error) error {
var mapped bridgev2.RespError
var cdsRateLimit signalmeow.ContactDiscoveryRateLimitError
switch {
case errors.As(err, &cdsRateLimit), errors.Is(err, signalmeow.RateLimitError), errors.Is(err, signalmeow.ErrRateLimited),
grpcstatus.Code(err) == codes.ResourceExhausted:
mapped = bridgev2.RespError(mautrix.MLimitExceeded.WithMessage("Rate limited by Signal, please try again later").WithStatus(http.StatusBadRequest))
case errors.Is(err, signalmeow.AuthorizationFailedError):
mapped = bridgev2.RespError(mautrix.MForbidden.WithMessage("Signal denied the request"))
default:
mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Request to Signal failed"))
}
return fmt.Errorf("%w: %w", mapped, err)
}
func wrapDirectMediaError(err error) error {
var statusErr web.HTTPStatusError
if !errors.As(err, &statusErr) {
return err
}
switch statusErr.StatusCode {
case http.StatusNotFound, http.StatusGone:
return mautrix.MNotFound.WithMessage("Media not found on Signal")
case http.StatusUnauthorized, http.StatusForbidden:
return mautrix.MForbidden.WithMessage("Signal denied access to the media")
case http.StatusTooManyRequests:
return mautrix.MLimitExceeded.WithMessage("Rate limited by Signal, please try again later").WithCanRetry(true)
default:
return mautrix.MUnknown.WithMessage("Unexpected response from Signal").WithStatus(http.StatusBadGateway).WithCanRetry(true)
}
}
func connectionErrorState(err error) status.BridgeState {
state := status.BridgeState{
StateEvent: status.StateTransientDisconnect,
Error: "signal-connection-error",
Info: map[string]any{"go_error": err.Error()},
}
var statusErr web.HTTPStatusError
if errors.As(err, &statusErr) {
if statusErr.StatusCode == http.StatusTooManyRequests {
state.Error = "signal-rate-limited"
} else if statusErr.StatusCode >= 500 {
state.Error = "signal-server-error"
}
}
return state
}
func (s *SignalClient) sendLoggedOutState(err error) {
state := status.BridgeState{
StateEvent: status.StateBadCredentials,
Error: "signal-logged-out",
UserAction: status.UserActionRelogin,
}
if err != nil {
state.Info = map[string]any{"go_error": err.Error()}
}
s.UserLogin.BridgeState.Send(state)
}

View file

@ -2,10 +2,11 @@
# {{.ProfileName}} - The Signal profile name set by the user. # {{.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. # {{.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. # {{.Nickname}} - The nickname set for the user in the native Signal app. This is not safe on multi-user instances.
# {{.Username}} - The username of the user. Only applicable when starting chats.
# {{.PhoneNumber}} - The phone number of the user. # {{.PhoneNumber}} - The phone number of the user.
# {{.UUID}} - The UUID of the Signal user. # {{.UUID}} - The UUID of the Signal user.
# {{.AboutEmoji}} - The emoji set by the user in their profile. # {{.AboutEmoji}} - The emoji set by the user in their profile.
displayname_template: '{{or .ProfileName .PhoneNumber "Unknown user"}}' displayname_template: '{{or .ProfileName .PhoneNumber .Username "Unknown user"}}'
# Should avatars from the user's contact list be used? This is not safe on multi-user instances. # Should avatars from the user's contact list be used? This is not safe on multi-user instances.
use_contact_avatars: false use_contact_avatars: false
# Should the bridge request the user's contact list from the phone on startup? # Should the bridge request the user's contact list from the phone on startup?
@ -22,7 +23,7 @@ note_to_self_avatar: mxc://maunium.net/REBIVrqjZwmaWpssCZpBlmlL
# Google Maps: 'https://www.google.com/maps/place/%[1]s,%[2]s' # Google Maps: 'https://www.google.com/maps/place/%[1]s,%[2]s'
# OpenStreetMap: 'https://www.openstreetmap.org/?mlat=%[1]s&mlon=%[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' 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? # Should polls be sent using unstable MSC3381 event types?
extev_polls: false extev_polls: false
# Should another user's safety number changing send a message to Matrix?
identity_change_notices: false

View file

@ -39,6 +39,7 @@ import (
var defaultPL = 0 var defaultPL = 0
var moderatorPL = 50 var moderatorPL = 50
var terminatedPL = 100
func roleToPL(role signalmeow.GroupMemberRole) *int { func roleToPL(role signalmeow.GroupMemberRole) *int {
switch role { switch role {
@ -57,6 +58,9 @@ func applyAnnouncementsOnly(plc *bridgev2.PowerLevelOverrides, announcementsOnly
} else { } else {
plc.EventsDefault = &defaultPL plc.EventsDefault = &defaultPL
} }
plc.Events[event.EventReaction] = defaultPL
plc.Events[event.EventRedaction] = defaultPL
plc.Events[event.EventUnstablePollResponse] = defaultPL
} }
func applyAttributesAccess(plc *bridgev2.PowerLevelOverrides, attributeAccess signalmeow.AccessControl) { func applyAttributesAccess(plc *bridgev2.PowerLevelOverrides, attributeAccess signalmeow.AccessControl) {
@ -70,6 +74,21 @@ func applyAttributesAccess(plc *bridgev2.PowerLevelOverrides, attributeAccess si
plc.Events[event.StateBeeperDisappearingTimer] = attributePL plc.Events[event.StateBeeperDisappearingTimer] = attributePL
} }
func applyTerminated(plc *bridgev2.PowerLevelOverrides) {
plc.EventsDefault = &terminatedPL
plc.StateDefault = &terminatedPL
plc.Invite = &terminatedPL
plc.Kick = &terminatedPL
plc.Ban = &terminatedPL
plc.Redact = &terminatedPL
for _, evtType := range []event.Type{
event.EventReaction, event.EventRedaction, event.EventUnstablePollResponse, event.StatePowerLevels, event.StateRoomName,
event.StateRoomAvatar, event.StateTopic, event.StateBeeperDisappearingTimer,
} {
plc.Events[evtType] = terminatedPL
}
}
func applyMembersAccess(plc *bridgev2.PowerLevelOverrides, memberAccess signalmeow.AccessControl) { func applyMembersAccess(plc *bridgev2.PowerLevelOverrides, memberAccess signalmeow.AccessControl) {
if memberAccess == signalmeow.AccessControl_ADMINISTRATOR { if memberAccess == signalmeow.AccessControl_ADMINISTRATOR {
plc.Invite = &moderatorPL plc.Invite = &moderatorPL
@ -123,6 +142,9 @@ func (s *SignalClient) wrapGroupInfo(ctx context.Context, groupInfo *signalmeow.
applyMembersAccess(members.PowerLevels, groupInfo.AccessControl.Members) applyMembersAccess(members.PowerLevels, groupInfo.AccessControl.Members)
joinRule = inviteLinkToJoinRule(groupInfo.AccessControl.AddFromInviteLink) joinRule = inviteLinkToJoinRule(groupInfo.AccessControl.AddFromInviteLink)
} }
if groupInfo.Terminated {
applyTerminated(members.PowerLevels)
}
for _, member := range groupInfo.RequestingMembers { for _, member := range groupInfo.RequestingMembers {
members.MemberMap.Set(bridgev2.ChatMember{ members.MemberMap.Set(bridgev2.ChatMember{
EventSender: s.makeEventSender(member.ACI), EventSender: s.makeEventSender(member.ACI),
@ -182,9 +204,12 @@ func addMemberToMap(mc map[networkid.UserID]bridgev2.ChatMember, member bridgev2
mc[member.EventSender.Sender] = member mc[member.EventSender.Sender] = member
} }
const currentPortalInfoVersion = 3
func updatePortalSyncMeta(ctx context.Context, portal *bridgev2.Portal) bool { func updatePortalSyncMeta(ctx context.Context, portal *bridgev2.Portal) bool {
meta := portal.Metadata.(*signalid.PortalMetadata) meta := portal.Metadata.(*signalid.PortalMetadata)
meta.LastSync = jsontime.UnixNow() meta.LastSync = jsontime.UnixNow()
meta.InfoVersion = currentPortalInfoVersion
return true return true
} }
@ -260,7 +285,8 @@ func (s *SignalClient) groupChangeToChatInfoChange(ctx context.Context, groupID
var pls *bridgev2.PowerLevelOverrides var pls *bridgev2.PowerLevelOverrides
if groupChange.ModifyAnnouncementsOnly != nil || if groupChange.ModifyAnnouncementsOnly != nil ||
groupChange.ModifyAttributesAccess != nil || groupChange.ModifyAttributesAccess != nil ||
groupChange.ModifyMemberAccess != nil { groupChange.ModifyMemberAccess != nil ||
groupChange.TerminateGroup {
pls = &bridgev2.PowerLevelOverrides{Events: make(map[event.Type]int)} pls = &bridgev2.PowerLevelOverrides{Events: make(map[event.Type]int)}
if groupChange.ModifyAnnouncementsOnly != nil { if groupChange.ModifyAnnouncementsOnly != nil {
applyAnnouncementsOnly(pls, *groupChange.ModifyAnnouncementsOnly) applyAnnouncementsOnly(pls, *groupChange.ModifyAnnouncementsOnly)
@ -271,6 +297,9 @@ func (s *SignalClient) groupChangeToChatInfoChange(ctx context.Context, groupID
if groupChange.ModifyMemberAccess != nil { if groupChange.ModifyMemberAccess != nil {
applyMembersAccess(pls, *groupChange.ModifyMemberAccess) applyMembersAccess(pls, *groupChange.ModifyMemberAccess)
} }
if groupChange.TerminateGroup {
applyTerminated(pls)
}
} }
if groupChange.ModifyAddFromInviteLinkAccess != nil { if groupChange.ModifyAddFromInviteLinkAccess != nil {
ic.ChatInfo.JoinRule = &event.JoinRulesEventContent{ ic.ChatInfo.JoinRule = &event.JoinRulesEventContent{

View file

@ -37,25 +37,29 @@ import (
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/pkg/libsignalgo" "go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/msgconv"
"go.mau.fi/mautrix-signal/pkg/signalid" "go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow" "go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf/signalpb" "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf/signalpb"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
) )
var ( var (
_ bridgev2.EditHandlingNetworkAPI = (*SignalClient)(nil) _ bridgev2.EditHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.ReactionHandlingNetworkAPI = (*SignalClient)(nil) _ bridgev2.ReactionHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.RedactionHandlingNetworkAPI = (*SignalClient)(nil) _ bridgev2.RedactionHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.ReadReceiptHandlingNetworkAPI = (*SignalClient)(nil) _ bridgev2.ReadReceiptHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.TypingHandlingNetworkAPI = (*SignalClient)(nil) _ bridgev2.TypingHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.RoomNameHandlingNetworkAPI = (*SignalClient)(nil) _ bridgev2.RoomNameHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.RoomAvatarHandlingNetworkAPI = (*SignalClient)(nil) _ bridgev2.RoomAvatarHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.RoomTopicHandlingNetworkAPI = (*SignalClient)(nil) _ bridgev2.RoomTopicHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.ChatViewingNetworkAPI = (*SignalClient)(nil) _ bridgev2.ChatViewingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.DisappearTimerChangingNetworkAPI = (*SignalClient)(nil) _ bridgev2.DisappearTimerChangingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.DeleteChatHandlingNetworkAPI = (*SignalClient)(nil) _ bridgev2.DeleteChatHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.PollHandlingNetworkAPI = (*SignalClient)(nil) _ bridgev2.PollHandlingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.MessageRequestAcceptingNetworkAPI = (*SignalClient)(nil) _ bridgev2.MessageRequestAcceptingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.UserBlockingNetworkAPI = (*SignalClient)(nil)
_ bridgev2.ViewLimitedMediaHandlingNetworkAPI = (*SignalClient)(nil)
) )
func (s *SignalClient) sendMessage(ctx context.Context, portalID networkid.PortalID, content *signalpb.Content) error { func (s *SignalClient) sendMessage(ctx context.Context, portalID networkid.PortalID, content *signalpb.Content) error {
@ -78,7 +82,7 @@ func (s *SignalClient) sendMessage(ctx context.Context, portalID networkid.Porta
log.Debug().Msg("No successes or failures - Probably sent to myself") log.Debug().Msg("No successes or failures - Probably sent to myself")
} else if len(result.SuccessfullySentTo) == 0 { } else if len(result.SuccessfullySentTo) == 0 {
log.Error().Msg("Failed to send event to all members of Signal group") 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") return fmt.Errorf("failed to send to any members of Signal group: %w", result.FailureError())
} else if len(result.SuccessfullySentTo) < totalRecipients { } else if len(result.SuccessfullySentTo) < totalRecipients {
if len(result.FailedToSendTo) > 0 { if len(result.FailedToSendTo) > 0 {
log.Warn().Msg("Failed to send event to some members of Signal group") log.Warn().Msg("Failed to send event to some members of Signal group")
@ -92,7 +96,10 @@ func (s *SignalClient) sendMessage(ctx context.Context, portalID networkid.Porta
} else { } else {
res := s.Client.SendMessage(ctx, userID, content) res := s.Client.SendMessage(ctx, userID, content)
if !res.WasSuccessful { if !res.WasSuccessful {
return res.Error if res.Error != nil {
return res.Error
}
return errors.New("failed to send message")
} }
return nil return nil
} }
@ -117,10 +124,11 @@ func (s *SignalClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Ma
ctx, s.Client, msg.Portal, msg.Event, msg.Content, msg.OrigSender != nil, msg.ReplyTo, ctx, s.Client, msg.Portal, msg.Event, msg.Content, msg.OrigSender != nil, msg.ReplyTo,
) )
if err != nil { if err != nil {
return nil, err return nil, wrapConversionError(err)
} }
return s.doSendMessage(ctx, msg, converted, &signalid.MessageMetadata{ return s.doSendMessage(ctx, msg, converted, &signalid.MessageMetadata{
ContainsAttachments: len(converted.Attachments) > 0, ContainsAttachments: msgconv.ContainsAttachments(converted),
ViewOnce: converted.GetIsViewOnce(),
}) })
} }
@ -139,7 +147,7 @@ func (s *SignalClient) doSendMessage(
msg.AddPendingToIgnore(networkid.TransactionID(msgID)) msg.AddPendingToIgnore(networkid.TransactionID(msgID))
err := s.sendMessage(ctx, msg.Portal.ID, signalmeow.WrapDataMessage(converted)) err := s.sendMessage(ctx, msg.Portal.ID, signalmeow.WrapDataMessage(converted))
if err != nil { if err != nil {
return nil, bridgev2.WrapErrorInStatus(err).WithSendNotice(true) return nil, wrapSendError(err)
} }
dbMsg := &database.Message{ dbMsg := &database.Message{
ID: msgID, ID: msgID,
@ -153,12 +161,34 @@ func (s *SignalClient) doSendMessage(
}, nil }, nil
} }
func (s *SignalClient) HandleMatrixViewLimitedMedia(ctx context.Context, msg *bridgev2.MatrixViewLimitedMedia) error {
meta, ok := msg.Message.Metadata.(*signalid.MessageMetadata)
if !ok || !meta.ViewOnce || !meta.ContainsAttachments || msg.Content == nil || *msg.Content != (event.BeeperViewLimitedMedia{Type: "count", Count: 1}) {
return bridgev2.ErrUnsupportedViewLimitedType
}
sender, timestamp, err := signalid.ParseMessageID(msg.Message.ID)
if err != nil {
return err
}
return wrapSendError(s.sendMessage(ctx, signalid.MakeDMPortalID(libsignalgo.NewACIServiceID(sender)), &signalpb.Content{
Content: &signalpb.Content_ReceiptMessage{
ReceiptMessage: &signalpb.ReceiptMessage{
Type: signalpb.ReceiptMessage_VIEWED.Enum(),
Timestamp: []uint64{timestamp},
},
},
}))
}
func (s *SignalClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error { func (s *SignalClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
if msg.Content.BeeperViewLimited != nil || msg.EditTarget.Metadata.(*signalid.MessageMetadata).ViewOnce {
return fmt.Errorf("%w of view-once media", bridgev2.ErrEditsNotSupported)
}
_, targetSentTimestamp, err := signalid.ParseMessageID(msg.EditTarget.ID) _, targetSentTimestamp, err := signalid.ParseMessageID(msg.EditTarget.ID)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse target message ID: %w", err) return fmt.Errorf("failed to parse target message ID: %w", err)
} else if msg.EditTarget.SenderID != signalid.MakeUserID(s.Client.Store.ACI) { } else if msg.EditTarget.SenderID != signalid.MakeUserID(s.Client.Store.ACI) {
return fmt.Errorf("cannot edit other people's messages") return errEditOthersMessage
} }
var replyTo *database.Message var replyTo *database.Message
if msg.EditTarget.ReplyTo.MessageID != "" { if msg.EditTarget.ReplyTo.MessageID != "" {
@ -169,7 +199,7 @@ func (s *SignalClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matri
} }
converted, err := s.Main.MsgConv.ToSignal(ctx, s.Client, msg.Portal, msg.Event, msg.Content, msg.OrigSender != nil, replyTo) converted, err := s.Main.MsgConv.ToSignal(ctx, s.Client, msg.Portal, msg.Event, msg.Content, msg.OrigSender != nil, replyTo)
if err != nil { if err != nil {
return err return wrapConversionError(err)
} }
ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender) ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)
converted.Timestamp = &ts converted.Timestamp = &ts
@ -178,11 +208,11 @@ func (s *SignalClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matri
DataMessage: converted, DataMessage: converted,
})) }))
if err != nil { if err != nil {
return bridgev2.WrapErrorInStatus(err).WithSendNotice(true) return wrapSendError(err)
} }
prevID := msg.EditTarget.ID prevID := msg.EditTarget.ID
msg.EditTarget.ID = signalid.MakeMessageID(s.Client.Store.ACI, ts) msg.EditTarget.ID = signalid.MakeMessageID(s.Client.Store.ACI, ts)
msg.EditTarget.Metadata = &signalid.MessageMetadata{ContainsAttachments: len(converted.Attachments) > 0} msg.EditTarget.Metadata = &signalid.MessageMetadata{ContainsAttachments: msgconv.ContainsAttachments(converted)}
msg.EditTarget.EditCount++ msg.EditTarget.EditCount++
if prevID != msg.EditTarget.ID { if prevID != msg.EditTarget.ID {
err = s.Main.Bridge.DB.DoTxn(ctx, nil, func(ctx context.Context) error { err = s.Main.Bridge.DB.DoTxn(ctx, nil, func(ctx context.Context) error {
@ -247,7 +277,7 @@ func (s *SignalClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.M
}, },
})) }))
if err != nil { if err != nil {
return nil, err return nil, wrapSendError(err)
} }
return &database.Reaction{}, nil return &database.Reaction{}, nil
} }
@ -268,30 +298,52 @@ func (s *SignalClient) HandleMatrixReactionRemove(ctx context.Context, msg *brid
TargetSentTimestamp: proto.Uint64(targetSentTimestamp), TargetSentTimestamp: proto.Uint64(targetSentTimestamp),
}, },
})) }))
if err != nil { return wrapSendError(err)
return err
}
return nil
} }
func (s *SignalClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { func (s *SignalClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error {
_, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetMessage.ID) targetAuthorACI, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetMessage.ID)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse target message ID: %w", err) 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) dm := &signalpb.DataMessage{
err = s.sendMessage(ctx, msg.Portal.ID, signalmeow.WrapDataMessage(&signalpb.DataMessage{ Timestamp: proto.Uint64(getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)),
Timestamp: proto.Uint64(ts), }
Delete: &signalpb.DataMessage_Delete{ if msg.TargetMessage.SenderID == signalid.MakeUserID(s.Client.Store.ACI) {
dm.Delete = &signalpb.DataMessage_Delete{
TargetSentTimestamp: proto.Uint64(targetSentTimestamp), TargetSentTimestamp: proto.Uint64(targetSentTimestamp),
}, }
})) } else if isAdmin, err := s.isGroupAdmin(ctx, msg.Portal); err != nil {
if err != nil { return wrapSendError(err)
return err } else if !isAdmin {
return errDeleteOthersMessage
} else {
dm.AdminDelete = &signalpb.DataMessage_AdminDelete{
TargetAuthorAciBinary: targetAuthorACI[:],
TargetSentTimestamp: proto.Uint64(targetSentTimestamp),
}
} }
return nil err = s.sendMessage(ctx, msg.Portal.ID, signalmeow.WrapDataMessage(dm))
return wrapSendError(err)
}
func (s *SignalClient) isGroupAdmin(ctx context.Context, portal *bridgev2.Portal) (bool, error) {
_, groupID, err := signalid.ParsePortalID(portal.ID)
if err != nil {
return false, err
} else if groupID == "" {
return false, nil
}
group, _, err := s.Client.RetrieveGroupByID(ctx, groupID, portal.Metadata.(*signalid.PortalMetadata).Revision)
if err != nil {
return false, fmt.Errorf("failed to get group info: %w", err)
}
for _, member := range group.Members {
if member.ACI == s.Client.Store.ACI {
return member.Role == signalmeow.GroupMember_ADMINISTRATOR, nil
}
}
return false, nil
} }
func (s *SignalClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bridgev2.MatrixReadReceipt) error { func (s *SignalClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bridgev2.MatrixReadReceipt) error {
@ -374,7 +426,7 @@ func (s *SignalClient) handleMatrixRoomMeta(ctx context.Context, portal *bridgev
gc.Revision = portal.Metadata.(*signalid.PortalMetadata).Revision + 1 gc.Revision = portal.Metadata.(*signalid.PortalMetadata).Revision + 1
revision, err := s.Client.UpdateGroup(ctx, gc, groupID) revision, err := s.Client.UpdateGroup(ctx, gc, groupID)
if err != nil { if err != nil {
return false, err return false, wrapSendError(err)
} }
if gc.ModifyTitle != nil { if gc.ModifyTitle != nil {
portal.Name = *gc.ModifyTitle portal.Name = *gc.ModifyTitle
@ -416,7 +468,7 @@ func (s *SignalClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridgev2
avatarHash = sha256.Sum256(data) avatarHash = sha256.Sum256(data)
avatarPath, err = s.Client.UploadGroupAvatar(ctx, data, groupID, "") avatarPath, err = s.Client.UploadGroupAvatar(ctx, data, groupID, "")
if err != nil { if err != nil {
return false, fmt.Errorf("failed to reupload avatar: %w", err) return false, wrapSendError(fmt.Errorf("failed to reupload avatar: %w", err))
} }
} }
return s.handleMatrixRoomMeta(ctx, msg.Portal, &signalmeow.GroupChange{ return s.handleMatrixRoomMeta(ctx, msg.Portal, &signalmeow.GroupChange{
@ -443,7 +495,7 @@ func (s *SignalClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2
if msg.Portal.RoomType == database.RoomTypeDM { if msg.Portal.RoomType == database.RoomTypeDM {
switch msg.Type { switch msg.Type {
case bridgev2.Invite: case bridgev2.Invite:
return nil, fmt.Errorf("cannot invite additional user to dm") return nil, errInviteToDM
default: default:
return nil, nil return nil, nil
} }
@ -572,7 +624,7 @@ func (s *SignalClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2
gc.Revision = msg.Portal.Metadata.(*signalid.PortalMetadata).Revision + 1 gc.Revision = msg.Portal.Metadata.(*signalid.PortalMetadata).Revision + 1
revision, err := s.Client.UpdateGroup(ctx, gc, groupID) revision, err := s.Client.UpdateGroup(ctx, gc, groupID)
if err != nil { if err != nil {
return nil, err return nil, wrapSendError(err)
} }
if (msg.Type == bridgev2.Invite || msg.Type == bridgev2.AcceptKnock) && targetSignalID.Type != libsignalgo.ServiceIDTypePNI { if (msg.Type == bridgev2.Invite || msg.Type == bridgev2.AcceptKnock) && targetSignalID.Type != libsignalgo.ServiceIDTypePNI {
err = targetIntent.EnsureJoined(ctx, msg.Portal.MXID) err = targetIntent.EnsureJoined(ctx, msg.Portal.MXID)
@ -643,7 +695,7 @@ func (s *SignalClient) HandleMatrixPowerLevels(ctx context.Context, msg *bridgev
} }
revision, err := s.Client.UpdateGroup(ctx, gc, groupID) revision, err := s.Client.UpdateGroup(ctx, gc, groupID)
if err != nil { if err != nil {
return false, err return false, wrapSendError(err)
} }
msg.Portal.Metadata.(*signalid.PortalMetadata).Revision = revision msg.Portal.Metadata.(*signalid.PortalMetadata).Revision = revision
return true, nil return true, nil
@ -694,7 +746,7 @@ func (s *SignalClient) HandleMatrixViewingChat(ctx context.Context, msg *bridgev
func (s *SignalClient) HandleMatrixDisappearingTimer(ctx context.Context, msg *bridgev2.MatrixDisappearingTimer) (bool, error) { func (s *SignalClient) HandleMatrixDisappearingTimer(ctx context.Context, msg *bridgev2.MatrixDisappearingTimer) (bool, error) {
if msg.Content.Type != event.DisappearingTypeAfterRead && msg.Content.Timer.Duration != 0 { if msg.Content.Type != event.DisappearingTypeAfterRead && msg.Content.Timer.Duration != 0 {
return false, fmt.Errorf("unsupported disappearing timer type: %s", msg.Content.Type) return false, errUnsupportedDisappearingType
} }
userID, groupID, err := signalid.ParsePortalID(msg.Portal.ID) userID, groupID, err := signalid.ParsePortalID(msg.Portal.ID)
if err != nil { if err != nil {
@ -721,7 +773,7 @@ func (s *SignalClient) HandleMatrixDisappearingTimer(ctx context.Context, msg *b
ExpireTimer: ptr.Ptr(uint32(msg.Content.Timer.Seconds())), ExpireTimer: ptr.Ptr(uint32(msg.Content.Timer.Seconds())),
})) }))
if !res.WasSuccessful { if !res.WasSuccessful {
return false, res.Error return false, wrapSendError(res.Error)
} }
msg.Portal.Disappear = newSetting msg.Portal.Disappear = newSetting
return true, nil return true, nil
@ -734,11 +786,29 @@ func (s *SignalClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2
return fmt.Errorf("failed to parse portal ID: %w", err) return fmt.Errorf("failed to parse portal ID: %w", err)
} }
if groupID != "" {
memberInfo, err := s.Main.Bridge.Matrix.GetMemberInfo(ctx, msg.Portal.MXID, s.UserLogin.UserMXID)
if err != nil {
return fmt.Errorf("failed to get own member info: %w", err)
} else if memberInfo.Membership == event.MembershipJoin {
portalMeta := msg.Portal.Metadata.(*signalid.PortalMetadata)
ownACI := s.Client.Store.ACI
revision, err := s.Client.UpdateGroup(ctx, &signalmeow.GroupChange{
DeleteMembers: []*uuid.UUID{&ownACI},
Revision: portalMeta.Revision + 1,
}, groupID)
if err != nil {
return wrapSendError(fmt.Errorf("failed to leave group before deleting chat: %w", err))
}
portalMeta.Revision = revision
}
}
if msg.Content.FromMessageRequest { if msg.Content.FromMessageRequest {
// TODO block and delete support? // TODO block and delete support?
err = s.syncMessageRequestResponse(ctx, msg.Portal, signalpb.SyncMessage_MessageRequestResponse_DELETE) err = s.syncMessageRequestResponse(ctx, msg.Portal, signalpb.SyncMessage_MessageRequestResponse_DELETE)
if err != nil { if err != nil {
return fmt.Errorf("failed to send message request delete sync: %w", err) return wrapSendError(fmt.Errorf("failed to send message request delete sync: %w", err))
} }
} }
@ -816,7 +886,7 @@ func (s *SignalClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2
Msg("Sent conversation deletion to Signal") Msg("Sent conversation deletion to Signal")
if !result.WasSuccessful { if !result.WasSuccessful {
return fmt.Errorf("failed to send delete conversation sync message: %w %s %s", result.Error, userID, groupID) return wrapSendError(fmt.Errorf("failed to send delete conversation sync message: %w %s %s", result.Error, userID, groupID))
} }
return nil return nil
@ -920,7 +990,7 @@ func (s *SignalClient) HandleMatrixAcceptMessageRequest(ctx context.Context, msg
} }
err = s.syncMessageRequestResponse(ctx, msg.Portal, signalpb.SyncMessage_MessageRequestResponse_ACCEPT) err = s.syncMessageRequestResponse(ctx, msg.Portal, signalpb.SyncMessage_MessageRequestResponse_ACCEPT)
if err != nil { if err != nil {
return fmt.Errorf("failed to sync message request acceptance: %w", err) return wrapSendError(fmt.Errorf("failed to sync message request acceptance: %w", err))
} }
if userID.Type == libsignalgo.ServiceIDTypeACI { if userID.Type == libsignalgo.ServiceIDTypeACI {
profileKey, err := s.Client.ProfileKeyForSignalID(ctx, s.Client.Store.ACI) profileKey, err := s.Client.ProfileKeyForSignalID(ctx, s.Client.Store.ACI)
@ -928,7 +998,7 @@ func (s *SignalClient) HandleMatrixAcceptMessageRequest(ctx context.Context, msg
return fmt.Errorf("failed to get own profile key: %w", err) return fmt.Errorf("failed to get own profile key: %w", err)
} }
var pniSig *signalpb.PniSignatureMessage var pniSig *signalpb.PniSignatureMessage
if s.Client.Store.AccountRecord.GetPhoneNumberSharingMode() == signalpb.AccountRecord_EVERYBODY { if s.Client.Store.PNI != uuid.Nil && s.Client.Store.AccountRecord.GetPhoneNumberSharingMode() == signalpb.AccountRecord_EVERYBODY {
sig, err := s.Client.Store.PNIIdentityKeyPair.SignAlternateIdentity(s.Client.Store.ACIIdentityKeyPair.GetIdentityKey()) sig, err := s.Client.Store.PNIIdentityKeyPair.SignAlternateIdentity(s.Client.Store.ACIIdentityKeyPair.GetIdentityKey())
if err != nil { if err != nil {
return fmt.Errorf("failed to generate PNI signature: %w", err) return fmt.Errorf("failed to generate PNI signature: %w", err)
@ -949,9 +1019,74 @@ func (s *SignalClient) HandleMatrixAcceptMessageRequest(ctx context.Context, msg
PniSignatureMessage: pniSig, PniSignatureMessage: pniSig,
}) })
if !res.WasSuccessful { if !res.WasSuccessful {
return fmt.Errorf("failed to share profile key to accept message request: %w", res.Error) return wrapSendError(fmt.Errorf("failed to share profile key to accept message request: %w", res.Error))
} }
// TODO send read receipts too? // TODO send read receipts too?
} }
return nil return nil
} }
func (s *SignalClient) HandleMatrixBlockUser(ctx context.Context, msg *bridgev2.MatrixBlockUser) error {
if s.Client == nil {
return bridgev2.ErrNotLoggedIn
}
userID, groupID, err := signalid.ParsePortalID(msg.Portal.ID)
if err != nil {
return err
}
if groupID != "" {
return bridgev2.ErrNonDMBlockUser
}
aci, pni := userID.ToACIAndPNI()
if aci == uuid.Nil {
return fmt.Errorf("blocking requires a known ACI")
}
respType := signalpb.SyncMessage_MessageRequestResponse_ACCEPT
if msg.Content.Block {
respType = signalpb.SyncMessage_MessageRequestResponse_BLOCK
}
if msg.Content.ReportSpam {
if err = s.reportSpam(ctx, msg.Portal, aci); err != nil {
return err
}
if msg.Content.Block {
respType = signalpb.SyncMessage_MessageRequestResponse_BLOCK_AND_SPAM
} else {
respType = signalpb.SyncMessage_MessageRequestResponse_SPAM
}
}
if err = s.syncMessageRequestResponse(ctx, msg.Portal, respType); err != nil {
return wrapSendError(err)
}
_, err = s.Client.Store.RecipientStore.LoadAndUpdateRecipient(ctx, aci, pni, func(recipient *types.Recipient) (bool, error) {
changed := recipient.Blocked != msg.Content.Block
recipient.Blocked = msg.Content.Block
return changed, nil
})
return err
}
func (s *SignalClient) reportSpam(ctx context.Context, portal *bridgev2.Portal, aci uuid.UUID) error {
messages, err := s.Main.Bridge.DB.Message.GetLastNInPortal(ctx, portal.PortalKey, 100)
if err != nil {
return err
}
seen := make(map[string]bool)
for _, message := range messages {
meta := message.Metadata.(*signalid.MessageMetadata)
if message.SenderID != signalid.MakeUserID(aci) || meta.ServerGUID == "" || seen[meta.ServerGUID] {
continue
}
if err = s.Client.ReportSpam(ctx, aci, meta.ServerGUID, meta.ReportingToken); err != nil {
return wrapSendError(err)
}
seen[meta.ServerGUID] = true
if len(seen) == 3 {
break
}
}
if len(seen) == 0 {
return fmt.Errorf("no received messages with server GUIDs available to report")
}
return nil
}

View file

@ -17,6 +17,7 @@
package connector package connector
import ( import (
"cmp"
"context" "context"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
@ -33,7 +34,6 @@ import (
"maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent" "maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/pkg/libsignalgo" "go.mau.fi/mautrix-signal/pkg/libsignalgo"
@ -47,11 +47,17 @@ import (
func (s *SignalClient) handleSignalEvent(rawEvt events.SignalEvent) bool { func (s *SignalClient) handleSignalEvent(rawEvt events.SignalEvent) bool {
switch evt := rawEvt.(type) { switch evt := rawEvt.(type) {
case *events.ChatEvent: case *events.ChatEvent:
return s.Main.Bridge.QueueRemoteEvent(s.UserLogin, &Bv2ChatEvent{ChatEvent: evt, s: s}).Success wrapped := &Bv2ChatEvent{ChatEvent: evt, s: s}
if msg, ok := evt.Event.(*signalpb.DataMessage); ok && msg.GetIsViewOnce() {
s.recentMessageRooms.Push(wrapped.GetID(), wrapped.GetPortalKey())
}
return s.Main.Bridge.QueueRemoteEvent(s.UserLogin, wrapped).Success
case *events.DecryptionError: case *events.DecryptionError:
return s.Main.Bridge.QueueRemoteEvent(s.UserLogin, s.wrapDecryptionError(evt)).Success return s.Main.Bridge.QueueRemoteEvent(s.UserLogin, s.wrapDecryptionError(evt)).Success
case *events.Receipt: case *events.Receipt:
return s.handleSignalReceipt(evt) return s.handleSignalReceipt(evt)
case *events.ViewOnceOpen:
return s.handleSignalViewOnceOpen(evt)
case *events.ReadSelf: case *events.ReadSelf:
return s.handleSignalReadSelf(evt) return s.handleSignalReadSelf(evt)
case *events.DeleteForMe: case *events.DeleteForMe:
@ -67,7 +73,9 @@ func (s *SignalClient) handleSignalEvent(rawEvt events.SignalEvent) bool {
case *events.QueueEmpty: case *events.QueueEmpty:
s.queueEmptyWaiter.Set() s.queueEmptyWaiter.Set()
case *events.LoggedOut: case *events.LoggedOut:
s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Message: evt.Error.Error()}) s.sendLoggedOutState(evt.Error)
case *events.IdentityChange:
s.handleSignalIdentityChange(evt)
default: default:
s.UserLogin.Log.Warn().Type("event_type", evt).Msg("Unrecognized signalmeow event type") s.UserLogin.Log.Warn().Type("event_type", evt).Msg("Unrecognized signalmeow event type")
} }
@ -153,6 +161,44 @@ func convertDecryptionError(_ context.Context, _ *bridgev2.Portal, _ bridgev2.Ma
}, nil }, nil
} }
func (s *SignalClient) handleSignalIdentityChange(evt *events.IdentityChange) {
if !s.Main.Config.IdentityChangeNotices || evt.ServiceID.Type != libsignalgo.ServiceIDTypeACI || evt.ServiceID.UUID == s.Client.Store.ACI {
return
}
s.UserLogin.QueueRemoteEvent(&simplevent.Message[*events.IdentityChange]{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventMessage,
LogContext: func(c zerolog.Context) zerolog.Context {
return c.Stringer("sender_id", evt.ServiceID)
},
PortalKey: s.makeDMPortalKey(evt.ServiceID),
Sender: s.makeEventSender(evt.ServiceID.UUID),
},
Data: evt,
ID: "idchange|" + signalid.MakeMessageID(evt.ServiceID.UUID, uint64(time.Now().UnixMilli())),
ConvertMessageFunc: convertIdentityChange,
})
}
func convertIdentityChange(ctx context.Context, portal *bridgev2.Portal, _ bridgev2.MatrixAPI, evt *events.IdentityChange) (*bridgev2.ConvertedMessage, error) {
name := evt.ServiceID.UUID.String()
ghost, err := portal.Bridge.GetGhostByID(ctx, signalid.MakeUserID(evt.ServiceID.UUID))
if err != nil {
return nil, err
} else if ghost.Name != "" {
name = ghost.Name
}
return &bridgev2.ConvertedMessage{
Parts: []*bridgev2.ConvertedMessagePart{{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: fmt.Sprintf("Your safety number with %s has changed.", name),
},
}},
}, nil
}
type Bv2ChatEvent struct { type Bv2ChatEvent struct {
*events.ChatEvent *events.ChatEvent
s *SignalClient s *SignalClient
@ -175,8 +221,9 @@ func (evt *Bv2ChatEvent) GetType() bridgev2.RemoteEventType {
switch innerEvt := evt.Event.(type) { switch innerEvt := evt.Event.(type) {
case *signalpb.DataMessage: case *signalpb.DataMessage:
switch { switch {
case innerEvt.Body != nil, innerEvt.Attachments != nil, innerEvt.Contact != nil, innerEvt.Sticker != nil, case innerEvt.GetIsViewOnce(), 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.Payment != nil, innerEvt.GiftBadge != nil, innerEvt.PollCreate != nil, innerEvt.PollVote != nil,
innerEvt.PinMessage != nil, innerEvt.UnpinMessage != nil,
innerEvt.GetRequiredProtocolVersion() > uint32(signalpb.DataMessage_CURRENT), innerEvt.GetRequiredProtocolVersion() > uint32(signalpb.DataMessage_CURRENT),
innerEvt.GetFlags()&uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE) != 0: innerEvt.GetFlags()&uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE) != 0:
return bridgev2.RemoteEventMessage return bridgev2.RemoteEventMessage
@ -198,17 +245,25 @@ func (evt *Bv2ChatEvent) GetType() bridgev2.RemoteEventType {
return bridgev2.RemoteEventUnknown return bridgev2.RemoteEventUnknown
} }
func (evt *Bv2ChatEvent) GetChatInfoChange(ctx context.Context) (*bridgev2.ChatInfoChange, error) { func (evt *Bv2ChatEvent) GetChatInfoChange(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfoChange, error) {
dm, _ := evt.Event.(*signalpb.DataMessage) dm, _ := evt.Event.(*signalpb.DataMessage)
gv2 := dm.GetGroupV2() gv2 := dm.GetGroupV2()
if gv2 == nil || gv2.GroupChange == nil { if gv2 == nil || gv2.GroupChange == nil {
return nil, fmt.Errorf("GetChatInfoChange() called for non-GroupChange event") return nil, fmt.Errorf("GetChatInfoChange() called for non-GroupChange event")
} }
portalRevision := portal.Metadata.(*signalid.PortalMetadata).Revision
groupChange, err := evt.s.Client.DecryptGroupChange(ctx, gv2) groupChange, err := evt.s.Client.DecryptGroupChange(ctx, gv2)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt group change: %w", err) zerolog.Ctx(ctx).Err(err).Msg("Failed to decrypt group change")
return nil, nil
} else if groupChange.Revision != gv2.GetRevision() || groupChange.Revision != portalRevision+1 {
zerolog.Ctx(ctx).Warn().
Uint32("decrypted_revision", groupChange.Revision).
Uint32("envelope_revision", gv2.GetRevision()).
Uint32("portal_revision", portalRevision).
Msg("Group change revision mismatch, ignoring event")
return nil, nil
} }
// XXX: is this ID compatible with types.GroupIdentifier?
return evt.s.groupChangeToChatInfoChange(ctx, types.GroupIdentifier(evt.Info.ChatID), gv2.GetRevision(), groupChange) return evt.s.groupChangeToChatInfoChange(ctx, types.GroupIdentifier(evt.Info.ChatID), gv2.GetRevision(), groupChange)
} }
@ -217,13 +272,16 @@ func (evt *Bv2ChatEvent) PreHandle(ctx context.Context, portal *bridgev2.Portal)
if !ok || dataMsg.GroupV2 == nil { if !ok || dataMsg.GroupV2 == nil {
return return
} }
portalRev := portal.Metadata.(*signalid.PortalMetadata).Revision portalMeta := portal.Metadata.(*signalid.PortalMetadata)
portalRev := portalMeta.Revision
if evt.Info.GroupRevision > portalRev { if evt.Info.GroupRevision > portalRev {
toRevision := evt.Info.GroupRevision toRevision := evt.Info.GroupRevision
if dataMsg.GetGroupV2().GetGroupChange() != nil { if dataMsg.GetGroupV2().GetGroupChange() != nil {
toRevision-- toRevision--
} }
evt.s.catchUpGroup(ctx, portal, portalRev, toRevision, dataMsg.GetTimestamp()) evt.s.catchUpGroup(ctx, portal, portalRev, toRevision, dataMsg.GetTimestamp())
} else if portalMeta.InfoVersion < currentPortalInfoVersion && portal.MXID != "" {
evt.s.catchUpGroup(ctx, portal, 0, portalRev, dataMsg.GetTimestamp())
} }
} }
@ -341,6 +399,16 @@ func (evt *Bv2ChatEvent) ConvertMessage(ctx context.Context, portal *bridgev2.Po
return nil, fmt.Errorf("ConvertMessage() called for non-DataMessage event") 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) converted := evt.s.Main.MsgConv.ToMatrix(ctx, evt.s.Client, portal, evt.Info.Sender, intent, dataMsg, nil)
for _, part := range converted.Parts {
meta, ok := part.DBMetadata.(*signalid.MessageMetadata)
if !ok {
meta = &signalid.MessageMetadata{}
part.DBMetadata = meta
}
meta.ServerGUID = evt.Info.ServerGUID
meta.ReportingToken = evt.Info.ReportingToken
}
if converted.Disappear.Type != "" { if converted.Disappear.Type != "" {
evtTS := evt.GetTimestamp() evtTS := evt.GetTimestamp()
if !dataMsg.GetIsViewOnce() { if !dataMsg.GetIsViewOnce() {
@ -384,6 +452,14 @@ func (evt *Bv2ChatEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Porta
if !ok { if !ok {
return nil, fmt.Errorf("ConvertEdit() called for non-EditMessage event") return nil, fmt.Errorf("ConvertEdit() called for non-EditMessage event")
} }
if editMsg.GetDataMessage().GetIsViewOnce() {
return nil, bridgev2.ErrIgnoringRemoteEvent
}
for _, part := range existing {
if part.Metadata.(*signalid.MessageMetadata).ViewOnce {
return nil, fmt.Errorf("%w: edit target is view-once media", bridgev2.ErrIgnoringRemoteEvent)
}
}
existing = slices.DeleteFunc(slices.Clone(existing), isEditStub) existing = slices.DeleteFunc(slices.Clone(existing), isEditStub)
if len(existing) == 0 { if len(existing) == 0 {
return nil, fmt.Errorf("%w: edit target has already been edited", bridgev2.ErrIgnoringRemoteEvent) return nil, fmt.Errorf("%w: edit target has already been edited", bridgev2.ErrIgnoringRemoteEvent)
@ -512,6 +588,54 @@ func (s *SignalClient) handleSignalReceipt(evt *events.Receipt) bool {
return s.dispatchReceipts(evt.Sender, evt.Content.GetType(), receipts) return s.dispatchReceipts(evt.Sender, evt.Content.GetType(), receipts)
} }
func (s *SignalClient) handleSignalViewOnceOpen(evt *events.ViewOnceOpen) bool {
ctx := s.Main.Bridge.BackgroundCtx
log := s.UserLogin.Log.With().
Str("action", "handle signal view once open").
Logger()
ctx = log.WithContext(ctx)
sender, err := signalmeow.ParseStringOrBinaryUUID(evt.GetSenderAci(), evt.GetSenderAciBinary())
if err != nil {
log.Err(err).Msg("Invalid sender in view-once open sync")
return true
}
messageID := signalid.MakeMessageID(sender, evt.GetTimestamp())
portalKey, ok := s.recentMessageRooms.Get(messageID)
if !ok {
msg, dbErr := s.Main.Bridge.DB.Message.GetFirstPartByID(ctx, s.UserLogin.ID, messageID)
if dbErr != nil {
log.Err(dbErr).Msg("Failed to get opened view-once message")
return ctx.Err() == nil
} else if msg == nil {
return true
}
portalKey = msg.Room
}
return s.Main.Bridge.QueueRemoteEvent(s.UserLogin, &simplevent.EventMeta{
Type: bridgev2.RemoteEventUnknown,
PortalKey: portalKey,
PreHandleFunc: func(ctx context.Context, portal *bridgev2.Portal) {
messages, dbErr := s.Main.Bridge.DB.Message.GetAllPartsByID(ctx, s.UserLogin.ID, messageID)
if dbErr != nil {
log.Err(dbErr).Msg("Failed to get opened view-once message")
return
}
for _, msg := range messages {
meta := msg.Metadata.(*signalid.MessageMetadata)
if !meta.ViewOnce || !meta.ContainsAttachments {
continue
}
if addErr := s.Main.Bridge.DisappearLoop.Add(ctx, &database.DisappearingMessage{
RoomID: portal.MXID, EventID: msg.MXID, Timestamp: msg.Timestamp,
DisappearingSetting: database.DisappearingSetting{Type: "view_limited", DisappearAt: time.Now()},
}); addErr != nil {
log.Err(addErr).Msg("Failed to queue opened view-once media expiry")
}
}
},
}).Success
}
func (s *SignalClient) handleSignalReadSelf(evt *events.ReadSelf) bool { func (s *SignalClient) handleSignalReadSelf(evt *events.ReadSelf) bool {
log := s.UserLogin.Log.With(). log := s.UserLogin.Log.With().
Str("action", "handle signal read self"). Str("action", "handle signal read self").
@ -689,8 +813,14 @@ func (s *SignalClient) handleSignalDeleteForMe(evt *events.DeleteForMe) bool {
} }
func (s *SignalClient) handleSignalMessageRequestResponse(evt *events.MessageRequestResponse) bool { func (s *SignalClient) handleSignalMessageRequestResponse(evt *events.MessageRequestResponse) bool {
if evt.Type != signalpb.SyncMessage_MessageRequestResponse_ACCEPT { info := &bridgev2.ChatInfo{}
// TODO do we need to do anything with blocks/deletes here or are they sent as normal delete events? switch evt.Type {
case signalpb.SyncMessage_MessageRequestResponse_ACCEPT:
info.MessageRequest = ptr.Ptr(false)
info.UserBlocked = ptr.Ptr(false)
case signalpb.SyncMessage_MessageRequestResponse_BLOCK, signalpb.SyncMessage_MessageRequestResponse_BLOCK_AND_SPAM, signalpb.SyncMessage_MessageRequestResponse_BLOCK_AND_DELETE:
info.UserBlocked = ptr.Ptr(true)
default:
return true return true
} }
var portalKey networkid.PortalKey var portalKey networkid.PortalKey
@ -712,9 +842,7 @@ func (s *SignalClient) handleSignalMessageRequestResponse(evt *events.MessageReq
}, },
}, },
ChatInfoChange: &bridgev2.ChatInfoChange{ ChatInfoChange: &bridgev2.ChatInfoChange{
ChatInfo: &bridgev2.ChatInfo{ ChatInfo: info,
MessageRequest: ptr.Ptr(false),
},
}, },
}) })
return res.Success return res.Success
@ -765,7 +893,7 @@ func (s *SignalClient) handleSignalContactList(evt *events.ContactList) {
log.Err(err).Msg("Failed to get ghost to update contact info") log.Err(err).Msg("Failed to get ghost to update contact info")
continue continue
} }
userInfo, err := s.contactToUserInfo(ctx, contact) userInfo, err := s.contactToUserInfo(ctx, contact, ghost.Metadata.(*signalid.GhostMetadata).Username, false)
if err != nil { if err != nil {
log.Err(err).Msg("Failed to convert contact info") log.Err(err).Msg("Failed to convert contact info")
continue continue
@ -774,28 +902,24 @@ func (s *SignalClient) handleSignalContactList(evt *events.ContactList) {
if contact.ACI == s.Client.Store.ACI { if contact.ACI == s.Client.Store.ACI {
s.updateRemoteProfile(ctx, true) s.updateRemoteProfile(ctx, true)
} }
if ptr.Val(contact.Whitelisted) { portal, err := s.Main.Bridge.GetExistingPortalByKey(ctx, s.makeDMPortalKey(libsignalgo.NewACIServiceID(contact.ACI)))
portal, err := s.Main.Bridge.GetExistingPortalByKey(ctx, s.makeDMPortalKey(libsignalgo.NewACIServiceID(contact.ACI))) if err != nil {
if err != nil { log.Err(err).Msg("Failed to get existing portal to update contact info")
log.Err(err).Msg("Failed to get existing portal to update contact info") continue
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),
},
},
})
}
} }
if portal != nil && (portal.UserBlocked != contact.Blocked || (portal.MessageRequest && ptr.Val(contact.Whitelisted))) {
info := &bridgev2.ChatInfo{UserBlocked: ptr.Ptr(contact.Blocked)}
if ptr.Val(contact.Whitelisted) {
info.MessageRequest = ptr.Ptr(false)
}
s.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
EventMeta: simplevent.EventMeta{Type: bridgev2.RemoteEventChatInfoChange, PortalKey: portal.PortalKey},
ChatInfoChange: &bridgev2.ChatInfoChange{ChatInfo: info},
})
}
}
if evt.IsFromDB {
return
} }
s.UserLogin.Metadata.(*signalid.UserLoginMetadata).LastContactSync = jsontime.UnixMilliNow() s.UserLogin.Metadata.(*signalid.UserLoginMetadata).LastContactSync = jsontime.UnixMilliNow()
err := s.UserLogin.Save(ctx) err := s.UserLogin.Save(ctx)
@ -814,6 +938,13 @@ func (s *SignalClient) updateRemoteProfile(ctx context.Context, resendState bool
} }
} }
changed := false changed := false
if s.Client.Store.AccountRecord != nil && s.Client.Store.AccountRecord.Username != s.UserLogin.RemoteProfile.Username {
s.UserLogin.RemoteProfile.Username = s.Client.Store.AccountRecord.Username
if s.Client.Store.PNI == uuid.Nil {
s.UserLogin.RemoteName = cmp.Or(s.Client.Store.AccountRecord.Username, s.Client.Store.ACI.String())
}
changed = true
}
if s.UserLogin.RemoteProfile.Name != s.Ghost.Name { if s.UserLogin.RemoteProfile.Name != s.Ghost.Name {
s.UserLogin.RemoteProfile.Name = s.Ghost.Name s.UserLogin.RemoteProfile.Name = s.Ghost.Name
changed = true changed = true

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