Skip to content

Commit e223709

Browse files
committed
feat: expose message balloon bundle metadata
1 parent 041b406 commit e223709

7 files changed

Lines changed: 69 additions & 1 deletion

File tree

Sources/IMsgCore/MessageStore+Messages.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ struct DecodedMessageRow {
7272
let threadOriginatorGUID: String
7373
let threadOriginatorPart: String
7474
let databaseReplyToGUID: String
75+
let balloonBundleID: String
7576
let poll: MessagePollEvent?
7677
}
7778

@@ -217,6 +218,7 @@ extension MessageStore {
217218
replyToText: parent?.text,
218219
replyToSender: parent?.sender
219220
),
221+
balloonBundleID: decoded.balloonBundleID.isEmpty ? nil : decoded.balloonBundleID,
220222
poll: poll
221223
))
222224
}
@@ -322,6 +324,7 @@ extension MessageStore {
322324
replyToText: parent?.text,
323325
replyToSender: parent?.sender
324326
),
327+
balloonBundleID: decoded.balloonBundleID.isEmpty ? nil : decoded.balloonBundleID,
325328
reaction: Message.ReactionMetadata(
326329
isReaction: reaction.isReaction,
327330
reactionType: reaction.reactionType,
@@ -395,6 +398,7 @@ extension MessageStore {
395398
replyToText: parent?.text,
396399
replyToSender: parent?.sender
397400
),
401+
balloonBundleID: decoded.balloonBundleID.isEmpty ? nil : decoded.balloonBundleID,
398402
poll: poll
399403
)
400404
}
@@ -508,6 +512,7 @@ extension MessageStore {
508512
threadOriginatorGUID: threadOriginatorGUID,
509513
threadOriginatorPart: threadOriginatorPart,
510514
databaseReplyToGUID: databaseReplyToGUID,
515+
balloonBundleID: balloonBundleID,
511516
poll: poll
512517
)
513518
}

Sources/IMsgCore/MessageStore+Search.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ extension MessageStore {
102102
replyToText: parent?.text,
103103
replyToSender: parent?.sender
104104
),
105+
balloonBundleID: decoded.balloonBundleID.isEmpty ? nil : decoded.balloonBundleID,
105106
poll: poll
106107
))
107108
}

Sources/IMsgCore/Models.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,10 @@ public struct Message: Sendable, Equatable {
315315
/// this can help distinguish between messages actually sent by the local user vs
316316
/// messages received on a secondary phone number registered with the same Apple ID.
317317
public let destinationCallerID: String?
318+
/// Raw Messages `message.balloon_bundle_id`, when present. Consumers can use
319+
/// Apple-owned bundle identifiers such as URLBalloonProvider as structural
320+
/// metadata instead of inferring message shape from user text.
321+
public let balloonBundleID: String?
318322
/// Native Messages Polls metadata when the row is a Polls extension balloon
319323
/// or a Polls vote update.
320324
public let poll: MessagePollEvent?
@@ -341,6 +345,7 @@ public struct Message: Sendable, Equatable {
341345
attachmentsCount: Int,
342346
guid: String = "",
343347
routing: RoutingMetadata = RoutingMetadata(),
348+
balloonBundleID: String? = nil,
344349
reaction: ReactionMetadata = ReactionMetadata(),
345350
poll: MessagePollEvent? = nil
346351
) {
@@ -360,6 +365,7 @@ public struct Message: Sendable, Equatable {
360365
self.handleID = handleID
361366
self.attachmentsCount = attachmentsCount
362367
self.destinationCallerID = routing.destinationCallerID
368+
self.balloonBundleID = balloonBundleID
363369
self.poll = poll
364370
self.isReaction = reaction.isReaction
365371
self.reactionType = reaction.reactionType
@@ -382,6 +388,7 @@ public struct Message: Sendable, Equatable {
382388
threadOriginatorGUID: String? = nil,
383389
threadOriginatorPart: String? = nil,
384390
destinationCallerID: String? = nil,
391+
balloonBundleID: String? = nil,
385392
replyToText: String? = nil,
386393
replyToSender: String? = nil,
387394
isReaction: Bool = false,
@@ -409,6 +416,7 @@ public struct Message: Sendable, Equatable {
409416
replyToText: replyToText,
410417
replyToSender: replyToSender
411418
),
419+
balloonBundleID: balloonBundleID,
412420
reaction: ReactionMetadata(
413421
isReaction: isReaction,
414422
reactionType: reactionType,

Sources/imsg/OutputModels.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ struct MessagePayload: Codable {
7777
/// this can help distinguish between messages actually sent by the local user vs
7878
/// messages received on a secondary phone number registered with the same Apple ID.
7979
let destinationCallerID: String?
80+
let balloonBundleID: String?
8081
let poll: MessagePollEvent?
8182

8283
// Reaction event metadata (populated when this message is a reaction event)
@@ -111,6 +112,7 @@ struct MessagePayload: Codable {
111112
ReactionPayload(reaction: $0, senderName: reactionSenderNames[$0.rowID])
112113
}
113114
self.destinationCallerID = message.destinationCallerID
115+
self.balloonBundleID = message.balloonBundleID
114116
self.poll = message.poll
115117

116118
// Reaction event metadata
@@ -146,6 +148,7 @@ struct MessagePayload: Codable {
146148
case attachments
147149
case reactions
148150
case destinationCallerID = "destination_caller_id"
151+
case balloonBundleID = "balloon_bundle_id"
149152
case poll
150153
case isReaction = "is_reaction"
151154
case reactionType = "reaction_type"

Tests/IMsgCoreTests/MessageStoreTests.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ func messagesAfterDeduplicatesURLBalloonsAcrossPolls() throws {
170170
let store = try MessageStore(connection: db, path: ":memory:")
171171
let firstPoll = try store.messagesAfter(afterRowID: 0, chatID: 1, limit: 10)
172172
#expect(firstPoll.map(\.rowID) == [1])
173+
#expect(firstPoll.first?.balloonBundleID == "com.apple.messages.URLBalloonProvider")
173174

174175
try db.run(
175176
"""
@@ -200,6 +201,53 @@ func messagesAfterDeduplicatesURLBalloonsAcrossPolls() throws {
200201

201202
let thirdPoll = try store.messagesAfter(afterRowID: 1, chatID: 1, limit: 10)
202203
#expect(thirdPoll.map(\.rowID) == [3])
204+
#expect(thirdPoll.first?.balloonBundleID == "com.apple.messages.URLBalloonProvider")
205+
}
206+
207+
@Test
208+
func messagesByChatPreservesBalloonBundleID() throws {
209+
let db = try makeInMemoryMessageDB(includeBalloonBundleID: true)
210+
let now = Date()
211+
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
212+
try db.run(
213+
"""
214+
INSERT INTO message(
215+
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
216+
balloon_bundle_id, date, is_from_me, service
217+
)
218+
VALUES (1, 1, 'https://example.com', 'msg-guid-1', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage')
219+
""",
220+
TestDatabase.appleEpoch(now)
221+
)
222+
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
223+
224+
let store = try MessageStore(connection: db, path: ":memory:")
225+
let messages = try store.messages(chatID: 1, limit: 10)
226+
#expect(messages.map(\.rowID) == [1])
227+
#expect(messages.first?.balloonBundleID == "com.apple.messages.URLBalloonProvider")
228+
}
229+
230+
@Test
231+
func searchMessagesPreservesBalloonBundleID() throws {
232+
let db = try makeInMemoryMessageDB(includeBalloonBundleID: true)
233+
let now = Date()
234+
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
235+
try db.run(
236+
"""
237+
INSERT INTO message(
238+
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
239+
balloon_bundle_id, date, is_from_me, service
240+
)
241+
VALUES (1, 1, 'https://example.com/search', 'msg-guid-1', NULL, 0, 'com.apple.messages.URLBalloonProvider', ?, 0, 'iMessage')
242+
""",
243+
TestDatabase.appleEpoch(now)
244+
)
245+
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
246+
247+
let store = try MessageStore(connection: db, path: ":memory:")
248+
let messages = try store.searchMessages(query: "example.com/search", match: "contains", limit: 10)
249+
#expect(messages.map(\.rowID) == [1])
250+
#expect(messages.first?.balloonBundleID == "com.apple.messages.URLBalloonProvider")
203251
}
204252

205253
@Test

Tests/imsgTests/WatchCommandTests.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ func watchCommandRunsWithJsonOutput() async throws {
9797
isFromMe: false,
9898
service: "iMessage",
9999
handleID: nil,
100-
attachmentsCount: 0
100+
attachmentsCount: 0,
101+
balloonBundleID: "com.apple.messages.URLBalloonProvider"
101102
)
102103
let (output, _) = try await StdoutCapture.capture {
103104
try await WatchCommand.run(
@@ -114,6 +115,7 @@ func watchCommandRunsWithJsonOutput() async throws {
114115
#expect(payload["chat_guid"] as? String == "iMessage;+;chat123")
115116
#expect(payload["chat_name"] as? String == "Group Chat")
116117
#expect(payload["participants"] as? [String] == ["+123", "me@icloud.com"])
118+
#expect(payload["balloon_bundle_id"] as? String == "com.apple.messages.URLBalloonProvider")
117119
}
118120

119121
@Test

docs/json.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Returned by `imsg history`, `imsg watch`, and the JSON-RPC `messages.history` an
4949
| `guid` | string | Message GUID. Stable across machines. |
5050
| `reply_to_guid` | string | When set, this message is an inline reply to that GUID. |
5151
| `destination_caller_id` | string | Outgoing only — which of your numbers Messages routed through. |
52+
| `balloon_bundle_id` | string | Raw Messages `message.balloon_bundle_id`, when present. URL preview rows use `com.apple.messages.URLBalloonProvider`, which lets consumers recognize link-preview payload rows without inferring from message text. |
5253
| `sender` | string | Raw handle. Empty for some self-sent messages. |
5354
| `sender_name` | string | Resolved Contacts name when permission granted. |
5455
| `is_from_me` | bool | True for outbound. |

0 commit comments

Comments
 (0)