From a630835d70e59d28b29ee6316da589a27a155ba5 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Thu, 5 Nov 2026 13:09:30 +0000 Subject: [PATCH 01/19] pre-commit: install bridgelint with the CI Go toolchain (#706) --- .github/workflows/go.yml | 1 + .pre-commit-config.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 8b988db..df15747 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -30,6 +30,7 @@ jobs: run: | go install golang.org/x/tools/cmd/goimports@latest go install honnef.co/go/tools/cmd/staticcheck@latest + go install github.com/beeper/pre-commit-go/bridgelint@latest export PATH="$HOME/go/bin:$PATH" - name: Run pre-commit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8ea1c20..1f1fe9c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,4 +27,5 @@ repos: - id: zerolog-ban-msgf - id: zerolog-use-stringer - id: bridgelint + language: system verbose: true From 34b4187e0796ee399245434e7a2657d97d25cd40 Mon Sep 17 00:00:00 2001 From: "indent[bot]" <297468268+indent[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:33:19 +0000 Subject: [PATCH 02/19] chatinfo: stop listing contacts after request cancellation (#705) Co-authored-by: Indent --- pkg/connector/chatinfo.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index 4668cd7..dbc524d 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -445,11 +445,15 @@ func (s *SignalClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveI } resp := make([]*bridgev2.ResolveIdentifierResponse, len(recipients)) for i, recipient := range recipients { - recipientResp := &bridgev2.ResolveIdentifierResponse{ - Chat: s.makeCreateDMResponse(ctx, recipient, nil), + if err = ctx.Err(); err != nil { + return nil, err } + recipientResp := &bridgev2.ResolveIdentifierResponse{} var username string if recipient.ACI != uuid.Nil { + recipientResp.Chat = &bridgev2.CreateChatResponse{ + PortalKey: s.makeDMPortalKey(libsignalgo.NewACIServiceID(recipient.ACI)), + } recipientResp.UserID = signalid.MakeUserID(recipient.ACI) ghost, err := s.Main.Bridge.GetGhostByID(ctx, recipientResp.UserID) if err != nil { @@ -458,7 +462,11 @@ func (s *SignalClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveI recipientResp.Ghost = ghost username = ghost.Metadata.(*signalid.GhostMetadata).Username } else { - recipientResp.UserID = signalid.MakeUserIDFromServiceID(libsignalgo.NewPNIServiceID(recipient.PNI)) + pniServiceID := libsignalgo.NewPNIServiceID(recipient.PNI) + recipientResp.Chat = &bridgev2.CreateChatResponse{ + PortalKey: s.makeDMPortalKey(pniServiceID), + } + recipientResp.UserID = signalid.MakeUserIDFromServiceID(pniServiceID) } recipientResp.UserInfo, err = s.contactToUserInfo(ctx, recipient, username, false) if err != nil { From c820321548483db3266dd130901123a87c124176 Mon Sep 17 00:00:00 2001 From: "indent[bot]" <297468268+indent[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:34:19 +0000 Subject: [PATCH 03/19] signalmeow/websocket: log alerts sent by the server when connecting (#709) Co-authored-by: Indent --- pkg/signalmeow/web/signalwebsocket.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/signalmeow/web/signalwebsocket.go b/pkg/signalmeow/web/signalwebsocket.go index 7ee54c7..bade56b 100644 --- a/pkg/signalmeow/web/signalwebsocket.go +++ b/pkg/signalmeow/web/signalwebsocket.go @@ -319,6 +319,11 @@ func (s *SignalWebsocket) connectLoop( } // Succssfully connected + if resp != nil { + if alerts := resp.Header.Values("X-Signal-Alert"); len(alerts) > 0 { + log.Warn().Strs("alerts", alerts).Msg("Signal server sent alerts when opening websocket") + } + } s.pushStatus(ctx, SignalWebsocketConnectionEventConnected, nil) s.ws.Store(ws) retrying = false From c827c97adb25656dfa6a1bcef609607c6fa6f0b4 Mon Sep 17 00:00:00 2001 From: "indent[bot]" <297468268+indent[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:41:17 +0000 Subject: [PATCH 04/19] chatinfo: fall back to profile avatar when contact avatar isn't loaded (#710) Co-authored-by: Indent --- pkg/connector/chatinfo.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index dbc524d..669c015 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -125,9 +125,10 @@ func (s *SignalClient) contactToUserInfo(ctx context.Context, contact *types.Rec } name := s.Main.Config.FormatDisplayname(contact, username) ui.Name = &name - if s.Main.Config.UseContactAvatars && contact.ContactAvatar.Hash != "" { + contactAvatarID := networkid.AvatarID("hash:" + contact.ContactAvatar.Hash) + if s.Main.Config.UseContactAvatars && contact.ContactAvatar.Hash != "" && s.canUseContactAvatar(ctx, contact, contactAvatarID) { ui.Avatar = &bridgev2.Avatar{ - ID: networkid.AvatarID("hash:" + contact.ContactAvatar.Hash), + ID: contactAvatarID, Get: func(ctx context.Context) ([]byte, error) { if contact.ContactAvatar.Image == nil { return nil, fmt.Errorf("contact avatar not available") @@ -172,6 +173,20 @@ func (s *SignalClient) contactToUserInfo(ctx context.Context, contact *types.Rec return ui, nil } +func (s *SignalClient) canUseContactAvatar(ctx context.Context, contact *types.Recipient, avatarID networkid.AvatarID) bool { + if contact.ContactAvatar.Image != nil { + return true + } else if contact.ACI == uuid.Nil { + return false + } + ghost, err := s.Main.Bridge.GetExistingGhostByID(ctx, signalid.MakeUserID(contact.ACI)) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get ghost to check contact avatar") + return true + } + return ghost != nil && ghost.AvatarID == avatarID && ghost.AvatarMXC != "" && ghost.AvatarSet +} + func (s *SignalConnector) ValidateUserID(id networkid.UserID) bool { _, err := signalid.ParseUserIDAsServiceID(id) return err == nil From d451962ba2e88f5888a098d0a29d1d282bfddeb0 Mon Sep 17 00:00:00 2001 From: "indent[bot]" <297468268+indent[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:42:33 +0000 Subject: [PATCH 05/19] msgconv: download stickers from their pack when the attachment is gone (#708) Co-authored-by: Indent --- pkg/msgconv/from-signal.go | 50 +++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/pkg/msgconv/from-signal.go b/pkg/msgconv/from-signal.go index 251e9dd..0bf2d3b 100644 --- a/pkg/msgconv/from-signal.go +++ b/pkg/msgconv/from-signal.go @@ -502,6 +502,11 @@ func (mc *MessageConverter) convertAttachmentToMatrix(ctx context.Context, index func (mc *MessageConverter) convertStickerToMatrix(ctx context.Context, sticker *signalpb.DataMessage_Sticker, attMap AttachmentMap) *bridgev2.ConvertedMessagePart { converted, err := mc.reuploadAttachment(ctx, sticker.GetData(), attMap) + hasPack := len(sticker.GetPackId()) == PackIDLength && len(sticker.GetPackKey()) == PackKeyLength && !bytes.Equal(sticker.GetPackId(), zeroPackID) + if err != nil && hasPack && (errors.Is(err, signalmeow.ErrAttachmentNotFound) || errors.Is(err, ErrAttachmentNotInBackup) || errors.Is(err, ErrBackupNotSupported)) { + zerolog.Ctx(ctx).Debug().Err(err).Msg("Sticker attachment is unavailable, downloading it from the sticker pack") + converted, err = mc.reuploadStickerFromPack(ctx, sticker) + } if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to handle sticker") return &bridgev2.ConvertedMessagePart{ @@ -519,7 +524,7 @@ func (mc *MessageConverter) convertStickerToMatrix(ctx context.Context, sticker converted.Content.Info.Height = 200 } converted.Content.Body = sticker.GetEmoji() - if len(sticker.GetPackId()) == PackIDLength && len(sticker.GetPackKey()) == PackKeyLength && !bytes.Equal(sticker.GetPackId(), zeroPackID) { + if hasPack { converted.Content.Info.BridgedSticker = &event.BridgedSticker{ Network: StickerSourceID, ID: strconv.FormatUint(uint64(sticker.GetStickerId()), 10), @@ -532,6 +537,49 @@ func (mc *MessageConverter) convertStickerToMatrix(ctx context.Context, sticker return converted } +func (mc *MessageConverter) reuploadStickerFromPack(ctx context.Context, sticker *signalpb.DataMessage_Sticker) (*bridgev2.ConvertedMessagePart, error) { + att := sticker.GetData() + content := &event.MessageEventContent{ + Body: att.GetFileName(), + Info: &event.FileInfo{ + MimeType: att.GetContentType(), + Width: int(att.GetWidth()), + Height: int(att.GetHeight()), + }, + } + if mc.DirectMedia { + mediaID, err := signalid.DirectMediaSticker{ + PackID: sticker.GetPackId(), + PackKey: sticker.GetPackKey(), + StickerID: sticker.GetStickerId(), + }.AsMediaID() + if err != nil { + return nil, err + } + content.URL, content.File, err = mc.Bridge.GenerateContentURI(ctx, getPortal(ctx).MXID, mediaID) + if err != nil { + return nil, err + } + } else { + data, err := signalmeow.DownloadStickerPackItem(ctx, sticker.GetPackId(), sticker.GetPackKey(), sticker.GetStickerId()) + if err != nil { + return nil, fmt.Errorf("failed to download sticker from pack: %w", err) + } + if content.Info.MimeType == "" { + content.Info.MimeType = http.DetectContentType(data) + } + content.Info.Size = len(data) + content.URL, content.File, err = getIntent(ctx).UploadMedia(ctx, getPortal(ctx).MXID, data, content.Body, content.Info.MimeType) + if err != nil { + return nil, fmt.Errorf("failed to upload sticker from pack: %w", err) + } + } + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: content, + }, nil +} + func (mc *MessageConverter) downloadSignalLongText(ctx context.Context, att *signalpb.AttachmentPointer, attMap AttachmentMap) (*string, error) { data, err := mc.downloadAttachment(ctx, att, attMap, nil) if err != nil { From 8cb696dcfcf248a34e1fe6af5510586753485dc6 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 10 Nov 2026 23:04:39 +0000 Subject: [PATCH 06/19] signalmeow/groups: decode announcements-only flag in full group state (#727) --- pkg/signalmeow/groups.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/signalmeow/groups.go b/pkg/signalmeow/groups.go index 0849f74..411427a 100644 --- a/pkg/signalmeow/groups.go +++ b/pkg/signalmeow/groups.go @@ -497,6 +497,7 @@ func decryptGroup(ctx context.Context, encryptedGroup *signalpb.Group, groupMast // These aren't encrypted decryptedGroup.AvatarPath = encryptedGroup.AvatarUrl decryptedGroup.Revision = encryptedGroup.Version + decryptedGroup.AnnouncementsOnly = encryptedGroup.AnnouncementsOnly // Decrypt members for _, member := range encryptedGroup.Members { From 74b4c70ee4182191333bea0d0432dfa18d79467f Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 10 Nov 2026 23:16:58 +0000 Subject: [PATCH 07/19] handlesignal: send a notice in DMs when a safety number changes (#723) --- pkg/connector/config.go | 2 ++ pkg/connector/example-config.yaml | 2 ++ pkg/connector/handlesignal.go | 40 ++++++++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/pkg/connector/config.go b/pkg/connector/config.go index 6e9f2e0..9a0d65a 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -42,6 +42,7 @@ type SignalConfig struct { NoteToSelfAvatar id.ContentURIString `yaml:"note_to_self_avatar"` LocationFormat string `yaml:"location_format"` ExtEvPolls bool `yaml:"extev_polls"` + IdentityChangeNotices bool `yaml:"identity_change_notices"` displaynameTemplate *template.Template `yaml:"-"` } @@ -106,6 +107,7 @@ func upgradeConfig(helper up.Helper) { helper.Copy(up.Str, "note_to_self_avatar") helper.Copy(up.Str, "location_format") helper.Copy(up.Bool, "extev_polls") + helper.Copy(up.Bool, "identity_change_notices") } func (s *SignalConnector) GetConfig() (string, any, up.Upgrader) { diff --git a/pkg/connector/example-config.yaml b/pkg/connector/example-config.yaml index f67fbc8..0a818d1 100644 --- a/pkg/connector/example-config.yaml +++ b/pkg/connector/example-config.yaml @@ -25,3 +25,5 @@ note_to_self_avatar: mxc://maunium.net/REBIVrqjZwmaWpssCZpBlmlL location_format: 'https://www.google.com/maps/place/%[1]s,%[2]s' # Should polls be sent using unstable MSC3381 event types? extev_polls: false +# Should another user's safety number changing send a message to Matrix? +identity_change_notices: false diff --git a/pkg/connector/handlesignal.go b/pkg/connector/handlesignal.go index ed2d33e..389f02c 100644 --- a/pkg/connector/handlesignal.go +++ b/pkg/connector/handlesignal.go @@ -75,7 +75,7 @@ func (s *SignalClient) handleSignalEvent(rawEvt events.SignalEvent) bool { case *events.LoggedOut: s.sendLoggedOutState(evt.Error) case *events.IdentityChange: - // TODO send notice + s.handleSignalIdentityChange(evt) default: s.UserLogin.Log.Warn().Type("event_type", evt).Msg("Unrecognized signalmeow event type") } @@ -161,6 +161,44 @@ func convertDecryptionError(_ context.Context, _ *bridgev2.Portal, _ bridgev2.Ma }, nil } +func (s *SignalClient) handleSignalIdentityChange(evt *events.IdentityChange) { + if !s.Main.Config.IdentityChangeNotices || evt.ServiceID.Type != libsignalgo.ServiceIDTypeACI || evt.ServiceID.UUID == s.Client.Store.ACI { + return + } + s.UserLogin.QueueRemoteEvent(&simplevent.Message[*events.IdentityChange]{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventMessage, + LogContext: func(c zerolog.Context) zerolog.Context { + return c.Stringer("sender_id", evt.ServiceID) + }, + PortalKey: s.makeDMPortalKey(evt.ServiceID), + Sender: s.makeEventSender(evt.ServiceID.UUID), + }, + Data: evt, + ID: "idchange|" + signalid.MakeMessageID(evt.ServiceID.UUID, uint64(time.Now().UnixMilli())), + ConvertMessageFunc: convertIdentityChange, + }) +} + +func convertIdentityChange(ctx context.Context, portal *bridgev2.Portal, _ bridgev2.MatrixAPI, evt *events.IdentityChange) (*bridgev2.ConvertedMessage, error) { + name := evt.ServiceID.UUID.String() + ghost, err := portal.Bridge.GetGhostByID(ctx, signalid.MakeUserID(evt.ServiceID.UUID)) + if err != nil { + return nil, err + } else if ghost.Name != "" { + name = ghost.Name + } + return &bridgev2.ConvertedMessage{ + Parts: []*bridgev2.ConvertedMessagePart{{ + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: fmt.Sprintf("Your safety number with %s has changed.", name), + }, + }}, + }, nil +} + type Bv2ChatEvent struct { *events.ChatEvent s *SignalClient From 0a4f1d81c8cf670d550b8dead774c70dde7450d3 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 10 Nov 2026 23:17:31 +0000 Subject: [PATCH 08/19] signalmeow/web: don't return an error when closing an already closed websocket (#721) --- pkg/signalmeow/web/signalwebsocket.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/signalmeow/web/signalwebsocket.go b/pkg/signalmeow/web/signalwebsocket.go index bade56b..2ee9ac8 100644 --- a/pkg/signalmeow/web/signalwebsocket.go +++ b/pkg/signalmeow/web/signalwebsocket.go @@ -22,6 +22,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "net/url" "strings" @@ -116,6 +117,9 @@ func (s *SignalWebsocket) Close() (err error) { s.closeCalled.Store(true) if ws := s.ws.Swap(nil); ws != nil { err = ws.Close(websocket.StatusNormalClosure, "") + if errors.Is(err, net.ErrClosed) { + err = nil + } } if cancelLoop := s.cancel.Swap(nil); cancelLoop != nil { (*cancelLoop)() From c6abc27db6490c0c330953d961f400501fba0b41 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 10 Nov 2026 23:17:39 +0000 Subject: [PATCH 09/19] signalmeow/storageservice: only fetch new records after the first sync (#719) --- pkg/signalmeow/client.go | 2 ++ pkg/signalmeow/storageservice.go | 23 +++++++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/pkg/signalmeow/client.go b/pkg/signalmeow/client.go index c8898e3..fad3f8e 100644 --- a/pkg/signalmeow/client.go +++ b/pkg/signalmeow/client.go @@ -72,6 +72,8 @@ type Client struct { storageSyncLock sync.Mutex storageSyncQueued atomic.Bool + storageVersion uint64 + storageRecords []string writeCallbackCounter chan time.Time } diff --git a/pkg/signalmeow/storageservice.go b/pkg/signalmeow/storageservice.go index cfc2951..ea45627 100644 --- a/pkg/signalmeow/storageservice.go +++ b/pkg/signalmeow/storageservice.go @@ -55,18 +55,30 @@ func (cli *Client) StorageSync(ctx context.Context) { cli.storageSyncQueued.Store(false) log := cli.Log.With().Str("action", "sync storage").Logger() ctx = log.WithContext(ctx) - // TODO only fetch changed entries - update, err := cli.FetchStorage(ctx, cli.Store.MasterKey, 0, nil) + update, err := cli.FetchStorage(ctx, cli.Store.MasterKey, cli.storageVersion, cli.storageRecords) if err != nil { log.Err(err).Msg("Failed to fetch storage") return + } else if update == nil { + log.Debug().Uint64("version", cli.storageVersion).Msg("Storage manifest is already up to date") + return } err = cli.Store.DoContactTxn(ctx, func(ctx context.Context) error { return cli.processStorageInTxn(ctx, update) }) if err != nil { log.Err(err).Msg("Failed to process storage update") + return } + log.Debug(). + Uint64("previous_version", cli.storageVersion). + Uint64("version", update.Version). + Int("new_records", len(update.NewRecords)). + Int("removed_records", len(update.RemovedRecords)). + Int("missing_records", len(update.MissingRecords)). + Msg("Processed storage update") + cli.storageVersion = update.Version + cli.storageRecords = update.KnownRecords } func (cli *Client) processStorageInTxn(ctx context.Context, update *StorageUpdate) error { @@ -178,6 +190,7 @@ type StorageUpdate struct { NewRecords []*DecryptedStorageRecord RemovedRecords []string MissingRecords []string + KnownRecords []string } func (cli *Client) FetchStorage(ctx context.Context, masterKey []byte, currentVersion uint64, existingKeys []string) (*StorageUpdate, error) { @@ -189,6 +202,7 @@ func (cli *Client) FetchStorage(ctx context.Context, masterKey []byte, currentVe return nil, nil } removedKeys := make([]string, 0) + knownKeys := make([]string, 0, len(manifest.GetIdentifiers())) newKeys := manifestRecordToMap(manifest.GetIdentifiers()) slices.Sort(existingKeys) existingKeys = slices.Compact(existingKeys) @@ -196,6 +210,7 @@ func (cli *Client) FetchStorage(ctx context.Context, masterKey []byte, currentVe _, isStillThere := newKeys[key] if isStillThere { delete(newKeys, key) + knownKeys = append(knownKeys, key) } else { removedKeys = append(removedKeys, key) } @@ -205,11 +220,15 @@ func (cli *Client) FetchStorage(ctx context.Context, masterKey []byte, currentVe if err != nil { return nil, err } + for _, record := range newRecords { + knownKeys = append(knownKeys, record.StorageID) + } return &StorageUpdate{ Version: manifest.GetVersion(), NewRecords: newRecords, RemovedRecords: removedKeys, MissingRecords: missingKeys, + KnownRecords: knownKeys, }, nil } From 5c740406dbd067dab3ce2b95cea941a96b2b084b Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 10 Nov 2026 23:17:50 +0000 Subject: [PATCH 10/19] signalmeow: handle server delivery receipts as delivery receipts (#718) --- pkg/signalmeow/receiving.go | 13 +++++++++++++ pkg/signalmeow/receiving_decrypt.go | 3 --- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/pkg/signalmeow/receiving.go b/pkg/signalmeow/receiving.go index 1f55b26..e99047d 100644 --- a/pkg/signalmeow/receiving.go +++ b/pkg/signalmeow/receiving.go @@ -378,6 +378,19 @@ func (cli *Client) incomingAPIMessageHandler(ctx context.Context, req *signalpb. Str("envelope_type", signalpb.Envelope_Type_name[int32(envelope.GetType())]). Msg("Received envelope") + if envelope.GetType() == signalpb.Envelope_SERVER_DELIVERY_RECEIPT { + if sourceServiceID.Type == libsignalgo.ServiceIDTypeACI && sourceServiceID.UUID != uuid.Nil && !cli.handleEvent(&events.Receipt{ + Sender: sourceServiceID.UUID, + Content: &signalpb.ReceiptMessage{ + Type: signalpb.ReceiptMessage_DELIVERY.Enum(), + Timestamp: []uint64{envelope.GetClientTimestamp()}, + }, + }) { + return nil, ErrHandlerFailed + } + return &web.SimpleResponse{Status: 200}, nil + } + result := cli.decryptEnvelope(ctx, envelope, sourceServiceID, destinationServiceID) err = cli.handleDecryptedResult(ctx, result, envelope, destinationServiceID) diff --git a/pkg/signalmeow/receiving_decrypt.go b/pkg/signalmeow/receiving_decrypt.go index e6abff4..8ad3e15 100644 --- a/pkg/signalmeow/receiving_decrypt.go +++ b/pkg/signalmeow/receiving_decrypt.go @@ -107,9 +107,6 @@ func (cli *Client) decryptEnvelope( Unencrypted: true, } - case signalpb.Envelope_SERVER_DELIVERY_RECEIPT: - return DecryptionResult{Err: fmt.Errorf("server delivery receipt envelopes are not yet supported")} - case signalpb.Envelope_UNKNOWN: return DecryptionResult{Err: fmt.Errorf("unknown envelope type")} From cd8bc557d6fe17a2afec00efa48f5a856a9ccd5c Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 10 Nov 2026 23:17:56 +0000 Subject: [PATCH 11/19] signalmeow/storageservice: ignore record types the bridge doesn't use (#717) --- pkg/signalmeow/storageservice.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/signalmeow/storageservice.go b/pkg/signalmeow/storageservice.go index ea45627..1eb3069 100644 --- a/pkg/signalmeow/storageservice.go +++ b/pkg/signalmeow/storageservice.go @@ -170,7 +170,9 @@ func (cli *Client) processStorageInTxn(ctx context.Context, update *StorageUpdat return fmt.Errorf("failed to save device after receiving account record: %w", err) } log.Debug().Msg("Saved device after receiving account record") - case *signalpb.StorageRecord_GroupV1, *signalpb.StorageRecord_StoryDistributionList: + case *signalpb.StorageRecord_GroupV1, *signalpb.StorageRecord_StoryDistributionList, *signalpb.StorageRecord_StickerPack, + *signalpb.StorageRecord_CallLink, *signalpb.StorageRecord_ChatFolder, *signalpb.StorageRecord_NotificationProfile, + *signalpb.StorageRecord_FavoriteSticker: // irrelevant data default: log.Warn().Type("type", data).Str("item_id", record.StorageID).Msg("Unknown storage record type") From d59791e7d3a070ea4d44e44008fcaa06a5244020 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 10 Nov 2026 23:18:01 +0000 Subject: [PATCH 12/19] signalmeow: send typing messages as online-only (#716) --- pkg/signalmeow/senderkey.go | 4 ++-- pkg/signalmeow/sending.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/signalmeow/senderkey.go b/pkg/signalmeow/senderkey.go index 2eefadd..322fcc8 100644 --- a/pkg/signalmeow/senderkey.go +++ b/pkg/signalmeow/senderkey.go @@ -221,8 +221,8 @@ func (cli *Client) sendToGroupWithSenderKey( header.Set("Unidentified-Access-Key", xak.String()) } path := fmt.Sprintf( - "/v1/messages/multi_recipient?ts=%d&urgent=%t&online=false", - messageTimestamp, isUrgent(content), + "/v1/messages/multi_recipient?ts=%d&urgent=%t&online=%t", + messageTimestamp, isUrgent(content), content.GetTypingMessage() != nil, ) log.Debug(). Any("recipients", ski.SharedWith). diff --git a/pkg/signalmeow/sending.go b/pkg/signalmeow/sending.go index 115b7e5..dce4eea 100644 --- a/pkg/signalmeow/sending.go +++ b/pkg/signalmeow/sending.go @@ -964,7 +964,7 @@ func (cli *Client) sendContent( outgoingMessages := MyMessages{ Timestamp: messageTimestamp, - Online: false, + Online: content.GetTypingMessage() != nil, Urgent: isUrgent(content), Messages: messages, } From fa622888a97e4f8f865300dbf76407ea8397c1d6 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 10 Nov 2026 23:18:04 +0000 Subject: [PATCH 13/19] client: don't connect logins that are already logged out (#713) --- pkg/connector/client.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index e6c5b5e..b5531b5 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -221,12 +221,18 @@ func (s *SignalClient) Connect(ctx context.Context) { if s.Client == nil { s.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateBadCredentials, Error: "signal-not-logged-in", UserAction: status.UserActionRelogin}) return + } else if !s.Client.IsLoggedIn() { + s.sendLoggedOutState(nil) + return } s.updateRemoteProfile(ctx, false) s.tryConnect(ctx, 0, true) } func (s *SignalClient) ConnectBackground(ctx context.Context, _ *bridgev2.ConnectBackgroundParams) error { + if !s.IsLoggedIn() { + return bridgev2.ErrNotLoggedIn + } s.queueEmptyWaiter.Clear() ch, unauthCh, err := s.Client.StartWebsockets(ctx) if err != nil { From 15674cc755ef6cc446c4418e5fb1c1bd56e96aa9 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 10 Nov 2026 23:18:13 +0000 Subject: [PATCH 14/19] signalmeow/groupcache: keep send endorsements for group changes without a response (#714) --- pkg/signalmeow/groupcache.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/signalmeow/groupcache.go b/pkg/signalmeow/groupcache.go index ee8259f..7f5d282 100644 --- a/pkg/signalmeow/groupcache.go +++ b/pkg/signalmeow/groupcache.go @@ -203,6 +203,7 @@ func (gc *GroupCache) ApplyUpdate(change *GroupChange, endorsementResponse libsi delete(gc.data, id) return nil } + prevMembers := cached.getMemberServiceIDs() // Pending member adds, promotes and removes cached.PendingMembers = append(cached.PendingMembers, change.AddPendingMembers...) @@ -319,6 +320,12 @@ func (gc *GroupCache) ApplyUpdate(change *GroupChange, endorsementResponse libsi cached.UpdatedAt = time.Now() cached.Revision = change.Revision + if len(endorsementResponse) == 0 { + if !slices.Equal(prevMembers, cached.getMemberServiceIDs()) { + delete(gc.data, id) + } + return nil + } endorsement, memberEndorsements, err := endorsementResponse.ReceiveWithServiceIDs( cached.getMemberServiceIDs(), gc.serviceID, From 27e3fc25829ed091660656a4197a628a0f3544c5 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 10 Nov 2026 23:18:23 +0000 Subject: [PATCH 15/19] signalmeow/storageservice: fix unnecessarily marking recipients as changed (#712) --- pkg/signalmeow/storageservice.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/signalmeow/storageservice.go b/pkg/signalmeow/storageservice.go index 1eb3069..f3b9aac 100644 --- a/pkg/signalmeow/storageservice.go +++ b/pkg/signalmeow/storageservice.go @@ -113,8 +113,9 @@ func (cli *Client) processStorageInTxn(ctx context.Context, update *StorageUpdat recipient.Profile.Name = strings.TrimSpace(fmt.Sprintf("%s %s", contact.GivenName, contact.FamilyName)) } if contact.SystemGivenName != "" || contact.SystemFamilyName != "" { - changed = true - recipient.ContactName = strings.TrimSpace(fmt.Sprintf("%s %s", contact.SystemGivenName, contact.SystemFamilyName)) + newContactName := strings.TrimSpace(fmt.Sprintf("%s %s", contact.SystemGivenName, contact.SystemFamilyName)) + changed = changed || recipient.ContactName != newContactName + recipient.ContactName = newContactName } newNickname := "" if contact.Nickname != nil { @@ -132,7 +133,7 @@ func (cli *Client) processStorageInTxn(ctx context.Context, update *StorageUpdat changed = true recipient.Blocked = contact.Blocked } - if !ptr.Val(recipient.Whitelisted) { + if !ptr.Val(recipient.Whitelisted) && (recipient.Whitelisted == nil || contact.Whitelisted) { changed = true recipient.Whitelisted = &contact.Whitelisted } From 59a4ff2afc39f5467adff794ec1ac82c491fd178 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 10 Nov 2026 23:18:34 +0000 Subject: [PATCH 16/19] msgconv/from-signal: send notice when a message is pinned/unpinned (#724) --- pkg/connector/handlesignal.go | 1 + pkg/msgconv/from-signal.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/pkg/connector/handlesignal.go b/pkg/connector/handlesignal.go index 389f02c..b6e746f 100644 --- a/pkg/connector/handlesignal.go +++ b/pkg/connector/handlesignal.go @@ -223,6 +223,7 @@ func (evt *Bv2ChatEvent) GetType() bridgev2.RemoteEventType { switch { case innerEvt.GetIsViewOnce(), innerEvt.Body != nil, innerEvt.Attachments != nil, innerEvt.Contact != nil, innerEvt.Sticker != nil, innerEvt.Payment != nil, innerEvt.GiftBadge != nil, innerEvt.PollCreate != nil, innerEvt.PollVote != nil, + innerEvt.PinMessage != nil, innerEvt.UnpinMessage != nil, innerEvt.GetRequiredProtocolVersion() > uint32(signalpb.DataMessage_CURRENT), innerEvt.GetFlags()&uint32(signalpb.DataMessage_EXPIRATION_TIMER_UPDATE) != 0: return bridgev2.RemoteEventMessage diff --git a/pkg/msgconv/from-signal.go b/pkg/msgconv/from-signal.go index 0bf2d3b..2140db9 100644 --- a/pkg/msgconv/from-signal.go +++ b/pkg/msgconv/from-signal.go @@ -153,6 +153,12 @@ func (mc *MessageConverter) ToMatrix( cm.Parts = append(cm.Parts, mc.convertPollTerminateToMatrix(ctx, sender, dm.PollTerminate)) return cm } + if dm.PinMessage != nil || dm.UnpinMessage != nil { + var part *bridgev2.ConvertedMessagePart + part, cm.ReplyTo = mc.convertPinToMatrix(dm) + cm.Parts = append(cm.Parts, part) + return cm + } if dm.GetIsViewOnce() { if !mc.Bridge.Config.ViewLimitedMedia && (cm.Disappear.Timer == 0 || cm.Disappear.Timer > 5*time.Minute) { cm.Disappear = database.DisappearingSetting{ @@ -841,6 +847,28 @@ func (mc *MessageConverter) convertPollTerminateToMatrix(ctx context.Context, se } } +func (mc *MessageConverter) convertPinToMatrix(dm *signalpb.DataMessage) (*bridgev2.ConvertedMessagePart, *networkid.MessageOptionalPartID) { + body := "Pinned a message" + targetAuthor, targetTS := dm.GetPinMessage().GetTargetAuthorAciBinary(), dm.GetPinMessage().GetTargetSentTimestamp() + if dm.PinMessage == nil { + body = "Unpinned a message" + targetAuthor, targetTS = dm.GetUnpinMessage().GetTargetAuthorAciBinary(), dm.GetUnpinMessage().GetTargetSentTimestamp() + } + var replyTo *networkid.MessageOptionalPartID + if len(targetAuthor) == 16 { + replyTo = &networkid.MessageOptionalPartID{ + MessageID: signalid.MakeMessageID(uuid.UUID(targetAuthor), targetTS), + } + } + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: body, + }, + }, replyTo +} + var invalidPollVote = &bridgev2.ConvertedMessagePart{ Type: event.EventUnstablePollResponse, Content: &event.MessageEventContent{}, From 03c8d29ae7ef6441ce0f5d33822710724cb90d2e Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Wed, 11 Nov 2026 02:36:40 +0000 Subject: [PATCH 17/19] signalmeow/receiving: fetch PNI identity from the primary device when verifying PNI signatures (#720) --- pkg/signalmeow/receiving.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/signalmeow/receiving.go b/pkg/signalmeow/receiving.go index e99047d..dcc748a 100644 --- a/pkg/signalmeow/receiving.go +++ b/pkg/signalmeow/receiving.go @@ -922,7 +922,7 @@ func (cli *Client) handlePNISignatureMessage(ctx context.Context, sender libsign Stringer("aci", sender.UUID). Stringer("pni", pni). Msg("Fetching PNI identity for signature verification as it wasn't found in store") - err = cli.FetchAndProcessPreKey(ctx, pniServiceID, 0) + err = cli.FetchAndProcessPreKey(ctx, pniServiceID, 1) if err != nil { return fmt.Errorf("failed to fetch prekey for PNI %s after identity wasn't found in store: %w", pni, err) } else if pniIdentity, err = cli.Store.IdentityKeyStore.GetIdentityKey(ctx, pniServiceID); err != nil { From 561064e8f572908019f16136b129d0187be71e1a Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Wed, 11 Nov 2026 02:36:43 +0000 Subject: [PATCH 18/19] handlematrix: send admin deletes for other members' messages in groups (#722) --- pkg/connector/handlematrix.go | 46 ++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index b384d58..ca7af09 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -302,22 +302,50 @@ func (s *SignalClient) HandleMatrixReactionRemove(ctx context.Context, msg *brid } func (s *SignalClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { - _, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetMessage.ID) + targetAuthorACI, targetSentTimestamp, err := signalid.ParseMessageID(msg.TargetMessage.ID) if err != nil { return fmt.Errorf("failed to parse target message ID: %w", err) - } else if msg.TargetMessage.SenderID != signalid.MakeUserID(s.Client.Store.ACI) { - return errDeleteOthersMessage } - ts := getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender) - err = s.sendMessage(ctx, msg.Portal.ID, signalmeow.WrapDataMessage(&signalpb.DataMessage{ - Timestamp: proto.Uint64(ts), - Delete: &signalpb.DataMessage_Delete{ + dm := &signalpb.DataMessage{ + Timestamp: proto.Uint64(getTimestampForEvent(msg.InputTransactionID, msg.Event, msg.OrigSender)), + } + if msg.TargetMessage.SenderID == signalid.MakeUserID(s.Client.Store.ACI) { + dm.Delete = &signalpb.DataMessage_Delete{ TargetSentTimestamp: proto.Uint64(targetSentTimestamp), - }, - })) + } + } else if isAdmin, err := s.isGroupAdmin(ctx, msg.Portal); err != nil { + return wrapSendError(err) + } else if !isAdmin { + return errDeleteOthersMessage + } else { + dm.AdminDelete = &signalpb.DataMessage_AdminDelete{ + TargetAuthorAciBinary: targetAuthorACI[:], + TargetSentTimestamp: proto.Uint64(targetSentTimestamp), + } + } + err = s.sendMessage(ctx, msg.Portal.ID, signalmeow.WrapDataMessage(dm)) return wrapSendError(err) } +func (s *SignalClient) isGroupAdmin(ctx context.Context, portal *bridgev2.Portal) (bool, error) { + _, groupID, err := signalid.ParsePortalID(portal.ID) + if err != nil { + return false, err + } else if groupID == "" { + return false, nil + } + group, _, err := s.Client.RetrieveGroupByID(ctx, groupID, portal.Metadata.(*signalid.PortalMetadata).Revision) + if err != nil { + return false, fmt.Errorf("failed to get group info: %w", err) + } + for _, member := range group.Members { + if member.ACI == s.Client.Store.ACI { + return member.Role == signalmeow.GroupMember_ADMINISTRATOR, nil + } + } + return false, nil +} + func (s *SignalClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bridgev2.MatrixReadReceipt) error { if !receipt.ReadUpTo.After(receipt.LastRead) { return nil From a96f214609cbe04426c637845902bc2fa5a0c537 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Wed, 11 Nov 2026 02:36:47 +0000 Subject: [PATCH 19/19] groupinfo,signalmeow: make ended groups read-only (#725) --- pkg/connector/groupinfo.go | 25 ++++++++++++++++++++++++- pkg/signalmeow/groupcache.go | 3 +++ pkg/signalmeow/groups.go | 10 +++++++--- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/pkg/connector/groupinfo.go b/pkg/connector/groupinfo.go index cd50964..fa75743 100644 --- a/pkg/connector/groupinfo.go +++ b/pkg/connector/groupinfo.go @@ -39,6 +39,7 @@ import ( var defaultPL = 0 var moderatorPL = 50 +var terminatedPL = 100 func roleToPL(role signalmeow.GroupMemberRole) *int { switch role { @@ -70,6 +71,21 @@ func applyAttributesAccess(plc *bridgev2.PowerLevelOverrides, attributeAccess si plc.Events[event.StateBeeperDisappearingTimer] = attributePL } +func applyTerminated(plc *bridgev2.PowerLevelOverrides) { + plc.EventsDefault = &terminatedPL + plc.StateDefault = &terminatedPL + plc.Invite = &terminatedPL + plc.Kick = &terminatedPL + plc.Ban = &terminatedPL + plc.Redact = &terminatedPL + for _, evtType := range []event.Type{ + event.EventReaction, event.EventRedaction, event.StatePowerLevels, event.StateRoomName, + event.StateRoomAvatar, event.StateTopic, event.StateBeeperDisappearingTimer, + } { + plc.Events[evtType] = terminatedPL + } +} + func applyMembersAccess(plc *bridgev2.PowerLevelOverrides, memberAccess signalmeow.AccessControl) { if memberAccess == signalmeow.AccessControl_ADMINISTRATOR { plc.Invite = &moderatorPL @@ -125,6 +141,9 @@ func (s *SignalClient) wrapGroupInfo(ctx context.Context, groupInfo *signalmeow. applyMembersAccess(members.PowerLevels, groupInfo.AccessControl.Members) joinRule = inviteLinkToJoinRule(groupInfo.AccessControl.AddFromInviteLink) } + if groupInfo.Terminated { + applyTerminated(members.PowerLevels) + } for _, member := range groupInfo.RequestingMembers { members.MemberMap.Set(bridgev2.ChatMember{ EventSender: s.makeEventSender(member.ACI), @@ -265,7 +284,8 @@ func (s *SignalClient) groupChangeToChatInfoChange(ctx context.Context, groupID var pls *bridgev2.PowerLevelOverrides if groupChange.ModifyAnnouncementsOnly != nil || groupChange.ModifyAttributesAccess != nil || - groupChange.ModifyMemberAccess != nil { + groupChange.ModifyMemberAccess != nil || + groupChange.TerminateGroup { pls = &bridgev2.PowerLevelOverrides{Events: make(map[event.Type]int)} if groupChange.ModifyAnnouncementsOnly != nil { applyAnnouncementsOnly(pls, *groupChange.ModifyAnnouncementsOnly) @@ -276,6 +296,9 @@ func (s *SignalClient) groupChangeToChatInfoChange(ctx context.Context, groupID if groupChange.ModifyMemberAccess != nil { applyMembersAccess(pls, *groupChange.ModifyMemberAccess) } + if groupChange.TerminateGroup { + applyTerminated(pls) + } } if groupChange.ModifyAddFromInviteLinkAccess != nil { ic.ChatInfo.JoinRule = &event.JoinRulesEventContent{ diff --git a/pkg/signalmeow/groupcache.go b/pkg/signalmeow/groupcache.go index 7f5d282..3a676fb 100644 --- a/pkg/signalmeow/groupcache.go +++ b/pkg/signalmeow/groupcache.go @@ -305,6 +305,9 @@ func (gc *GroupCache) ApplyUpdate(change *GroupChange, endorsementResponse libsi if change.ModifyAnnouncementsOnly != nil { cached.AnnouncementsOnly = *change.ModifyAnnouncementsOnly } + if change.TerminateGroup { + cached.Terminated = true + } if change.ModifyDisappearingMessagesDuration != nil { cached.DisappearingMessagesDuration = *change.ModifyDisappearingMessagesDuration } diff --git a/pkg/signalmeow/groups.go b/pkg/signalmeow/groups.go index 411427a..882e048 100644 --- a/pkg/signalmeow/groups.go +++ b/pkg/signalmeow/groups.go @@ -81,6 +81,7 @@ type Group struct { Members []*GroupMember Description string AnnouncementsOnly bool + Terminated bool Revision uint32 DisappearingMessagesDuration uint32 AccessControl *GroupAccessControl @@ -206,6 +207,7 @@ type GroupChange struct { DeleteBannedMembers []*libsignalgo.ServiceID PromotePendingPniAciMembers []*PromotePendingPniAciMember ModifyInviteLinkPassword *types.SerializedInviteLinkPassword + TerminateGroup bool } func (groupChange *GroupChange) isEmpty() bool { @@ -226,7 +228,8 @@ func (groupChange *GroupChange) isEmpty() bool { len(groupChange.PromoteRequestingMembers) == 0 && groupChange.ModifyDescription == nil && groupChange.ModifyAnnouncementsOnly == nil && - len(groupChange.AddBannedMembers) == 0 + len(groupChange.AddBannedMembers) == 0 && + !groupChange.TerminateGroup } func (groupChange *GroupChange) resolveConflict(group *Group) { @@ -497,6 +500,7 @@ func decryptGroup(ctx context.Context, encryptedGroup *signalpb.Group, groupMast // These aren't encrypted decryptedGroup.AvatarPath = encryptedGroup.AvatarUrl decryptedGroup.Revision = encryptedGroup.Version + decryptedGroup.Terminated = encryptedGroup.Terminated decryptedGroup.AnnouncementsOnly = encryptedGroup.AnnouncementsOnly // Decrypt members @@ -1077,6 +1081,7 @@ func (cli *Client) decryptGroupChange(ctx context.Context, encryptedGroupChange if encryptedActions.ModifyAnnouncementsOnly != nil { decryptedGroupChange.ModifyAnnouncementsOnly = &encryptedActions.ModifyAnnouncementsOnly.AnnouncementsOnly } + decryptedGroupChange.TerminateGroup = encryptedActions.TerminateGroup != nil if encryptedActions.ModifyDisappearingMessageTimer != nil && len(encryptedActions.ModifyDisappearingMessageTimer.Timer) > 0 { timerBlob, err := decryptGroupPropertyIntoBlob(groupSecretParams, encryptedActions.ModifyDisappearingMessageTimer.Timer) if err != nil { @@ -1802,8 +1807,7 @@ func (cli *Client) GetGroupHistoryPage(ctx context.Context, gid types.GroupIdent "Cached-Send-Endorsements": "0", }, } - // highest known epoch seems to always be 5, but that may change in the future. includeLastState is always false - path := fmt.Sprintf("/v2/groups/logs/%d?maxSupportedChangeEpoch=%d&includeFirstState=%t&includeLastState=false", fromRevision, 5, includeFirstState) + path := fmt.Sprintf("/v2/groups/logs/%d?maxSupportedChangeEpoch=%d&includeFirstState=%t&includeLastState=false", fromRevision, 7, includeFirstState) response, err := web.SendHTTPRequest(ctx, web.StorageHostname, http.MethodGet, path, opts) defer web.CloseBody(response) if err != nil {