all: delete legacy bridge

This commit is contained in:
Tulir Asokan 2024-08-07 02:14:39 +03:00
parent c246473b52
commit 0c9f2c19d2
75 changed files with 562 additions and 10737 deletions

2
.gitignore vendored
View file

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

View file

@ -1,17 +1,12 @@
include:
- project: 'mautrix/ci'
file: '/go.yml'
- project: 'mautrix/ci'
file: '/gov2.yml'
variables:
BUILDER_IMAGE: dock.mau.dev/tulir/gomuks-build-docker/signal
BINARY_NAME_V2: mautrix-signal
# 32-bit arm builds aren't supported
build arm:
rules:
- when: never
build arm v2:
rules:
- when: never

View file

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
rev: v4.6.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
@ -18,11 +18,11 @@ repos:
- "go.mau.fi/mautrix-signal"
- "-w"
- id: go-vet-mod
#- id: go-staticcheck-repo-mod
# - id: go-staticcheck-repo-mod
# TODO: reenable this and fix all the problems
- repo: https://github.com/beeper/pre-commit-go
rev: v0.3.0
rev: v0.3.1
hooks:
- id: zerolog-ban-msgf
- id: zerolog-use-stringer

View file

@ -1,3 +1,9 @@
# v0.7.0 (unreleased)
* Updated to libsignal v0.54.0.
* Rewrote bridge using bridgev2 architecture.
* It is recommended to check the config file after upgrading.
# v0.6.3 (2024-07-16)
* Updated to libsignal v0.52.0.

View file

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

View file

@ -1,28 +0,0 @@
.PHONY: all build_rust copy_library build_go clean
all: build_rust copy_library build_go
LIBRARY_FILENAME=libsignal_ffi.a
RUST_DIR=pkg/libsignalgo/libsignal
GO_BINARY=mautrix-signal
# TODO fix linking with debug library
#ifneq ($(DBG),1)
RUST_TARGET_SUBDIR=release
#else
#RUST_TARGET_SUBDIR=debug
#endif
build_rust:
./build-rust.sh
copy_library:
cp $(RUST_DIR)/target/$(RUST_TARGET_SUBDIR)/$(LIBRARY_FILENAME) .
build_go:
LIBRARY_PATH="$${LIBRARY_PATH}:." ./build-go.sh
clean:
rm -f ./$(LIBRARY_FILENAME)
cd $(RUST_DIR) && cargo clean
rm -f $(GO_BINARY)

View file

@ -1,17 +1,17 @@
# Features & roadmap
* Matrix → Signal
* [ ] Message content
* [x] Message content
* [x] Text
* [x] Formatting
* [x] Mentions
* [ ] Media
* [x] Media
* [x] Images
* [x] Audio files
* [x] Voice messages
* [x] Files
* [x] Gifs
* [ ] Locations
* [x] Locations
* [x] Stickers
* [x] Message edits
* [x] Message reactions
@ -22,9 +22,9 @@
* [x] Topic
* [ ] Membership actions
* [ ] Join (accepting invites)
* [x] Invite
* [x] Leave
* [x] Kick/Ban/Unban
* [ ] Invite
* [ ] Leave
* [ ] Kick/Ban/Unban
* [x] Group permissions
* [x] Typing notifications
* [x] Read receipts
@ -70,5 +70,5 @@
* [x] When receiving message
* [x] Linking as secondary device
* [ ] Registering as primary device
* [x] Private chat/group creation by inviting Matrix puppet of Signal user to new room
* [ ] Private chat/group creation by inviting Matrix puppet of Signal user to new room
* [x] Option to use own Matrix account for messages sent from other Signal clients

View file

@ -1,9 +0,0 @@
#!/bin/sh
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
GO_LDFLAGS="-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
if [ "$DBG" = 1 ]; then
GO_GCFLAGS='all=-N -l'
else
GO_LDFLAGS="-s -w ${GO_LDFLAGS}"
fi
go build -gcflags="$GO_GCFLAGS" -ldflags="$GO_LDFLAGS" -o mautrix-signal-v2 ./cmd/mautrix-signal-v2 "$@"

View file

@ -1,9 +1,9 @@
#!/bin/sh
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
GO_LDFLAGS="-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
GO_LDFLAGS="-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
if [ "$DBG" = 1 ]; then
GO_GCFLAGS='all=-N -l'
else
GO_LDFLAGS="-s -w ${GO_LDFLAGS}"
fi
go build -gcflags="$GO_GCFLAGS" -ldflags="$GO_LDFLAGS" -o mautrix-signal "$@"
go build -gcflags="$GO_GCFLAGS" -ldflags="$GO_LDFLAGS" -o mautrix-signal ./cmd/mautrix-signal "$@"

View file

@ -1,4 +1,4 @@
#!/bin/sh
#./build-rust.sh
#cp -f pkg/libsignalgo/libsignal/target/release/libsignal_ffi.a .
LIBRARY_PATH=.:$LIBRARY_PATH ./build-go-v2.sh
./build-rust.sh
cp -f pkg/libsignalgo/libsignal/target/release/libsignal_ffi.a .
LIBRARY_PATH=.:$LIBRARY_PATH ./build-go.sh

View file

@ -1,4 +0,0 @@
#!/bin/sh
git submodule init
git submodule update
make

View file

@ -28,8 +28,8 @@ import (
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/legacyprovision"
"go.mau.fi/mautrix-signal/pkg/connector"
)
@ -54,7 +54,7 @@ func legacyProvLinkNew(w http.ResponseWriter, r *http.Request) {
user := m.Matrix.Provisioning.GetUser(r)
defLogin := user.GetDefaultLogin()
if defLogin != nil && defLogin.Client != nil && defLogin.Client.IsLoggedIn() {
legacyprovision.JSONResponse(w, http.StatusConflict, &legacyprovision.Error{
JSONResponse(w, http.StatusConflict, &Error{
Error: "Already logged in",
ErrCode: "FI.MAU.ALREADY_LOGGED_IN",
})
@ -64,7 +64,7 @@ func legacyProvLinkNew(w http.ResponseWriter, r *http.Request) {
login, err := m.Connector.CreateLogin(r.Context(), user, "qr")
if err != nil {
log.Err(err).Msg("Failed to create login")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, &legacyprovision.Error{
JSONResponse(w, http.StatusInternalServerError, &Error{
Error: "Internal error starting login",
ErrCode: "M_UNKNOWN",
})
@ -73,14 +73,14 @@ func legacyProvLinkNew(w http.ResponseWriter, r *http.Request) {
firstStep, err := login.Start(r.Context())
if err != nil {
log.Err(err).Msg("Failed to start login")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, &legacyprovision.Error{
JSONResponse(w, http.StatusInternalServerError, &Error{
Error: "Internal error starting login",
ErrCode: "M_UNKNOWN",
})
return
} else if firstStep.StepID != connector.LoginStepQR || firstStep.Type != bridgev2.LoginStepTypeDisplayAndWait || firstStep.DisplayAndWaitParams.Type != bridgev2.LoginDisplayTypeQR {
log.Error().Any("first_step", firstStep).Msg("Unexpected first step")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, &legacyprovision.Error{
JSONResponse(w, http.StatusInternalServerError, &Error{
Error: "Unexpected first login step",
ErrCode: "M_UNKNOWN",
})
@ -93,7 +93,7 @@ func legacyProvLinkNew(w http.ResponseWriter, r *http.Request) {
User: user,
}
loginSessionsLock.Unlock()
legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{
JSONResponse(w, http.StatusOK, Response{
Success: true,
Status: "provisioning_url_received",
SessionID: strconv.Itoa(int(handleID)),
@ -102,10 +102,10 @@ func legacyProvLinkNew(w http.ResponseWriter, r *http.Request) {
}
func getLoginProcess(w http.ResponseWriter, r *http.Request) *legacyLoginProcess {
var body legacyprovision.LinkWaitForAccountRequest
var body LinkWaitForAccountRequest
err := json.NewDecoder(r.Body).Decode(&body)
if err != nil {
legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{
JSONResponse(w, http.StatusBadRequest, Error{
Success: false,
Error: "Error decoding JSON body",
ErrCode: mautrix.MBadJSON.ErrCode,
@ -114,7 +114,7 @@ func getLoginProcess(w http.ResponseWriter, r *http.Request) *legacyLoginProcess
}
sessionID, err := strconv.Atoi(body.SessionID)
if err != nil {
legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{
JSONResponse(w, http.StatusBadRequest, Error{
Success: false,
Error: "Error decoding session ID in JSON body",
ErrCode: mautrix.MBadJSON.ErrCode,
@ -124,7 +124,7 @@ func getLoginProcess(w http.ResponseWriter, r *http.Request) *legacyLoginProcess
process, ok := loginSessions[uint32(sessionID)]
user := m.Matrix.Provisioning.GetUser(r)
if !ok || process.User != user {
legacyprovision.JSONResponse(w, http.StatusNotFound, legacyprovision.Error{
JSONResponse(w, http.StatusNotFound, Error{
Success: false,
Error: "No session found",
ErrCode: mautrix.MNotFound.ErrCode,
@ -142,7 +142,7 @@ func legacyProvLinkWaitScan(w http.ResponseWriter, r *http.Request) {
res, err := login.Login.(bridgev2.LoginProcessDisplayAndWait).Wait(r.Context())
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to log in")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{
JSONResponse(w, http.StatusInternalServerError, Error{
Error: "Failed to log in",
ErrCode: "M_UNKNOWN",
})
@ -150,14 +150,14 @@ func legacyProvLinkWaitScan(w http.ResponseWriter, r *http.Request) {
return
} else if res.StepID != connector.LoginStepProcess {
zerolog.Ctx(r.Context()).Error().Any("first_step", res).Msg("Unexpected login step")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{
JSONResponse(w, http.StatusInternalServerError, Error{
Error: "Unexpected login step",
ErrCode: "M_UNKNOWN",
})
login.Delete()
return
}
legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{
JSONResponse(w, http.StatusOK, Response{
Success: true,
Status: "provisioning_data_received",
})
@ -171,18 +171,18 @@ func legacyProvLinkWaitAccount(w http.ResponseWriter, r *http.Request) {
res, err := login.Login.(bridgev2.LoginProcessDisplayAndWait).Wait(r.Context())
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to log in")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{
JSONResponse(w, http.StatusInternalServerError, Error{
Error: "Failed to log in",
ErrCode: "M_UNKNOWN",
})
} else if res.StepID != connector.LoginStepComplete || res.Type != bridgev2.LoginStepTypeComplete {
zerolog.Ctx(r.Context()).Error().Any("first_step", res).Msg("Unexpected login step")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{
JSONResponse(w, http.StatusInternalServerError, Error{
Error: "Unexpected login step",
ErrCode: "M_UNKNOWN",
})
} else {
legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{
JSONResponse(w, http.StatusOK, Response{
Success: true,
Status: "prekeys_registered",
UUID: string(res.CompleteParams.UserLogin.ID),
@ -194,7 +194,7 @@ func legacyProvLinkWaitAccount(w http.ResponseWriter, r *http.Request) {
func legacyProvLogout(w http.ResponseWriter, r *http.Request) {
// No-op for backwards compatibility
legacyprovision.JSONResponse(w, http.StatusOK, nil)
JSONResponse(w, http.StatusOK, nil)
}
func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request, create bool) {
@ -206,21 +206,21 @@ func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request,
resp, err := api.ResolveIdentifier(r.Context(), mux.Vars(r)["phonenum"], create)
if err != nil {
zerolog.Ctx(r.Context()).Err(err).Msg("Failed to resolve identifier")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, &legacyprovision.Error{
JSONResponse(w, http.StatusInternalServerError, &Error{
Error: fmt.Sprintf("Failed to resolve identifier: %v", err),
ErrCode: "M_UNKNOWN",
})
return
} else if resp == nil {
legacyprovision.JSONResponse(w, http.StatusNotFound, &legacyprovision.Error{
JSONResponse(w, http.StatusNotFound, &Error{
ErrCode: mautrix.MNotFound.ErrCode,
Error: "User not found on Signal",
})
return
}
status := http.StatusOK
apiResp := &legacyprovision.ResolveIdentifierResponse{
ChatID: legacyprovision.ResolveIdentifierResponseChatID{
apiResp := &ResolveIdentifierResponse{
ChatID: ResolveIdentifierResponseChatID{
UUID: string(resp.UserID),
Number: "",
},
@ -229,7 +229,7 @@ func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request,
if resp.UserInfo != nil {
resp.Ghost.UpdateInfo(r.Context(), resp.UserInfo)
}
apiResp.OtherUser = &legacyprovision.ResolveIdentifierResponseOtherUser{
apiResp.OtherUser = &ResolveIdentifierResponseOtherUser{
MXID: resp.Ghost.Intent.GetMXID(),
DisplayName: resp.Ghost.Name,
AvatarURL: resp.Ghost.AvatarMXC.ParseOrIgnore(),
@ -240,7 +240,7 @@ func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request,
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")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, &mautrix.RespError{
JSONResponse(w, http.StatusInternalServerError, &mautrix.RespError{
Err: "Failed to get portal",
ErrCode: "M_UNKNOWN",
})
@ -253,7 +253,7 @@ func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request,
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")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, &mautrix.RespError{
JSONResponse(w, http.StatusInternalServerError, &mautrix.RespError{
Err: "Failed to create portal room",
ErrCode: "M_UNKNOWN",
})
@ -262,7 +262,7 @@ func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request,
}
apiResp.RoomID = resp.Chat.Portal.MXID
}
legacyprovision.JSONResponse(w, status, &legacyprovision.Response{
JSONResponse(w, status, &Response{
Success: true,
Status: "ok",
ResolveIdentifierResponse: apiResp,
@ -276,3 +276,71 @@ func legacyProvResolveIdentifier(w http.ResponseWriter, r *http.Request) {
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 LinkNew
SessionID string `json:"session_id,omitempty"`
URI string `json:"uri,omitempty"`
// For response in LinkWaitForAccount
UUID string `json:"uuid,omitempty"`
Number string `json:"number,omitempty"`
// For response in ResolveIdentifier
*ResolveIdentifierResponse
}
type WhoAmIResponse struct {
Permissions int `json:"permissions"`
MXID string `json:"mxid"`
Signal *WhoAmIResponseSignal `json:"signal,omitempty"`
}
type WhoAmIResponseSignal struct {
Number string `json:"number"`
UUID string `json:"uuid"`
Name string `json:"name"`
Ok bool `json:"ok"`
}
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"`
}
type LinkWaitForScanRequest struct {
SessionID string `json:"session_id"`
}
type LinkWaitForAccountRequest struct {
SessionID string `json:"session_id"`
DeviceName string `json:"device_name"` // TODO this seems to not be used anywhere
}

File diff suppressed because it is too large Load diff

View file

@ -1,241 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package config
import (
"errors"
"fmt"
"strings"
"text/template"
"time"
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
type BridgeConfig struct {
UsernameTemplate string `yaml:"username_template"`
DisplaynameTemplate string `yaml:"displayname_template"`
PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"`
UseContactAvatars bool `yaml:"use_contact_avatars"`
UseOutdatedProfiles bool `yaml:"use_outdated_profiles"`
NumberInTopic bool `yaml:"number_in_topic"`
NoteToSelfAvatar id.ContentURIString `yaml:"note_to_self_avatar"`
PortalMessageBuffer int `yaml:"portal_message_buffer"`
PersonalFilteringSpaces bool `yaml:"personal_filtering_spaces"`
BridgeNotices bool `yaml:"bridge_notices"`
DeliveryReceipts bool `yaml:"delivery_receipts"`
MessageStatusEvents bool `yaml:"message_status_events"`
MessageErrorNotices bool `yaml:"message_error_notices"`
SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
ResendBridgeInfo bool `yaml:"resend_bridge_info"`
PublicPortals bool `yaml:"public_portals"`
CaptionInMessage bool `yaml:"caption_in_message"`
LocationFormat string `yaml:"location_format"`
FederateRooms bool `yaml:"federate_rooms"`
BridgeMatrixLeave bool `yaml:"bridge_matrix_leave"`
DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"`
MessageHandlingTimeout struct {
ErrorAfterStr string `yaml:"error_after"`
DeadlineStr string `yaml:"deadline"`
ErrorAfter time.Duration `yaml:"-"`
Deadline time.Duration `yaml:"-"`
} `yaml:"message_handling_timeout"`
CommandPrefix string `yaml:"command_prefix"`
ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"`
Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"`
Provisioning struct {
Prefix string `yaml:"prefix"`
SharedSecret string `yaml:"shared_secret"`
DebugEndpoints bool `yaml:"debug_endpoints"`
} `yaml:"provisioning"`
Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`
Relay RelaybotConfig `yaml:"relay"`
usernameTemplate *template.Template `yaml:"-"`
displaynameTemplate *template.Template `yaml:"-"`
}
func (bc *BridgeConfig) GetResendBridgeInfo() bool {
return bc.ResendBridgeInfo
}
func (bc *BridgeConfig) EnableMessageStatusEvents() bool {
return bc.MessageStatusEvents
}
func (bc *BridgeConfig) EnableMessageErrorNotices() bool {
return bc.MessageErrorNotices
}
func boolToInt(val bool) int {
if val {
return 1
}
return 0
}
func (bc *BridgeConfig) Validate() error {
_, hasWildcard := bc.Permissions["*"]
_, hasExampleDomain := bc.Permissions["example.com"]
_, hasExampleUser := bc.Permissions["@admin:example.com"]
exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain)
if len(bc.Permissions) <= exampleLen {
return errors.New("bridge.permissions not configured")
}
return nil
}
type umBridgeConfig BridgeConfig
func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
err := unmarshal((*umBridgeConfig)(bc))
if err != nil {
return err
}
bc.usernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate)
if err != nil {
return err
} else if !strings.Contains(bc.FormatUsername("1234567890"), "1234567890") {
return fmt.Errorf("username template is missing user ID placeholder")
}
bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate)
if err != nil {
return err
}
return nil
}
var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil)
func (bc BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig {
return bc.DoublePuppetConfig
}
func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
return bc.Encryption
}
func (bc BridgeConfig) GetCommandPrefix() string {
return bc.CommandPrefix
}
func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts {
return bc.ManagementRoomText
}
func (bc BridgeConfig) FormatUsername(userID string) string {
var buffer strings.Builder
_ = bc.usernameTemplate.Execute(&buffer, userID)
return buffer.String()
}
type DisplaynameParams struct {
ProfileName string
ContactName string
Username string
PhoneNumber string
UUID string
ACI string
PNI string
AboutEmoji string
}
func (bc BridgeConfig) FormatDisplayname(contact *types.Recipient) string {
var buffer strings.Builder
_ = bc.displaynameTemplate.Execute(&buffer, DisplaynameParams{
ProfileName: contact.Profile.Name,
ContactName: contact.ContactName,
//Username: contact.Username,
PhoneNumber: contact.E164,
UUID: contact.ACI.String(),
ACI: contact.ACI.String(),
PNI: contact.PNI.String(),
AboutEmoji: contact.Profile.AboutEmoji,
})
return buffer.String()
}
type RelaybotConfig struct {
Enabled bool `yaml:"enabled"`
AdminOnly bool `yaml:"admin_only"`
MessageFormats map[event.MessageType]string `yaml:"message_formats"`
messageTemplates *template.Template `yaml:"-"`
}
type umRelaybotConfig RelaybotConfig
func (rc *RelaybotConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
err := unmarshal((*umRelaybotConfig)(rc))
if err != nil {
return err
}
rc.messageTemplates = template.New("messageTemplates")
for key, format := range rc.MessageFormats {
_, err := rc.messageTemplates.New(string(key)).Parse(format)
if err != nil {
return err
}
}
return nil
}
type Sender struct {
UserID string
event.MemberEventContent
}
type formatData struct {
Sender Sender
Message string
Content *event.MessageEventContent
}
func (rc *RelaybotConfig) FormatMessage(content *event.MessageEventContent, sender id.UserID, member event.MemberEventContent) (string, error) {
if len(member.Displayname) == 0 {
member.Displayname = sender.String()
}
member.Displayname = template.HTMLEscapeString(member.Displayname)
var output strings.Builder
err := rc.messageTemplates.ExecuteTemplate(&output, string(content.MsgType), formatData{
Sender: Sender{
UserID: template.HTMLEscapeString(sender.String()),
MemberEventContent: member,
},
Content: content,
Message: content.FormattedBody,
})
return output.String(), err
}

View file

@ -1,44 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package config
import (
"maunium.net/go/mautrix/bridge/bridgeconfig"
"maunium.net/go/mautrix/id"
)
type Config struct {
*bridgeconfig.BaseConfig `yaml:",inline"`
Metrics struct {
Enabled bool `yaml:"enabled"`
Listen string `yaml:"listen"`
} `yaml:"metrics"`
Signal struct {
DeviceName string `yaml:"device_name"`
} `yaml:"signal"`
Bridge BridgeConfig `yaml:"bridge"`
}
func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
_, homeserver, _ := userID.Parse()
_, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver]
return hasSecret
}

View file

@ -1,167 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2022 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package config
import (
"net/url"
"strings"
up "go.mau.fi/util/configupgrade"
"go.mau.fi/util/random"
"maunium.net/go/mautrix/bridge/bridgeconfig"
)
func DoUpgrade(helper up.Helper) {
bridgeconfig.Upgrader.DoUpgrade(helper)
legacyDB, ok := helper.Get(up.Str, "appservice", "database")
if ok {
if strings.HasPrefix(legacyDB, "postgres") {
parsedDB, err := url.Parse(legacyDB)
if err != nil {
panic(err)
}
q := parsedDB.Query()
if parsedDB.Host == "" && !q.Has("host") {
q.Set("host", "/var/run/postgresql")
} else if !q.Has("sslmode") {
q.Set("sslmode", "disable")
}
parsedDB.RawQuery = q.Encode()
helper.Set(up.Str, parsedDB.String(), "appservice", "database", "uri")
helper.Set(up.Str, "postgres", "appservice", "database", "type")
} else {
dbPath := strings.TrimPrefix(strings.TrimPrefix(legacyDB, "sqlite:"), "///")
helper.Set(up.Str, dbPath, "appservice", "database", "uri")
helper.Set(up.Str, "sqlite3-fk-wal", "appservice", "database", "type")
}
}
if legacyDBMinSize, ok := helper.Get(up.Int, "appservice", "database_opts", "min_size"); ok {
helper.Set(up.Int, legacyDBMinSize, "appservice", "database", "max_idle_conns")
}
if legacyDBMaxSize, ok := helper.Get(up.Int, "appservice", "database_opts", "max_size"); ok {
helper.Set(up.Int, legacyDBMaxSize, "appservice", "database", "max_open_conns")
}
if legacyBotUsername, ok := helper.Get(up.Str, "appservice", "bot_username"); ok {
helper.Set(up.Str, legacyBotUsername, "appservice", "bot", "username")
}
if legacyBotDisplayname, ok := helper.Get(up.Str, "appservice", "bot_displayname"); ok {
helper.Set(up.Str, legacyBotDisplayname, "appservice", "bot", "displayname")
}
if legacyBotAvatar, ok := helper.Get(up.Str, "appservice", "bot_avatar"); ok {
helper.Set(up.Str, legacyBotAvatar, "appservice", "bot", "avatar")
}
helper.Copy(up.Bool, "metrics", "enabled")
helper.Copy(up.Str, "metrics", "listen")
helper.Copy(up.Str, "signal", "device_name")
if usernameTemplate, ok := helper.Get(up.Str, "bridge", "username_template"); ok && strings.Contains(usernameTemplate, "{userid}") {
helper.Set(up.Str, strings.ReplaceAll(usernameTemplate, "{userid}", "{{.}}"), "bridge", "username_template")
} else {
helper.Copy(up.Str, "bridge", "username_template")
}
if displaynameTemplate, ok := helper.Get(up.Str, "bridge", "displayname_template"); ok && strings.Contains(displaynameTemplate, "{displayname}") {
helper.Set(up.Str, strings.ReplaceAll(displaynameTemplate, "{displayname}", `{{or .ProfileName .PhoneNumber "Unknown user"}}`), "bridge", "displayname_template")
} else {
helper.Copy(up.Str, "bridge", "displayname_template")
}
helper.Copy(up.Str, "bridge", "private_chat_portal_meta")
helper.Copy(up.Bool, "bridge", "use_contact_avatars")
helper.Copy(up.Bool, "bridge", "use_outdated_profiles")
helper.Copy(up.Bool, "bridge", "number_in_topic")
helper.Copy(up.Str, "bridge", "note_to_self_avatar")
helper.Copy(up.Int, "bridge", "portal_message_buffer")
helper.Copy(up.Bool, "bridge", "personal_filtering_spaces")
helper.Copy(up.Bool, "bridge", "bridge_notices")
helper.Copy(up.Bool, "bridge", "delivery_receipts")
helper.Copy(up.Bool, "bridge", "message_status_events")
helper.Copy(up.Bool, "bridge", "message_error_notices")
helper.Copy(up.Bool, "bridge", "sync_direct_chat_list")
helper.Copy(up.Bool, "bridge", "resend_bridge_info")
helper.Copy(up.Bool, "bridge", "public_portals")
helper.Copy(up.Bool, "bridge", "caption_in_message")
helper.Copy(up.Str, "bridge", "location_format")
helper.Copy(up.Bool, "bridge", "federate_rooms")
helper.Copy(up.Map, "bridge", "double_puppet_server_map")
helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery")
helper.Copy(up.Map, "bridge", "login_shared_secret_map")
helper.Copy(up.Str, "bridge", "command_prefix")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected")
helper.Copy(up.Str, "bridge", "management_room_text", "welcome_unconnected")
helper.Copy(up.Str|up.Null, "bridge", "management_room_text", "additional_help")
helper.Copy(up.Bool, "bridge", "encryption", "allow")
helper.Copy(up.Bool, "bridge", "encryption", "default")
helper.Copy(up.Bool, "bridge", "encryption", "require")
helper.Copy(up.Bool, "bridge", "encryption", "appservice")
helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "dont_store_outbound")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "ratchet_on_decrypt")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_prev_on_new_session")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_on_device_delete")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "periodically_delete_expired")
helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outdated_inbound")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "receive")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send")
helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share")
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom")
helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds")
helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages")
helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation")
helper.Copy(up.Bool, "bridge", "bridge_matrix_leave")
helper.Copy(up.Str, "bridge", "provisioning", "prefix")
if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
sharedSecret := random.String(64)
helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret")
} else {
helper.Copy(up.Str, "bridge", "provisioning", "shared_secret")
}
helper.Copy(up.Bool, "bridge", "provisioning", "debug_endpoints")
helper.Copy(up.Map, "bridge", "permissions")
helper.Copy(up.Bool, "bridge", "relay", "enabled")
helper.Copy(up.Bool, "bridge", "relay", "admin_only")
if textRelayFormat, ok := helper.Get(up.Str, "bridge", "relay", "message_formats", "m.text"); ok && strings.Contains(textRelayFormat, "$message") && !strings.Contains(textRelayFormat, ".Message") {
// don't copy legacy message formats
} else {
helper.Copy(up.Map, "bridge", "relay", "message_formats")
}
}
var SpacedBlocks = [][]string{
{"homeserver", "software"},
{"appservice"},
{"appservice", "hostname"},
{"appservice", "database"},
{"appservice", "id"},
{"appservice", "as_token"},
{"metrics"},
{"signal"},
{"bridge"},
{"bridge", "personal_filtering_spaces"},
{"bridge", "command_prefix"},
{"bridge", "management_room_text"},
{"bridge", "encryption"},
{"bridge", "provisioning"},
{"bridge", "permissions"},
{"logging"},
}

View file

@ -1,97 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
"fmt"
"maunium.net/go/mautrix/id"
)
func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
puppet.CustomMXID = mxid
puppet.AccessToken = accessToken
err := puppet.Update(context.TODO())
if err != nil {
return fmt.Errorf("failed to save access token: %w", err)
}
err = puppet.StartCustomMXID(false)
if err != nil {
return err
}
// TODO leave rooms with default puppet
return nil
}
func (puppet *Puppet) ClearCustomMXID() {
save := puppet.CustomMXID != "" || puppet.AccessToken != ""
puppet.bridge.puppetsLock.Lock()
if puppet.CustomMXID != "" && puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] == puppet {
delete(puppet.bridge.puppetsByCustomMXID, puppet.CustomMXID)
}
puppet.bridge.puppetsLock.Unlock()
puppet.CustomMXID = ""
puppet.AccessToken = ""
puppet.customIntent = nil
puppet.customUser = nil
if save {
err := puppet.Update(context.TODO())
if err != nil {
puppet.log.Err(err).Msg("Failed to clear custom MXID")
}
}
}
func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error {
newIntent, newAccessToken, err := puppet.bridge.DoublePuppet.Setup(context.TODO(), puppet.CustomMXID, puppet.AccessToken, reloginOnFail)
if err != nil {
puppet.ClearCustomMXID()
return err
}
puppet.bridge.puppetsLock.Lock()
puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
puppet.bridge.puppetsLock.Unlock()
if puppet.AccessToken != newAccessToken {
puppet.AccessToken = newAccessToken
err = puppet.Update(context.TODO())
}
puppet.customIntent = newIntent
puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID)
return err
}
func (user *User) tryAutomaticDoublePuppeting() {
if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) {
return
}
user.log.Debug().Msg("Checking if double puppeting needs to be enabled")
puppet := user.bridge.GetPuppetBySignalID(user.SignalID)
if puppet.CustomMXID == user.MXID {
user.log.Debug().Msg("User already has double-puppeting enabled")
// Custom puppet already enabled
return
}
puppet.CustomMXID = user.MXID
err := puppet.StartCustomMXID(true)
if err != nil {
user.log.Warn().Err(err).Msg("Failed to login with shared secret")
} else {
// TODO leave rooms with default puppet
user.log.Debug().Msg("Successfully automatically enabled custom puppet")
}
}

View file

@ -1,53 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
_ "embed"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"go.mau.fi/util/dbutil"
"go.mau.fi/mautrix-signal/database/upgrades"
)
type Database struct {
*dbutil.Database
User *UserQuery
Portal *PortalQuery
LostPortal *LostPortalQuery
Puppet *PuppetQuery
Message *MessageQuery
Reaction *ReactionQuery
DisappearingMessage *DisappearingMessageQuery
}
func New(db *dbutil.Database) *Database {
db.UpgradeTable = upgrades.Table
return &Database{
Database: db,
User: &UserQuery{dbutil.MakeQueryHelper(db, newUser)},
Portal: &PortalQuery{dbutil.MakeQueryHelper(db, newPortal)},
LostPortal: &LostPortalQuery{dbutil.MakeQueryHelper(db, newLostPortal)},
Puppet: &PuppetQuery{dbutil.MakeQueryHelper(db, newPuppet)},
Message: &MessageQuery{dbutil.MakeQueryHelper(db, newMessage)},
Reaction: &ReactionQuery{dbutil.MakeQueryHelper(db, newReaction)},
DisappearingMessage: &DisappearingMessageQuery{dbutil.MakeQueryHelper(db, newDisappearingMessage)},
}
}

View file

@ -1,125 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"context"
"database/sql"
"time"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getUnscheduledDisappearingMessagesForRoomQuery = `
SELECT room_id, mxid, expiration_seconds, expiration_ts
FROM disappearing_message WHERE expiration_ts IS NULL AND room_id = $1
`
getExpiredDisappearingMessagesQuery = `
SELECT room_id, mxid, expiration_seconds, expiration_ts
FROM disappearing_message WHERE expiration_ts IS NOT NULL AND expiration_ts <= $1
`
getNextDisappearingMessageQuery = `
SELECT room_id, mxid, expiration_seconds, expiration_ts
FROM disappearing_message WHERE expiration_ts IS NOT NULL ORDER BY expiration_ts ASC LIMIT 1
`
insertDisappearingMessageQuery = `
INSERT INTO disappearing_message (room_id, mxid, expiration_seconds, expiration_ts) VALUES ($1, $2, $3, $4)
`
updateDisappearingMessageQuery = `
UPDATE disappearing_message SET expiration_ts=$2 WHERE mxid=$1
`
deleteDisappearingMessageQuery = `
DELETE FROM disappearing_message WHERE mxid=$1
`
)
type DisappearingMessageQuery struct {
*dbutil.QueryHelper[*DisappearingMessage]
}
type DisappearingMessage struct {
qh *dbutil.QueryHelper[*DisappearingMessage]
RoomID id.RoomID
EventID id.EventID
ExpireIn time.Duration
ExpireAt time.Time
}
func newDisappearingMessage(qh *dbutil.QueryHelper[*DisappearingMessage]) *DisappearingMessage {
return &DisappearingMessage{qh: qh}
}
func (dmq *DisappearingMessageQuery) NewWithValues(roomID id.RoomID, eventID id.EventID, expireIn time.Duration, expireAt time.Time) *DisappearingMessage {
return &DisappearingMessage{
qh: dmq.QueryHelper,
RoomID: roomID,
EventID: eventID,
ExpireIn: expireIn,
ExpireAt: expireAt,
}
}
func (dmq *DisappearingMessageQuery) GetUnscheduledForRoom(ctx context.Context, roomID id.RoomID) ([]*DisappearingMessage, error) {
return dmq.QueryMany(ctx, getUnscheduledDisappearingMessagesForRoomQuery, roomID)
}
func (dmq *DisappearingMessageQuery) GetExpiredMessages(ctx context.Context) ([]*DisappearingMessage, error) {
return dmq.QueryMany(ctx, getExpiredDisappearingMessagesQuery, time.Now().Unix()+1)
}
func (dmq *DisappearingMessageQuery) GetNextScheduledMessage(ctx context.Context) (*DisappearingMessage, error) {
return dmq.QueryOne(ctx, getNextDisappearingMessageQuery)
}
func (msg *DisappearingMessage) Scan(row dbutil.Scannable) (*DisappearingMessage, error) {
var expireIn int64
var expireAt sql.NullInt64
err := row.Scan(&msg.RoomID, &msg.EventID, &expireIn, &expireAt)
if err != nil {
return nil, err
}
msg.ExpireIn = time.Duration(expireIn) * time.Second
if expireAt.Valid {
msg.ExpireAt = time.Unix(expireAt.Int64, 0)
}
return msg, nil
}
func (msg *DisappearingMessage) sqlVariables() []any {
var expireAt sql.NullInt64
if !msg.ExpireAt.IsZero() {
expireAt.Valid = true
expireAt.Int64 = msg.ExpireAt.Unix()
}
return []any{msg.RoomID, msg.EventID, int64(msg.ExpireIn.Seconds()), expireAt}
}
func (msg *DisappearingMessage) Insert(ctx context.Context) error {
return msg.qh.Exec(ctx, insertDisappearingMessageQuery, msg.sqlVariables()...)
}
func (msg *DisappearingMessage) StartExpirationTimer(ctx context.Context) error {
msg.ExpireAt = time.Now().Add(msg.ExpireIn)
return msg.qh.Exec(ctx, updateDisappearingMessageQuery, msg.EventID, msg.ExpireAt.Unix())
}
func (msg *DisappearingMessage) Delete(ctx context.Context) error {
return msg.qh.Exec(ctx, deleteDisappearingMessageQuery, msg.EventID)
}

View file

@ -1,58 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"context"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getLostPortalsQuery = `SELECT chat_id, receiver, mxid FROM lost_portals`
deleteLostPortalQuery = `DELETE FROM lost_portals WHERE mxid=$1`
)
type LostPortalQuery struct {
*dbutil.QueryHelper[*LostPortal]
}
func (lpq *LostPortalQuery) GetAll(ctx context.Context) ([]*LostPortal, error) {
return lpq.QueryMany(ctx, getLostPortalsQuery)
}
type LostPortal struct {
qh *dbutil.QueryHelper[*LostPortal]
ChatID string
Receiver string
MXID id.RoomID
}
func newLostPortal(qh *dbutil.QueryHelper[*LostPortal]) *LostPortal {
return &LostPortal{qh: qh}
}
func (l *LostPortal) Scan(row dbutil.Scannable) (*LostPortal, error) {
err := row.Scan(&l.ChatID, &l.Receiver, &l.MXID)
return l, err
}
func (l *LostPortal) Delete(ctx context.Context) error {
return l.qh.Exec(ctx, deleteLostPortalQuery, l.MXID)
}

View file

@ -1,179 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/lib/pq"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getMessageByMXIDQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE mxid=$1
`
getMessagePartBySignalIDQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE sender=$1 AND timestamp=$2 AND part_index=$3 AND signal_receiver=$4
`
getLastMessagePartBySignalIDQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE sender=$1 AND timestamp=$2 AND signal_receiver=$3
ORDER BY part_index DESC LIMIT 1
`
getAllMessagePartsBySignalIDQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE sender=$1 AND timestamp=$2 AND signal_receiver=$3
`
getMessageLastPartBySignalIDWithUnknownReceiverQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE sender=$1 AND timestamp=$2 AND (signal_receiver=$3 OR signal_receiver='00000000-0000-0000-0000-000000000000')
ORDER BY part_index DESC LIMIT 1
`
getManyMessagesBySignalIDQueryPostgres = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE sender=$1 AND (signal_receiver=$2 OR signal_receiver=$3) AND timestamp=ANY($4)
ORDER BY timestamp DESC, part_index DESC
`
getManyMessagesBySignalIDQuerySQLite = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE sender=?1 AND (signal_receiver=?2 OR signal_receiver=?3) AND timestamp IN (?4)
ORDER BY timestamp DESC, part_index DESC
`
getFirstBeforeQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE mx_room=$1 AND timestamp <= $2
ORDER BY timestamp DESC
LIMIT 1
`
getMessagesBetweenTimeQuery = `
SELECT sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room FROM message
WHERE signal_chat_id=$1 AND signal_receiver=$2 AND timestamp>$3 AND timestamp<=$4 AND part_index=0
ORDER BY timestamp ASC
`
insertMessageQuery = `
INSERT INTO message (sender, timestamp, part_index, signal_chat_id, signal_receiver, mxid, mx_room)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
deleteMessageQuery = `
DELETE FROM message
WHERE sender=$1 AND timestamp=$2 AND part_index=$3 AND signal_receiver=$4
`
updateMessageTimestampQuery = `
UPDATE message SET timestamp=$4 WHERE sender=$1 AND timestamp=$2 AND signal_receiver=$3
`
)
type MessageQuery struct {
*dbutil.QueryHelper[*Message]
}
type Message struct {
qh *dbutil.QueryHelper[*Message]
Sender uuid.UUID
Timestamp uint64
PartIndex int
SignalChatID string
SignalReceiver uuid.UUID
MXID id.EventID
RoomID id.RoomID
}
func newMessage(qh *dbutil.QueryHelper[*Message]) *Message {
return &Message{qh: qh}
}
func (mq *MessageQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Message, error) {
return mq.QueryOne(ctx, getMessageByMXIDQuery, mxid)
}
func (mq *MessageQuery) GetBySignalID(ctx context.Context, sender uuid.UUID, timestamp uint64, partIndex int, receiver uuid.UUID) (*Message, error) {
return mq.QueryOne(ctx, getMessagePartBySignalIDQuery, sender, timestamp, partIndex, receiver)
}
func (mq *MessageQuery) GetLastPartBySignalID(ctx context.Context, sender uuid.UUID, timestamp uint64, receiver uuid.UUID) (*Message, error) {
return mq.QueryOne(ctx, getLastMessagePartBySignalIDQuery, sender, timestamp, receiver)
}
func (mq *MessageQuery) GetAllPartsBySignalID(ctx context.Context, sender uuid.UUID, timestamp uint64, receiver uuid.UUID) ([]*Message, error) {
return mq.QueryMany(ctx, getAllMessagePartsBySignalIDQuery, sender, timestamp, receiver)
}
func (mq *MessageQuery) GetAllBetweenTimestamps(ctx context.Context, key PortalKey, min, max uint64) ([]*Message, error) {
return mq.QueryMany(ctx, getMessagesBetweenTimeQuery, key.ChatID, key.Receiver, int64(min), int64(max))
}
func (mq *MessageQuery) GetLastPartBySignalIDWithUnknownReceiver(ctx context.Context, sender uuid.UUID, timestamp uint64, receiver uuid.UUID) (*Message, error) {
return mq.QueryOne(ctx, getMessageLastPartBySignalIDWithUnknownReceiverQuery, sender, timestamp, receiver)
}
func (mq *MessageQuery) GetManyBySignalID(ctx context.Context, sender uuid.UUID, timestamps []uint64, receiver uuid.UUID, strictReceiver bool) ([]*Message, error) {
receiver2 := uuid.Nil
if strictReceiver {
receiver2 = receiver
}
if mq.GetDB().Dialect == dbutil.Postgres {
int64Array := make([]int64, len(timestamps))
for i, timestamp := range timestamps {
int64Array[i] = int64(timestamp)
}
return mq.QueryMany(ctx, getManyMessagesBySignalIDQueryPostgres, sender, receiver, receiver2, pq.Array(int64Array))
} else {
const varargIndex = 3
arguments := make([]any, len(timestamps)+varargIndex)
placeholders := make([]string, len(timestamps))
arguments[0] = sender
arguments[1] = receiver
arguments[2] = receiver2
for i, timestamp := range timestamps {
arguments[i+varargIndex] = timestamp
placeholders[i] = fmt.Sprintf("?%d", i+varargIndex+1)
}
return mq.QueryMany(ctx, strings.Replace(getManyMessagesBySignalIDQuerySQLite, fmt.Sprintf("?%d", varargIndex+1), strings.Join(placeholders, ", "), 1), arguments...)
}
}
func (msg *Message) Scan(row dbutil.Scannable) (*Message, error) {
return dbutil.ValueOrErr(msg, row.Scan(
&msg.Sender, &msg.Timestamp, &msg.PartIndex, &msg.SignalChatID, &msg.SignalReceiver, &msg.MXID, &msg.RoomID,
))
}
func (msg *Message) sqlVariables() []any {
return []any{msg.Sender, msg.Timestamp, msg.PartIndex, msg.SignalChatID, msg.SignalReceiver, msg.MXID, msg.RoomID}
}
func (msg *Message) Insert(ctx context.Context) error {
return msg.qh.Exec(ctx, insertMessageQuery, msg.sqlVariables()...)
}
func (msg *Message) Delete(ctx context.Context) error {
return msg.qh.Exec(ctx, deleteMessageQuery, msg.Sender, msg.Timestamp, msg.PartIndex, msg.SignalReceiver)
}
func (msg *Message) SetTimestamp(ctx context.Context, editTime uint64) error {
return msg.qh.Exec(ctx, updateMessageTimestampQuery, msg.Sender, msg.Timestamp, msg.SignalReceiver, editTime)
}

View file

@ -1,206 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"context"
"database/sql"
"github.com/google/uuid"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
const (
portalBaseSelect = `
SELECT chat_id, receiver, mxid, name, topic, avatar_path, avatar_hash, avatar_url,
name_set, avatar_set, topic_set, revision, encrypted, relay_user_id, expiration_time
FROM portal
`
getPortalByMXIDQuery = portalBaseSelect + `WHERE mxid=$1`
getPortalByChatIDQuery = portalBaseSelect + `WHERE chat_id=$1 AND receiver=$2`
getPortalsByReceiver = portalBaseSelect + `WHERE receiver=$1`
getPortalsByUser = portalBaseSelect + `WHERE chat_id=$1`
getAllPortalsWithMXIDQuery = portalBaseSelect + `WHERE mxid IS NOT NULL`
getChatsNotInSpaceQuery = `
SELECT chat_id FROM portal
LEFT JOIN user_portal ON portal.chat_id=user_portal.portal_chat_id AND portal.receiver=user_portal.portal_receiver
WHERE mxid<>'' AND receiver=$1 AND (user_portal.in_space=false OR user_portal.in_space IS NULL)
`
insertPortalQuery = `
INSERT INTO portal (
chat_id, receiver, mxid, name, topic, avatar_path, avatar_hash, avatar_url,
name_set, avatar_set, topic_set, revision, encrypted, relay_user_id, expiration_time
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
`
updatePortalQuery = `
UPDATE portal SET
mxid=$3, name=$4, topic=$5, avatar_path=$6, avatar_hash=$7, avatar_url=$8,
name_set=$9, avatar_set=$10, topic_set=$11, revision=$12, encrypted=$13, relay_user_id=$14, expiration_time=$15
WHERE chat_id=$1 AND receiver=$2
`
deletePortalQuery = `DELETE FROM portal WHERE chat_id=$1 AND receiver=$2`
reIDPortalQuery = `UPDATE portal SET chat_id=$2 WHERE chat_id=$1 AND receiver=$3`
)
type PortalQuery struct {
*dbutil.QueryHelper[*Portal]
}
type PortalKey struct {
ChatID string
Receiver uuid.UUID
}
func (pk *PortalKey) UserID() libsignalgo.ServiceID {
parsed, _ := libsignalgo.ServiceIDFromString(pk.ChatID)
return parsed
}
func (pk *PortalKey) GroupID() types.GroupIdentifier {
if len(pk.ChatID) == 44 {
return types.GroupIdentifier(pk.ChatID)
}
return ""
}
func NewPortalKey(chatID string, receiver uuid.UUID) PortalKey {
return PortalKey{
ChatID: chatID,
Receiver: receiver,
}
}
type Portal struct {
qh *dbutil.QueryHelper[*Portal]
PortalKey
MXID id.RoomID
Name string
Topic string
AvatarPath string
AvatarHash string
AvatarURL id.ContentURI
NameSet bool
AvatarSet bool
TopicSet bool
Revision uint32
Encrypted bool
RelayUserID id.UserID
ExpirationTime uint32
}
func newPortal(qh *dbutil.QueryHelper[*Portal]) *Portal {
return &Portal{qh: qh}
}
func (pq *PortalQuery) GetByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) {
return pq.QueryOne(ctx, getPortalByMXIDQuery, mxid)
}
func (pq *PortalQuery) GetByChatID(ctx context.Context, pk PortalKey) (*Portal, error) {
return pq.QueryOne(ctx, getPortalByChatIDQuery, pk.ChatID, pk.Receiver)
}
func (pq *PortalQuery) FindPrivateChatsWith(ctx context.Context, userID uuid.UUID) ([]*Portal, error) {
return pq.QueryMany(ctx, getPortalsByUser, userID.String())
}
func (pq *PortalQuery) FindPrivateChatsOf(ctx context.Context, receiver uuid.UUID) ([]*Portal, error) {
return pq.QueryMany(ctx, getPortalsByReceiver, receiver)
}
func (pq *PortalQuery) GetAllWithMXID(ctx context.Context) ([]*Portal, error) {
return pq.QueryMany(ctx, getAllPortalsWithMXIDQuery)
}
func (pq *PortalQuery) FindPrivateChatsNotInSpace(ctx context.Context, receiver uuid.UUID) ([]PortalKey, error) {
rows, err := pq.GetDB().Query(ctx, getChatsNotInSpaceQuery, receiver)
if err != nil {
return nil, err
}
return dbutil.NewRowIter(rows, func(rows dbutil.Scannable) (key PortalKey, err error) {
err = rows.Scan(&key.ChatID)
key.Receiver = receiver
return
}).AsList()
}
func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) {
var mxid sql.NullString
err := row.Scan(
&p.ChatID,
&p.Receiver,
&mxid,
&p.Name,
&p.Topic,
&p.AvatarPath,
&p.AvatarHash,
&p.AvatarURL,
&p.NameSet,
&p.AvatarSet,
&p.TopicSet,
&p.Revision,
&p.Encrypted,
&p.RelayUserID,
&p.ExpirationTime,
)
if err != nil {
return nil, err
}
p.MXID = id.RoomID(mxid.String)
return p, nil
}
func (p *Portal) sqlVariables() []any {
return []any{
p.ChatID,
p.Receiver,
dbutil.StrPtr(p.MXID),
p.Name,
p.Topic,
p.AvatarPath,
p.AvatarHash,
&p.AvatarURL,
p.NameSet,
p.AvatarSet,
p.TopicSet,
p.Revision,
p.Encrypted,
p.RelayUserID,
p.ExpirationTime,
}
}
func (p *Portal) Insert(ctx context.Context) error {
return p.qh.Exec(ctx, insertPortalQuery, p.sqlVariables()...)
}
func (p *Portal) Update(ctx context.Context) error {
return p.qh.Exec(ctx, updatePortalQuery, p.sqlVariables()...)
}
func (p *Portal) Delete(ctx context.Context) error {
return p.qh.Exec(ctx, deletePortalQuery, p.ChatID, p.Receiver)
}
func (p *Portal) ReID(ctx context.Context, newID string) error {
return p.qh.Exec(ctx, reIDPortalQuery, p.ChatID, newID, p.Receiver)
}

View file

@ -1,158 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
puppetBaseSelect = `
SELECT uuid, number, name, name_quality, avatar_path, avatar_hash, avatar_url, name_set, avatar_set,
contact_info_set, is_registered, profile_fetched_at, custom_mxid, access_token
FROM puppet
`
getPuppetBySignalIDQuery = puppetBaseSelect + `WHERE uuid=$1`
getPuppetByNumberQuery = puppetBaseSelect + `WHERE number=$1`
getPuppetByCustomMXIDQuery = puppetBaseSelect + `WHERE custom_mxid=$1`
getPuppetsWithCustomMXID = puppetBaseSelect + `WHERE custom_mxid<>''`
updatePuppetQuery = `
UPDATE puppet SET
number=$2, name=$3, name_quality=$4, avatar_path=$5, avatar_hash=$6, avatar_url=$7,
name_set=$8, avatar_set=$9, contact_info_set=$10, is_registered=$11, profile_fetched_at=$12,
custom_mxid=$13, access_token=$14
WHERE uuid=$1
`
insertPuppetQuery = `
INSERT INTO puppet (
uuid, number, name, name_quality, avatar_path, avatar_hash, avatar_url,
name_set, avatar_set, contact_info_set, is_registered, profile_fetched_at,
custom_mxid, access_token
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
)
`
)
type PuppetQuery struct {
*dbutil.QueryHelper[*Puppet]
}
type Puppet struct {
qh *dbutil.QueryHelper[*Puppet]
SignalID uuid.UUID
Number string
Name string
NameQuality int
AvatarPath string
AvatarHash string
AvatarURL id.ContentURI
NameSet bool
AvatarSet bool
IsRegistered bool
ContactInfoSet bool
ProfileFetchedAt time.Time
CustomMXID id.UserID
AccessToken string
}
func newPuppet(qh *dbutil.QueryHelper[*Puppet]) *Puppet {
return &Puppet{qh: qh}
}
func (pq *PuppetQuery) GetBySignalID(ctx context.Context, signalID uuid.UUID) (*Puppet, error) {
return pq.QueryOne(ctx, getPuppetBySignalIDQuery, signalID)
}
func (pq *PuppetQuery) GetByNumber(ctx context.Context, number string) (*Puppet, error) {
return pq.QueryOne(ctx, getPuppetByNumberQuery, number)
}
func (pq *PuppetQuery) GetByCustomMXID(ctx context.Context, mxid id.UserID) (*Puppet, error) {
return pq.QueryOne(ctx, getPuppetByCustomMXIDQuery, mxid)
}
func (pq *PuppetQuery) GetAllWithCustomMXID(ctx context.Context) ([]*Puppet, error) {
return pq.QueryMany(ctx, getPuppetsWithCustomMXID)
}
func (p *Puppet) Scan(row dbutil.Scannable) (*Puppet, error) {
var number, customMXID sql.NullString
var profileFetchedAt sql.NullInt64
err := row.Scan(
&p.SignalID,
&number,
&p.Name,
&p.NameQuality,
&p.AvatarPath,
&p.AvatarHash,
&p.AvatarURL,
&p.NameSet,
&p.AvatarSet,
&p.ContactInfoSet,
&p.IsRegistered,
&profileFetchedAt,
&customMXID,
&p.AccessToken,
)
if err != nil {
return nil, err
}
p.Number = number.String
p.CustomMXID = id.UserID(customMXID.String)
if profileFetchedAt.Valid {
p.ProfileFetchedAt = time.UnixMilli(profileFetchedAt.Int64)
}
return p, nil
}
func (p *Puppet) sqlVariables() []any {
return []any{
p.SignalID,
dbutil.StrPtr(p.Number),
p.Name,
p.NameQuality,
p.AvatarPath,
p.AvatarHash,
&p.AvatarURL,
p.NameSet,
p.AvatarSet,
p.ContactInfoSet,
p.IsRegistered,
dbutil.UnixMilliPtr(p.ProfileFetchedAt),
dbutil.StrPtr(p.CustomMXID),
p.AccessToken,
}
}
func (p *Puppet) Insert(ctx context.Context) error {
return p.qh.Exec(ctx, insertPuppetQuery, p.sqlVariables()...)
}
func (p *Puppet) Update(ctx context.Context) error {
return p.qh.Exec(ctx, updatePuppetQuery, p.sqlVariables()...)
}

View file

@ -1,97 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"context"
"github.com/google/uuid"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getReactionByMXIDQuery = `SELECT msg_author, msg_timestamp, author, emoji, signal_chat_id, signal_receiver, mxid, mx_room FROM reaction WHERE mxid=$1`
getReactionBySignalIDQuery = `SELECT msg_author, msg_timestamp, author, emoji, signal_chat_id, signal_receiver, mxid, mx_room FROM reaction WHERE msg_author=$1 AND msg_timestamp=$2 AND author=$3 AND signal_receiver=$4`
insertReactionQuery = `
INSERT INTO reaction (msg_author, msg_timestamp, author, emoji, signal_chat_id, signal_receiver, mxid, mx_room)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
updateReactionQuery = `
UPDATE reaction
SET mxid=$1, emoji=$2
WHERE msg_author=$3 AND msg_timestamp=$4 AND author=$5 AND signal_receiver=$6
`
deleteReactionQuery = `
DELETE FROM reaction WHERE msg_author=$1 AND msg_timestamp=$2 AND author=$3 AND signal_receiver=$4
`
)
type ReactionQuery struct {
*dbutil.QueryHelper[*Reaction]
}
func newReaction(qh *dbutil.QueryHelper[*Reaction]) *Reaction {
return &Reaction{qh: qh}
}
type Reaction struct {
qh *dbutil.QueryHelper[*Reaction]
MsgAuthor uuid.UUID
MsgTimestamp uint64
Author uuid.UUID
Emoji string
SignalChatID string
SignalReceiver uuid.UUID
MXID id.EventID
RoomID id.RoomID
}
func (rq *ReactionQuery) GetByMXID(ctx context.Context, mxid id.EventID) (*Reaction, error) {
return rq.QueryOne(ctx, getReactionByMXIDQuery, mxid)
}
func (rq *ReactionQuery) GetBySignalID(ctx context.Context, msgAuthor uuid.UUID, msgTimestamp uint64, author, signalReceiver uuid.UUID) (*Reaction, error) {
return rq.QueryOne(ctx, getReactionBySignalIDQuery, msgAuthor, msgTimestamp, author, signalReceiver)
}
func (r *Reaction) Scan(row dbutil.Scannable) (*Reaction, error) {
return dbutil.ValueOrErr(r, row.Scan(
&r.MsgAuthor, &r.MsgTimestamp, &r.Author, &r.Emoji, &r.SignalChatID, &r.SignalReceiver, &r.MXID, &r.RoomID,
))
}
func (r *Reaction) sqlVariables() []any {
return []any{
r.MsgAuthor, r.MsgTimestamp, r.Author, r.Emoji, r.SignalChatID, r.SignalReceiver, r.MXID, r.RoomID,
}
}
func (r *Reaction) Insert(ctx context.Context) error {
return r.qh.Exec(ctx, insertReactionQuery, r.sqlVariables()...)
}
func (r *Reaction) Update(ctx context.Context) error {
return r.qh.Exec(ctx, updateReactionQuery, r.MXID, r.Emoji, r.MsgAuthor, r.MsgTimestamp, r.Author, r.SignalReceiver)
}
func (r *Reaction) Delete(ctx context.Context) error {
return r.qh.Exec(ctx, deleteReactionQuery, r.MsgAuthor, r.MsgTimestamp, r.Author, r.SignalReceiver)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +0,0 @@
-- v20 (compatible with v17+): Add profile fetch timestamp for puppets
ALTER TABLE puppet ADD profile_fetched_at BIGINT;

View file

@ -1,115 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"context"
"database/sql"
"sync"
"github.com/google/uuid"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getUserByMXIDQuery = `SELECT mxid, phone, uuid, management_room, space_room FROM "user" WHERE mxid=$1`
getUserByPhoneQuery = `SELECT mxid, phone, uuid, management_room, space_room FROM "user" WHERE phone=$1`
getUserByUUIDQuery = `SELECT mxid, phone, uuid, management_room, space_room FROM "user" WHERE uuid=$1`
getAllLoggedInUsersQuery = `SELECT mxid, phone, uuid, management_room, space_room FROM "user" WHERE phone IS NOT NULL`
insertUserQuery = `INSERT INTO "user" (mxid, phone, uuid, management_room, space_room) VALUES ($1, $2, $3, $4, $5)`
updateUserQuery = `UPDATE "user" SET phone=$2, uuid=$3, management_room=$4, space_room=$5 WHERE mxid=$1`
)
type UserQuery struct {
*dbutil.QueryHelper[*User]
}
type User struct {
qh *dbutil.QueryHelper[*User]
MXID id.UserID
SignalUsername string
SignalID uuid.UUID
ManagementRoom id.RoomID
SpaceRoom id.RoomID
lastReadCache map[PortalKey]uint64
lastReadCacheLock sync.Mutex
inSpaceCache map[PortalKey]bool
inSpaceCacheLock sync.Mutex
}
func newUser(qh *dbutil.QueryHelper[*User]) *User {
return &User{
qh: qh,
lastReadCache: make(map[PortalKey]uint64),
inSpaceCache: make(map[PortalKey]bool),
}
}
func (uq *UserQuery) GetByMXID(ctx context.Context, mxid id.UserID) (*User, error) {
return uq.QueryOne(ctx, getUserByMXIDQuery, mxid)
}
func (uq *UserQuery) GetByPhone(ctx context.Context, phone string) (*User, error) {
return uq.QueryOne(ctx, getUserByPhoneQuery, phone)
}
func (uq *UserQuery) GetBySignalID(ctx context.Context, uuid uuid.UUID) (*User, error) {
return uq.QueryOne(ctx, getUserByUUIDQuery, uuid)
}
func (uq *UserQuery) GetAllLoggedIn(ctx context.Context) ([]*User, error) {
return uq.QueryMany(ctx, getAllLoggedInUsersQuery)
}
func (u *User) sqlVariables() []any {
var nu uuid.NullUUID
nu.UUID = u.SignalID
nu.Valid = u.SignalID != uuid.Nil
return []any{u.MXID, dbutil.StrPtr(u.SignalUsername), nu, dbutil.StrPtr(u.ManagementRoom), dbutil.StrPtr(u.SpaceRoom)}
}
func (u *User) Insert(ctx context.Context) error {
return u.qh.Exec(ctx, insertUserQuery, u.sqlVariables()...)
}
func (u *User) Update(ctx context.Context) error {
return u.qh.Exec(ctx, updateUserQuery, u.sqlVariables()...)
}
func (u *User) Scan(row dbutil.Scannable) (*User, error) {
var phone, managementRoom, spaceRoom sql.NullString
var signalID uuid.NullUUID
err := row.Scan(
&u.MXID,
&phone,
&signalID,
&managementRoom,
&spaceRoom,
)
if err != nil {
return nil, err
}
u.SignalUsername = phone.String
u.SignalID = signalID.UUID
u.ManagementRoom = id.RoomID(managementRoom.String)
u.SpaceRoom = id.RoomID(spaceRoom.String)
return u, nil
}

View file

@ -1,116 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber, Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"context"
"database/sql"
"errors"
"github.com/rs/zerolog"
)
const (
getLastReadTSQuery = `SELECT last_read_ts FROM user_portal WHERE user_mxid=$1 AND portal_chat_id=$2 AND portal_receiver=$3`
setLastReadTSQuery = `
INSERT INTO user_portal (user_mxid, portal_chat_id, portal_receiver, last_read_ts) VALUES ($1, $2, $3, $4)
ON CONFLICT (user_mxid, portal_chat_id, portal_receiver) DO UPDATE
SET last_read_ts=excluded.last_read_ts WHERE user_portal.last_read_ts<excluded.last_read_ts
`
getIsInSpaceQuery = `SELECT in_space FROM user_portal WHERE user_mxid=$1 AND portal_chat_id=$2 AND portal_receiver=$3`
setIsInSpaceQuery = `
INSERT INTO user_portal (user_mxid, portal_chat_id, portal_receiver, in_space) VALUES ($1, $2, $3, true)
ON CONFLICT (user_mxid, portal_chat_id, portal_receiver) DO UPDATE SET in_space=true
`
)
func (u *User) GetLastReadTS(ctx context.Context, portal PortalKey) uint64 {
u.lastReadCacheLock.Lock()
defer u.lastReadCacheLock.Unlock()
if cached, ok := u.lastReadCache[portal]; ok {
return cached
}
var ts int64
err := u.qh.GetDB().QueryRow(ctx, getLastReadTSQuery, u.MXID, portal.ChatID, portal.Receiver).Scan(&ts)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
zerolog.Ctx(ctx).Err(err).
Stringer("user_id", u.MXID).
Any("portal_key", portal).
Msg("Failed to query last read timestamp")
return 0
}
u.lastReadCache[portal] = uint64(ts)
return uint64(ts)
}
func (u *User) SetLastReadTS(ctx context.Context, portal PortalKey, ts uint64) {
u.lastReadCacheLock.Lock()
defer u.lastReadCacheLock.Unlock()
err := u.qh.Exec(ctx, setLastReadTSQuery, u.MXID, portal.ChatID, portal.Receiver, int64(ts))
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("user_id", u.MXID).
Any("portal_key", portal).
Msg("Failed to update last read timestamp")
} else {
zerolog.Ctx(ctx).Debug().
Stringer("user_id", u.MXID).
Any("portal_key", portal).
Uint64("last_read_ts", ts).
Msg("Updated last read timestamp of portal")
u.lastReadCache[portal] = ts
}
}
func (u *User) IsInSpace(ctx context.Context, portal PortalKey) bool {
u.inSpaceCacheLock.Lock()
defer u.inSpaceCacheLock.Unlock()
if cached, ok := u.inSpaceCache[portal]; ok {
return cached
}
var inSpace bool
err := u.qh.GetDB().QueryRow(ctx, getIsInSpaceQuery, u.MXID, portal.ChatID, portal.Receiver).Scan(&inSpace)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
zerolog.Ctx(ctx).Err(err).
Stringer("user_id", u.MXID).
Any("portal_key", portal).
Msg("Failed to query in space status")
return false
}
u.inSpaceCache[portal] = inSpace
return inSpace
}
func (u *User) MarkInSpace(ctx context.Context, portal PortalKey) {
u.inSpaceCacheLock.Lock()
defer u.inSpaceCacheLock.Unlock()
err := u.qh.Exec(ctx, setIsInSpaceQuery, u.MXID, portal.ChatID, portal.Receiver)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("user_id", u.MXID).
Any("portal_key", portal).
Msg("Failed to update in space status")
} else {
u.inSpaceCache[portal] = true
}
}
func (u *User) RemoveInSpaceCache(key PortalKey) {
u.inSpaceCacheLock.Lock()
defer u.inSpaceCacheLock.Unlock()
delete(u.inSpaceCache, key)
}

View file

@ -1,156 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/database"
)
type DisappearingMessagesManager struct {
DB *database.Database
Log zerolog.Logger
Bridge *SignalBridge
checkMessagesChan chan struct{}
}
func (dmm *DisappearingMessagesManager) ScheduleDisappearingForRoom(ctx context.Context, roomID id.RoomID) {
log := dmm.Log.With().Stringer("room_id", roomID).Logger()
disappearingMessages, err := dmm.DB.DisappearingMessage.GetUnscheduledForRoom(ctx, roomID)
if err != nil {
log.Err(err).Msg("Failed to get unscheduled disappearing messages")
return
}
for _, disappearingMessage := range disappearingMessages {
err = disappearingMessage.StartExpirationTimer(ctx)
if err != nil {
log.Err(err).Msg("Failed to schedule disappearing message")
} else {
log.Debug().
Stringer("event_id", disappearingMessage.EventID).
Time("expire_at", disappearingMessage.ExpireAt).
Msg("Scheduling disappearing message")
}
}
// Tell the disappearing messages loop to check again
dmm.checkMessagesChan <- struct{}{}
}
func (dmm *DisappearingMessagesManager) StartDisappearingLoop(ctx context.Context) {
dmm.checkMessagesChan = make(chan struct{}, 1)
go func() {
log := dmm.Log.With().Str("action", "loop").Logger()
ctx = log.WithContext(ctx)
for {
dmm.redactExpiredMessages(ctx)
duration := 10 * time.Minute // Check again in 10 minutes just in case
nextMsg, err := dmm.DB.DisappearingMessage.GetNextScheduledMessage(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
log.Err(err).Msg("Failed to get next disappearing message")
continue
} else if nextMsg != nil {
duration = time.Until(nextMsg.ExpireAt)
}
select {
case <-time.After(duration):
case <-dmm.checkMessagesChan:
case <-ctx.Done():
return
}
}
}()
}
func (dmm *DisappearingMessagesManager) redactExpiredMessages(ctx context.Context) {
log := zerolog.Ctx(ctx)
expiredMessages, err := dmm.DB.DisappearingMessage.GetExpiredMessages(ctx)
if err != nil {
log.Err(err).Msg("Failed to get expired disappearing messages")
return
}
for _, msg := range expiredMessages {
portal := dmm.Bridge.GetPortalByMXID(msg.RoomID)
if portal == nil {
log.Warn().Stringer("event_id", msg.EventID).Stringer("room_id", msg.RoomID).Msg("Failed to redact message: portal not found")
err = msg.Delete(ctx)
if err != nil {
log.Err(err).
Stringer("event_id", msg.EventID).
Msg("Failed to delete disappearing message row in database")
}
continue
}
_, err = portal.MainIntent().RedactEvent(ctx, msg.RoomID, msg.EventID, mautrix.ReqRedact{
Reason: "Message expired",
TxnID: fmt.Sprintf("mxsg_disappear_%s", msg.EventID),
})
if err != nil {
log.Err(err).
Stringer("event_id", msg.EventID).
Stringer("room_id", msg.RoomID).
Msg("Failed to redact message")
} else {
log.Err(err).
Stringer("event_id", msg.EventID).
Stringer("room_id", msg.RoomID).
Msg("Redacted message")
}
err = msg.Delete(ctx)
if err != nil {
log.Err(err).
Stringer("event_id", msg.EventID).
Msg("Failed to delete disappearing message row in database")
}
}
}
func (dmm *DisappearingMessagesManager) AddDisappearingMessage(ctx context.Context, eventID id.EventID, roomID id.RoomID, expireIn time.Duration, startTimerNow bool) {
if expireIn == 0 {
return
}
var expireAt time.Time
if startTimerNow {
expireAt = time.Now().Add(expireIn)
}
disappearingMessage := dmm.DB.DisappearingMessage.NewWithValues(roomID, eventID, expireIn, expireAt)
err := disappearingMessage.Insert(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("event_id", eventID).
Msg("Failed to add disappearing message to database")
return
}
zerolog.Ctx(ctx).Debug().Stringer("event_id", eventID).
Msg("Added disappearing message row to database")
if startTimerNow {
// Tell the disappearing messages loop to check again
dmm.checkMessagesChan <- struct{}{}
}
}

View file

@ -17,11 +17,7 @@ function fixperms {
}
if [[ ! -f /data/config.yaml ]]; then
if [[ "$BRIDGEV2" == "1" ]]; then
$BINARY_NAME -c /data/config.yaml -e
else
cp /opt/mautrix-signal/example-config.yaml /data/config.yaml
fi
$BINARY_NAME -c /data/config.yaml -e
echo "Didn't find a config file."
echo "Copied default config file to /data/config.yaml"
echo "Modify that config file to your liking."

View file

@ -1,317 +0,0 @@
# Homeserver details.
homeserver:
# The address that this appservice can use to connect to the homeserver.
address: https://matrix.example.com
# The domain of the homeserver (also known as server_name, used for MXIDs, etc).
domain: example.com
# What software is the homeserver running?
# Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here.
software: standard
# The URL to push real-time bridge status to.
# If set, the bridge will make POST requests to this URL whenever a user's Signal connection state changes.
# The bridge will use the appservice as_token to authorize requests.
status_endpoint: null
# Endpoint for reporting per-message status.
message_send_checkpoint_endpoint: null
# Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
async_media: false
# Should the bridge use a websocket for connecting to the homeserver?
# The server side is currently not documented anywhere and is only implemented by mautrix-wsproxy,
# mautrix-asmux (deprecated), and hungryserv (proprietary).
websocket: false
# How often should the websocket be pinged? Pinging will be disabled if this is zero.
ping_interval_seconds: 0
# Application service host/registration related details.
# Changing these values requires regeneration of the registration.
appservice:
# The address that the homeserver can use to connect to this appservice.
address: http://localhost:29328
# The hostname and port where this appservice should listen.
hostname: 0.0.0.0
port: 29328
# Database config.
database:
# The database type. "sqlite3-fk-wal" and "postgres" are supported.
type: postgres
# The database URI.
# SQLite: A raw file path is supported, but `file:<path>?_txlock=immediate` is recommended.
# https://github.com/mattn/go-sqlite3#connection-string
# Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable
# To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql
uri: postgres://user:password@host/database?sslmode=disable
# Maximum number of connections. Mostly relevant for Postgres.
max_open_conns: 20
max_idle_conns: 2
# Maximum connection idle time and lifetime before they're closed. Disabled if null.
# Parsed with https://pkg.go.dev/time#ParseDuration
max_conn_idle_time: null
max_conn_lifetime: null
# The unique ID of this appservice.
id: signal
# Appservice bot details.
bot:
# Username of the appservice bot.
username: signalbot
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
# to leave display name/avatar as-is.
displayname: Signal bridge bot
avatar: mxc://maunium.net/wPJgTQbZOtpBFmDNkiNEMDUp
# Whether or not to receive ephemeral events via appservice transactions.
# Requires MSC2409 support (i.e. Synapse 1.22+).
ephemeral_events: true
# Should incoming events be handled asynchronously?
# This may be necessary for large public instances with lots of messages going through.
# However, messages will not be guaranteed to be bridged in the same order they were sent in.
async_transactions: false
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
as_token: "This value is generated when generating the registration"
hs_token: "This value is generated when generating the registration"
# Prometheus config.
metrics:
# Enable prometheus metrics?
enabled: false
# IP and port where the metrics listener should be. The path is always /metrics
listen: 127.0.0.1:8000
signal:
# Default device name that shows up in the Signal app.
device_name: mautrix-signal
# Bridge config
bridge:
# Localpart template of MXIDs for Signal users.
# {{.}} is replaced with the internal ID of the Signal user.
username_template: signal_{{.}}
# Displayname template for Signal users. This is also used as the room name in DMs if private_chat_portal_meta is enabled.
# {{.ProfileName}} - The Signal profile name set by the user.
# {{.ContactName}} - The name for the user from your phone's contact list. This is not safe on multi-user instances.
# {{.PhoneNumber}} - The phone number of the user.
# {{.UUID}} - The UUID of the Signal user.
# {{.AboutEmoji}} - The emoji set by the user in their profile.
displayname_template: '{{or .ProfileName .PhoneNumber "Unknown user"}}'
# Whether to explicitly set the avatar and room name for private chat portal rooms.
# If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
# If set to `always`, all DM rooms will have explicit names and avatars set.
# If set to `never`, DM rooms will never have names and avatars set.
private_chat_portal_meta: default
# Should avatars from the user's contact list be used? This is not safe on multi-user instances.
use_contact_avatars: false
# Should the bridge sync ghost user info even if profile fetching fails? This is not safe on multi-user instances.
use_outdated_profiles: false
# Should the Signal user's phone number be included in the room topic in private chat portal rooms?
number_in_topic: true
# Avatar image for the Note to Self room.
note_to_self_avatar: mxc://maunium.net/REBIVrqjZwmaWpssCZpBlmlL
portal_message_buffer: 128
# Should the bridge create a space for each logged-in user and add bridged rooms to it?
# Users who logged in before turning this on should run `!signal sync-space` to create and fill the space for the first time.
personal_filtering_spaces: false
# Should Matrix m.notice-type messages be bridged?
bridge_notices: true
# Should the bridge send a read receipt from the bridge bot when a message has been sent to Signal?
delivery_receipts: false
# Whether the bridge should send the message status as a custom com.beeper.message_send_status event.
message_status_events: false
# Whether the bridge should send error notices via m.notice events when a message fails to bridge.
message_error_notices: true
# Should the bridge update the m.direct account data event when double puppeting is enabled.
# Note that updating the m.direct event is not atomic (except with mautrix-asmux)
# and is therefore prone to race conditions.
sync_direct_chat_list: false
# Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
# This field will automatically be changed back to false after it, except if the config file is not writable.
resend_bridge_info: false
# Whether or not to make portals of groups that don't need approval of an admin to join by invite
# link publicly joinable on Matrix.
public_portals: false
# Send captions in the same message as images. This will send data compatible with both MSC2530.
# This is currently not supported in most clients.
caption_in_message: false
# Format for generating URLs from location messages for sending to Signal
# Google Maps: 'https://www.google.com/maps/place/%[1]s,%[2]s'
# OpenStreetMap: 'https://www.openstreetmap.org/?mlat=%[1]s&mlon=%[2]s'
location_format: 'https://www.google.com/maps/place/%[1]s,%[2]s'
# Whether or not created rooms should have federation enabled.
# If false, created portal rooms will never be federated.
federate_rooms: true
# Servers to always allow double puppeting from
double_puppet_server_map:
example.com: https://example.com
# Allow using double puppeting from any server with a valid client .well-known file.
double_puppet_allow_discovery: false
# Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth
#
# If set, double puppeting will be enabled automatically for local users
# instead of users having to find an access token and run `login-matrix`
# manually.
login_shared_secret_map:
example.com: foobar
# Maximum time for handling Matrix events. Duration strings formatted for https://pkg.go.dev/time#ParseDuration
# Null means there's no enforced timeout.
message_handling_timeout:
# Send an error message after this timeout, but keep waiting for the response until the deadline.
# This is counted from the origin_server_ts, so the warning time is consistent regardless of the source of delay.
# If the message is older than this when it reaches the bridge, the message won't be handled at all.
error_after: null
# Drop messages after this timeout. They may still go through if the message got sent to the servers.
# This is counted from the time the bridge starts handling the message.
deadline: 120s
# The prefix for commands. Only required in non-management rooms.
command_prefix: '!signal'
# Messages sent upon joining a management room.
# Markdown is supported. The defaults are listed below.
management_room_text:
# Sent when joining a room.
welcome: "Hello, I'm a Signal bridge bot."
# Sent when joining a management room and the user is already logged in.
welcome_connected: "Use `help` for help."
# Sent when joining a management room and the user is not logged in.
welcome_unconnected: "Use `help` for help or `login` to log in."
# Optional extra text sent when joining a management room.
additional_help: ""
# End-to-bridge encryption support options.
#
# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
encryption:
# Allow encryption, work in group chat rooms with e2ee enabled
allow: false
# Default to encryption, force-enable encryption in all portals the bridge creates
# This will cause the bridge bot to be in private chats for the encryption to work properly.
default: false
# Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
appservice: false
# Require encryption, drop any unencrypted messages.
require: false
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
# You must use a client that supports requesting keys from other users to use this feature.
allow_key_sharing: false
# Options for deleting megolm sessions from the bridge.
delete_keys:
# Beeper-specific: delete outbound sessions when hungryserv confirms
# that the user has uploaded the key to key backup.
delete_outbound_on_ack: false
# Don't store outbound sessions in the inbound table.
dont_store_outbound: false
# Ratchet megolm sessions forward after decrypting messages.
ratchet_on_decrypt: false
# Delete fully used keys (index >= max_messages) after decrypting messages.
delete_fully_used_on_decrypt: false
# Delete previous megolm sessions from same device when receiving a new one.
delete_prev_on_new_session: false
# Delete megolm sessions received from a device when the device is deleted.
delete_on_device_delete: false
# Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
periodically_delete_expired: false
# Delete inbound megolm sessions that don't have the received_at field used for
# automatic ratcheting and expired session deletion. This is meant as a migration
# to delete old keys prior to the bridge update.
delete_outdated_inbound: false
# What level of device verification should be required from users?
#
# Valid levels:
# unverified - Send keys to all device in the room.
# cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys.
# cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes).
# cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot.
# Note that creating user signatures from the bridge bot is not currently possible.
# verified - Require manual per-device verification
# (currently only possible by modifying the `trust` column in the `crypto_device` database table).
verification_levels:
# Minimum level for which the bridge should send keys to when bridging messages from Signal to Matrix.
receive: unverified
# Minimum level that the bridge should accept for incoming Matrix messages.
send: unverified
# Minimum level that the bridge should require for accepting key requests.
share: cross-signed-tofu
# Options for Megolm room key rotation. These options allow you to
# configure the m.room.encryption event content. See:
# https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for
# more information about that event.
rotation:
# Enable custom Megolm room key rotation settings. Note that these
# settings will only apply to rooms created after this option is
# set.
enable_custom: false
# The maximum number of milliseconds a session should be used
# before changing it. The Matrix spec recommends 604800000 (a week)
# as the default.
milliseconds: 604800000
# The maximum number of messages that should be sent with a given a
# session before changing it. The Matrix spec recommends 100 as the
# default.
messages: 100
# Disable rotating keys when a user's devices change?
# You should not enable this option unless you understand all the implications.
disable_device_change_key_rotation: false
# Should leaving the room on Matrix make the user leave on Signal?
bridge_matrix_leave: true
# Settings for provisioning API
provisioning:
# Prefix for the provisioning API paths.
prefix: /_matrix/provision
# Shared secret for authentication. If set to "generate", a random secret will be generated,
# or if set to "disable", the provisioning API will be disabled.
shared_secret: generate
# Enable debug API at /debug with provisioning authentication.
debug_endpoints: false
# Permissions for using the bridge.
# Permitted values:
# relay - Talk through the relaybot (if enabled), no access otherwise
# user - Access to use the bridge to chat with a Signal account.
# admin - User level and some additional administration tools
# Permitted keys:
# * - All Matrix users
# domain - All users on that homeserver
# mxid - Specific user
permissions:
"*": relay
"example.com": user
"@admin:example.com": admin
# Settings for relay mode
relay:
# Whether relay mode should be allowed. If allowed, `!signal set-relay` can be used to turn any
# authenticated user into a relaybot for that chat.
enabled: false
# Should only admins be allowed to set themselves as relay users?
admin_only: true
# The formats to use when sending messages to Signal via the relaybot.
message_formats:
m.text: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"
m.notice: "<b>{{ .Sender.Displayname }}</b>: {{ .Message }}"
m.emote: "* <b>{{ .Sender.Displayname }}</b> {{ .Message }}"
m.file: "<b>{{ .Sender.Displayname }}</b> sent a file"
m.image: "<b>{{ .Sender.Displayname }}</b> sent an image"
m.audio: "<b>{{ .Sender.Displayname }}</b> sent an audio file"
m.video: "<b>{{ .Sender.Displayname }}</b> sent a video"
m.location: "<b>{{ .Sender.Displayname }}</b> sent a location"
# Logging config. See https://github.com/tulir/zeroconfig for details.
logging:
min_level: debug
writers:
- type: stdout
format: pretty-colored
- type: file
format: json
filename: ./logs/mautrix-signal.log
max_size: 100
max_backups: 10
compress: true

3
go.mod
View file

@ -48,3 +48,6 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect
)
//replace maunium.net/go/mautrix => ../mautrix-go
//replace go.mau.fi/util => ../../Go/go-util

View file

@ -1,92 +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 legacyprovision
import (
"encoding/json"
"net/http"
"maunium.net/go/mautrix/id"
)
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 LinkNew
SessionID string `json:"session_id,omitempty"`
URI string `json:"uri,omitempty"`
// For response in LinkWaitForAccount
UUID string `json:"uuid,omitempty"`
Number string `json:"number,omitempty"`
// For response in ResolveIdentifier
*ResolveIdentifierResponse
}
type WhoAmIResponse struct {
Permissions int `json:"permissions"`
MXID string `json:"mxid"`
Signal *WhoAmIResponseSignal `json:"signal,omitempty"`
}
type WhoAmIResponseSignal struct {
Number string `json:"number"`
UUID string `json:"uuid"`
Name string `json:"name"`
Ok bool `json:"ok"`
}
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"`
}
type LinkWaitForScanRequest struct {
SessionID string `json:"session_id"`
}
type LinkWaitForAccountRequest struct {
SessionID string `json:"session_id"`
DeviceName string `json:"device_name"` // TODO this seems to not be used anywhere
}

352
main.go
View file

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

View file

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

View file

@ -1,281 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Element
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
"net/http"
"runtime/debug"
"sync"
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/database"
)
type MetricsHandler struct {
db *database.Database
server *http.Server
log zerolog.Logger
running bool
ctx context.Context
stopRecorder func()
matrixEventHandling *prometheus.HistogramVec
signalMessageAge prometheus.Histogram
signalMessageHandling *prometheus.HistogramVec
countCollection prometheus.Histogram
puppetCount prometheus.Gauge
userCount prometheus.Gauge
messageCount prometheus.Gauge
portalCount *prometheus.GaugeVec
encryptedGroupCount prometheus.Gauge
encryptedPrivateCount prometheus.Gauge
unencryptedGroupCount prometheus.Gauge
unencryptedPrivateCount prometheus.Gauge
connected prometheus.Gauge
connectedState map[uuid.UUID]bool
connectedStateLock sync.Mutex
loggedIn prometheus.Gauge
loggedInState map[uuid.UUID]bool
loggedInStateLock sync.Mutex
}
func NewMetricsHandler(address string, log zerolog.Logger, db *database.Database) *MetricsHandler {
portalCount := promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "signal_portals_total",
Help: "Number of portal rooms on Matrix",
}, []string{"type", "encrypted"})
return &MetricsHandler{
db: db,
server: &http.Server{Addr: address, Handler: promhttp.Handler()},
log: log,
running: false,
matrixEventHandling: promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "matrix_event",
Help: "Time spent processing Matrix events",
}, []string{"event_type"}),
signalMessageAge: promauto.NewHistogram(prometheus.HistogramOpts{
Name: "remote_event_age",
Help: "Age of messages received from Signal",
Buckets: []float64{1, 2, 3, 5, 7.5, 10, 20, 30, 60},
}),
signalMessageHandling: promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "remote_event",
Help: "Time spent processing Signal messages",
}, []string{"message_type"}),
countCollection: promauto.NewHistogram(prometheus.HistogramOpts{
Name: "signal_count_collection",
Help: "Time spent collecting the bridge_*_total metrics",
}),
puppetCount: promauto.NewGauge(prometheus.GaugeOpts{
Name: "signal_puppets_total",
Help: "Number of Signal users bridged into Matrix",
}),
userCount: promauto.NewGauge(prometheus.GaugeOpts{
Name: "signal_users_total",
Help: "Number of Matrix users using the bridge",
}),
messageCount: promauto.NewGauge(prometheus.GaugeOpts{
Name: "signal_messages_total",
Help: "Number of messages bridged",
}),
portalCount: portalCount,
encryptedGroupCount: portalCount.With(prometheus.Labels{"type": "group", "encrypted": "true"}),
encryptedPrivateCount: portalCount.With(prometheus.Labels{"type": "private", "encrypted": "true"}),
unencryptedGroupCount: portalCount.With(prometheus.Labels{"type": "group", "encrypted": "false"}),
unencryptedPrivateCount: portalCount.With(prometheus.Labels{"type": "private", "encrypted": "false"}),
loggedIn: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bridge_logged_in",
Help: "Bridge users logged into Signal",
}),
loggedInState: make(map[uuid.UUID]bool),
connected: promauto.NewGauge(prometheus.GaugeOpts{
Name: "bridge_connected",
Help: "Bridge users connected to Signal",
}),
connectedState: make(map[uuid.UUID]bool),
}
}
func noop() {}
func (mh *MetricsHandler) TrackMatrixEvent(eventType event.Type) func() {
if !mh.running {
return noop
}
start := time.Now()
return func() {
duration := time.Since(start)
mh.matrixEventHandling.
With(prometheus.Labels{"event_type": eventType.Type}).
Observe(duration.Seconds())
}
}
func (mh *MetricsHandler) TrackSignalMessage(timestamp time.Time, messageType string) func() {
if !mh.running {
return noop
}
start := time.Now()
return func() {
duration := time.Since(start)
mh.signalMessageHandling.
With(prometheus.Labels{"message_type": messageType}).
Observe(duration.Seconds())
mh.signalMessageAge.Observe(time.Since(timestamp).Seconds())
}
}
func (mh *MetricsHandler) TrackLoginState(signalID uuid.UUID, loggedIn bool) {
if !mh.running {
return
}
mh.loggedInStateLock.Lock()
defer mh.loggedInStateLock.Unlock()
currentVal, ok := mh.loggedInState[signalID]
if !ok || currentVal != loggedIn {
mh.loggedInState[signalID] = loggedIn
if loggedIn {
mh.loggedIn.Inc()
} else if ok {
mh.loggedIn.Dec()
}
}
}
func (mh *MetricsHandler) TrackConnectionState(signalID uuid.UUID, connected bool) {
if !mh.running {
return
}
mh.connectedStateLock.Lock()
defer mh.connectedStateLock.Unlock()
currentVal, ok := mh.connectedState[signalID]
if !ok || currentVal != connected {
mh.connectedState[signalID] = connected
if connected {
mh.connected.Inc()
} else if ok {
mh.connected.Dec()
}
}
}
func (mh *MetricsHandler) updateStats() {
start := time.Now()
var puppetCount int
err := mh.db.QueryRow(mh.ctx, "SELECT COUNT(*) FROM puppet").Scan(&puppetCount)
if err != nil {
mh.log.Warn().Err(err).Msg("Failed to scan number of puppets")
} else {
mh.puppetCount.Set(float64(puppetCount))
}
var userCount int
err = mh.db.QueryRow(mh.ctx, `SELECT COUNT(*) FROM "user"`).Scan(&userCount)
if err != nil {
mh.log.Warn().Err(err).Msg("Failed to scan number of users:")
} else {
mh.userCount.Set(float64(userCount))
}
var messageCount int
err = mh.db.QueryRow(mh.ctx, "SELECT COUNT(*) FROM message").Scan(&messageCount)
if err != nil {
mh.log.Warn().Err(err).Msg("Failed to scan number of messages")
} else {
mh.messageCount.Set(float64(messageCount))
}
var encryptedGroupCount, encryptedPrivateCount, unencryptedGroupCount, unencryptedPrivateCount int
// TODO Use a more precise way to check if a chat_id is a UUID.
// It should also be compatible with both SQLite & Postgres.
err = mh.db.QueryRow(mh.ctx, `
SELECT
COUNT(CASE WHEN chat_id NOT LIKE '%-%-%-%-%' AND encrypted THEN 1 END) AS encrypted_group_portals,
COUNT(CASE WHEN chat_id LIKE '%-%-%-%-%' AND encrypted THEN 1 END) AS encrypted_private_portals,
COUNT(CASE WHEN chat_id NOT LIKE '%-%-%-%-%' AND NOT encrypted THEN 1 END) AS unencrypted_group_portals,
COUNT(CASE WHEN chat_id LIKE '%-%-%-%-%' AND NOT encrypted THEN 1 END) AS unencrypted_private_portals
FROM portal WHERE mxid<>''
`).Scan(&encryptedGroupCount, &encryptedPrivateCount, &unencryptedGroupCount, &unencryptedPrivateCount)
if err != nil {
mh.log.Warn().Err(err).Msg("Failed to scan number of portals")
} else {
mh.encryptedGroupCount.Set(float64(encryptedGroupCount))
mh.encryptedPrivateCount.Set(float64(encryptedPrivateCount))
mh.unencryptedGroupCount.Set(float64(unencryptedGroupCount))
mh.unencryptedPrivateCount.Set(float64(encryptedPrivateCount))
}
mh.countCollection.Observe(time.Since(start).Seconds())
}
func (mh *MetricsHandler) startUpdatingStats() {
defer func() {
r := recover()
if r != nil {
evt := mh.log.Fatal().Str("stack", string(debug.Stack()))
if err, ok := r.(error); ok {
evt = evt.Err(err)
} else {
evt = evt.Any("error", r)
}
evt.Msg("Panic in metric updater")
}
}()
ticker := time.Tick(10 * time.Second)
for {
mh.updateStats()
select {
case <-mh.ctx.Done():
return
case <-ticker:
}
}
}
func (mh *MetricsHandler) Start() {
mh.running = true
mh.ctx, mh.stopRecorder = context.WithCancel(context.Background())
go mh.startUpdatingStats()
err := mh.server.ListenAndServe()
mh.running = false
if err != nil && err != http.ErrServerClosed {
mh.log.Fatal().Err(err).Msg("Error in metrics listener")
}
}
func (mh *MetricsHandler) Stop() {
if !mh.running {
return
}
mh.stopRecorder()
err := mh.server.Close()
if err != nil {
mh.log.Err(err).Msg("Error closing metrics listener")
}
}

View file

@ -1,65 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package msgconv
import (
"context"
"time"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/database"
"go.mau.fi/mautrix-signal/msgconv/matrixfmt"
"go.mau.fi/mautrix-signal/msgconv/signalfmt"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)
type PortalMethods interface {
UploadMatrixMedia(ctx context.Context, data []byte, fileName, contentType string) (id.ContentURIString, error)
DownloadMatrixMedia(ctx context.Context, uri id.ContentURIString) ([]byte, error)
GetMatrixReply(ctx context.Context, msg *signalpb.DataMessage_Quote) (replyTo id.EventID, replyTargetSender id.UserID)
GetSignalReply(ctx context.Context, content *event.MessageEventContent) *signalpb.DataMessage_Quote
GetClient(ctx context.Context) *signalmeow.Client
GetData(ctx context.Context) *database.Portal
}
type ExtendedPortalMethods interface {
QueueFileTransfer(ctx context.Context, msgTS uint64, fileName string, ap *signalpb.AttachmentPointer) (id.ContentURIString, error)
}
type MessageConverter struct {
PortalMethods
SignalFmtParams *signalfmt.FormatParams
MatrixFmtParams *matrixfmt.HTMLParser
ConvertVoiceMessages bool
ConvertGIFToAPNG bool
MaxFileSize int64
AsyncFiles bool
UpdateDisappearing func(ctx context.Context, newTimer time.Duration)
LocationFormat string
}
func (mc *MessageConverter) IsPrivateChat(ctx context.Context) bool {
return !mc.GetData(ctx).UserID().IsEmpty()
}

View file

@ -32,6 +32,7 @@ import (
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
@ -39,7 +40,7 @@ const PrivateChatTopic = "Signal private chat"
const NoteToSelfName = "Signal Note to Self"
func (s *SignalClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
userID, err := parseUserID(ghost.ID)
userID, err := signalid.ParseUserID(ghost.ID)
if err != nil {
return nil, err
}
@ -51,7 +52,7 @@ func (s *SignalClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (
}
func (s *SignalClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
userID, groupID, err := parsePortalID(portal.ID)
userID, groupID, err := signalid.ParsePortalID(portal.ID)
if err != nil {
return nil, err
}
@ -143,19 +144,19 @@ func (s *SignalClient) ResolveIdentifier(ctx context.Context, number string, cre
// createChat is a no-op: chats don't need to be created, and we always return chat info
if aci != uuid.Nil {
ghost, err := s.Main.Bridge.GetGhostByID(ctx, makeUserID(aci))
ghost, err := s.Main.Bridge.GetGhostByID(ctx, signalid.MakeUserID(aci))
if err != nil {
return nil, fmt.Errorf("failed to get ghost: %w", err)
}
return &bridgev2.ResolveIdentifierResponse{
UserID: makeUserID(aci),
UserID: signalid.MakeUserID(aci),
UserInfo: s.contactToUserInfo(recipient),
Ghost: ghost,
Chat: s.makeCreateDMResponse(recipient),
}, nil
} else {
return &bridgev2.ResolveIdentifierResponse{
UserID: makeUserIDFromServiceID(libsignalgo.NewPNIServiceID(pni)),
UserID: signalid.MakeUserIDFromServiceID(libsignalgo.NewPNIServiceID(pni)),
UserInfo: s.contactToUserInfo(recipient),
Chat: s.makeCreateDMResponse(recipient),
}, nil
@ -179,14 +180,14 @@ func (s *SignalClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveI
Chat: s.makeCreateDMResponse(recipient),
}
if recipient.ACI != uuid.Nil {
recipientResp.UserID = makeUserID(recipient.ACI)
recipientResp.UserID = signalid.MakeUserID(recipient.ACI)
ghost, err := s.Main.Bridge.GetGhostByID(ctx, recipientResp.UserID)
if err != nil {
return nil, fmt.Errorf("failed to get ghost for %s: %w", recipient.ACI, err)
}
recipientResp.Ghost = ghost
} else {
recipientResp.UserID = makeUserIDFromServiceID(libsignalgo.NewPNIServiceID(recipient.PNI))
recipientResp.UserID = signalid.MakeUserIDFromServiceID(libsignalgo.NewPNIServiceID(recipient.PNI))
}
resp[i] = recipientResp
}
@ -215,7 +216,7 @@ func (s *SignalClient) makeCreateDMResponse(recipient *types.Recipient) *bridgev
name = s.Main.Config.FormatDisplayname(recipient)
serviceID = libsignalgo.NewPNIServiceID(recipient.PNI)
} else {
members.OtherUserID = makeUserID(recipient.ACI)
members.OtherUserID = signalid.MakeUserID(recipient.ACI)
if recipient.ACI == s.Client.Store.ACI {
name = NoteToSelfName
avatar = &bridgev2.Avatar{

View file

@ -11,6 +11,7 @@ import (
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
)
@ -112,7 +113,7 @@ func (s *SignalClient) IsThisUser(_ context.Context, userID networkid.UserID) bo
if s.Client == nil {
return false
}
return userID == makeUserID(s.Client.Store.ACI)
return userID == signalid.MakeUserID(s.Client.Store.ACI)
}
func (s *SignalClient) bridgeStateLoop(statusChan <-chan signalmeow.SignalConnectionStatus) {

View file

@ -20,19 +20,12 @@ import (
"context"
"fmt"
"text/template"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/msgconv"
"go.mau.fi/mautrix-signal/msgconv/matrixfmt"
"go.mau.fi/mautrix-signal/msgconv/signalfmt"
"go.mau.fi/mautrix-signal/pkg/msgconv"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/store"
)
@ -82,65 +75,7 @@ func (s *SignalConnector) Init(bridge *bridgev2.Bridge) {
}
s.Store = store.NewStore(bridge.DB.Database, dbutil.ZeroLogger(bridge.Log.With().Str("db_section", "signalmeow").Logger()))
s.Bridge = bridge
s.MsgConv = &msgconv.MessageConverter{
PortalMethods: &msgconvPortalMethods{},
SignalFmtParams: &signalfmt.FormatParams{
GetUserInfo: func(ctx context.Context, uuid uuid.UUID) signalfmt.UserInfo {
ghost, err := s.Bridge.GetGhostByID(ctx, makeUserID(uuid))
if err != nil {
// TODO log?
return signalfmt.UserInfo{}
}
userInfo := signalfmt.UserInfo{
MXID: ghost.Intent.GetMXID(),
Name: ghost.Name,
}
userLogin := s.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(uuid.String()))
if userLogin != nil {
userInfo.MXID = userLogin.UserMXID
// TODO find matrix user displayname?
}
return userInfo
},
},
MatrixFmtParams: &matrixfmt.HTMLParser{
GetUUIDFromMXID: func(ctx context.Context, userID id.UserID) uuid.UUID {
parsed, ok := s.Bridge.Matrix.ParseGhostMXID(userID)
if ok {
u, _ := uuid.Parse(string(parsed))
return u
}
user, _ := s.Bridge.GetExistingUserByMXID(ctx, userID)
// TODO log errors?
if user != nil {
preferredLogin, _, _ := ctx.Value(msgconvContextKey).(*msgconvContext).Portal.FindPreferredLogin(ctx, user, true)
if preferredLogin != nil {
u, _ := uuid.Parse(string(preferredLogin.ID))
return u
}
}
return uuid.Nil
},
},
ConvertVoiceMessages: true,
ConvertGIFToAPNG: true,
MaxFileSize: 50 * 1024 * 1024,
AsyncFiles: true,
LocationFormat: s.Config.LocationFormat,
UpdateDisappearing: func(ctx context.Context, newTimer time.Duration) {
portal := ctx.Value(msgconvContextKey).(*msgconvContext).Portal
portal.Disappear.Timer = newTimer
if newTimer == 0 {
portal.Disappear.Type = ""
} else {
portal.Disappear.Type = database.DisappearingTypeAfterRead
}
err := portal.Save(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to update portal disappearing timer in database")
}
},
}
s.MsgConv = msgconv.NewMessageConverter(bridge, s.Config.LocationFormat)
}
func (s *SignalConnector) SetMaxFileSize(maxSize int64) {

View file

@ -18,26 +18,20 @@ package connector
import (
"maunium.net/go/mautrix/bridgev2/database"
"go.mau.fi/mautrix-signal/pkg/signalid"
)
func (s *SignalConnector) GetDBMetaTypes() database.MetaTypes {
return database.MetaTypes{
Portal: func() any {
return &PortalMetadata{}
return &signalid.PortalMetadata{}
},
Ghost: nil,
Message: func() any {
return &MessageMetadata{}
return &signalid.MessageMetadata{}
},
Reaction: nil,
UserLogin: nil,
}
}
type PortalMetadata struct {
Revision uint32 `json:"revision"`
}
type MessageMetadata struct {
ContainsAttachments bool `json:"contains_attachments,omitempty"`
}

View file

@ -27,6 +27,7 @@ import (
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
@ -174,7 +175,7 @@ func (s *SignalClient) makeGroupAvatar(meta signalmeow.GroupAvatarMeta) *bridgev
func makeRevisionUpdater(rev uint32) func(ctx context.Context, portal *bridgev2.Portal) bool {
return func(ctx context.Context, portal *bridgev2.Portal) bool {
meta := portal.Metadata.(*PortalMetadata)
meta := portal.Metadata.(*signalid.PortalMetadata)
if meta.Revision < rev {
meta.Revision = rev
return true

View file

@ -32,12 +32,13 @@ import (
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)
func (s *SignalClient) sendMessage(ctx context.Context, portalID networkid.PortalID, content *signalpb.Content) error {
userID, groupID, err := parsePortalID(portalID)
userID, groupID, err := signalid.ParsePortalID(portalID)
if err != nil {
return err
}
@ -77,15 +78,7 @@ func (s *SignalClient) sendMessage(ctx context.Context, portalID networkid.Porta
}
func (s *SignalClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (message *bridgev2.MatrixMessageResponse, err error) {
mcCtx := &msgconvContext{
Connector: s.Main,
Intent: nil,
Client: s,
Portal: msg.Portal,
ReplyTo: msg.ReplyTo,
}
ctx = context.WithValue(ctx, msgconvContextKey, mcCtx)
converted, err := s.Main.MsgConv.ToSignal(ctx, msg.Event, msg.Content, msg.OrigSender != nil)
converted, err := s.Main.MsgConv.ToSignal(ctx, s.Client, msg.Portal, msg.Event, msg.Content, msg.OrigSender != nil, msg.ReplyTo)
if err != nil {
return nil, err
}
@ -94,10 +87,10 @@ func (s *SignalClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Ma
return nil, err
}
dbMsg := &database.Message{
ID: makeMessageID(s.Client.Store.ACI, converted.GetTimestamp()),
SenderID: makeUserID(s.Client.Store.ACI),
ID: signalid.MakeMessageID(s.Client.Store.ACI, converted.GetTimestamp()),
SenderID: signalid.MakeUserID(s.Client.Store.ACI),
Timestamp: time.UnixMilli(int64(converted.GetTimestamp())),
Metadata: &MessageMetadata{
Metadata: &signalid.MessageMetadata{
ContainsAttachments: len(converted.Attachments) > 0,
},
}
@ -107,27 +100,20 @@ func (s *SignalClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Ma
}
func (s *SignalClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
_, targetSentTimestamp, err := parseMessageID(msg.EditTarget.ID)
_, targetSentTimestamp, err := signalid.ParseMessageID(msg.EditTarget.ID)
if err != nil {
return fmt.Errorf("failed to parse target message ID: %w", err)
} else if msg.EditTarget.SenderID != 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")
}
mcCtx := &msgconvContext{
Connector: s.Main,
Intent: nil,
Client: s,
Portal: msg.Portal,
}
var replyTo *database.Message
if msg.EditTarget.ReplyTo.MessageID != "" {
var err error
mcCtx.ReplyTo, err = s.Main.Bridge.DB.Message.GetFirstOrSpecificPartByID(ctx, msg.Portal.Receiver, msg.EditTarget.ReplyTo)
replyTo, err = s.Main.Bridge.DB.Message.GetFirstOrSpecificPartByID(ctx, msg.Portal.Receiver, msg.EditTarget.ReplyTo)
if err != nil {
return fmt.Errorf("failed to get message reply target: %w", err)
}
}
ctx = context.WithValue(ctx, msgconvContextKey, mcCtx)
converted, err := s.Main.MsgConv.ToSignal(ctx, msg.Event, msg.Content, msg.OrigSender != nil)
converted, err := s.Main.MsgConv.ToSignal(ctx, s.Client, msg.Portal, msg.Event, msg.Content, msg.OrigSender != nil, replyTo)
if err != nil {
return err
}
@ -138,22 +124,22 @@ func (s *SignalClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Matri
if err != nil {
return err
}
msg.EditTarget.ID = makeMessageID(s.Client.Store.ACI, converted.GetTimestamp())
msg.EditTarget.Metadata = &MessageMetadata{ContainsAttachments: len(converted.Attachments) > 0}
msg.EditTarget.ID = signalid.MakeMessageID(s.Client.Store.ACI, converted.GetTimestamp())
msg.EditTarget.Metadata = &signalid.MessageMetadata{ContainsAttachments: len(converted.Attachments) > 0}
msg.EditTarget.EditCount++
return nil
}
func (s *SignalClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
return bridgev2.MatrixReactionPreResponse{
SenderID: makeUserID(s.Client.Store.ACI),
SenderID: signalid.MakeUserID(s.Client.Store.ACI),
EmojiID: "",
Emoji: variationselector.FullyQualify(msg.Content.RelatesTo.Key),
}, nil
}
func (s *SignalClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (reaction *database.Reaction, err error) {
targetAuthorACI, targetSentTimestamp, err := parseMessageID(msg.TargetMessage.ID)
targetAuthorACI, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetMessage.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse target message ID: %w", err)
}
@ -177,7 +163,7 @@ func (s *SignalClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.M
}
func (s *SignalClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error {
targetAuthorACI, targetSentTimestamp, err := parseMessageID(msg.TargetReaction.MessageID)
targetAuthorACI, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetReaction.MessageID)
if err != nil {
return fmt.Errorf("failed to parse target message ID: %w", err)
}
@ -201,10 +187,10 @@ func (s *SignalClient) HandleMatrixReactionRemove(ctx context.Context, msg *brid
}
func (s *SignalClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error {
_, targetSentTimestamp, err := parseMessageID(msg.TargetMessage.ID)
_, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetMessage.ID)
if err != nil {
return fmt.Errorf("failed to parse target message ID: %w", err)
} else if msg.TargetMessage.SenderID != makeUserID(s.Client.Store.ACI) {
} else if msg.TargetMessage.SenderID != signalid.MakeUserID(s.Client.Store.ACI) {
return fmt.Errorf("cannot delete other people's messages")
}
wrappedContent := &signalpb.Content{
@ -237,7 +223,7 @@ func (s *SignalClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bri
}
messagesToRead := map[uuid.UUID][]uint64{}
for _, msg := range dbMessages {
userID, timestamp, err := parseMessageID(msg.ID)
userID, timestamp, err := signalid.ParseMessageID(msg.ID)
if err != nil {
return fmt.Errorf("failed to parse message ID %q: %w", msg.ID, err)
}
@ -275,7 +261,7 @@ func (s *SignalClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bri
}
func (s *SignalClient) HandleMatrixTyping(ctx context.Context, typing *bridgev2.MatrixTyping) error {
userID, _, err := parsePortalID(typing.Portal.ID)
userID, _, err := signalid.ParsePortalID(typing.Portal.ID)
if err != nil {
return err
}
@ -292,11 +278,11 @@ func (s *SignalClient) HandleMatrixTyping(ctx context.Context, typing *bridgev2.
}
func (s *SignalClient) handleMatrixRoomMeta(ctx context.Context, portal *bridgev2.Portal, gc *signalmeow.GroupChange, postUpdatePortal func()) (bool, error) {
_, groupID, err := parsePortalID(portal.ID)
_, groupID, err := signalid.ParsePortalID(portal.ID)
if err != nil || groupID == "" {
return false, err
}
gc.Revision = portal.Metadata.(*PortalMetadata).Revision + 1
gc.Revision = portal.Metadata.(*signalid.PortalMetadata).Revision + 1
revision, err := s.Client.UpdateGroup(ctx, gc, groupID)
if err != nil {
return false, err
@ -316,7 +302,7 @@ func (s *SignalClient) handleMatrixRoomMeta(ctx context.Context, portal *bridgev
if postUpdatePortal != nil {
postUpdatePortal()
}
portal.Metadata.(*PortalMetadata).Revision = revision
portal.Metadata.(*signalid.PortalMetadata).Revision = revision
return true, nil
}
@ -327,7 +313,7 @@ func (s *SignalClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.M
}
func (s *SignalClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridgev2.MatrixRoomAvatar) (bool, error) {
_, groupID, err := parsePortalID(msg.Portal.ID)
_, groupID, err := signalid.ParsePortalID(msg.Portal.ID)
if err != nil || groupID == "" {
return false, err
}

View file

@ -30,6 +30,7 @@ import (
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow/events"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)
@ -63,7 +64,7 @@ func (s *SignalClient) wrapCallEvent(evt *events.Call) bridgev2.RemoteMessage {
PortalKey: s.makePortalKey(evt.Info.ChatID),
Data: evt,
CreatePortal: true,
ID: makeMessageID(evt.Info.Sender, evt.Timestamp),
ID: signalid.MakeMessageID(evt.Info.Sender, evt.Timestamp),
Sender: s.makeEventSender(evt.Info.Sender),
Timestamp: time.UnixMilli(int64(evt.Timestamp)),
ConvertMessageFunc: convertCallEvent,
@ -76,7 +77,7 @@ func convertCallEvent(ctx context.Context, portal *bridgev2.Portal, intent bridg
}
if data.IsRinging {
content.Body = "Incoming call"
if userID, _, _ := parsePortalID(portal.ID); !userID.IsEmpty() {
if userID, _, _ := signalid.ParsePortalID(portal.ID); !userID.IsEmpty() {
content.MsgType = event.MsgText
}
} else {
@ -152,7 +153,7 @@ func (evt *Bv2ChatEvent) PreHandle(ctx context.Context, portal *bridgev2.Portal)
if !ok || dataMsg.GroupV2 == nil {
return
}
portalRev := portal.Metadata.(*PortalMetadata).Revision
portalRev := portal.Metadata.(*signalid.PortalMetadata).Revision
if evt.Info.GroupRevision > portalRev {
toRevision := evt.Info.GroupRevision
if dataMsg.GetGroupV2().GetGroupChange() != nil {
@ -206,7 +207,7 @@ func (evt *Bv2ChatEvent) GetID() networkid.MessageID {
if ts == 0 {
panic(fmt.Errorf("GetID() called for non-DataMessage event"))
}
return makeMessageID(evt.Info.Sender, ts)
return signalid.MakeMessageID(evt.Info.Sender, ts)
}
func (evt *Bv2ChatEvent) getDataMsgTimestamp() uint64 {
@ -251,7 +252,7 @@ func (evt *Bv2ChatEvent) GetTargetMessage() networkid.MessageID {
if targetAuthorACI != "" {
targetAuthorUUID, _ = uuid.Parse(targetAuthorACI)
}
return makeMessageID(targetAuthorUUID, targetSentTS)
return signalid.MakeMessageID(targetAuthorUUID, targetSentTS)
}
func (evt *Bv2ChatEvent) GetReactionEmoji() (string, networkid.EmojiID) {
@ -267,82 +268,35 @@ func (evt *Bv2ChatEvent) GetRemovedEmojiID() networkid.EmojiID {
}
func (evt *Bv2ChatEvent) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
mcCtx := &msgconvContext{
Connector: evt.s.Main,
Intent: intent,
Client: evt.s,
Portal: portal,
}
ctx = context.WithValue(ctx, msgconvContextKey, mcCtx)
dataMsg, ok := evt.Event.(*signalpb.DataMessage)
if !ok {
return nil, fmt.Errorf("ConvertMessage() called for non-DataMessage event")
}
converted := evt.s.Main.MsgConv.ToMatrix(ctx, dataMsg)
converted.MergeCaption()
var replyTo *networkid.MessageOptionalPartID
if dataMsg.GetQuote() != nil {
quoteAuthor, _ := uuid.Parse(dataMsg.Quote.GetAuthorAci())
replyTo = &networkid.MessageOptionalPartID{
MessageID: makeMessageID(quoteAuthor, dataMsg.Quote.GetId()),
}
}
convertedParts := make([]*bridgev2.ConvertedMessagePart, len(converted.Parts))
for i, part := range converted.Parts {
convertedParts[i] = &bridgev2.ConvertedMessagePart{
ID: makeMessagePartID(i),
Type: part.Type,
Content: part.Content,
Extra: part.Extra,
DBMetadata: &MessageMetadata{ContainsAttachments: len(dataMsg.GetAttachments()) > 0},
}
}
var disappear database.DisappearingSetting
if converted.DisappearIn != 0 {
disappear = database.DisappearingSetting{
Type: database.DisappearingTypeAfterRead,
Timer: time.Duration(converted.DisappearIn) * time.Second,
}
dataMsgTS := time.UnixMilli(int64(dataMsg.GetTimestamp()))
converted := evt.s.Main.MsgConv.ToMatrix(ctx, evt.s.Client, portal, intent, dataMsg)
if converted.Disappear.Type != "" {
evtTS := evt.GetTimestamp()
portal.UpdateDisappearingSetting(ctx, converted.Disappear, nil, evtTS, true, true)
if evt.Info.Sender == evt.s.Client.Store.ACI {
disappear.DisappearAt = dataMsgTS.Add(disappear.Timer)
converted.Disappear.DisappearAt = evtTS.Add(converted.Disappear.Timer)
}
portal.UpdateDisappearingSetting(ctx, disappear, nil, dataMsgTS, true, true)
}
return &bridgev2.ConvertedMessage{
ReplyTo: replyTo,
Parts: convertedParts,
Disappear: disappear,
}, nil
return converted, nil
}
func (evt *Bv2ChatEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (*bridgev2.ConvertedEdit, error) {
mcCtx := &msgconvContext{
Connector: evt.s.Main,
Intent: intent,
Client: evt.s,
Portal: portal,
}
ctx = context.WithValue(ctx, msgconvContextKey, mcCtx)
editMsg, ok := evt.Event.(*signalpb.EditMessage)
if !ok {
return nil, fmt.Errorf("ConvertEdit() called for non-EditMessage event")
}
// TODO tell converter about existing parts to avoid reupload?
converted := evt.s.Main.MsgConv.ToMatrix(ctx, editMsg.GetDataMessage())
converted.MergeCaption()
convertedEdit := &bridgev2.ConvertedEdit{}
converted := evt.s.Main.MsgConv.ToMatrix(ctx, evt.s.Client, portal, intent, editMsg.GetDataMessage())
// TODO can anything other than the text be edited?
lastPart := converted.Parts[len(converted.Parts)-1]
convertedEdit.ModifiedParts = append(convertedEdit.ModifiedParts, &bridgev2.ConvertedEditPart{
Part: existing[len(existing)-1],
Type: lastPart.Type,
Content: lastPart.Content,
Extra: lastPart.Extra,
})
convertedEdit.ModifiedParts[0].Part.EditCount++
convertedEdit.ModifiedParts[0].Part.ID = makeMessageID(evt.Info.Sender, editMsg.GetDataMessage().GetTimestamp())
return convertedEdit, nil
editPart := converted.Parts[len(converted.Parts)-1].ToEditPart(existing[len(existing)-1])
editPart.Part.EditCount++
editPart.Part.ID = signalid.MakeMessageID(evt.Info.Sender, editMsg.GetDataMessage().GetTimestamp())
return &bridgev2.ConvertedEdit{
ModifiedParts: []*bridgev2.ConvertedEditPart{editPart},
}, nil
}
type Bv2Receipt struct {
@ -438,7 +392,7 @@ func (s *SignalClient) handleSignalReceipt(evt *events.Receipt) {
Logger()
ctx := log.WithContext(context.TODO())
receipts := convertReceipts(ctx, evt.Content.Timestamp, func(ctx context.Context, msgTS uint64) (*database.Message, error) {
return s.Main.Bridge.DB.Message.GetFirstPartByID(ctx, s.UserLogin.ID, makeMessageID(s.Client.Store.ACI, msgTS))
return s.Main.Bridge.DB.Message.GetFirstPartByID(ctx, s.UserLogin.ID, signalid.MakeMessageID(s.Client.Store.ACI, msgTS))
})
s.dispatchReceipts(evt.Sender, evt.Content.GetType(), receipts)
}
@ -453,7 +407,7 @@ func (s *SignalClient) handleSignalReadSelf(evt *events.ReadSelf) {
if err != nil {
return nil, err
}
return s.Main.Bridge.DB.Message.GetFirstPartByID(ctx, s.UserLogin.ID, makeMessageID(aciUUID, msgInfo.GetTimestamp()))
return s.Main.Bridge.DB.Message.GetFirstPartByID(ctx, s.UserLogin.ID, signalid.MakeMessageID(aciUUID, msgInfo.GetTimestamp()))
})
s.dispatchReceipts(s.Client.Store.ACI, signalpb.ReceiptMessage_READ, receipts)
}
@ -495,7 +449,7 @@ func (s *SignalClient) handleSignalContactList(evt *events.ContactList) {
continue
}
fullContact.ContactAvatar = contact.ContactAvatar
ghost, err := s.Main.Bridge.GetGhostByID(ctx, makeUserID(contact.ACI))
ghost, err := s.Main.Bridge.GetGhostByID(ctx, signalid.MakeUserID(contact.ACI))
if err != nil {
log.Err(err).Msg("Failed to get ghost to update contact info")
continue
@ -510,7 +464,7 @@ func (s *SignalClient) handleSignalContactList(evt *events.ContactList) {
func (s *SignalClient) updateRemoteProfile(ctx context.Context, resendState bool) {
var err error
if s.Ghost == nil {
s.Ghost, err = s.Main.Bridge.GetGhostByID(ctx, makeUserID(s.Client.Store.ACI))
s.Ghost, err = s.Main.Bridge.GetGhostByID(ctx, signalid.MakeUserID(s.Client.Store.ACI))
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost for remote profile update")
return

View file

@ -17,71 +17,14 @@
package connector
import (
"fmt"
"strconv"
"strings"
"github.com/google/uuid"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
"go.mau.fi/mautrix-signal/pkg/signalid"
)
func parseUserID(userID networkid.UserID) (uuid.UUID, error) {
serviceID, err := parseUserIDAsServiceID(userID)
if err != nil {
return uuid.Nil, err
} else if serviceID.Type != libsignalgo.ServiceIDTypeACI {
return uuid.Nil, fmt.Errorf("invalid user ID: expected ACI type")
} else {
return serviceID.UUID, nil
}
}
func parseUserIDAsServiceID(userID networkid.UserID) (libsignalgo.ServiceID, error) {
return libsignalgo.ServiceIDFromString(string(userID))
}
func parsePortalID(portalID networkid.PortalID) (userID libsignalgo.ServiceID, groupID types.GroupIdentifier, err error) {
if len(portalID) == 44 {
groupID = types.GroupIdentifier(portalID)
} else {
userID, err = libsignalgo.ServiceIDFromString(string(portalID))
}
return
}
func parseMessageID(messageID networkid.MessageID) (sender uuid.UUID, timestamp uint64, err error) {
parts := strings.Split(string(messageID), "|")
if len(parts) != 2 {
err = fmt.Errorf("invalid message ID: expected two pipe-separated parts")
return
}
sender, err = uuid.Parse(parts[0])
if err != nil {
return
}
timestamp, err = strconv.ParseUint(parts[1], 10, 64)
return
}
func makeGroupPortalID(groupID types.GroupIdentifier) networkid.PortalID {
return networkid.PortalID(groupID)
}
func makeGroupPortalKey(groupID types.GroupIdentifier) networkid.PortalKey {
return networkid.PortalKey{
ID: makeGroupPortalID(groupID),
Receiver: "",
}
}
func makeDMPortalID(serviceID libsignalgo.ServiceID) networkid.PortalID {
return networkid.PortalID(serviceID.String())
}
func (s *SignalClient) makePortalKey(chatID string) networkid.PortalKey {
key := networkid.PortalKey{ID: networkid.PortalID(chatID)}
// For non-group chats, add receiver
@ -93,38 +36,15 @@ func (s *SignalClient) makePortalKey(chatID string) networkid.PortalKey {
func (s *SignalClient) makeDMPortalKey(serviceID libsignalgo.ServiceID) networkid.PortalKey {
return networkid.PortalKey{
ID: makeDMPortalID(serviceID),
ID: signalid.MakeDMPortalID(serviceID),
Receiver: s.UserLogin.ID,
}
}
func makeMessageID(sender uuid.UUID, timestamp uint64) networkid.MessageID {
return networkid.MessageID(fmt.Sprintf("%s|%d", sender, timestamp))
}
func makeUserID(user uuid.UUID) networkid.UserID {
return networkid.UserID(user.String())
}
func makeUserIDFromServiceID(user libsignalgo.ServiceID) networkid.UserID {
return networkid.UserID(user.String())
}
func makeUserLoginID(user uuid.UUID) networkid.UserLoginID {
return networkid.UserLoginID(user.String())
}
func (s *SignalClient) makeEventSender(sender uuid.UUID) bridgev2.EventSender {
return bridgev2.EventSender{
IsFromMe: sender == s.Client.Store.ACI,
SenderLogin: makeUserLoginID(sender),
Sender: makeUserID(sender),
SenderLogin: signalid.MakeUserLoginID(sender),
Sender: signalid.MakeUserID(sender),
}
}
func makeMessagePartID(index int) networkid.PartID {
if index == 0 {
return ""
}
return networkid.PartID(strconv.Itoa(index))
}

View file

@ -25,6 +25,7 @@ import (
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/store"
)
@ -144,7 +145,7 @@ func (qr *QRLogin) qrWait(ctx context.Context) (*bridgev2.LoginStep, error) {
func (qr *QRLogin) processingWait(ctx context.Context) (*bridgev2.LoginStep, error) {
defer qr.cancelChan()
newLoginID := makeUserLoginID(qr.ProvData.ACI)
newLoginID := signalid.MakeUserLoginID(qr.ProvData.ACI)
select {
case resp := <-qr.ProvChan:

View file

@ -1,117 +0,0 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"strings"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
legacydb "go.mau.fi/mautrix-signal/database"
"go.mau.fi/mautrix-signal/msgconv"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)
type contextKey int
var msgconvContextKey contextKey
type msgconvContext struct {
Connector *SignalConnector
Intent bridgev2.MatrixAPI
Client *SignalClient
Portal *bridgev2.Portal
ReplyTo *database.Message
}
type msgconvPortalMethods struct{}
var _ msgconv.PortalMethods = (*msgconvPortalMethods)(nil)
func (mpm *msgconvPortalMethods) UploadMatrixMedia(ctx context.Context, data []byte, fileName, contentType string) (id.ContentURIString, error) {
mcCtx := ctx.Value(msgconvContextKey).(*msgconvContext)
uri, _, err := mcCtx.Intent.UploadMedia(ctx, "", data, fileName, contentType)
return uri, err
}
func (mpm *msgconvPortalMethods) DownloadMatrixMedia(ctx context.Context, uri id.ContentURIString) ([]byte, error) {
return ctx.Value(msgconvContextKey).(*msgconvContext).Connector.Bridge.Bot.DownloadMedia(ctx, uri, nil)
}
func (mpm *msgconvPortalMethods) GetMatrixReply(ctx context.Context, msg *signalpb.DataMessage_Quote) (replyTo id.EventID, replyTargetSender id.UserID) {
// Matrix replies are handled in bridgev2 code
return "", ""
}
func (mpm *msgconvPortalMethods) GetSignalReply(ctx context.Context, content *event.MessageEventContent) *signalpb.DataMessage_Quote {
mcCtx := ctx.Value(msgconvContextKey).(*msgconvContext)
if mcCtx.ReplyTo == nil {
return nil
}
quote := &signalpb.DataMessage_Quote{
Id: proto.Uint64(uint64(mcCtx.ReplyTo.Timestamp.UnixMilli())),
AuthorAci: proto.String(string(mcCtx.ReplyTo.SenderID)),
Type: signalpb.DataMessage_Quote_NORMAL.Enum(),
}
if mcCtx.ReplyTo.Metadata.(*MessageMetadata).ContainsAttachments {
quote.Attachments = make([]*signalpb.DataMessage_Quote_QuotedAttachment, 1)
}
return quote
}
func (mpm *msgconvPortalMethods) GetClient(ctx context.Context) *signalmeow.Client {
return ctx.Value(msgconvContextKey).(*msgconvContext).Client.Client
}
func (mpm *msgconvPortalMethods) GetData(ctx context.Context) *legacydb.Portal {
mcCtx := ctx.Value(msgconvContextKey).(*msgconvContext)
portal := mcCtx.Portal
userID, groupID, _ := parsePortalID(portal.ID)
chatID := string(groupID)
if chatID == "" {
chatID = userID.String()
}
pk := legacydb.PortalKey{
ChatID: chatID,
}
if len(chatID) != 44 {
pk.Receiver = mcCtx.Client.Client.Store.ACI
}
return &legacydb.Portal{
PortalKey: pk,
MXID: portal.MXID,
Name: portal.Name,
Topic: portal.Topic,
//AvatarPath: "",
//AvatarHash: "",
//AvatarURL: id.ContentURI{},
NameSet: portal.NameSet,
AvatarSet: portal.AvatarSet,
TopicSet: portal.TopicSet,
Revision: portal.Metadata.(*PortalMetadata).Revision,
// Hack to prevent encryption while using the bridge as a "local bridge"
Encrypted: !strings.HasSuffix(portal.Bridge.Matrix.ServerName(), ".localhost"),
//RelayUserID: portal.Relay.UserMXID,
ExpirationTime: uint32(portal.Disappear.Timer.Seconds()),
}
}

View file

@ -18,35 +18,37 @@ package msgconv
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exmime"
"go.mau.fi/util/ffmpeg"
"go.mau.fi/util/variationselector"
"golang.org/x/exp/constraints"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/msgconv/matrixfmt"
"go.mau.fi/mautrix-signal/pkg/msgconv/matrixfmt"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)
var (
ErrUnsupportedMsgType = errors.New("unsupported msgtype")
ErrMediaDownloadFailed = errors.New("failed to download media")
ErrMediaDecryptFailed = errors.New("failed to decrypt media")
ErrMediaConvertFailed = errors.New("failed to convert")
ErrMediaUploadFailed = errors.New("failed to upload media")
ErrInvalidGeoURI = errors.New("invalid `geo:` URI in message")
)
func (mc *MessageConverter) ToSignal(ctx context.Context, evt *event.Event, content *event.MessageEventContent, relaybotFormatted bool) (*signalpb.DataMessage, error) {
func (mc *MessageConverter) ToSignal(
ctx context.Context,
client *signalmeow.Client,
portal *bridgev2.Portal,
evt *event.Event,
content *event.MessageEventContent,
relaybotFormatted bool,
replyTo *database.Message,
) (*signalpb.DataMessage, error) {
ctx = context.WithValue(ctx, contextKeyClient, client)
ctx = context.WithValue(ctx, contextKeyPortal, portal)
if evt.Type == event.EventSticker {
content.MsgType = event.MessageType(event.EventSticker.Type)
}
@ -59,11 +61,23 @@ func (mc *MessageConverter) ToSignal(ctx context.Context, evt *event.Event, cont
}
dm := &signalpb.DataMessage{
Timestamp: &ts,
Quote: mc.GetSignalReply(ctx, content),
Preview: mc.convertURLPreviewToSignal(ctx, evt),
Preview: mc.convertURLPreviewToSignal(ctx, content),
}
if expirationTime := mc.GetData(ctx).ExpirationTime; expirationTime != 0 {
dm.ExpireTimer = proto.Uint32(uint32(expirationTime))
if replyTo != nil {
authorACI, messageID, err := signalid.ParseMessageID(replyTo.ID)
if err == nil {
dm.Quote = &signalpb.DataMessage_Quote{
Id: proto.Uint64(messageID),
AuthorAci: proto.String(authorACI.String()),
Type: signalpb.DataMessage_Quote_NORMAL.Enum(),
}
if replyTo.Metadata.(*signalid.MessageMetadata).ContainsAttachments {
dm.Quote.Attachments = make([]*signalpb.DataMessage_Quote_QuotedAttachment, 1)
}
}
}
if portal.Disappear.Timer > 0 {
dm.ExpireTimer = proto.Uint32(uint32(portal.Disappear.Timer.Seconds()))
}
if content.MsgType == event.MsgEmote && !relaybotFormatted {
content.Body = "/me " + content.Body
@ -113,13 +127,13 @@ func (mc *MessageConverter) ToSignal(ctx context.Context, evt *event.Event, cont
case event.MsgLocation:
lat, lon, err := parseGeoURI(content.GeoURI)
if err != nil {
log.Err(err).Msg("Invalid geo URI")
zerolog.Ctx(ctx).Err(err).Msg("Invalid geo URI")
return nil, err
}
locationString := fmt.Sprintf(mc.LocationFormat, lat, lon)
dm.Body = &locationString
default:
return nil, fmt.Errorf("%w %s", ErrUnsupportedMsgType, content.MsgType)
return nil, fmt.Errorf("%w %s", bridgev2.ErrUnsupportedMessageType, content.MsgType)
}
return dm, nil
}
@ -133,44 +147,33 @@ func maybeInt[T constraints.Integer](v T) *T {
func (mc *MessageConverter) convertFileToSignal(ctx context.Context, evt *event.Event, content *event.MessageEventContent) (*signalpb.AttachmentPointer, error) {
log := zerolog.Ctx(ctx)
mxc := content.URL
if content.File != nil {
mxc = content.File.URL
}
data, err := mc.DownloadMatrixMedia(ctx, mxc)
data, err := mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File)
if err != nil {
return nil, exerrors.NewDualError(ErrMediaDownloadFailed, err)
}
if content.File != nil {
err = content.File.DecryptInPlace(data)
if err != nil {
return nil, exerrors.NewDualError(ErrMediaDecryptFailed, err)
}
return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err)
}
fileName := content.Body
if content.FileName != "" {
fileName = content.FileName
}
_, isVoice := evt.Content.Raw["org.matrix.msc3245.voice"]
mime := content.GetInfo().MimeType
if isVoice {
if content.MSC3245Voice != nil && ffmpeg.Supported() {
data, err = ffmpeg.ConvertBytes(ctx, data, ".m4a", []string{}, []string{"-c:a", "aac"}, mime)
if err != nil {
return nil, err
}
mime = "audio/aac"
fileName += ".m4a"
} else if evt.Type == event.EventSticker && mime != "image/webp" && mime != "image/png" && mime != "image/apng" {
} else if evt.Type == event.EventSticker {
switch mime {
case "image/webp", "image/png", "image/apng":
// allowed
case "image/gif":
if !mc.ConvertGIFToAPNG {
if !ffmpeg.Supported() {
return nil, fmt.Errorf("converting gif stickers is not supported")
}
data, err = ffmpeg.ConvertBytes(ctx, data, ".apng", []string{}, []string{}, mime)
if err != nil {
return nil, fmt.Errorf("%w gif to apng: %w", ErrMediaConvertFailed, err)
return nil, fmt.Errorf("%w (gif to apng): %w", bridgev2.ErrMediaConvertFailed, err)
}
fileName += ".apng"
mime = "image/apng"
@ -178,12 +181,12 @@ func (mc *MessageConverter) convertFileToSignal(ctx context.Context, evt *event.
return nil, fmt.Errorf("unsupported content type for sticker %s", mime)
}
}
att, err := mc.GetClient(ctx).UploadAttachment(ctx, data)
att, err := getClient(ctx).UploadAttachment(ctx, data)
if err != nil {
log.Err(err).Msg("Failed to upload file")
return nil, exerrors.NewDualError(ErrMediaUploadFailed, err)
return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaReuploadFailed, err)
}
if isVoice {
if content.MSC3245Voice != nil && mime == "audio/aac" {
att.Flags = proto.Uint32(uint32(signalpb.AttachmentPointer_VOICE_MESSAGE))
}
att.ContentType = proto.String(mime)

View file

@ -26,49 +26,22 @@ import (
"time"
"github.com/emersion/go-vcard"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.mau.fi/util/exfmt"
"go.mau.fi/util/exmime"
"go.mau.fi/util/ffmpeg"
"golang.org/x/exp/slices"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-signal/msgconv/signalfmt"
"go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)
type ConvertedMessage struct {
Parts []*ConvertedMessagePart
Timestamp uint64
DisappearIn uint32
}
func (cm *ConvertedMessage) MergeCaption() {
if len(cm.Parts) != 2 || cm.Parts[1].Content.MsgType != event.MsgText {
return
}
switch cm.Parts[0].Content.MsgType {
case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile:
default:
return
}
mediaContent := cm.Parts[0].Content
textContent := cm.Parts[1].Content
mediaContent.FileName = mediaContent.Body
mediaContent.Body = textContent.Body
mediaContent.Format = textContent.Format
mediaContent.FormattedBody = textContent.FormattedBody
cm.Parts = cm.Parts[:1]
}
type ConvertedMessagePart struct {
Type event.Type
Content *event.MessageEventContent
Extra map[string]any
}
func calculateLength(dm *signalpb.DataMessage) int {
if dm.GetFlags()&uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE) != 0 {
return 1
@ -96,19 +69,30 @@ func CanConvertSignal(dm *signalpb.DataMessage) bool {
return calculateLength(dm) > 0
}
func (mc *MessageConverter) ToMatrix(ctx context.Context, dm *signalpb.DataMessage) *ConvertedMessage {
cm := &ConvertedMessage{
Timestamp: dm.GetTimestamp(),
DisappearIn: dm.GetExpireTimer(),
Parts: make([]*ConvertedMessagePart, 0, calculateLength(dm)),
func (mc *MessageConverter) ToMatrix(
ctx context.Context,
client *signalmeow.Client,
portal *bridgev2.Portal,
intent bridgev2.MatrixAPI,
dm *signalpb.DataMessage,
) *bridgev2.ConvertedMessage {
ctx = context.WithValue(ctx, contextKeyClient, client)
ctx = context.WithValue(ctx, contextKeyPortal, portal)
ctx = context.WithValue(ctx, contextKeyIntent, intent)
cm := &bridgev2.ConvertedMessage{
ReplyTo: nil,
ThreadRoot: nil,
Parts: make([]*bridgev2.ConvertedMessagePart, 0, calculateLength(dm)),
}
if dm.GetFlags()&uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE) != 0 {
cm.Parts = append(cm.Parts, mc.ConvertDisappearingTimerChangeToMatrix(ctx, dm.GetExpireTimer(), true))
// Don't disappear disappearing timer changes
cm.DisappearIn = 0
// Don't allow any other parts in a disappearing timer change message
return cm
}
if dm.GetExpireTimer() > 0 {
cm.Disappear.Type = database.DisappearingTypeAfterRead
cm.Disappear.Timer = time.Duration(dm.GetExpireTimer()) * time.Second
}
if dm.Sticker != nil {
cm.Parts = append(cm.Parts, mc.convertStickerToMatrix(ctx, dm.Sticker))
// Don't allow any other parts in a sticker message
@ -139,7 +123,7 @@ func (mc *MessageConverter) ToMatrix(ctx context.Context, dm *signalpb.DataMessa
cm.Parts = append(cm.Parts, mc.convertTextToMatrix(ctx, dm))
}
if len(cm.Parts) == 0 && dm.GetRequiredProtocolVersion() > uint32(signalpb.DataMessage_CURRENT) {
cm.Parts = append(cm.Parts, &ConvertedMessagePart{
cm.Parts = append(cm.Parts, &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
@ -147,23 +131,28 @@ func (mc *MessageConverter) ToMatrix(ctx context.Context, dm *signalpb.DataMessa
},
})
}
replyTo, sender := mc.GetMatrixReply(ctx, dm.Quote)
for _, part := range cm.Parts {
if part.Content.Mentions == nil {
part.Content.Mentions = &event.Mentions{}
cm.MergeCaption()
for i, part := range cm.Parts {
part.ID = signalid.MakeMessagePartID(i)
part.DBMetadata = &signalid.MessageMetadata{
ContainsAttachments: len(dm.GetAttachments()) > 0,
}
if replyTo != "" {
part.Content.RelatesTo = (&event.RelatesTo{}).SetReplyTo(replyTo)
if !slices.Contains(part.Content.Mentions.UserIDs, sender) {
part.Content.Mentions.UserIDs = append(part.Content.Mentions.UserIDs, sender)
}
if dm.Quote != nil {
authorACI, err := uuid.Parse(dm.Quote.GetAuthorAci())
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("author_aci", dm.Quote.GetAuthorAci()).Msg("Failed to parse quote author ACI")
} else {
cm.ReplyTo = &networkid.MessageOptionalPartID{
MessageID: signalid.MakeMessageID(authorACI, dm.Quote.GetId()),
}
}
}
return cm
}
func (mc *MessageConverter) ConvertDisappearingTimerChangeToMatrix(ctx context.Context, timer uint32, updatePortal bool) *ConvertedMessagePart {
part := &ConvertedMessagePart{
func (mc *MessageConverter) ConvertDisappearingTimerChangeToMatrix(ctx context.Context, timer uint32, updatePortal bool) *bridgev2.ConvertedMessagePart {
part := &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
@ -174,35 +163,36 @@ func (mc *MessageConverter) ConvertDisappearingTimerChangeToMatrix(ctx context.C
part.Content.Body = "Disappearing messages disabled"
}
if updatePortal {
if mc.UpdateDisappearing != nil {
mc.UpdateDisappearing(ctx, time.Duration(timer)*time.Second)
portal := getPortal(ctx)
portal.Disappear.Timer = time.Duration(timer) * time.Second
if timer == 0 {
portal.Disappear.Type = ""
} else {
portal := mc.GetData(ctx)
portal.ExpirationTime = timer
err := portal.Update(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to update portal disappearing timer in database")
}
portal.Disappear.Type = database.DisappearingTypeAfterRead
}
err := portal.Save(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to update portal disappearing timer in database")
}
}
return part
}
func (mc *MessageConverter) convertTextToMatrix(ctx context.Context, dm *signalpb.DataMessage) *ConvertedMessagePart {
func (mc *MessageConverter) convertTextToMatrix(ctx context.Context, dm *signalpb.DataMessage) *bridgev2.ConvertedMessagePart {
content := signalfmt.Parse(ctx, dm.GetBody(), dm.GetBodyRanges(), mc.SignalFmtParams)
extra := map[string]any{}
if len(dm.Preview) > 0 {
extra["com.beeper.linkpreviews"] = mc.convertURLPreviewsToBeeper(ctx, dm.Preview)
content.BeeperLinkPreviews = mc.convertURLPreviewsToBeeper(ctx, dm.Preview)
}
return &ConvertedMessagePart{
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: content,
Extra: extra,
}
}
func (mc *MessageConverter) convertPaymentToMatrix(_ context.Context, payment *signalpb.DataMessage_Payment) *ConvertedMessagePart {
return &ConvertedMessagePart{
func (mc *MessageConverter) convertPaymentToMatrix(_ context.Context, payment *signalpb.DataMessage_Payment) *bridgev2.ConvertedMessagePart {
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
@ -214,8 +204,8 @@ func (mc *MessageConverter) convertPaymentToMatrix(_ context.Context, payment *s
}
}
func (mc *MessageConverter) convertGiftBadgeToMatrix(_ context.Context, giftBadge *signalpb.DataMessage_GiftBadge) *ConvertedMessagePart {
return &ConvertedMessagePart{
func (mc *MessageConverter) convertGiftBadgeToMatrix(_ context.Context, giftBadge *signalpb.DataMessage_GiftBadge) *bridgev2.ConvertedMessagePart {
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
@ -303,7 +293,7 @@ func (mc *MessageConverter) convertContactToVCard(ctx context.Context, contact *
return card
}
func (mc *MessageConverter) convertContactToMatrix(ctx context.Context, contact *signalpb.DataMessage_Contact) *ConvertedMessagePart {
func (mc *MessageConverter) convertContactToMatrix(ctx context.Context, contact *signalpb.DataMessage_Contact) *bridgev2.ConvertedMessagePart {
card := mc.convertContactToVCard(ctx, contact)
contact.Avatar = nil
extraData := map[string]any{
@ -313,7 +303,7 @@ func (mc *MessageConverter) convertContactToMatrix(ctx context.Context, contact
err := vcard.NewEncoder(&buf).Encode(card)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to encode vCard")
return &ConvertedMessagePart{
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
@ -323,30 +313,6 @@ func (mc *MessageConverter) convertContactToMatrix(ctx context.Context, contact
}
}
data := buf.Bytes()
var file *event.EncryptedFileInfo
uploadMime := "text/vcard"
uploadFileName := "contact.vcf"
if mc.GetData(ctx).Encrypted {
file = &event.EncryptedFileInfo{
EncryptedFile: *attachment.NewEncryptedFile(),
URL: "",
}
file.EncryptInPlace(data)
uploadMime = "application/octet-stream"
uploadFileName = ""
}
mxc, err := mc.UploadMatrixMedia(ctx, data, uploadFileName, uploadMime)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to upload vCard")
return &ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Failed to upload vCard",
},
Extra: extraData,
}
}
displayName := contact.GetName().GetDisplayName()
if displayName == "" {
displayName = contact.GetName().GetGivenName()
@ -368,24 +334,30 @@ func (mc *MessageConverter) convertContactToMatrix(ctx context.Context, contact
Size: len(data),
},
}
if file != nil {
file.URL = mxc
content.File = file
} else {
content.URL = mxc
content.URL, content.File, err = getIntent(ctx).UploadMedia(ctx, getPortal(ctx).MXID, data, content.Info.MimeType, content.Body)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to upload vCard")
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Failed to upload vCard",
},
Extra: extraData,
}
}
return &ConvertedMessagePart{
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: content,
Extra: extraData,
}
}
func (mc *MessageConverter) convertAttachmentToMatrix(ctx context.Context, index int, att *signalpb.AttachmentPointer) *ConvertedMessagePart {
func (mc *MessageConverter) convertAttachmentToMatrix(ctx context.Context, index int, att *signalpb.AttachmentPointer) *bridgev2.ConvertedMessagePart {
part, err := mc.reuploadAttachment(ctx, att)
if err != nil {
zerolog.Ctx(ctx).Err(err).Int("attachment_index", index).Msg("Failed to handle attachment")
return &ConvertedMessagePart{
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
@ -396,11 +368,11 @@ func (mc *MessageConverter) convertAttachmentToMatrix(ctx context.Context, index
return part
}
func (mc *MessageConverter) convertStickerToMatrix(ctx context.Context, sticker *signalpb.DataMessage_Sticker) *ConvertedMessagePart {
func (mc *MessageConverter) convertStickerToMatrix(ctx context.Context, sticker *signalpb.DataMessage_Sticker) *bridgev2.ConvertedMessagePart {
converted, err := mc.reuploadAttachment(ctx, sticker.GetData())
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to handle sticker")
return &ConvertedMessagePart{
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
@ -437,7 +409,7 @@ func (mc *MessageConverter) downloadSignalLongText(ctx context.Context, att *sig
return &longBody, nil
}
func (mc *MessageConverter) reuploadAttachment(ctx context.Context, att *signalpb.AttachmentPointer) (*ConvertedMessagePart, error) {
func (mc *MessageConverter) reuploadAttachment(ctx context.Context, att *signalpb.AttachmentPointer) (*bridgev2.ConvertedMessagePart, error) {
data, err := signalmeow.DownloadAttachment(ctx, att)
if err != nil {
return nil, fmt.Errorf("failed to download attachment: %w", err)
@ -447,43 +419,28 @@ func (mc *MessageConverter) reuploadAttachment(ctx context.Context, att *signalp
mimeType = http.DetectContentType(data)
}
fileName := att.GetFileName()
extra := map[string]any{}
if mc.ConvertVoiceMessages && att.GetFlags()&uint32(signalpb.AttachmentPointer_VOICE_MESSAGE) != 0 {
content := &event.MessageEventContent{
Info: &event.FileInfo{
Width: int(att.GetWidth()),
Height: int(att.GetHeight()),
Size: len(data),
},
}
if att.GetFlags()&uint32(signalpb.AttachmentPointer_VOICE_MESSAGE) != 0 && ffmpeg.Supported() {
data, err = ffmpeg.ConvertBytes(ctx, data, ".ogg", []string{}, []string{"-c:a", "libopus"}, mimeType)
if err != nil {
return nil, fmt.Errorf("failed to convert audio to ogg/opus: %w", err)
}
fileName += ".ogg"
mimeType = "audio/ogg"
extra["org.matrix.msc3245.voice"] = map[string]any{}
content.MSC3245Voice = &event.MSC3245Voice{}
// TODO include duration here (and in info) if there's some easy way to extract it with ffmpeg
//extra["org.matrix.msc1767.audio"] = map[string]any{"duration": ???}
//content.MSC1767Audio = &event.MSC1767Audio{}
}
var file *event.EncryptedFileInfo
uploadMime := mimeType
uploadFileName := fileName
if mc.GetData(ctx).Encrypted {
file = &event.EncryptedFileInfo{
EncryptedFile: *attachment.NewEncryptedFile(),
URL: "",
}
file.EncryptInPlace(data)
uploadMime = "application/octet-stream"
uploadFileName = ""
}
mxc, err := mc.UploadMatrixMedia(ctx, data, uploadFileName, uploadMime)
content.URL, content.File, err = getIntent(ctx).UploadMedia(ctx, getPortal(ctx).MXID, data, fileName, mimeType)
if err != nil {
return nil, err
}
content := &event.MessageEventContent{
Body: fileName,
Info: &event.FileInfo{
MimeType: mimeType,
Width: int(att.GetWidth()),
Height: int(att.GetHeight()),
Size: len(data),
},
}
if att.GetBlurHash() != "" {
content.Info.Blurhash = att.GetBlurHash()
content.Info.AnoaBlurhash = att.GetBlurHash()
@ -498,18 +455,13 @@ func (mc *MessageConverter) reuploadAttachment(ctx context.Context, att *signalp
default:
content.MsgType = event.MsgFile
}
content.Body = fileName
content.Info.MimeType = mimeType
if content.Body == "" {
content.Body = strings.TrimPrefix(string(content.MsgType), "m.") + exmime.ExtensionFromMimetype(mimeType)
}
if file != nil {
file.URL = mxc
content.File = file
} else {
content.URL = mxc
}
return &ConvertedMessagePart{
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: content,
Extra: extra,
}, nil
}

View file

@ -10,8 +10,8 @@ import (
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/msgconv/matrixfmt"
"go.mau.fi/mautrix-signal/msgconv/signalfmt"
"go.mau.fi/mautrix-signal/pkg/msgconv/matrixfmt"
"go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt"
)
var formatParams = &matrixfmt.HTMLParser{

View file

@ -13,7 +13,7 @@ import (
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/msgconv/signalfmt"
"go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt"
)
type EntityString struct {

107
pkg/msgconv/msgconv.go Normal file
View file

@ -0,0 +1,107 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package msgconv
import (
"context"
"github.com/google/uuid"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/pkg/msgconv/matrixfmt"
"go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt"
"go.mau.fi/mautrix-signal/pkg/signalid"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
)
type contextKey int
const (
contextKeyPortal contextKey = iota
contextKeyClient
contextKeyIntent
)
type MessageConverter struct {
Bridge *bridgev2.Bridge
SignalFmtParams *signalfmt.FormatParams
MatrixFmtParams *matrixfmt.HTMLParser
MaxFileSize int64
LocationFormat string
}
func NewMessageConverter(br *bridgev2.Bridge, locationFormat string) *MessageConverter {
return &MessageConverter{
Bridge: br,
SignalFmtParams: &signalfmt.FormatParams{
GetUserInfo: func(ctx context.Context, uuid uuid.UUID) signalfmt.UserInfo {
ghost, err := br.GetGhostByID(ctx, signalid.MakeUserID(uuid))
if err != nil {
// TODO log?
return signalfmt.UserInfo{}
}
userInfo := signalfmt.UserInfo{
MXID: ghost.Intent.GetMXID(),
Name: ghost.Name,
}
userLogin := br.GetCachedUserLoginByID(networkid.UserLoginID(uuid.String()))
if userLogin != nil {
userInfo.MXID = userLogin.UserMXID
// TODO find matrix user displayname?
}
return userInfo
},
},
MatrixFmtParams: &matrixfmt.HTMLParser{
GetUUIDFromMXID: func(ctx context.Context, userID id.UserID) uuid.UUID {
parsed, ok := br.Matrix.ParseGhostMXID(userID)
if ok {
u, _ := uuid.Parse(string(parsed))
return u
}
user, _ := br.GetExistingUserByMXID(ctx, userID)
// TODO log errors?
if user != nil {
preferredLogin, _, _ := getPortal(ctx).FindPreferredLogin(ctx, user, true)
if preferredLogin != nil {
u, _ := uuid.Parse(string(preferredLogin.ID))
return u
}
}
return uuid.Nil
},
},
MaxFileSize: 50 * 1024 * 1024,
LocationFormat: locationFormat,
}
}
func getClient(ctx context.Context) *signalmeow.Client {
return ctx.Value(contextKeyClient).(*signalmeow.Client)
}
func getPortal(ctx context.Context) *bridgev2.Portal {
return ctx.Value(contextKeyPortal).(*bridgev2.Portal)
}
func getIntent(ctx context.Context) bridgev2.MatrixAPI {
return ctx.Value(contextKeyIntent).(bridgev2.MatrixAPI)
}

View file

@ -26,7 +26,7 @@ import (
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/msgconv/signalfmt"
"go.mau.fi/mautrix-signal/pkg/msgconv/signalfmt"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)

View file

@ -18,37 +18,27 @@ package msgconv
import (
"context"
"encoding/json"
"regexp"
"time"
"github.com/rs/zerolog"
"github.com/tidwall/gjson"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
signalpb "go.mau.fi/mautrix-signal/pkg/signalmeow/protobuf"
)
type BeeperLinkPreview struct {
mautrix.RespPreviewURL
MatchedURL string `json:"matched_url"`
ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"`
}
func (mc *MessageConverter) convertURLPreviewsToBeeper(ctx context.Context, preview []*signalpb.Preview) []*BeeperLinkPreview {
output := make([]*BeeperLinkPreview, len(preview))
func (mc *MessageConverter) convertURLPreviewsToBeeper(ctx context.Context, preview []*signalpb.Preview) []*event.BeeperLinkPreview {
output := make([]*event.BeeperLinkPreview, len(preview))
for i, p := range preview {
output[i] = mc.convertURLPreviewToBeeper(ctx, p)
}
return output
}
func (mc *MessageConverter) convertURLPreviewToBeeper(ctx context.Context, preview *signalpb.Preview) *BeeperLinkPreview {
output := &BeeperLinkPreview{
func (mc *MessageConverter) convertURLPreviewToBeeper(ctx context.Context, preview *signalpb.Preview) *event.BeeperLinkPreview {
output := &event.BeeperLinkPreview{
MatchedURL: preview.GetUrl(),
RespPreviewURL: mautrix.RespPreviewURL{
LinkPreview: event.LinkPreview{
CanonicalURL: preview.GetUrl(),
Title: preview.GetTitle(),
Description: preview.GetDescription(),
@ -70,65 +60,34 @@ func (mc *MessageConverter) convertURLPreviewToBeeper(ctx context.Context, previ
return output
}
var URLRegex = regexp.MustCompile(`https?://[^\s/_*]+(?:/\S*)?`)
func (mc *MessageConverter) convertURLPreviewToSignal(ctx context.Context, evt *event.Event) []*signalpb.Preview {
var previews []*BeeperLinkPreview
log := zerolog.Ctx(ctx)
rawPreview := gjson.GetBytes(evt.Content.VeryRaw, `com\.beeper\.linkpreviews`)
if rawPreview.Exists() && rawPreview.IsArray() {
if err := json.Unmarshal([]byte(rawPreview.Raw), &previews); err != nil || len(previews) == 0 {
return nil
}
} /* else if portal.bridge.Config.Bridge.URLPreviews {
if matchedURL := URLRegex.FindString(evt.Content.AsMessage().Body); len(matchedURL) == 0 {
return nil
} else if parsed, err := url.Parse(matchedURL); err != nil {
return nil
} else if parsed.Host, err = idna.ToASCII(parsed.Host); err != nil {
return nil
} else if mxPreview, err := portal.MainIntent().GetURLPreview(parsed.String()); err != nil {
log.Err(err).Str("matched_url", matchedURL).Msg("Failed to fetch preview for URL found in message")
return nil
} else {
previews = []*BeeperLinkPreview{{
RespPreviewURL: *mxPreview,
MatchedURL: matchedURL,
}}
}
}*/
if len(previews) == 0 {
func (mc *MessageConverter) convertURLPreviewToSignal(ctx context.Context, content *event.MessageEventContent) []*signalpb.Preview {
if len(content.BeeperLinkPreviews) == 0 {
return nil
}
output := make([]*signalpb.Preview, len(previews))
for i, preview := range previews {
output := make([]*signalpb.Preview, len(content.BeeperLinkPreviews))
for i, preview := range content.BeeperLinkPreviews {
output[i] = &signalpb.Preview{
Url: proto.String(preview.MatchedURL),
Title: proto.String(preview.Title),
Description: proto.String(preview.Description),
Date: proto.Uint64(uint64(time.Now().UnixMilli())),
}
imageMXC := preview.ImageURL
if preview.ImageEncryption != nil {
imageMXC = preview.ImageEncryption.URL
}
if imageMXC != "" {
data, err := mc.DownloadMatrixMedia(ctx, imageMXC)
if preview.ImageURL != "" || preview.ImageEncryption != nil {
data, err := mc.Bridge.Bot.DownloadMedia(ctx, preview.ImageURL, preview.ImageEncryption)
if err != nil {
log.Err(err).Int("preview_index", i).Msg("Failed to download URL preview image")
zerolog.Ctx(ctx).Err(err).Int("preview_index", i).Msg("Failed to download URL preview image")
continue
}
if preview.ImageEncryption != nil {
err = preview.ImageEncryption.DecryptInPlace(data)
if err != nil {
log.Err(err).Int("preview_index", i).Msg("Failed to decrypt URL preview image")
zerolog.Ctx(ctx).Err(err).Int("preview_index", i).Msg("Failed to decrypt URL preview image")
continue
}
}
uploaded, err := mc.GetClient(ctx).UploadAttachment(ctx, data)
uploaded, err := getClient(ctx).UploadAttachment(ctx, data)
if err != nil {
log.Err(err).Int("preview_index", i).Msg("Failed to reupload URL preview image")
zerolog.Ctx(ctx).Err(err).Int("preview_index", i).Msg("Failed to reupload URL preview image")
continue
}
uploaded.ContentType = proto.String(preview.ImageType)

View file

@ -1,5 +1,5 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2023 Tulir Asokan
// 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
@ -14,27 +14,12 @@
// 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 upgrades
package signalid
import (
"context"
"embed"
"errors"
"go.mau.fi/util/dbutil"
)
var Table dbutil.UpgradeTable
//go:embed *.sql
var rawUpgrades embed.FS
func init() {
Table.Register(-1, 12, 0, "Unsupported version", dbutil.TxnModeOff, func(ctx context.Context, database *dbutil.Database) error {
return errors.New("please upgrade to mautrix-signal v0.4.3 before upgrading to a newer version")
})
Table.Register(1, 13, 0, "Jump to version 13", dbutil.TxnModeOff, func(ctx context.Context, database *dbutil.Database) error {
return nil
})
Table.RegisterFS(rawUpgrades)
type PortalMetadata struct {
Revision uint32 `json:"revision"`
}
type MessageMetadata struct {
ContainsAttachments bool `json:"contains_attachments,omitempty"`
}

105
pkg/signalid/ids.go Normal file
View file

@ -0,0 +1,105 @@
// mautrix-signal - A Matrix-Signal puppeting bridge.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package signalid
import (
"fmt"
"strconv"
"strings"
"github.com/google/uuid"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
func ParseUserID(userID networkid.UserID) (uuid.UUID, error) {
serviceID, err := ParseUserIDAsServiceID(userID)
if err != nil {
return uuid.Nil, err
} else if serviceID.Type != libsignalgo.ServiceIDTypeACI {
return uuid.Nil, fmt.Errorf("invalid user ID: expected ACI type")
} else {
return serviceID.UUID, nil
}
}
func ParseUserIDAsServiceID(userID networkid.UserID) (libsignalgo.ServiceID, error) {
return libsignalgo.ServiceIDFromString(string(userID))
}
func ParsePortalID(portalID networkid.PortalID) (userID libsignalgo.ServiceID, groupID types.GroupIdentifier, err error) {
if len(portalID) == 44 {
groupID = types.GroupIdentifier(portalID)
} else {
userID, err = libsignalgo.ServiceIDFromString(string(portalID))
}
return
}
func ParseMessageID(messageID networkid.MessageID) (sender uuid.UUID, timestamp uint64, err error) {
parts := strings.Split(string(messageID), "|")
if len(parts) != 2 {
err = fmt.Errorf("invalid message ID: expected two pipe-separated parts")
return
}
sender, err = uuid.Parse(parts[0])
if err != nil {
return
}
timestamp, err = strconv.ParseUint(parts[1], 10, 64)
return
}
func MakeGroupPortalID(groupID types.GroupIdentifier) networkid.PortalID {
return networkid.PortalID(groupID)
}
func MakeGroupPortalKey(groupID types.GroupIdentifier) networkid.PortalKey {
return networkid.PortalKey{
ID: MakeGroupPortalID(groupID),
Receiver: "",
}
}
func MakeDMPortalID(serviceID libsignalgo.ServiceID) networkid.PortalID {
return networkid.PortalID(serviceID.String())
}
func MakeMessageID(sender uuid.UUID, timestamp uint64) networkid.MessageID {
return networkid.MessageID(fmt.Sprintf("%s|%d", sender, timestamp))
}
func MakeUserID(user uuid.UUID) networkid.UserID {
return networkid.UserID(user.String())
}
func MakeUserIDFromServiceID(user libsignalgo.ServiceID) networkid.UserID {
return networkid.UserID(user.String())
}
func MakeUserLoginID(user uuid.UUID) networkid.UserLoginID {
return networkid.UserLoginID(user.String())
}
func MakeMessagePartID(index int) networkid.PartID {
if index == 0 {
return ""
}
return networkid.PartID(strconv.Itoa(index))
}

3026
portal.go

File diff suppressed because it is too large Load diff

View file

@ -1,640 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
_ "net/http/pprof"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/rs/zerolog"
"github.com/rs/zerolog/hlog"
"go.mau.fi/util/requestlog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/legacyprovision"
"go.mau.fi/mautrix-signal/pkg/libsignalgo"
"go.mau.fi/mautrix-signal/pkg/signalmeow"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
type provisioningContextKey int
const (
provisioningUserKey provisioningContextKey = iota
)
type provisioningHandle struct {
id int
context context.Context
cancel context.CancelFunc
channel <-chan signalmeow.ProvisioningResponse
}
type ProvisioningAPI struct {
bridge *SignalBridge
log zerolog.Logger
provisioningHandles []*provisioningHandle
provisioningUsers map[string]int
provisioningMutexes map[string]*sync.Mutex
}
func (prov *ProvisioningAPI) Init() {
prov.log.Debug().Str("prefix", prov.bridge.Config.Bridge.Provisioning.Prefix).Msg("Enabling provisioning API")
prov.provisioningUsers = make(map[string]int)
prov.provisioningMutexes = make(map[string]*sync.Mutex)
r := prov.bridge.AS.Router.PathPrefix(prov.bridge.Config.Bridge.Provisioning.Prefix).Subrouter()
r.Use(hlog.NewHandler(prov.log))
r.Use(requestlog.AccessLogger(true))
r.Use(prov.AuthMiddleware)
r.HandleFunc("/v2/whoami", prov.WhoAmI).Methods(http.MethodGet)
r.HandleFunc("/v2/link/new", prov.LinkNew).Methods(http.MethodPost)
r.HandleFunc("/v2/link/wait/scan", prov.LinkWaitForScan).Methods(http.MethodPost)
r.HandleFunc("/v2/link/wait/account", prov.LinkWaitForAccount).Methods(http.MethodPost)
r.HandleFunc("/v2/logout", prov.Logout).Methods(http.MethodPost)
r.HandleFunc("/v2/resolve_identifier/{phonenum}", prov.ResolveIdentifier).Methods(http.MethodGet)
r.HandleFunc("/v2/pm/{phonenum}", prov.StartPM).Methods(http.MethodPost)
if prov.bridge.Config.Bridge.Provisioning.DebugEndpoints {
prov.log.Debug().Msg("Enabling debug API at /debug")
r := prov.bridge.AS.Router.PathPrefix("/debug").Subrouter()
r.Use(prov.AuthMiddleware)
r.PathPrefix("/pprof").Handler(http.DefaultServeMux)
}
}
func (prov *ProvisioningAPI) AuthMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if auth != prov.bridge.Config.Bridge.Provisioning.SharedSecret {
zerolog.Ctx(r.Context()).Warn().Msg("Authentication token does not match shared secret")
legacyprovision.JSONResponse(w, http.StatusForbidden, &mautrix.RespError{
Err: "Authentication token does not match shared secret",
ErrCode: mautrix.MForbidden.ErrCode,
})
return
}
userID := r.URL.Query().Get("user_id")
user := prov.bridge.GetUserByMXID(id.UserID(userID))
h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), provisioningUserKey, user)))
})
}
func (prov *ProvisioningAPI) resolveIdentifier(ctx context.Context, user *User, inputPhone string) (int, *legacyprovision.ResolveIdentifierResponse, error) {
if user.Client == nil {
return http.StatusUnauthorized, nil, errors.New("not currently connected to Signal")
}
e164Number, err := strconv.ParseUint(numberCleaner.Replace(inputPhone), 10, 64)
if err != nil {
return http.StatusBadRequest, nil, fmt.Errorf("error parsing phone number: %w", err)
}
e164String := fmt.Sprintf("+%d", e164Number)
var aci, pni uuid.UUID
var recipient *types.Recipient
if recipient, err = user.Client.ContactByE164(ctx, e164String); err != nil {
return http.StatusInternalServerError, nil, fmt.Errorf("error looking up number in local contact list: %w", err)
} else if recipient != nil {
aci = recipient.ACI
pni = recipient.PNI
} else if resp, err := user.Client.LookupPhone(ctx, e164Number); err != nil {
return http.StatusInternalServerError, 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 http.StatusNotFound, nil, errors.New("user not found on Signal")
}
recipient, err = user.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
}
zerolog.Ctx(ctx).Debug().
Uint64("e164", e164Number).
Stringer("aci", aci).
Stringer("pni", pni).
Msg("Found DM target user")
var targetServiceID libsignalgo.ServiceID
var otherUserInfo *legacyprovision.ResolveIdentifierResponseOtherUser
if aci != uuid.Nil {
targetServiceID = libsignalgo.NewACIServiceID(aci)
puppet := prov.bridge.GetPuppetBySignalID(aci)
otherUserInfo = &legacyprovision.ResolveIdentifierResponseOtherUser{
MXID: puppet.MXID,
DisplayName: puppet.Name,
AvatarURL: puppet.AvatarURL,
}
} else {
targetServiceID = libsignalgo.NewPNIServiceID(pni)
// TODO fill other user displayname/avatar if there's a contact entry?
}
portal := user.GetPortalByChatID(targetServiceID.String())
return http.StatusOK, &legacyprovision.ResolveIdentifierResponse{
RoomID: portal.MXID,
ChatID: legacyprovision.ResolveIdentifierResponseChatID{
UUID: targetServiceID.String(),
Number: e164String,
},
OtherUser: otherUserInfo,
}, nil
}
func (prov *ProvisioningAPI) ResolveIdentifier(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(provisioningUserKey).(*User)
phoneNum := mux.Vars(r)["phonenum"]
log := prov.log.With().
Str("action", "resolve_identifier").
Stringer("user_id", user.MXID).
Str("phone_num", phoneNum).
Logger()
ctx := log.WithContext(r.Context())
log.Debug().Msg("resolving identifier")
status, resp, err := prov.resolveIdentifier(ctx, user, phoneNum)
if err != nil {
errCode := "M_INTERNAL"
if status == http.StatusNotFound {
log.Debug().Msg("contact not found")
errCode = "M_NOT_FOUND"
} else {
log.Err(err).Msg("error looking up contact")
}
legacyprovision.JSONResponse(w, status, legacyprovision.Error{
Success: false,
Error: err.Error(),
ErrCode: errCode,
})
return
}
legacyprovision.JSONResponse(w, status, legacyprovision.Response{
Success: true,
Status: "ok",
ResolveIdentifierResponse: resp,
})
}
func (prov *ProvisioningAPI) StartPM(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(provisioningUserKey).(*User)
phoneNum := mux.Vars(r)["phonenum"]
log := prov.log.With().
Str("action", "start_pm").
Stringer("user_id", user.MXID).
Str("phone_num", phoneNum).
Logger()
ctx := log.WithContext(r.Context())
log.Debug().Msg("starting private message")
status, resp, err := prov.resolveIdentifier(ctx, user, phoneNum)
if err != nil {
errCode := "M_INTERNAL"
if status == http.StatusNotFound {
log.Debug().Msg("contact not found")
errCode = "M_NOT_FOUND"
} else {
log.Err(err).Msg("error looking up contact")
}
legacyprovision.JSONResponse(w, status, legacyprovision.Error{
Success: false,
Error: err.Error(),
ErrCode: errCode,
})
return
}
portal := user.GetPortalByChatID(resp.ChatID.UUID)
if portal.MXID == "" {
if err := portal.CreateMatrixRoom(r.Context(), user, 0); err != nil {
log.Err(err).Msg("error looking up contact")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{
Success: false,
Error: "Error creating Matrix room",
ErrCode: "M_INTERNAL",
})
return
}
resp.JustCreated = true
resp.RoomID = portal.MXID
}
if resp.JustCreated {
status = http.StatusCreated
}
legacyprovision.JSONResponse(w, status, legacyprovision.Response{
Success: true,
Status: "ok",
ResolveIdentifierResponse: resp,
})
}
func (prov *ProvisioningAPI) mutexForUser(user *User) *sync.Mutex {
if _, ok := prov.provisioningMutexes[user.MXID.String()]; !ok {
prov.provisioningMutexes[user.MXID.String()] = &sync.Mutex{}
}
return prov.provisioningMutexes[user.MXID.String()]
}
func (prov *ProvisioningAPI) newOrExistingSession(user *User) (newSessionLoggedIn bool, handle *provisioningHandle, err error) {
prov.mutexForUser(user).Lock()
defer prov.mutexForUser(user).Unlock()
if existingSessionID, ok := prov.provisioningUsers[user.MXID.String()]; ok {
provisioningHandle := prov.provisioningHandles[existingSessionID]
return false, provisioningHandle, nil
}
provChan, err := user.Login()
if err != nil {
return false, nil, fmt.Errorf("Error logging in: %w", err)
}
provisioningCtx, cancel := context.WithCancel(context.TODO())
handle = &provisioningHandle{
context: provisioningCtx,
cancel: cancel,
channel: provChan,
}
prov.provisioningHandles = append(prov.provisioningHandles, handle)
handle.id = len(prov.provisioningHandles) - 1
prov.provisioningUsers[user.MXID.String()] = handle.id
return true, handle, nil
}
func (prov *ProvisioningAPI) existingSession(user *User) (handle *provisioningHandle) {
prov.mutexForUser(user).Lock()
defer prov.mutexForUser(user).Unlock()
if existingSessionID, ok := prov.provisioningUsers[user.MXID.String()]; ok {
provisioningHandle := prov.provisioningHandles[existingSessionID]
return provisioningHandle
}
return nil
}
func (prov *ProvisioningAPI) clearSession(ctx context.Context, user *User) {
log := zerolog.Ctx(ctx).With().Str("function", "clearSession").Logger()
prov.mutexForUser(user).Lock()
defer prov.mutexForUser(user).Unlock()
if existingSessionID, ok := prov.provisioningUsers[user.MXID.String()]; ok {
log.Debug().Int("existing_session_id", existingSessionID).Msg("clearing existing session")
if existingSessionID >= len(prov.provisioningHandles) {
log.Warn().Msg("session does not exist")
return
}
if prov.provisioningHandles[existingSessionID].cancel != nil {
prov.provisioningHandles[existingSessionID].cancel()
}
prov.provisioningHandles[existingSessionID] = nil
delete(prov.provisioningUsers, user.MXID.String())
} else {
prov.log.Debug().Msg("no session found")
}
}
func (prov *ProvisioningAPI) loginOrSendError(ctx context.Context, w http.ResponseWriter, user *User) (*provisioningHandle, error) {
newSessionLoggedIn, handle, err := prov.newOrExistingSession(user)
if err != nil {
return nil, err
}
if !newSessionLoggedIn {
zerolog.Ctx(ctx).Debug().
Int("existing_provisioning_handle", handle.id).
Msg("user already has pending provisioning request, cancelling")
prov.clearSession(ctx, user)
_, handle, err = prov.newOrExistingSession(user)
if err != nil {
return nil, fmt.Errorf("error logging in after cancelling existing session: %w", err)
}
}
return handle, nil
}
func (prov *ProvisioningAPI) checkSessionAndReturnHandle(ctx context.Context, w http.ResponseWriter, currentSession int) *provisioningHandle {
log := zerolog.Ctx(ctx).With().Str("function", "checkSessionAndReturnHandle").Logger()
user := ctx.Value(provisioningUserKey).(*User)
handle := prov.existingSession(user)
if handle == nil {
log.Warn().Msg("no session found")
legacyprovision.JSONResponse(w, http.StatusNotFound, legacyprovision.Error{
Success: false,
Error: "No session found",
ErrCode: "M_NOT_FOUND",
})
return nil
}
if handle.id != currentSession {
log.Warn().
Int("handle_id", handle.id).
Int("current_session", currentSession).
Msg("session_id does not match user's session_id")
legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{
Success: false,
Error: "session_id does not match user's session_id",
ErrCode: "M_BAD_JSON",
})
return nil
}
return handle
}
func (prov *ProvisioningAPI) WhoAmI(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(provisioningUserKey).(*User)
log := prov.log.With().
Str("action", "whoami").
Stringer("user_id", user.MXID).
Logger()
log.Debug().Msg("getting whoami")
data := legacyprovision.WhoAmIResponse{
Permissions: int(user.PermissionLevel),
MXID: user.MXID.String(),
}
if user.IsLoggedIn() {
data.Signal = &legacyprovision.WhoAmIResponseSignal{
Number: user.SignalUsername,
UUID: user.SignalID.String(),
Ok: user.Client.IsConnected(),
}
puppet := user.bridge.GetPuppetBySignalID(user.SignalID)
if puppet != nil {
data.Signal.Name = puppet.Name
}
}
legacyprovision.JSONResponse(w, http.StatusOK, data)
}
func (prov *ProvisioningAPI) LinkNew(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(provisioningUserKey).(*User)
log := prov.log.With().
Str("action", "link_new").
Stringer("user_id", user.MXID).
Logger()
ctx := log.WithContext(r.Context())
log.Debug().Msg("starting login")
handle, err := prov.loginOrSendError(ctx, w, user)
if err != nil {
legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{
Success: false,
Error: err.Error(),
ErrCode: "M_INTERNAL",
})
return
}
log = log.With().Int("session_id", handle.id).Logger()
log.Debug().Msg("waiting for provisioning response")
select {
case resp := <-handle.channel:
if resp.Err != nil || resp.State == signalmeow.StateProvisioningError {
log.Err(resp.Err).Msg("Error getting provisioning URL")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{
Success: false,
Error: resp.Err.Error(),
ErrCode: "M_INTERNAL",
})
return
}
if resp.State != signalmeow.StateProvisioningURLReceived {
log.Err(resp.Err).Stringer("state", resp.State).Msg("unexpected state")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{
Success: false,
Error: fmt.Sprintf("Unexpected state %s", resp.State.String()),
ErrCode: "M_INTERNAL",
})
return
}
log.Debug().Str("provisioning_url", resp.ProvisioningURL).Msg("provisioning URL received")
legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{
Success: true,
Status: "provisioning_url_received",
SessionID: fmt.Sprintf("%d", handle.id),
URI: resp.ProvisioningURL,
})
case <-time.After(30 * time.Second):
log.Warn().Msg("Timeout waiting for provisioning response (new)")
legacyprovision.JSONResponse(w, http.StatusGatewayTimeout, legacyprovision.Error{
Success: false,
Error: "Timeout waiting for provisioning response (new)",
ErrCode: "M_TIMEOUT",
})
}
}
func (prov *ProvisioningAPI) LinkWaitForScan(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(provisioningUserKey).(*User)
var body legacyprovision.LinkWaitForScanRequest
err := json.NewDecoder(r.Body).Decode(&body)
if err != nil {
legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{
Success: false,
Error: "Error decoding JSON body",
ErrCode: "M_BAD_JSON",
})
return
}
sessionID, err := strconv.Atoi(body.SessionID)
if err != nil {
legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{
Success: false,
Error: "Error decoding session ID in JSON body",
ErrCode: "M_BAD_JSON",
})
return
}
log := prov.log.With().
Str("action", "link_wait_for_scan").
Stringer("user_id", user.MXID).
Str("session_id", body.SessionID).
Logger()
ctx := log.WithContext(r.Context())
log.Debug().Msg("waiting for scan")
handle := prov.checkSessionAndReturnHandle(ctx, w, sessionID)
if handle == nil {
return
}
select {
case resp := <-handle.channel:
if resp.Err != nil || resp.State == signalmeow.StateProvisioningError {
log.Err(resp.Err).Msg("Error waiting for scan")
// If context was cancelled be chill
if errors.Is(resp.Err, context.Canceled) {
log.Debug().Msg("Context cancelled waiting for scan")
return
}
// If we error waiting for the scan, treat it as a normal error not 5xx
// so that the client will retry quietly. Also, it's really not an internal
// error, sitting with a WS open waiting for a scan is inherently flaky.
legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{
Success: false,
Error: resp.Err.Error(),
ErrCode: "M_INTERNAL",
})
return
}
if resp.State != signalmeow.StateProvisioningDataReceived {
log.Err(resp.Err).Stringer("state", resp.State).Msg("unexpected state")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{
Success: false,
Error: fmt.Sprintf("Unexpected state %s", resp.State.String()),
ErrCode: "M_INTERNAL",
})
return
}
log.Debug().Msg("provisioning data received")
legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{
Success: true,
Status: "provisioning_data_received",
})
// Update user with SignalID
if resp.ProvisioningData.ACI != uuid.Nil {
user.saveSignalID(ctx, resp.ProvisioningData.ACI, resp.ProvisioningData.Number)
}
return
case <-time.After(45 * time.Second):
log.Warn().Msg("Timeout waiting for provisioning response (scan)")
// Using 400 here to match the old bridge
legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{
Success: false,
Error: "Timeout waiting for QR code scan",
ErrCode: "M_BAD_REQUEST",
})
return
}
}
func (prov *ProvisioningAPI) LinkWaitForAccount(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(provisioningUserKey).(*User)
var body legacyprovision.LinkWaitForAccountRequest
err := json.NewDecoder(r.Body).Decode(&body)
if err != nil {
legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{
Success: false,
Error: "Error decoding JSON body",
ErrCode: "M_BAD_JSON",
})
return
}
sessionID, err := strconv.Atoi(body.SessionID)
if err != nil {
legacyprovision.JSONResponse(w, http.StatusBadRequest, legacyprovision.Error{
Success: false,
Error: "Error decoding session ID in JSON body",
ErrCode: "M_BAD_JSON",
})
return
}
deviceName := body.DeviceName
log := prov.log.With().
Str("action", "link_wait_for_account").
Stringer("user_id", user.MXID).
Int("session_id", sessionID).
Str("device_name", deviceName).
Logger()
ctx := log.WithContext(r.Context())
log.Debug().Msg("waiting for account")
handle := prov.checkSessionAndReturnHandle(ctx, w, sessionID)
if handle == nil {
return
}
select {
case resp := <-handle.channel:
if resp.Err != nil || resp.State == signalmeow.StateProvisioningError {
log.Err(resp.Err).Msg("Error waiting for account")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{
Success: false,
Error: resp.Err.Error(),
ErrCode: "M_INTERNAL",
})
return
}
if resp.State != signalmeow.StateProvisioningPreKeysRegistered {
log.Err(resp.Err).Stringer("state", resp.State).Msg("unexpected state")
legacyprovision.JSONResponse(w, http.StatusInternalServerError, legacyprovision.Error{
Success: false,
Error: fmt.Sprintf("Unexpected state %s", resp.State.String()),
ErrCode: "M_INTERNAL",
})
return
}
log.Debug().Msg("prekeys registered")
legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{
Success: true,
Status: "prekeys_registered",
UUID: user.SignalID.String(),
Number: user.SignalUsername,
})
// Connect to Signal!!
user.Connect()
return
case <-time.After(30 * time.Second):
log.Warn().Msg("Timeout waiting for provisioning response (account)")
legacyprovision.JSONResponse(w, http.StatusGatewayTimeout, legacyprovision.Error{
Success: false,
Error: "Timeout waiting for provisioning response (account)",
ErrCode: "M_TIMEOUT",
})
return
}
}
func (prov *ProvisioningAPI) Logout(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(provisioningUserKey).(*User)
log := prov.log.With().
Str("action", "logout").
Stringer("user_id", user.MXID).
Logger()
ctx := log.WithContext(r.Context())
log.Debug().Msg("Logout called (but not logging out)")
prov.clearSession(ctx, user)
// For now do nothing - we need this API to return 200 to be compatible with
// the old Signal bridge, which needed a call to Logout before allowing LinkNew
// to be called, but we don't actually want to logout, we want to allow a reconnect.
legacyprovision.JSONResponse(w, http.StatusOK, legacyprovision.Response{
Success: true,
Status: "logged_out",
})
}

411
puppet.go
View file

@ -1,411 +0,0 @@
// mautrix-signal - A Matrix-signal puppeting bridge.
// Copyright (C) 2023 Scott Weber
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"regexp"
"github.com/google/uuid"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-signal/database"
"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
)
func (br *SignalBridge) GetPuppetByMXID(mxid id.UserID) *Puppet {
signalID, ok := br.ParsePuppetMXID(mxid)
if !ok {
return nil
}
return br.GetPuppetBySignalID(signalID)
}
func (br *SignalBridge) GetPuppetBySignalIDString(id string) *Puppet {
parsed, err := uuid.Parse(id)
if err != nil {
return nil
}
return br.GetPuppetBySignalID(parsed)
}
func (br *SignalBridge) GetPuppetBySignalID(id uuid.UUID) *Puppet {
if id == uuid.Nil {
br.ZLog.Warn().Msg("Trying to get puppet with empty signal_user_id")
return nil
}
br.puppetsLock.Lock()
defer br.puppetsLock.Unlock()
puppet, ok := br.puppets[id]
if !ok {
dbPuppet, err := br.DB.Puppet.GetBySignalID(context.TODO(), id)
if err != nil {
br.ZLog.Err(err).Msg("Failed to get puppet from database")
return nil
}
return br.loadPuppet(context.TODO(), dbPuppet, &id)
}
return puppet
}
func (br *SignalBridge) GetPuppetByCustomMXID(mxid id.UserID) *Puppet {
br.puppetsLock.Lock()
defer br.puppetsLock.Unlock()
puppet, ok := br.puppetsByCustomMXID[mxid]
if !ok {
dbPuppet, err := br.DB.Puppet.GetByCustomMXID(context.TODO(), mxid)
if err != nil {
br.ZLog.Err(err).Msg("Failed to get puppet from database")
return nil
}
return br.loadPuppet(context.TODO(), dbPuppet, nil)
}
return puppet
}
func (br *SignalBridge) GetAllPuppetsWithCustomMXID() []*Puppet {
puppets, err := br.DB.Puppet.GetAllWithCustomMXID(context.TODO())
if err != nil {
br.ZLog.Error().Err(err).Msg("Failed to get all puppets with custom MXID")
return nil
}
return br.dbPuppetsToPuppets(puppets)
}
func (br *SignalBridge) FormatPuppetMXID(u uuid.UUID) id.UserID {
return id.NewUserID(
br.Config.Bridge.FormatUsername(u.String()),
br.Config.Homeserver.Domain,
)
}
func (br *SignalBridge) loadPuppet(ctx context.Context, dbPuppet *database.Puppet, u *uuid.UUID) *Puppet {
if dbPuppet == nil {
if u == nil {
return nil
}
dbPuppet = br.DB.Puppet.New()
dbPuppet.SignalID = *u
err := dbPuppet.Insert(ctx)
if err != nil {
br.ZLog.Error().Err(err).Stringer("signal_user_id", *u).Msg("Failed to insert new puppet")
return nil
}
}
puppet := br.NewPuppet(dbPuppet)
br.puppets[puppet.SignalID] = puppet
if puppet.CustomMXID != "" {
br.puppetsByCustomMXID[puppet.CustomMXID] = puppet
}
return puppet
}
func (br *SignalBridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet {
br.puppetsLock.Lock()
defer br.puppetsLock.Unlock()
output := make([]*Puppet, len(dbPuppets))
for index, dbPuppet := range dbPuppets {
if dbPuppet == nil {
continue
}
puppet, ok := br.puppets[dbPuppet.SignalID]
if !ok {
puppet = br.loadPuppet(context.TODO(), dbPuppet, nil)
}
output[index] = puppet
}
return output
}
func (br *SignalBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet {
return &Puppet{
Puppet: dbPuppet,
bridge: br,
log: br.ZLog.With().Stringer("signal_user_id", dbPuppet.SignalID).Logger(),
MXID: br.FormatPuppetMXID(dbPuppet.SignalID),
}
}
func (br *SignalBridge) ParsePuppetMXID(mxid id.UserID) (uuid.UUID, bool) {
if userIDRegex == nil {
pattern := fmt.Sprintf(
"^@%s:%s$",
// The "SignalID" portion of the MXID is a (lowercase) UUID
br.Config.Bridge.FormatUsername("([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"),
br.Config.Homeserver.Domain,
)
br.ZLog.Debug().Str("pattern", pattern).Msg("Compiling userIDRegex")
userIDRegex = regexp.MustCompile(pattern)
}
match := userIDRegex.FindStringSubmatch(string(mxid))
if len(match) == 2 {
parsed, err := uuid.Parse(match[1])
if err != nil {
return uuid.Nil, false
}
return parsed, true
}
return uuid.Nil, false
}
type Puppet struct {
*database.Puppet
bridge *SignalBridge
log zerolog.Logger
MXID id.UserID
customIntent *appservice.IntentAPI
customUser *User
}
var userIDRegex *regexp.Regexp
var (
_ bridge.Ghost = (*Puppet)(nil)
_ bridge.GhostWithProfile = (*Puppet)(nil)
)
func (puppet *Puppet) GetMXID() id.UserID {
return puppet.MXID
}
func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI {
return puppet.bridge.AS.Intent(puppet.MXID)
}
func (puppet *Puppet) CustomIntent() *appservice.IntentAPI {
if puppet == nil {
return nil
}
return puppet.customIntent
}
func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI {
if puppet != nil {
if puppet.customIntent == nil || portal.UserID().UUID == puppet.SignalID {
return puppet.DefaultIntent()
}
return puppet.customIntent
}
return nil
}
func (puppet *Puppet) GetDisplayname() string {
return puppet.Name
}
func (puppet *Puppet) GetAvatarURL() id.ContentURI {
return puppet.AvatarURL
}
func (puppet *Puppet) UpdateInfo(ctx context.Context, source *User, contactAvatar *types.ContactAvatar) {
log := zerolog.Ctx(ctx).With().
Str("function", "Puppet.UpdateInfo").
Stringer("signal_user_id", puppet.SignalID).
Logger()
ctx = log.WithContext(ctx)
var err error
log.Debug().Msg("Fetching contact info to update puppet")
info, err := source.Client.ContactByACI(ctx, puppet.SignalID)
if err != nil {
log.Err(err).Msg("Failed to fetch contact info")
return
}
if !puppet.bridge.Config.Bridge.UseOutdatedProfiles && puppet.ProfileFetchedAt.After(info.Profile.FetchedAt) {
log.Debug().
Time("contact_profile_fetched_at", info.Profile.FetchedAt).
Time("puppet_profile_fetched_at", puppet.ProfileFetchedAt).
Msg("Ignoring outdated contact info")
return
}
if contactAvatar != nil {
info.ContactAvatar = *contactAvatar
}
log.Trace().Msg("Updating puppet info")
update := false
if puppet.ProfileFetchedAt.IsZero() && !info.Profile.FetchedAt.IsZero() {
update = true
}
puppet.ProfileFetchedAt = info.Profile.FetchedAt
if info.E164 != "" && puppet.Number != info.E164 {
puppet.Number = info.E164
update = true
}
update = puppet.updateName(ctx, info) || update
update = puppet.updateAvatar(ctx, source, info) || update
if update {
puppet.ContactInfoSet = false
puppet.UpdateContactInfo(ctx)
err = puppet.Update(ctx)
if err != nil {
log.Err(err).Msg("Failed to save puppet to database after updating")
}
go puppet.updatePortalMeta(ctx)
log.Debug().Msg("Puppet info updated")
}
}
func (puppet *Puppet) UpdateContactInfo(ctx context.Context) {
if !puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || puppet.ContactInfoSet {
return
}
identifiers := []string{
fmt.Sprintf("signal:%s", puppet.SignalID),
}
if puppet.Number != "" {
identifiers = append(identifiers, fmt.Sprintf("tel:%s", puppet.Number))
}
contactInfo := map[string]any{
"com.beeper.bridge.identifiers": identifiers,
"com.beeper.bridge.remote_id": puppet.SignalID.String(),
"com.beeper.bridge.service": "signal",
"com.beeper.bridge.network": "signal",
}
err := puppet.DefaultIntent().BeeperUpdateProfile(ctx, contactInfo)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to store custom contact info in profile")
} else {
puppet.ContactInfoSet = true
}
}
func (puppet *Puppet) updatePortalMeta(ctx context.Context) {
for _, portal := range puppet.bridge.FindPrivateChatPortalsWith(puppet.SignalID) {
// Get room create lock to prevent races between receiving contact info and room creation.
portal.roomCreateLock.Lock()
portal.UpdateDMInfo(ctx, false)
portal.roomCreateLock.Unlock()
}
}
func (puppet *Puppet) updateAvatar(ctx context.Context, source *User, info *types.Recipient) bool {
var avatarData []byte
var avatarContentType string
log := zerolog.Ctx(ctx)
if puppet.bridge.Config.Bridge.UseContactAvatars && info.ContactAvatar.Hash != "" {
if puppet.AvatarHash == info.ContactAvatar.Hash && puppet.AvatarSet {
return false
}
avatarData = info.ContactAvatar.Image
avatarContentType = info.ContactAvatar.ContentType
if avatarData == nil {
// TODO what to do? 🤔
return false
}
puppet.AvatarSet = false
puppet.AvatarPath = ""
} else {
if puppet.AvatarPath == info.Profile.AvatarPath && puppet.AvatarSet {
return false
}
if info.Profile.AvatarPath == "" {
puppet.AvatarURL = id.ContentURI{}
puppet.AvatarPath = ""
puppet.AvatarHash = ""
puppet.AvatarSet = false
err := puppet.DefaultIntent().SetAvatarURL(ctx, puppet.AvatarURL)
if err != nil {
log.Err(err).Msg("Failed to remove user avatar")
return true
}
log.Debug().Msg("Avatar removed")
puppet.AvatarSet = true
return true
}
var err error
avatarData, err = source.Client.DownloadUserAvatar(ctx, info.Profile.AvatarPath, info.Profile.Key)
if err != nil {
log.Err(err).
Str("profile_avatar_path", info.Profile.AvatarPath).
Msg("Failed to download new user avatar")
return true
}
avatarContentType = http.DetectContentType(avatarData)
}
hash := sha256.Sum256(avatarData)
newHash := hex.EncodeToString(hash[:])
if puppet.AvatarHash == newHash && puppet.AvatarSet {
log.Debug().
Str("avatar_hash", newHash).
Str("new_avatar_path", puppet.AvatarPath).
Msg("Avatar path changed, but hash didn't")
// Path changed, but actual avatar didn't
return true
}
puppet.AvatarPath = info.Profile.AvatarPath
puppet.AvatarHash = newHash
puppet.AvatarSet = false
puppet.AvatarURL = id.ContentURI{}
resp, err := puppet.DefaultIntent().UploadBytes(ctx, avatarData, avatarContentType)
if err != nil {
log.Err(err).
Str("avatar_hash", puppet.AvatarHash).
Msg("Failed to upload new user avatar")
return true
}
puppet.AvatarURL = resp.ContentURI
err = puppet.DefaultIntent().SetAvatarURL(ctx, puppet.AvatarURL)
if err != nil {
log.Err(err).Msg("Failed to update user avatar")
return true
}
log.Debug().
Str("avatar_hash", newHash).
Stringer("avatar_mxc", resp.ContentURI).
Msg("Avatar updated successfully")
puppet.AvatarSet = true
return true
}
func (puppet *Puppet) updateName(ctx context.Context, contact *types.Recipient) bool {
// TODO set name quality
newName := puppet.bridge.Config.Bridge.FormatDisplayname(contact)
if puppet.NameSet && puppet.Name == newName {
return false
}
puppet.Name = newName
puppet.NameSet = false
err := puppet.DefaultIntent().SetDisplayName(ctx, newName)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to update user displayname")
} else {
puppet.NameSet = true
}
return true
}

1020
user.go

File diff suppressed because it is too large Load diff