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 @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion Sources/IMsgCore/MessageStore+Messages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion Sources/IMsgCore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion Sources/imsg/OutputModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions Sources/imsg/RPCPayloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Tests/IMsgCoreTests/MessageStoreSenderFallbackTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
5 changes: 4 additions & 1 deletion Tests/imsgTests/RPCPayloadsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}
Expand Down
4 changes: 3 additions & 1 deletion Tests/imsgTests/UtilitiesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)

Expand Down
1 change: 1 addition & 0 deletions docs/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down