From 2262e16ed120eb4a21b8eef7821fb6e92a7fe047 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 30 Jan 2026 21:59:57 -0500 Subject: [PATCH] feat: expose destination_caller_id in message output This field helps distinguish between messages actually sent by the local user vs messages received on a secondary phone number registered with the same Apple ID. When is_from_me is true but destination_caller_id differs from the user's own numbers, the message was actually received from another device/person messaging that secondary number. This enables tools like Clawdbot to properly detect inbound messages on secondary iMessage phone numbers. --- CHANGELOG.md | 1 + README.md | 4 ++-- Sources/IMsgCore/MessageStore+Messages.swift | 4 +++- Sources/IMsgCore/Models.swift | 8 +++++++- Sources/imsg/OutputModels.swift | 8 +++++++- Sources/imsg/RPCPayloads.swift | 3 +++ Tests/IMsgCoreTests/MessageStoreSenderFallbackTests.swift | 1 + Tests/imsgTests/RPCPayloadsTests.swift | 5 ++++- Tests/imsgTests/UtilitiesTests.swift | 4 +++- docs/rpc.md | 1 + 10 files changed, 32 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a8f224..99cb4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - fix: apply history filters before limit (#20, thanks @tommybananas) - fix: flush watch output immediately when stdout is buffered (#43, thanks @ccaum) - feat: include `thread_originator_guid` in message output (#39, thanks @ruthmade) +- feat: expose `destination_caller_id` in message output (#29, thanks @commander-alexander) - fix: detect groups from `;+;` prefix in guid/identifier for RPC payloads (#42, thanks @shivshil) ## 0.4.0 - 2026-01-07 diff --git a/README.md b/README.md index 424497a..b4cdf94 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,9 @@ imsg send --to "+14155551212" --text "hi" --file ~/Desktop/pic.jpg --service ime ## JSON output `imsg chats --json` emits one JSON object per chat with fields: `id`, `name`, `identifier`, `service`, `last_message_at`. -`imsg history --json` and `imsg watch --json` emit one JSON object per message with fields: `id`, `chat_id`, `guid`, `reply_to_guid`, `sender`, `is_from_me`, `text`, `created_at`, `attachments` (array of metadata with `filename`, `transfer_name`, `uti`, `mime_type`, `total_bytes`, `is_sticker`, `original_path`, `missing`), `reactions`. +`imsg history --json` and `imsg watch --json` emit one JSON object per message with fields: `id`, `chat_id`, `guid`, `reply_to_guid`, `destination_caller_id`, `sender`, `is_from_me`, `text`, `created_at`, `attachments` (array of metadata with `filename`, `transfer_name`, `uti`, `mime_type`, `total_bytes`, `is_sticker`, `original_path`, `missing`), `reactions`. -Note: `reply_to_guid` and `reactions` are read-only metadata. +Note: `reply_to_guid`, `destination_caller_id`, and `reactions` are read-only metadata. ## Permissions troubleshooting If you see “unable to open database file” or empty output: diff --git a/Sources/IMsgCore/MessageStore+Messages.swift b/Sources/IMsgCore/MessageStore+Messages.swift index 98e2721..e053a55 100644 --- a/Sources/IMsgCore/MessageStore+Messages.swift +++ b/Sources/IMsgCore/MessageStore+Messages.swift @@ -115,7 +115,8 @@ extension MessageStore { attachmentsCount: attachments, guid: guid, replyToGUID: replyToGUID, - threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID + threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID, + destinationCallerID: destinationCallerID.isEmpty ? nil : destinationCallerID )) } return messages @@ -241,6 +242,7 @@ extension MessageStore { guid: guid, replyToGUID: replyToGUID, threadOriginatorGUID: threadOriginatorGUID.isEmpty ? nil : threadOriginatorGUID, + destinationCallerID: destinationCallerID.isEmpty ? nil : destinationCallerID, isReaction: isReactionEvent, reactionType: reactionType, isReactionAdd: isReactionAdd, diff --git a/Sources/IMsgCore/Models.swift b/Sources/IMsgCore/Models.swift index da4b9aa..0408737 100644 --- a/Sources/IMsgCore/Models.swift +++ b/Sources/IMsgCore/Models.swift @@ -229,7 +229,11 @@ public struct Message: Sendable, Equatable { public let service: String public let handleID: Int64? public let attachmentsCount: Int - + /// The destination_caller_id from the database. For messages where is_from_me is true, + /// this can help distinguish between messages actually sent by the local user vs + /// messages received on a secondary phone number registered with the same Apple ID. + public let destinationCallerID: String? + // Reaction metadata (populated when message is a reaction event) /// Whether this message is a reaction event (tapback add/remove) public let isReaction: Bool @@ -253,6 +257,7 @@ public struct Message: Sendable, Equatable { guid: String = "", replyToGUID: String? = nil, threadOriginatorGUID: String? = nil, + destinationCallerID: String? = nil, isReaction: Bool = false, reactionType: ReactionType? = nil, isReactionAdd: Bool? = nil, @@ -270,6 +275,7 @@ public struct Message: Sendable, Equatable { self.service = service self.handleID = handleID self.attachmentsCount = attachmentsCount + self.destinationCallerID = destinationCallerID self.isReaction = isReaction self.reactionType = reactionType self.isReactionAdd = isReactionAdd diff --git a/Sources/imsg/OutputModels.swift b/Sources/imsg/OutputModels.swift index f56cdd2..9d66967 100644 --- a/Sources/imsg/OutputModels.swift +++ b/Sources/imsg/OutputModels.swift @@ -37,7 +37,11 @@ struct MessagePayload: Codable { let createdAt: String let attachments: [AttachmentPayload] let reactions: [ReactionPayload] - + /// The destination_caller_id from the database. For messages where is_from_me is true, + /// this can help distinguish between messages actually sent by the local user vs + /// messages received on a secondary phone number registered with the same Apple ID. + let destinationCallerID: String? + // Reaction event metadata (populated when this message is a reaction event) let isReaction: Bool? let reactionType: String? @@ -57,6 +61,7 @@ struct MessagePayload: Codable { self.createdAt = CLIISO8601.format(message.date) self.attachments = attachments.map { AttachmentPayload(meta: $0) } self.reactions = reactions.map { ReactionPayload(reaction: $0) } + self.destinationCallerID = message.destinationCallerID // Reaction event metadata if message.isReaction { @@ -86,6 +91,7 @@ struct MessagePayload: Codable { case createdAt = "created_at" case attachments case reactions + case destinationCallerID = "destination_caller_id" case isReaction = "is_reaction" case reactionType = "reaction_type" case reactionEmoji = "reaction_emoji" diff --git a/Sources/imsg/RPCPayloads.swift b/Sources/imsg/RPCPayloads.swift index de428c4..68efbb8 100644 --- a/Sources/imsg/RPCPayloads.swift +++ b/Sources/imsg/RPCPayloads.swift @@ -51,6 +51,9 @@ func messagePayload( if let replyToGUID = message.replyToGUID, !replyToGUID.isEmpty { payload["reply_to_guid"] = replyToGUID } + if let destinationCallerID = message.destinationCallerID, !destinationCallerID.isEmpty { + payload["destination_caller_id"] = destinationCallerID + } // Add reaction event metadata if this message is a reaction if message.isReaction { payload["is_reaction"] = true diff --git a/Tests/IMsgCoreTests/MessageStoreSenderFallbackTests.swift b/Tests/IMsgCoreTests/MessageStoreSenderFallbackTests.swift index 00c9d42..f06dfd6 100644 --- a/Tests/IMsgCoreTests/MessageStoreSenderFallbackTests.swift +++ b/Tests/IMsgCoreTests/MessageStoreSenderFallbackTests.swift @@ -60,4 +60,5 @@ func messagesUseDestinationCallerIDWhenSenderMissing() throws { let store = try MessageStore(connection: db, path: ":memory:") let messages = try store.messages(chatID: 1, limit: 5) #expect(messages.first?.sender == "me@icloud.com") + #expect(messages.first?.destinationCallerID == "me@icloud.com") } diff --git a/Tests/imsgTests/RPCPayloadsTests.swift b/Tests/imsgTests/RPCPayloadsTests.swift index f6da860..0560a9a 100644 --- a/Tests/imsgTests/RPCPayloadsTests.swift +++ b/Tests/imsgTests/RPCPayloadsTests.swift @@ -43,7 +43,8 @@ func messagePayloadIncludesChatFields() { attachmentsCount: 1, guid: "msg-guid-5", replyToGUID: "msg-guid-1", - threadOriginatorGUID: "thread-guid-5" + threadOriginatorGUID: "thread-guid-5", + destinationCallerID: "me@icloud.com" ) let chatInfo = ChatInfo( id: 10, @@ -80,6 +81,7 @@ func messagePayloadIncludesChatFields() { #expect(payload["chat_id"] as? Int64 == 10) #expect(payload["guid"] as? String == "msg-guid-5") #expect(payload["reply_to_guid"] as? String == "msg-guid-1") + #expect(payload["destination_caller_id"] as? String == "me@icloud.com") #expect(payload["thread_originator_guid"] as? String == "thread-guid-5") #expect(payload["chat_identifier"] as? String == "iMessage;+;chat123") #expect(payload["chat_name"] as? String == "Group") @@ -113,6 +115,7 @@ func messagePayloadOmitsEmptyReplyToGuid() { reactions: [] ) #expect(payload["reply_to_guid"] == nil) + #expect(payload["destination_caller_id"] == nil) #expect(payload["thread_originator_guid"] == nil) #expect(payload["guid"] as? String == "msg-guid-6") } diff --git a/Tests/imsgTests/UtilitiesTests.swift b/Tests/imsgTests/UtilitiesTests.swift index 753db1e..9458c0e 100644 --- a/Tests/imsgTests/UtilitiesTests.swift +++ b/Tests/imsgTests/UtilitiesTests.swift @@ -84,7 +84,8 @@ func outputModelsEncodeExpectedKeys() throws { attachmentsCount: 0, guid: "msg-guid-7", replyToGUID: "msg-guid-1", - threadOriginatorGUID: "thread-guid-7" + threadOriginatorGUID: "thread-guid-7", + destinationCallerID: "me@icloud.com" ) let attachment = AttachmentMeta( filename: "file.dat", @@ -111,6 +112,7 @@ func outputModelsEncodeExpectedKeys() throws { #expect(messageObject?["chat_id"] as? Int64 == 1) #expect(messageObject?["guid"] as? String == "msg-guid-7") #expect(messageObject?["reply_to_guid"] as? String == "msg-guid-1") + #expect(messageObject?["destination_caller_id"] as? String == "me@icloud.com") #expect(messageObject?["thread_originator_guid"] as? String == "thread-guid-7") #expect(messageObject?["created_at"] != nil) diff --git a/docs/rpc.md b/docs/rpc.md index 916aa29..c5e8510 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -80,6 +80,7 @@ Result: - `chat_id` (always present; preferred handle for routing) - `guid` (string) - `reply_to_guid` (string, optional) +- `destination_caller_id` (string, optional) - `sender` - `is_from_me` - `text`