Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

### JSON Output
- feat: expose raw Messages `balloon_bundle_id` in message JSON/RPC payloads so consumers can recognize URL-preview rows without parsing text (#137, thanks @omarshahine).
- fix: coalesce Apple URL-preview split-send rows into one logical message across history, search, and watch, with `url_preview` metadata when the preview row is folded into the text row (#141, thanks @omarshahine).

### Advanced IMCore
- fix: defer injected bridge bootstrap until after Messages startup so macOS 26 dyld constructor ordering cannot touch ObjC/Foundation/IMCore before the process is ready (#138, thanks @omarshahine).
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ Message objects include:
- `reply_to_text`, `reply_to_sender` (parent body + handle for threaded
replies and non-reaction associations, when the parent row is still in
chat.db)
- `balloon_bundle_id`, `url_preview`
- `sender`, `sender_name`, `is_from_me`, `text`, `created_at`
- `attachments`, `reactions`
- `poll` for native Apple Messages poll creation and vote rows
Expand Down
49 changes: 49 additions & 0 deletions Sources/IMsgCore/Message+URLPreview.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Foundation

extension Message {
public struct URLPreviewMetadata: Sendable, Equatable {
public let rowID: Int64
public let guid: String
public let balloonBundleID: String
public let date: Date

public init(rowID: Int64, guid: String, balloonBundleID: String, date: Date) {
self.rowID = rowID
self.guid = guid
self.balloonBundleID = balloonBundleID
self.date = date
}
}

public func withURLPreview(_ preview: URLPreviewMetadata) -> Message {
Message(
rowID: rowID,
chatID: chatID,
sender: sender,
text: text,
date: date,
isFromMe: isFromMe,
service: service,
handleID: handleID,
attachmentsCount: attachmentsCount,
guid: guid,
routing: RoutingMetadata(
replyToGUID: replyToGUID,
threadOriginatorGUID: threadOriginatorGUID,
threadOriginatorPart: threadOriginatorPart,
destinationCallerID: destinationCallerID,
replyToText: replyToText,
replyToSender: replyToSender
),
balloonBundleID: balloonBundleID,
urlPreview: preview,
reaction: ReactionMetadata(
isReaction: isReaction,
reactionType: reactionType,
isReactionAdd: isReactionAdd,
reactedToGUID: reactedToGUID
),
poll: poll
)
}
}
61 changes: 61 additions & 0 deletions Sources/IMsgCore/MessageSendStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation

public enum MessageSendState: String, Sendable, Equatable {
case pending
case sent
case delivered
case failed
}

public struct MessageSendStatus: Sendable, Equatable {
public let rowID: Int64
public let guid: String
public let service: String
public let error: Int
public let dateDelivered: Date?
public let dateRead: Date?
public let isSent: Bool
public let isDelivered: Bool
public let isFinished: Bool
public let isDelayed: Bool
public let isPrepared: Bool
public let isPendingSatelliteSend: Bool
public let wasDowngraded: Bool

public var state: MessageSendState {
if error != 0 { return .failed }
if isDelivered || dateDelivered != nil { return .delivered }
if isSent { return .sent }
return .pending
}

public init(
rowID: Int64,
guid: String,
service: String,
error: Int,
dateDelivered: Date?,
dateRead: Date?,
isSent: Bool,
isDelivered: Bool,
isFinished: Bool,
isDelayed: Bool,
isPrepared: Bool,
isPendingSatelliteSend: Bool,
wasDowngraded: Bool
) {
self.rowID = rowID
self.guid = guid
self.service = service
self.error = error
self.dateDelivered = dateDelivered
self.dateRead = dateRead
self.isSent = isSent
self.isDelivered = isDelivered
self.isFinished = isFinished
self.isDelayed = isDelayed
self.isPrepared = isPrepared
self.isPendingSatelliteSend = isPendingSatelliteSend
self.wasDowngraded = wasDowngraded
}
}
104 changes: 104 additions & 0 deletions Sources/IMsgCore/MessageStore+MessageConstruction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Foundation
import SQLite

extension MessageStore {
func message(
from decoded: DecodedMessageRow,
_ db: Connection,
parentCache: inout ReplyParentCache,
pollOptionCache: inout PollOptionTextCache
) throws -> Message {
let poll = try enrichedPollEvent(
decoded.poll,
db: db,
cache: &pollOptionCache
)
let reaction = decodeReaction(
associatedType: decoded.associatedType,
associatedGUID: decoded.associatedGUID,
text: decoded.text
)
let replyToGUID = routedReplyToGUID(decoded)
let threadOriginatorGUID =
reaction.isReaction || decoded.threadOriginatorGUID.isEmpty
? nil : decoded.threadOriginatorGUID
let threadOriginatorPart =
reaction.isReaction || decoded.threadOriginatorPart.isEmpty
? nil : decoded.threadOriginatorPart
let parent =
reaction.isReaction
? nil
: enrichedReplyContext(
db,
replyToGUID: replyToGUID,
threadOriginatorGUID: threadOriginatorGUID,
cache: &parentCache
)

return Message(
rowID: decoded.rowID,
chatID: decoded.chatID,
sender: decoded.sender,
text: decoded.text,
date: decoded.date,
isFromMe: decoded.isFromMe,
service: decoded.service,
handleID: decoded.handleID,
attachmentsCount: decoded.attachments,
guid: decoded.guid,
routing: Message.RoutingMetadata(
replyToGUID: replyToGUID,
threadOriginatorGUID: threadOriginatorGUID,
threadOriginatorPart: threadOriginatorPart,
destinationCallerID: decoded.destinationCallerID.isEmpty
? nil : decoded.destinationCallerID,
replyToText: parent?.text,
replyToSender: parent?.sender
),
balloonBundleID: decoded.balloonBundleID.isEmpty ? nil : decoded.balloonBundleID,
reaction: Message.ReactionMetadata(
isReaction: reaction.isReaction,
reactionType: reaction.reactionType,
isReactionAdd: reaction.isReactionAdd,
reactedToGUID: reaction.reactedToGUID
),
poll: poll
)
}

func precedingTextMessageForURLPreview(_ preview: Message, db: Connection) throws -> Message? {
guard isURLPreviewBalloon(preview) else { return nil }
let selection = MessageRowSelection(store: self, includeChatID: true)
let reactionFilter =
schema.hasReactionColumns
? "AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
: ""
let sql = """
SELECT \(selection.selectList)
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE m.ROWID < ?
AND cmj.chat_id = ?
\(reactionFilter)
ORDER BY m.ROWID DESC
LIMIT 1
"""
let rows = try db.prepareRowIterator(sql, bindings: [preview.rowID, preview.chatID])
guard let row = try rows.failableNext() else { return nil }
let decoded = try decodeMessageRow(
row,
columns: selection.columns,
fallbackChatID: preview.chatID
)
var parentCache: ReplyParentCache = [:]
var pollOptionCache = PollOptionTextCache()
let previous = try message(
from: decoded,
db,
parentCache: &parentCache,
pollOptionCache: &pollOptionCache
)
return canCoalesceURLPreview(textMessage: previous, previewMessage: preview) ? previous : nil
}
}
155 changes: 155 additions & 0 deletions Sources/IMsgCore/MessageStore+MessageRows.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import Foundation

struct MessageRowColumns {
static let balloonBundleID = "balloon_bundle_id"
static let payloadData = "payload_data"
static let messageSummaryInfo = "message_summary_info"

let rowID: String
let chatID: String?
let handleID: String
let sender: String
let text: String
let date: String
let isFromMe: String
let service: String
let isAudioMessage: String
let destinationCallerID: String
let guid: String
let associatedGUID: String
let associatedType: String
let attachments: String
let body: String
let threadOriginatorGUID: String
let threadOriginatorPart: String
let replyToGUID: String
let balloonBundleID: String
let payloadData: String
let messageSummaryInfo: String

static func message(chatID: String?) -> MessageRowColumns {
MessageRowColumns(
rowID: "message_rowid",
chatID: chatID,
handleID: "handle_id",
sender: "sender",
text: "text",
date: "date",
isFromMe: "is_from_me",
service: "service",
isAudioMessage: "is_audio_message",
destinationCallerID: "destination_caller_id",
guid: "guid",
associatedGUID: "associated_guid",
associatedType: "associated_type",
attachments: "attachments",
body: "body",
threadOriginatorGUID: "thread_originator_guid",
threadOriginatorPart: "thread_originator_part",
replyToGUID: "reply_to_guid",
balloonBundleID: MessageRowColumns.balloonBundleID,
payloadData: MessageRowColumns.payloadData,
messageSummaryInfo: MessageRowColumns.messageSummaryInfo
)
}
}

struct DecodedMessageRow {
let rowID: Int64
let chatID: Int64
let handleID: Int64?
let sender: String
let text: String
let date: Date
let isFromMe: Bool
let service: String
let destinationCallerID: String
let guid: String
let associatedGUID: String
let associatedType: Int?
let attachments: Int
let threadOriginatorGUID: String
let threadOriginatorPart: String
let databaseReplyToGUID: String
let balloonBundleID: String
let poll: MessagePollEvent?
}

struct PollOptionTextCache {
var optionsByPollGUID: [String: [String: String]] = [:]
var missingPollGUIDs = Set<String>()
}

struct MessagesAfterBatch {
let messages: [Message]
let maxScannedRowID: Int64
}

struct MessageRowSelection {
let selectList: String
let columns: MessageRowColumns

init(store: MessageStore, includeChatID: Bool) {
let columns = MessageRowColumns.message(chatID: includeChatID ? "chat_id" : nil)
let schema = store.schema
let bodyColumn = schema.hasAttributedBody ? "m.attributedBody" : "NULL"
let guidColumn = schema.hasReactionColumns ? "m.guid" : "NULL"
let associatedGuidColumn = schema.hasReactionColumns ? "m.associated_message_guid" : "NULL"
let associatedTypeColumn = schema.hasReactionColumns ? "m.associated_message_type" : "NULL"
let destinationCallerColumn =
schema.hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
let audioMessageColumn = schema.hasAudioMessageColumn ? "m.is_audio_message" : "0"
let threadOriginatorColumn =
schema.hasThreadOriginatorGUIDColumn ? "m.thread_originator_guid" : "NULL"
let threadOriginatorPartColumn =
schema.hasThreadOriginatorPartColumn ? "m.thread_originator_part" : "NULL"
let replyToColumn = schema.hasReplyToGUIDColumn ? "m.reply_to_guid" : "NULL"
let balloonColumn = schema.hasBalloonBundleIDColumn ? "m.balloon_bundle_id" : "NULL"
let pollCandidatePredicate = Self.pollCandidatePredicate(schema: schema)
let payloadDataColumn =
schema.hasPayloadDataColumn
? "CASE WHEN \(pollCandidatePredicate) THEN m.payload_data ELSE NULL END" : "NULL"
let summaryInfoColumn =
schema.hasMessageSummaryInfoColumn
? "CASE WHEN \(pollCandidatePredicate) THEN m.message_summary_info ELSE NULL END" : "NULL"
let chatColumn = includeChatID ? ", cmj.chat_id AS \(columns.chatID!)" : ""

let selectList = """
m.ROWID AS \(columns.rowID)\(chatColumn), m.handle_id AS \(columns.handleID),
h.id AS \(columns.sender), IFNULL(m.text, '') AS \(columns.text),
m.date AS \(columns.date), m.is_from_me AS \(columns.isFromMe),
m.service AS \(columns.service),
\(audioMessageColumn) AS \(columns.isAudioMessage),
\(destinationCallerColumn) AS \(columns.destinationCallerID),
\(guidColumn) AS \(columns.guid), \(associatedGuidColumn) AS \(columns.associatedGUID),
\(associatedTypeColumn) AS \(columns.associatedType),
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS \(columns.attachments),
\(bodyColumn) AS \(columns.body),
\(threadOriginatorColumn) AS \(columns.threadOriginatorGUID),
\(threadOriginatorPartColumn) AS \(columns.threadOriginatorPart),
\(replyToColumn) AS \(columns.replyToGUID),
\(balloonColumn) AS \(columns.balloonBundleID),
\(payloadDataColumn) AS \(columns.payloadData),
\(summaryInfoColumn) AS \(columns.messageSummaryInfo)
"""
self.selectList = selectList
self.columns = columns
}

private static func pollCandidatePredicate(schema: MessageStoreSchema) -> String {
let pollBundle = sqlStringLiteral(MessagePollDecoder.pollsBundleIdentifier)
let pollBalloonPredicate =
schema.hasBalloonBundleIDColumn
? "(m.balloon_bundle_id = \(pollBundle) OR m.balloon_bundle_id LIKE '%:' || \(pollBundle))"
: "0"
let votePredicate =
schema.hasReactionColumns
? "m.associated_message_type = \(MessagePollDecoder.voteAssociatedMessageType)"
: "0"
return "(\(pollBalloonPredicate) OR \(votePredicate))"
}

private static func sqlStringLiteral(_ value: String) -> String {
"'\(value.replacingOccurrences(of: "'", with: "''"))'"
}
}
Loading