diff --git a/ROADMAP.md b/ROADMAP.md index 26789536..5fdc4152 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,14 +12,16 @@ Note that Barcelona, which the mac-nosip connector uses, is no longer maintained | Media/files | ✔️ | ✔️ | ✔️ | | Replies | 🛑 | ✔️ | ✔️† | | Reactions | 🛑 | ✔️ | ✔️ | -| Edits | 🛑 | ❌ | ❌ | -| Unsends | 🛑 | ❌ | ❌ | +| Edits | 🛑 | ❌ | ✔️* | +| Unsends | 🛑 | ❌ | ✔️* | | Redactions | 🛑 | ✔️ | ✔️ | | Read receipts | 🛑 | ✔️ | ✔️ | | Typing notifications | 🛑 | ✔️ | ✔️ | † BlueBubbles had bugs with replies until v1.9.5 +\* macOS Ventura or higher is required + ## iMessage → Matrix | Feature | mac | mac-nosip | bluebubbles | |----------------------------------|-----|-----------|-------------| @@ -27,17 +29,17 @@ Note that Barcelona, which the mac-nosip connector uses, is no longer maintained | Media/files | ✔️ | ✔️ | ✔️ | | Replies | ✔️ | ✔️ | ✔️ | | Tapbacks | ✔️ | ✔️ | ✔️ | -| Edits | ❌ | ❌ | ❌ | -| Unsends | ❌ | ❌ | ❌ | +| Edits | ❌ | ❌ | ✔️ (BlueBubbles Server >= 1.9.6) | +| Unsends | ❌ | ❌ | ✔️ (BlueBubbles Server >= 1.9.6) | | Own read receipts | ✔️ | ✔️ | ✔️ | | Other read receipts | ✔️ | ✔️ | ✔️ | | Typing notifications | 🛑 | ✔️ | ✔️ | | User metadata | ✔️ | ✔️ | ✔️ | | Group metadata | ✔️ | ✔️ | ✔️ | | Group Participants Added/Removed | ✔️ | ✔️ | ✔️ | -| Backfilling history | ✔️ | ✔️ | ✔️‡ | +| Backfilling history | ✔️‡ | ✔️‡ | ✔️‡ | -‡The BlueBubbles connector doesn't support backfilling tapbacks yet +‡ Backfilling tapbacks is not yet supported ## Android SMS The android-sms connector is deprecated in favor of [mautrix-gmessages](https://github.com/mautrix/gmessages). diff --git a/imessage/bluebubbles/api.go b/imessage/bluebubbles/api.go index 8275ba99..cf227e27 100644 --- a/imessage/bluebubbles/api.go +++ b/imessage/bluebubbles/api.go @@ -84,14 +84,14 @@ func init() { func (bb *blueBubbles) Start(readyCallback func()) error { bb.log.Trace().Msg("Start") - if err := bb.connectAndListen(); err != nil { - return err - } - // Preload some caches bb.usingPrivateAPI = bb.isPrivateAPI() bb.RefreshContactList() + if err := bb.connectAndListen(); err != nil { + return err + } + // Notify main this API is fully loaded readyCallback() @@ -129,17 +129,15 @@ func (bb *blueBubbles) connectToWebSocket() (*websocket.Conn, error) { } func (bb *blueBubbles) listenWebSocket() { - defer func() { - if bb.ws != nil { - bb.ws.Close() - } - }() for { if err := bb.pollMessages(); err != nil { bb.log.Error().Err(err).Msg("Error polling messages from WebSocket") // Reconnect logic here if err := bb.reconnect(); err != nil { bb.log.Error().Err(err).Msg("Failed to reconnect to WebSocket") + bb.stopListening() + return + } else { return } } @@ -244,6 +242,9 @@ func (bb *blueBubbles) pollMessages() error { func (bb *blueBubbles) reconnect() error { const maxRetryCount = 12 retryCount := 0 + + bb.stopListening() + for { bb.log.Info().Msg("Attempting to reconnect to BlueBubbles WebSocket...") if retryCount >= maxRetryCount { @@ -268,6 +269,7 @@ func (bb *blueBubbles) reconnect() error { func (bb *blueBubbles) stopListening() { if bb.ws != nil { bb.ws.WriteMessage(websocket.CloseMessage, []byte{}) + bb.ws.Close() } } @@ -294,6 +296,33 @@ func (bb *blueBubbles) handleNewMessage(rawMessage json.RawMessage) (err error) bb.log.Warn().Msg("Incoming message buffer is full") } + if message.IsEdited || message.IsRetracted { + return nil // the regular message channel should handle edits and unsends updates + } else if message.IsRead { + select { + case bb.receiptChan <- &imessage.ReadReceipt{ + SenderGUID: message.ChatGUID, + IsFromMe: !message.IsFromMe, + ChatGUID: message.ChatGUID, + ReadUpTo: message.GUID, + ReadAt: message.ReadAt, + }: + default: + bb.log.Warn().Msg("Incoming message buffer is full") + } + } else if message.IsDelivered { + select { + case bb.messageStatusChan <- &imessage.SendMessageStatus{ + GUID: message.GUID, + ChatGUID: message.ChatGUID, + Status: "delivered", + Service: imessage.ParseIdentifier(message.ChatGUID).Service, + }: + default: + bb.log.Warn().Msg("Incoming message buffer is full") + } + } + return nil } @@ -319,10 +348,35 @@ func (bb *blueBubbles) handleMessageUpdated(rawMessage json.RawMessage) (err err return err } - select { - case bb.messageChan <- message: - default: - bb.log.Warn().Msg("Incoming message buffer is full") + if message.IsEdited || message.IsRetracted { + select { + case bb.messageChan <- message: + default: + bb.log.Warn().Msg("Incoming message buffer is full") + } + } else if message.IsRead { + select { + case bb.receiptChan <- &imessage.ReadReceipt{ + SenderGUID: message.ChatGUID, + IsFromMe: !message.IsFromMe, + ChatGUID: message.ChatGUID, + ReadUpTo: message.GUID, + ReadAt: message.ReadAt, + }: + default: + bb.log.Warn().Msg("Incoming message buffer is full") + } + } else if message.IsDelivered { + select { + case bb.messageStatusChan <- &imessage.SendMessageStatus{ + GUID: message.GUID, + ChatGUID: message.ChatGUID, + Status: "delivered", + Service: imessage.ParseIdentifier(message.ChatGUID).Service, + }: + default: + bb.log.Warn().Msg("Incoming message buffer is full") + } } return nil @@ -474,7 +528,7 @@ func (bb *blueBubbles) queryChatMessages(query MessageQueryRequest, allResults [ bb.log.Info().Interface("query", query).Msg("queryChatMessages") var resp MessageQueryResponse - err := bb.apiPost("/api/v1/message/query", query, &resp) + err := bb.apiGet(fmt.Sprintf("/api/v1/chat/%s/message", query.ChatGUID), bb.messageQueryRequestToMap(&query), &resp) if err != nil { return nil, err } @@ -482,14 +536,64 @@ func (bb *blueBubbles) queryChatMessages(query MessageQueryRequest, allResults [ allResults = append(allResults, resp.Data...) nextPageOffset := resp.Metadata.Offset + resp.Metadata.Limit + + // Determine the limit for the next page + var nextLimit int + if query.Max != nil && *query.Max > 0 { + nextLimit = int(math.Min(float64(*query.Max), 1000)) + } else { + nextLimit = 1000 + } + + // If there are more messages to fetch and pagination is enabled if paginate && (nextPageOffset < resp.Metadata.Total) { - query.Offset = nextPageOffset + // If the next page offset exceeds the maximum limit, adjust the query + if nextLimit > 0 && nextPageOffset+int64(nextLimit) > resp.Metadata.Total { + nextLimit = int(resp.Metadata.Total - nextPageOffset) + } + + // Update the query with the new offset and limit + query.Offset = int(nextPageOffset) + query.Limit = nextLimit + + // Recursively call the function for the next page return bb.queryChatMessages(query, allResults, paginate) } return allResults, nil } +func (bb *blueBubbles) messageQueryRequestToMap(req *MessageQueryRequest) map[string]string { + m := make(map[string]string) + + m["limit"] = fmt.Sprintf("%d", req.Limit) + m["offset"] = fmt.Sprintf("%d", req.Offset) + m["sort"] = string(req.Sort) + + if req.Before != nil { + m["before"] = fmt.Sprintf("%d", *req.Before) + } + + if req.After != nil { + m["after"] = fmt.Sprintf("%d", *req.After) + } + + // Handling slice of MessageQueryWith + if len(req.With) > 0 { + with := "" + for index, withItem := range req.With { + if index == 0 { + with = string(withItem) + } else { + with = with + "," + string(withItem) + } + } + m["with"] = with + } + + return m +} + func (bb *blueBubbles) GetMessagesSinceDate(chatID string, minDate time.Time, backfillID string) (resp []*imessage.Message, err error) { bb.log.Trace().Str("chatID", chatID).Time("minDate", minDate).Str("backfillID", backfillID).Msg("GetMessagesSinceDate") @@ -499,11 +603,12 @@ func (bb *blueBubbles) GetMessagesSinceDate(chatID string, minDate time.Time, ba Limit: 100, Offset: 0, With: []MessageQueryWith{ - MessageQueryWith(MessageQueryWithChat), MessageQueryWith(MessageQueryWithChatParticipants), MessageQueryWith(MessageQueryWithAttachment), MessageQueryWith(MessageQueryWithHandle), - MessageQueryWith(MessageQueryWithSMS), + MessageQueryWith(MessageQueryWithAttributeBody), + MessageQueryWith(MessageQueryWithMessageSummary), + MessageQueryWith(MessageQueryWithPayloadData), }, After: &after, Sort: MessageQuerySortDesc, @@ -539,11 +644,12 @@ func (bb *blueBubbles) GetMessagesBetween(chatID string, minDate, maxDate time.T Limit: 100, Offset: 0, With: []MessageQueryWith{ - MessageQueryWith(MessageQueryWithChat), MessageQueryWith(MessageQueryWithChatParticipants), MessageQueryWith(MessageQueryWithAttachment), MessageQueryWith(MessageQueryWithHandle), - MessageQueryWith(MessageQueryWithSMS), + MessageQueryWith(MessageQueryWithAttributeBody), + MessageQueryWith(MessageQueryWithMessageSummary), + MessageQueryWith(MessageQueryWithPayloadData), }, After: &after, Before: &before, @@ -574,16 +680,24 @@ func (bb *blueBubbles) GetMessagesBeforeWithLimit(chatID string, before time.Tim bb.log.Trace().Str("chatID", chatID).Time("before", before).Int("limit", limit).Msg("GetMessagesBeforeWithLimit") _before := before.UnixNano() / int64(time.Millisecond) + + queryLimit := limit + if queryLimit > 1000 { + queryLimit = 1000 + } + request := MessageQueryRequest{ ChatGUID: chatID, - Limit: int64(limit), + Limit: queryLimit, + Max: &limit, Offset: 0, With: []MessageQueryWith{ - MessageQueryWith(MessageQueryWithChat), MessageQueryWith(MessageQueryWithChatParticipants), MessageQueryWith(MessageQueryWithAttachment), MessageQueryWith(MessageQueryWithHandle), - MessageQueryWith(MessageQueryWithSMS), + MessageQueryWith(MessageQueryWithAttributeBody), + MessageQueryWith(MessageQueryWithMessageSummary), + MessageQueryWith(MessageQueryWithPayloadData), }, Before: &_before, Sort: MessageQuerySortDesc, @@ -612,16 +726,23 @@ func (bb *blueBubbles) GetMessagesBeforeWithLimit(chatID string, before time.Tim func (bb *blueBubbles) GetMessagesWithLimit(chatID string, limit int, backfillID string) (resp []*imessage.Message, err error) { bb.log.Trace().Str("chatID", chatID).Int("limit", limit).Str("backfillID", backfillID).Msg("GetMessagesWithLimit") + queryLimit := limit + if queryLimit > 1000 { + queryLimit = 1000 + } + request := MessageQueryRequest{ ChatGUID: chatID, - Limit: int64(limit), + Limit: queryLimit, + Max: &limit, Offset: 0, With: []MessageQueryWith{ - MessageQueryWith(MessageQueryWithChat), MessageQueryWith(MessageQueryWithChatParticipants), MessageQueryWith(MessageQueryWithAttachment), MessageQueryWith(MessageQueryWithHandle), - MessageQueryWith(MessageQueryWithSMS), + MessageQueryWith(MessageQueryWithAttributeBody), + MessageQueryWith(MessageQueryWithMessageSummary), + MessageQueryWith(MessageQueryWithPayloadData), }, Sort: MessageQuerySortDesc, } @@ -681,7 +802,6 @@ func (bb *blueBubbles) GetMessage(guid string) (resp *imessage.Message, err erro } return resp, nil - } func (bb *blueBubbles) queryChats(query ChatQueryRequest, allResults []Chat) ([]Chat, error) { @@ -1010,6 +1130,74 @@ func (bb *blueBubbles) SendMessage(chatID, text string, replyTo string, replyToP }, nil } +func (bb *blueBubbles) UnsendMessage(chatID, targetGUID string, targetPart int) (*imessage.SendResponse, error) { + bb.log.Trace().Str("chatID", chatID).Str("targetGUID", targetGUID).Int("targetPart", targetPart).Msg("UnsendMessage") + + if !bb.usingPrivateAPI { + bb.log.Warn().Str("chatID", chatID).Str("targetGUID", targetGUID).Int("targetPart", targetPart).Msg("The private-api isn't enabled in BlueBubbles, can't unsend message") + return nil, errors.ErrUnsupported + } + + request := UnsendMessage{ + PartIndex: targetPart, + } + + var res UnsendMessageResponse + + err := bb.apiPost("/api/v1/message/"+targetGUID+"/unsend", request, &res) + if err != nil { + bb.log.Error().Any("response", res).Msg("Failure when unsending message in BlueBubbles") + return nil, err + } + + if res.Status != 200 { + bb.log.Error().Int64("statusCode", res.Status).Any("response", res).Msg("Failure when unsending message in BlueBubbles") + + return nil, errors.New("could not unsend message") + } + + return &imessage.SendResponse{ + GUID: res.Data.GUID, + Service: res.Data.Handle.Service, + Time: time.UnixMilli(res.Data.DateCreated), + }, nil +} + +func (bb *blueBubbles) EditMessage(chatID string, targetGUID string, newText string, targetPart int) (*imessage.SendResponse, error) { + bb.log.Trace().Str("chatID", chatID).Str("targetGUID", targetGUID).Str("newText", newText).Int("targetPart", targetPart).Msg("EditMessage") + + if !bb.usingPrivateAPI { + bb.log.Warn().Str("chatID", chatID).Str("targetGUID", targetGUID).Str("newText", newText).Int("targetPart", targetPart).Msg("The private-api isn't enabled in BlueBubbles, can't edit message") + return nil, errors.ErrUnsupported + } + + request := EditMessage{ + EditedMessage: newText, + BackwwardsCompatibilityMessage: "Edited to \"" + newText + "\"", + PartIndex: targetPart, + } + + var res EditMessageResponse + + err := bb.apiPost("/api/v1/message/"+targetGUID+"/edit", request, &res) + if err != nil { + bb.log.Error().Any("response", res).Msg("Failure when editing message in BlueBubbles") + return nil, err + } + + if res.Status != 200 { + bb.log.Error().Int64("statusCode", res.Status).Any("response", res).Msg("Failure when editing message in BlueBubbles") + + return nil, errors.New("could not edit message") + } + + return &imessage.SendResponse{ + GUID: res.Data.GUID, + Service: res.Data.Handle.Service, + Time: time.UnixMilli(res.Data.DateCreated), + }, nil +} + func (bb *blueBubbles) isPrivateAPI() bool { var serverInfo ServerInfoResponse err := bb.apiGet("/api/v1/server/info", nil, &serverInfo) @@ -1196,6 +1384,11 @@ func (bb *blueBubbles) ResolveIdentifier(address string) (string, error) { return "", err } + if identifierResponse.Data.Service == "" || identifierResponse.Data.Address == "" { + bb.log.Warn().Any("response", identifierResponse).Str("address", address).Msg("No results found for provided identifier. Assuming 'iMessage' service.") + return "iMessage;-;" + handle, nil + } + return identifierResponse.Data.Service + ";-;" + identifierResponse.Data.Address, nil } @@ -1463,9 +1656,11 @@ func (bb *blueBubbles) convertBBMessageToiMessage(bbMessage Message) (*imessage. message.ReadAt = time.UnixMilli(bbMessage.DateRead) } message.IsDelivered = bbMessage.DateDelivered != 0 - message.IsSent = true // assume yes because we made it to this part of the code - message.IsEmote = false // emojis seem to send either way, and BB doesn't say whether there is one or not + message.IsSent = bbMessage.DateCreated != 0 // assume yes because we made it to this part of the code + message.IsEmote = false // emojis seem to send either way, and BB doesn't say whether there is one or not message.IsAudioMessage = bbMessage.IsAudioMessage + message.IsEdited = bbMessage.DateEdited != 0 + message.IsRetracted = bbMessage.DateRetracted != 0 message.ReplyToGUID = bbMessage.ThreadOriginatorGUID @@ -1675,13 +1870,15 @@ func (bb *blueBubbles) PostStartupSyncHook() { func (bb *blueBubbles) Capabilities() imessage.ConnectorCapabilities { return imessage.ConnectorCapabilities{ MessageSendResponses: true, - SendTapbacks: true, - SendReadReceipts: true, - SendTypingNotifications: true, + SendTapbacks: bb.usingPrivateAPI, + UnsendMessages: bb.usingPrivateAPI, + EditMessages: bb.usingPrivateAPI, + SendReadReceipts: bb.usingPrivateAPI, + SendTypingNotifications: bb.usingPrivateAPI, SendCaptions: true, BridgeState: false, MessageStatusCheckpoints: false, - DeliveredStatus: true, + DeliveredStatus: bb.usingPrivateAPI, ContactChatMerging: false, RichLinks: false, ChatBridgeResult: false, diff --git a/imessage/bluebubbles/interface.go b/imessage/bluebubbles/interface.go index 2f7bf312..f9c12bd4 100644 --- a/imessage/bluebubbles/interface.go +++ b/imessage/bluebubbles/interface.go @@ -17,8 +17,9 @@ const ( type MessageQueryRequest struct { // TODO Other Fields ChatGUID string `json:"chatGuid"` - Limit int64 `json:"limit"` - Offset int64 `json:"offset"` + Limit int `json:"limit"` + Max *int `json:"max"` + Offset int `json:"offset"` With []MessageQueryWith `json:"with"` Sort MessageQuerySort `json:"sort"` Before *int64 `json:"before,omitempty"` @@ -33,6 +34,9 @@ const ( MessageQueryWithAttachment ChatQueryWith = "attachment" MessageQueryWithHandle ChatQueryWith = "handle" MessageQueryWithSMS ChatQueryWith = "sms" + MessageQueryWithAttributeBody ChatQueryWith = "message.attributedBody" + MessageQueryWithMessageSummary ChatQueryWith = "message.messageSummaryInfo" + MessageQueryWithPayloadData ChatQueryWith = "message.payloadData" ) type MessageQueryResponse struct { @@ -240,6 +244,30 @@ type SendTextRequest struct { PartIndex int `json:"partIndex,omitempty"` } +type UnsendMessage struct { + PartIndex int `json:"partIndex"` +} + +type EditMessage struct { + EditedMessage string `json:"editedMessage"` + BackwwardsCompatibilityMessage string `json:"backwardsCompatibilityMessage"` + PartIndex int `json:"partIndex"` +} + +type UnsendMessageResponse struct { + Status int64 `json:"status"` + Message string `json:"message"` + Data Message `json:"data,omitempty"` + Error any `json:"error"` +} + +type EditMessageResponse struct { + Status int64 `json:"status"` + Message string `json:"message"` + Data Message `json:"data,omitempty"` + Error any `json:"error"` +} + type SendTextResponse struct { Status int64 `json:"status"` Message string `json:"message"` diff --git a/imessage/interface.go b/imessage/interface.go index 0915070d..45e96eb7 100644 --- a/imessage/interface.go +++ b/imessage/interface.go @@ -47,6 +47,11 @@ type ChatInfoAPI interface { GetGroupAvatar(chatID string) (*Attachment, error) } +type VenturaFeatures interface { + UnsendMessage(chatID, targetGUID string, targetPart int) (*SendResponse, error) + EditMessage(chatID, targetGUID string, newText string, targetPart int) (*SendResponse, error) +} + type API interface { Start(readyCallback func()) error Stop() diff --git a/imessage/struct.go b/imessage/struct.go index 4fc4e540..a26b0e4d 100644 --- a/imessage/struct.go +++ b/imessage/struct.go @@ -52,6 +52,8 @@ type Message struct { IsSent bool IsEmote bool IsAudioMessage bool `json:"is_audio_message"` + IsEdited bool + IsRetracted bool ReplyToGUID string `json:"thread_originator_guid,omitempty"` ReplyToPart int `json:"thread_originator_part,omitempty"` @@ -267,6 +269,8 @@ type ConnectorCapabilities struct { SendReadReceipts bool SendTypingNotifications bool SendCaptions bool + UnsendMessages bool + EditMessages bool BridgeState bool MessageStatusCheckpoints bool DeliveredStatus bool diff --git a/portal.go b/portal.go index 095b733c..03a00297 100644 --- a/portal.go +++ b/portal.go @@ -450,6 +450,7 @@ func (portal *Portal) markRead(intent *appservice.IntentAPI, eventID id.EventID, if intent == nil { return nil } + var extra CustomReadReceipt if intent == portal.bridge.user.DoublePuppetIntent { extra.DoublePuppetSource = portal.bridge.Name @@ -683,7 +684,7 @@ func (portal *Portal) getBridgeInfo() (string, CustomBridgeInfoContent) { } else if portal.bridge.Config.IMessage.Platform == "mac-nosip" { bridgeInfo.Protocol.ID = "imessage-nosip" } else if portal.bridge.Config.IMessage.Platform == "bluebubbles" { - bridgeInfo.Protocol.ID = "imessage-nosip" + bridgeInfo.Protocol.ID = "imessagego" } return portal.getBridgeInfoStateKey(), bridgeInfo } @@ -1229,15 +1230,43 @@ func (portal *Portal) HandleMatrixMessage(evt *event.Event) { return } + editEventID := msg.RelatesTo.GetReplaceID() + if editEventID != "" && msg.NewContent != nil { + msg = msg.NewContent + } + + var err error + var resp *imessage.SendResponse + var wasEdit bool + + if editEventID != "" { + wasEdit = true + if !portal.bridge.IM.Capabilities().EditMessages { + portal.zlog.Err(errors.ErrUnsupported).Msg("Bridge doesn't support editing messages!") + return + } + + editedMessage := portal.bridge.DB.Message.GetByMXID(editEventID) + if editedMessage == nil { + portal.zlog.Error().Msg("Failed to get message by MXID") + return + } + + if portal.bridge.IM.(imessage.VenturaFeatures) != nil { + resp, err = portal.bridge.IM.(imessage.VenturaFeatures).EditMessage(portal.getTargetGUID("message edit", evt.ID, editedMessage.HandleGUID), editedMessage.GUID, msg.Body, editedMessage.Part) + } else { + portal.zlog.Err(errors.ErrUnsupported).Msg("Bridge didn't implment EditMessage!") + return + } + } + var imessageRichLink *imessage.RichLink if portal.bridge.IM.Capabilities().RichLinks { imessageRichLink = portal.convertURLPreviewToIMessage(evt) } metadata, _ := evt.Content.Raw["com.beeper.message_metadata"].(imessage.MessageMetadata) - var err error - var resp *imessage.SendResponse - if msg.MsgType == event.MsgText || msg.MsgType == event.MsgNotice || msg.MsgType == event.MsgEmote { + if (msg.MsgType == event.MsgText || msg.MsgType == event.MsgNotice || msg.MsgType == event.MsgEmote) && !wasEdit { if evt.Sender != portal.bridge.user.MXID { portal.addRelaybotFormat(evt.Sender, msg) if len(msg.Body) == 0 { @@ -1251,6 +1280,7 @@ func (portal *Portal) HandleMatrixMessage(evt *event.Event) { } else if len(msg.URL) > 0 || msg.File != nil { resp, err = portal.handleMatrixMedia(msg, evt, messageReplyID, messageReplyPart, metadata) } + if err != nil { portal.log.Errorln("Error sending to iMessage:", err) statusCode := status.MsgStatusPermFailure @@ -1542,7 +1572,7 @@ func (portal *Portal) HandleMatrixReaction(evt *event.Event) { func (portal *Portal) HandleMatrixRedaction(evt *event.Event) { if !portal.bridge.IM.Capabilities().SendTapbacks { - portal.sendUnsupportedCheckpoint(evt, status.MsgStepRemote, errors.New("redactions are not supported")) + portal.sendUnsupportedCheckpoint(evt, status.MsgStepRemote, errors.New("reactions are not supported")) return } @@ -1554,7 +1584,7 @@ func (portal *Portal) HandleMatrixRedaction(evt *event.Event) { redactedTapback := portal.bridge.DB.Tapback.GetByMXID(evt.Redacts) if redactedTapback != nil { - portal.log.Debugln("Starting handling of Matrix redaction", evt.ID) + portal.log.Debugln("Starting handling of Matrix redaction of tapback", evt.ID) redactedTapback.Delete() _, err := portal.bridge.IM.SendTapback(portal.getTargetGUID("tapback redaction", evt.ID, redactedTapback.HandleGUID), redactedTapback.MessageGUID, redactedTapback.MessagePart, redactedTapback.Type, true) if err != nil { @@ -1568,6 +1598,37 @@ func (portal *Portal) HandleMatrixRedaction(evt *event.Event) { } return } + + if !portal.bridge.IM.Capabilities().UnsendMessages { + portal.sendUnsupportedCheckpoint(evt, status.MsgStepRemote, errors.New("redactions of messages are not supported")) + return + } + + redactedText := portal.bridge.DB.Message.GetByMXID(evt.Redacts) + if redactedText != nil { + portal.log.Debugln("Starting handling of Matrix redaction of text", evt.ID) + redactedText.Delete() + + var err error + if portal.bridge.IM.(imessage.VenturaFeatures) != nil { + _, err = portal.bridge.IM.(imessage.VenturaFeatures).UnsendMessage(portal.getTargetGUID("message redaction", evt.ID, redactedText.HandleGUID), redactedText.GUID, redactedText.Part) + } else { + portal.zlog.Err(errors.ErrUnsupported).Msg("Bridge didn't implment UnsendMessage!") + return + } + + //_, err := portal.bridge.IM.UnsendMessage(portal.getTargetGUID("message redaction", evt.ID, redactedText.HandleGUID), redactedText.GUID, redactedText.Part) + if err != nil { + portal.log.Errorfln("Failed to send unsend of message %s/%d: %v", redactedText.GUID, redactedText.Part, err) + portal.bridge.SendMessageErrorCheckpoint(evt, status.MsgStepRemote, err, true, 0) + } else { + portal.log.Debugfln("Handled Matrix redaction %s of iMessage message %s/%d", evt.ID, redactedText.GUID, redactedText.Part) + if !portal.bridge.IM.Capabilities().MessageStatusCheckpoints { + portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, 0) + } + } + return + } portal.sendUnsupportedCheckpoint(evt, status.MsgStepRemote, fmt.Errorf("can't redact non-reaction event")) } @@ -1946,7 +2007,7 @@ func (portal *Portal) convertiMessage(msg *imessage.Message, intent *appservice. return attachments } -func (portal *Portal) handleNormaliMessage(msg *imessage.Message, dbMessage *database.Message, intent *appservice.IntentAPI) { +func (portal *Portal) handleNormaliMessage(msg *imessage.Message, dbMessage *database.Message, intent *appservice.IntentAPI, mxid *id.EventID) { if msg.Metadata != nil && portal.bridge.Config.HackyStartupTest.Key != "" { if portal.bridge.Config.HackyStartupTest.EchoMode { _, ok := msg.Metadata[startupTestKey].(map[string]any) @@ -1966,16 +2027,23 @@ func (portal *Portal) handleNormaliMessage(msg *imessage.Message, dbMessage *dat portal.log.Warnfln("iMessage %s doesn't contain any attachments nor text", msg.GUID) } for index, converted := range parts { + if mxid != nil { + if len(parts) == 1 { + converted.Content.SetEdit(*mxid) + } + } portal.log.Debugfln("Sending iMessage attachment %s.%d", msg.GUID, index) resp, err := portal.sendMessage(intent, converted.Type, converted.Content, converted.Extra, dbMessage.Timestamp) if err != nil { portal.log.Errorfln("Failed to send attachment %s.%d: %v", msg.GUID, index, err) } else { portal.log.Debugfln("Handled iMessage attachment %s.%d -> %s", msg.GUID, index, resp.EventID) - dbMessage.MXID = resp.EventID - dbMessage.Part = index - dbMessage.Insert(nil) - dbMessage.Part++ + if mxid == nil { + dbMessage.MXID = resp.EventID + dbMessage.Part = index + dbMessage.Insert(nil) + dbMessage.Part++ + } } } } @@ -2035,6 +2103,7 @@ func (portal *Portal) getIntentForMessage(msg *imessage.Message, dbMessage *data func (portal *Portal) HandleiMessage(msg *imessage.Message) id.EventID { var dbMessage *database.Message var overrideSuccess bool + defer func() { if err := recover(); err != nil { portal.log.Errorfln("Panic while handling %s: %v\n%s", msg.GUID, err, string(debug.Stack())) @@ -2047,22 +2116,61 @@ func (portal *Portal) HandleiMessage(msg *imessage.Message) id.EventID { portal.bridge.IM.SendMessageBridgeResult(msg.ChatGUID, msg.GUID, eventID, overrideSuccess || hasMXID) }() + // Look up the message in the database + dbMessage = portal.bridge.DB.Message.GetLastByGUID(portal.GUID, msg.GUID) + if portal.IsPrivateChat() && msg.ChatGUID != portal.LastSeenHandle { portal.log.Debugfln("Updating last seen handle from %s to %s", portal.LastSeenHandle, msg.ChatGUID) portal.LastSeenHandle = msg.ChatGUID portal.Update(nil) } + // Handle message tapbacks if msg.Tapback != nil { portal.HandleiMessageTapback(msg) return "" - } else if portal.bridge.DB.Message.GetLastByGUID(portal.GUID, msg.GUID) != nil { - portal.log.Debugln("Ignoring duplicate message", msg.GUID) - // Send a success confirmation since it's a duplicate message - overrideSuccess = true + } + + // If the message exists in the database, handle edits or retractions + if dbMessage != nil && dbMessage.MXID != "" { + // DEVNOTE: It seems sometimes the message is just edited to remove data instead of actually retracting it + + if msg.IsRetracted || + (len(msg.Attachments) == 0 && len(msg.Text) == 0 && len(msg.Subject) == 0) { + + // Retract existing message + if portal.HandleMessageRevoke(*msg) { + portal.zlog.Debug().Str("messageGUID", msg.GUID).Str("chatGUID", msg.ChatGUID).Msg("Revoked message") + } else { + portal.zlog.Warn().Str("messageGUID", msg.GUID).Str("chatGUID", msg.ChatGUID).Msg("Failed to revoke message") + } + + overrideSuccess = true + } else if msg.IsEdited && dbMessage.Part > 0 { + + // Edit existing message + intent := portal.getIntentForMessage(msg, nil) + portal.handleNormaliMessage(msg, dbMessage, intent, &dbMessage.MXID) + + overrideSuccess = true + } else if msg.IsRead && msg.IsFromMe { + + // Send read receipt + err := portal.markRead(portal.MainIntent(), dbMessage.MXID, msg.ReadAt) + if err != nil { + portal.log.Warnfln("Failed to send read receipt for %s: %v", dbMessage.MXID, err) + } + + overrideSuccess = true + } else { + portal.log.Debugln("Ignoring duplicate message", msg.GUID) + // Send a success confirmation since it's a duplicate message + overrideSuccess = true + } return "" } + // If the message is not found in the database, proceed with handling as usual portal.log.Debugfln("Starting handling of iMessage %s (type: %d, attachments: %d, text: %d)", msg.GUID, msg.ItemType, len(msg.Attachments), len(msg.Text)) dbMessage = portal.bridge.DB.Message.New() dbMessage.PortalGUID = portal.GUID @@ -2081,7 +2189,7 @@ func (portal *Portal) HandleiMessage(msg *imessage.Message) id.EventID { switch msg.ItemType { case imessage.ItemTypeMessage: - portal.handleNormaliMessage(msg, dbMessage, intent) + portal.handleNormaliMessage(msg, dbMessage, intent, nil) case imessage.ItemTypeMember: groupUpdateEventID = portal.handleIMMemberChange(msg, dbMessage, intent) case imessage.ItemTypeName: @@ -2186,6 +2294,29 @@ func (portal *Portal) HandleiMessageTapback(msg *imessage.Message) { } } +func (portal *Portal) HandleMessageRevoke(msg imessage.Message) bool { + dbMessage := portal.bridge.DB.Message.GetLastByGUID(portal.GUID, msg.GUID) + if dbMessage == nil { + return true + } + intent := portal.getIntentForMessage(&msg, nil) + if intent == nil { + return false + } + _, err := intent.RedactEvent(portal.MXID, dbMessage.MXID) + if err != nil { + if errors.Is(err, mautrix.MForbidden) { + _, err = portal.MainIntent().RedactEvent(portal.MXID, dbMessage.MXID) + if err != nil { + portal.log.Errorln("Failed to redact %s: %v", msg.GUID, err) + } + } + } else { + dbMessage.Delete() + } + return true +} + func (portal *Portal) Delete() { portal.bridge.portalsLock.Lock() portal.unlockedDelete()