imsg/Sources/IMsgCore/MessageStore+Messages.swift
Omar Shahine c56c24d488
feat: port BlueBubbles private-API bridge
Port the BlueBubbles-inspired IMCore bridge surface into imsg with rich sends, message mutation, chat management, account/nickname introspection, live bridge events, and v2 UUID-keyed IPC.

Fixes #60.

Co-authored-by: Omar Shahine <omarshahine@users.noreply.github.com>
2026-05-06 06:28:00 +01:00

476 lines
16 KiB
Swift

import Foundation
import SQLite
struct MessageRowColumns {
static let balloonBundleID = "balloon_bundle_id"
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
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"
)
}
}
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
}
struct MessageRowSelection {
let selectList: String
let columns: MessageRowColumns
init(store: MessageStore, includeChatID: Bool, includeBalloonBundleID: Bool = false) {
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 chatColumn = includeChatID ? ", cmj.chat_id AS \(columns.chatID!)" : ""
var 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)
"""
if includeBalloonBundleID {
let balloonColumn = schema.hasBalloonBundleIDColumn ? "m.balloon_bundle_id" : "NULL"
selectList += ",\n \(balloonColumn) AS \(MessageRowColumns.balloonBundleID)"
}
self.selectList = selectList
self.columns = columns
}
}
private struct ChatMessagesQuery {
let sql: String
let bindings: [Binding?]
let selection: MessageRowSelection
let fallbackChatID: Int64
init(store: MessageStore, chatID: ChatID, limit: Int, filter: MessageFilter?) {
self.selection = MessageRowSelection(store: store, includeChatID: false)
let destinationCallerColumn =
store.schema.hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
let reactionFilter =
store.schema.hasReactionColumns
? " AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
: ""
var 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 cmj.chat_id = ?\(reactionFilter)
"""
var bindings: [Binding?] = [chatID.rawValue]
if let filter {
if let startDate = filter.startDate {
sql += " AND m.date >= ?"
bindings.append(MessageStore.appleEpoch(startDate))
}
if let endDate = filter.endDate {
sql += " AND m.date < ?"
bindings.append(MessageStore.appleEpoch(endDate))
}
if !filter.participants.isEmpty {
let placeholders = Array(repeating: "?", count: filter.participants.count).joined(
separator: ",")
sql +=
" AND COALESCE(NULLIF(h.id,''), \(destinationCallerColumn)) COLLATE NOCASE IN (\(placeholders))"
for participant in filter.participants {
bindings.append(participant)
}
}
}
sql += " ORDER BY m.date DESC LIMIT ?"
bindings.append(limit)
self.sql = sql
self.bindings = bindings
self.fallbackChatID = chatID.rawValue
}
}
private struct MessagesAfterQuery {
let sql: String
let bindings: [Binding?]
let selection: MessageRowSelection
let fallbackChatID: Int64?
init(
store: MessageStore,
afterRowID: MessageID,
chatID: ChatID?,
limit: Int,
includeReactions: Bool
) {
self.selection = MessageRowSelection(
store: store,
includeChatID: true,
includeBalloonBundleID: true
)
let reactionFilter: String
if includeReactions || !store.schema.hasReactionColumns {
reactionFilter = ""
} else {
reactionFilter =
" AND (m.associated_message_type IS NULL OR m.associated_message_type < 2000 OR m.associated_message_type > 3006)"
}
var sql = """
SELECT \(selection.selectList)
FROM message m
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE m.ROWID > ?\(reactionFilter)
"""
var bindings: [Binding?] = [afterRowID.rawValue]
if let chatID {
sql += " AND cmj.chat_id = ?"
bindings.append(chatID.rawValue)
}
sql += " ORDER BY m.ROWID ASC LIMIT ?"
bindings.append(limit)
self.sql = sql
self.bindings = bindings
self.fallbackChatID = chatID?.rawValue
}
}
private struct LatestSentMessageQuery {
let sql: String
let bindings: [Binding?]
let selection: MessageRowSelection
let fallbackChatID: Int64?
init(store: MessageStore, text: String, chatID: ChatID?, since date: Date) {
self.selection = MessageRowSelection(store: store, includeChatID: true)
var sql = """
SELECT \(selection.selectList)
FROM message m
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE m.is_from_me = 1
AND IFNULL(m.text, '') = ?
AND m.date >= ?
"""
var bindings: [Binding?] = [text, MessageStore.appleEpoch(date)]
if let chatID {
sql += " AND cmj.chat_id = ?"
bindings.append(chatID.rawValue)
}
sql += " ORDER BY m.date DESC, m.ROWID DESC LIMIT 1"
self.sql = sql
self.bindings = bindings
self.fallbackChatID = chatID?.rawValue
}
}
extension MessageStore {
public func maxRowID() throws -> Int64 {
return try withConnection { db in
let value = try db.scalar("SELECT MAX(ROWID) FROM message")
return int64Value(value) ?? 0
}
}
public func messages(chatID: Int64, limit: Int) throws -> [Message] {
return try messages(chatID: chatID, limit: limit, filter: nil)
}
public func messages(chatID: Int64, limit: Int, filter: MessageFilter?) throws -> [Message] {
let query = ChatMessagesQuery(
store: self,
chatID: ChatID(rawValue: chatID),
limit: limit,
filter: filter
)
return try withConnection { db in
var messages: [Message] = []
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
while let row = try rows.failableNext() {
let decoded = try decodeMessageRow(
row,
columns: query.selection.columns,
fallbackChatID: query.fallbackChatID
)
let replyToGUID = replyToGUID(
associatedGuid: decoded.associatedGUID,
associatedType: decoded.associatedType
)
messages.append(
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: decoded.threadOriginatorGUID.isEmpty
? nil : decoded.threadOriginatorGUID,
destinationCallerID: decoded.destinationCallerID.isEmpty
? nil : decoded.destinationCallerID
)
))
}
return messages
}
}
public func messagesAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws -> [Message] {
return try messagesAfter(
afterRowID: afterRowID,
chatID: chatID,
limit: limit,
includeReactions: false
)
}
public func messagesAfter(
afterRowID: Int64,
chatID: Int64?,
limit: Int,
includeReactions: Bool
) throws -> [Message] {
let query = MessagesAfterQuery(
store: self,
afterRowID: MessageID(rawValue: afterRowID),
chatID: chatID.map { ChatID(rawValue: $0) },
limit: limit,
includeReactions: includeReactions
)
return try withConnection { db in
var messages: [Message] = []
let urlBalloonProvider = "com.apple.messages.URLBalloonProvider"
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
while let row = try rows.failableNext() {
let decoded = try decodeMessageRow(
row,
columns: query.selection.columns,
fallbackChatID: query.fallbackChatID
)
let balloonBundleID = try stringValue(row, MessageRowColumns.balloonBundleID)
if balloonBundleID == urlBalloonProvider,
shouldSkipURLBalloonDuplicate(
chatID: decoded.chatID,
sender: decoded.sender,
text: decoded.text,
isFromMe: decoded.isFromMe,
date: decoded.date,
rowID: decoded.rowID
)
{
continue
}
let replyToGUID = replyToGUID(
associatedGuid: decoded.associatedGUID,
associatedType: decoded.associatedType
)
let reaction = decodeReaction(
associatedType: decoded.associatedType,
associatedGUID: decoded.associatedGUID,
text: decoded.text
)
messages.append(
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: decoded.threadOriginatorGUID.isEmpty
? nil : decoded.threadOriginatorGUID,
destinationCallerID: decoded.destinationCallerID.isEmpty
? nil : decoded.destinationCallerID
),
reaction: Message.ReactionMetadata(
isReaction: reaction.isReaction,
reactionType: reaction.reactionType,
isReactionAdd: reaction.isReactionAdd,
reactedToGUID: reaction.reactedToGUID
)
))
}
return messages
}
}
public func latestSentMessage(matchingText text: String, chatID: Int64?, since date: Date)
throws -> Message?
{
guard !text.isEmpty else { return nil }
let query = LatestSentMessageQuery(
store: self,
text: text,
chatID: chatID.map { ChatID(rawValue: $0) },
since: date
)
return try withConnection { db in
let rows = try db.prepareRowIterator(query.sql, bindings: query.bindings)
guard let row = try rows.failableNext() else { return nil }
let decoded = try decodeMessageRow(
row,
columns: query.selection.columns,
fallbackChatID: query.fallbackChatID
)
let replyToGUID = replyToGUID(
associatedGuid: decoded.associatedGUID,
associatedType: decoded.associatedType
)
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: decoded.threadOriginatorGUID.isEmpty
? nil : decoded.threadOriginatorGUID,
destinationCallerID: decoded.destinationCallerID.isEmpty
? nil : decoded.destinationCallerID
)
)
}
}
func decodeMessageRow(
_ row: Row,
columns: MessageRowColumns,
fallbackChatID: Int64?
) throws -> DecodedMessageRow {
let rowID = try int64Value(row, columns.rowID) ?? 0
let resolvedChatID =
try columns.chatID.flatMap { try int64Value(row, $0) } ?? fallbackChatID ?? 0
let handleID = try int64Value(row, columns.handleID)
let sender = try stringValue(row, columns.sender)
let text = try stringValue(row, columns.text)
let date = try appleDate(from: int64Value(row, columns.date))
let isFromMe = try boolValue(row, columns.isFromMe)
let service = try stringValue(row, columns.service)
let isAudioMessage = try boolValue(row, columns.isAudioMessage)
let destinationCallerID = try stringValue(row, columns.destinationCallerID)
let guid = try stringValue(row, columns.guid)
let associatedGUID = try stringValue(row, columns.associatedGUID)
let associatedType = try intValue(row, columns.associatedType)
let attachments = try intValue(row, columns.attachments) ?? 0
let body = try dataValue(row, columns.body)
let threadOriginatorGUID = try stringValue(row, columns.threadOriginatorGUID)
var resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
if isAudioMessage, let transcription = try audioTranscription(for: rowID) {
resolvedText = transcription
}
var resolvedSender = sender
if resolvedSender.isEmpty && !destinationCallerID.isEmpty {
resolvedSender = destinationCallerID
}
return DecodedMessageRow(
rowID: rowID,
chatID: resolvedChatID,
handleID: handleID,
sender: resolvedSender,
text: resolvedText,
date: date,
isFromMe: isFromMe,
service: service,
destinationCallerID: destinationCallerID,
guid: guid,
associatedGUID: associatedGUID,
associatedType: associatedType,
attachments: attachments,
threadOriginatorGUID: threadOriginatorGUID
)
}
}