Skip to content

Commit 5203f74

Browse files
steipetejsindyregaw-leinad
committed
feat: resolve contact names
Co-authored-by: Joshua Sindy <josh@root.bz> Co-authored-by: Dan Wager <danielwager@gmail.com>
1 parent 7725473 commit 5203f74

22 files changed

Lines changed: 807 additions & 43 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
- fix: normalize IMCore typing chat lookup across `iMessage`, `SMS`, and `any` prefixes (#51, #54, #56, #58)
2323
- docs: document macOS 26 advanced IMCore injection limits (#60)
2424
- docs: add a local release helper for dispatching Homebrew tap updates (#97, thanks @dinakars777)
25+
- feat: resolve contact names in chat/message output and direct sends (#75, #77, thanks @regaw-leinad and @jsindy)
2526

2627
## 0.5.0 - 2026-02-16
2728

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ let package = Package(
2222
],
2323
linkerSettings: [
2424
.linkedFramework("ScriptingBridge"),
25+
.linkedFramework("Contacts"),
2526
]
2627
),
2728
.executableTarget(

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ A macOS Messages.app CLI to send, read, and stream iMessage/SMS (with attachment
1111
- Filters: participants, start/end time, JSON output for tooling.
1212
- Read-only DB access (`mode=ro`), no DB writes.
1313
- Event-driven watch via filesystem events, with a fallback poll for missed file events.
14+
- Optional Contacts integration resolves phone numbers and emails to names.
1415
- Optional advanced IMCore features (`typing`, `launch`, `status`) behind explicit SIP-off setup.
1516

1617
## Requirements
1718
- macOS 14+ with Messages.app signed in.
1819
- Full Disk Access for your terminal to read `~/Library/Messages/chat.db`.
1920
- Automation permission for your terminal to control Messages.app (for sending).
21+
- Contacts permission is optional; without it, raw phone numbers/emails are shown.
2022
- For SMS relay, enable “Text Message Forwarding” on your iPhone to this Mac.
2123

2224
## Install
@@ -39,7 +41,7 @@ make build
3941
- `imsg group --chat-id <id> [--json]` — show identity and participants for one chat.
4042
- `imsg history --chat-id <id> [--limit 50] [--attachments] [--participants +15551234567,...] [--start 2025-01-01T00:00:00Z] [--end 2025-02-01T00:00:00Z] [--json]`
4143
- `imsg watch [--chat-id <id>] [--since-rowid <n>] [--debounce 250ms] [--attachments] [--reactions] [--participants …] [--start …] [--end …] [--json]`
42-
- `imsg send --to <handle> [--text "hi"] [--file /path/file] [--service imessage|sms|auto] [--region US]`
44+
- `imsg send --to <handle-or-contact-name> [--text "hi"] [--file /path/file] [--service imessage|sms|auto] [--region US]`
4345
- `imsg react --chat-id <id> --reaction love|like|dislike|laugh|emphasis|question`
4446
- `imsg read --to <handle> [--chat-id <id> | --chat-identifier <id> | --chat-guid <guid>]`
4547
- `imsg typing --to <handle> [--duration 5s] [--stop true] [--service imessage|sms|auto]`
@@ -108,8 +110,8 @@ the calling terminal or parent app; Automation permission is only needed for
108110
send/read/typing/reaction commands that control Messages.app.
109111

110112
## JSON output
111-
`imsg chats --json` emits one JSON object per chat with fields: `id`, `name`, `identifier`, `service`, `last_message_at`, `guid`, `display_name`, `is_group`, `participants`.
112-
`imsg history --json` and `imsg watch --json` emit one JSON object per message with fields: `id`, `chat_id`, `chat_identifier`, `chat_guid`, `chat_name`, `participants`, `is_group`, `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`.
113+
`imsg chats --json` emits one JSON object per chat with fields: `id`, `name`, `identifier`, `service`, `last_message_at`, `guid`, `display_name`, `contact_name`, `is_group`, `participants`.
114+
`imsg history --json` and `imsg watch --json` emit one JSON object per message with fields: `id`, `chat_id`, `chat_identifier`, `chat_guid`, `chat_name`, `participants`, `is_group`, `guid`, `reply_to_guid`, `destination_caller_id`, `sender`, `sender_name`, `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`.
113115
When `watch --reactions --json` sees a tapback event, the message object also includes `is_reaction`, `reaction_type`, `reaction_emoji`, `is_reaction_add`, and `reacted_to_guid`.
114116

115117
Note: `reply_to_guid`, `destination_caller_id`, and `reactions` are read-only metadata.

Resources/imsg.entitlements

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@
44
<dict>
55
<key>com.apple.security.automation.apple-events</key>
66
<true/>
7+
<key>com.apple.security.personal-information.addressbook</key>
8+
<true/>
79
</dict>
810
</plist>
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
@preconcurrency import Contacts
2+
import Foundation
3+
4+
public struct ContactMatch: Equatable, Sendable {
5+
public let name: String
6+
public let handle: String
7+
8+
public init(name: String, handle: String) {
9+
self.name = name
10+
self.handle = handle
11+
}
12+
}
13+
14+
public protocol ContactResolving: Sendable {
15+
var contactsUnavailable: Bool { get }
16+
17+
func displayName(for handle: String) -> String?
18+
func displayNames(for handles: [String]) -> [String: String]
19+
func searchByName(_ query: String) -> [ContactMatch]
20+
}
21+
22+
public final class NoOpContactResolver: ContactResolving, Sendable {
23+
public let contactsUnavailable: Bool
24+
25+
public init(contactsUnavailable: Bool = false) {
26+
self.contactsUnavailable = contactsUnavailable
27+
}
28+
29+
public func displayName(for handle: String) -> String? { nil }
30+
public func displayNames(for handles: [String]) -> [String: String] { [:] }
31+
public func searchByName(_ query: String) -> [ContactMatch] { [] }
32+
}
33+
34+
public final class ContactResolver: ContactResolving, @unchecked Sendable {
35+
private let phoneToName: [String: String]
36+
private let emailToName: [String: String]
37+
private let contacts: [ContactRecord]
38+
private let normalizer = PhoneNumberNormalizer()
39+
private let region: String
40+
41+
public let contactsUnavailable: Bool
42+
43+
private init(
44+
phoneToName: [String: String],
45+
emailToName: [String: String],
46+
contacts: [ContactRecord],
47+
region: String
48+
) {
49+
self.phoneToName = phoneToName
50+
self.emailToName = emailToName
51+
self.contacts = contacts
52+
self.region = region
53+
self.contactsUnavailable = false
54+
}
55+
56+
public static func create(region: String = "US") async -> any ContactResolving {
57+
let store = CNContactStore()
58+
switch CNContactStore.authorizationStatus(for: .contacts) {
59+
case .authorized:
60+
return load(store: store, region: region)
61+
case .notDetermined:
62+
let granted = await requestAccess(store: store)
63+
return granted
64+
? load(store: store, region: region) : NoOpContactResolver(contactsUnavailable: true)
65+
case .denied, .restricted:
66+
return NoOpContactResolver(contactsUnavailable: true)
67+
@unknown default:
68+
return NoOpContactResolver(contactsUnavailable: true)
69+
}
70+
}
71+
72+
public func displayName(for handle: String) -> String? {
73+
let lookup = normalizedLookupHandle(handle)
74+
if lookup.contains("@") {
75+
return emailToName[lookup.lowercased()]
76+
}
77+
return phoneToName[normalizer.normalize(lookup, region: region)]
78+
}
79+
80+
public func displayNames(for handles: [String]) -> [String: String] {
81+
var resolved: [String: String] = [:]
82+
for handle in handles {
83+
if let name = displayName(for: handle) {
84+
resolved[handle] = name
85+
}
86+
}
87+
return resolved
88+
}
89+
90+
public func searchByName(_ query: String) -> [ContactMatch] {
91+
let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
92+
guard !normalizedQuery.isEmpty else { return [] }
93+
94+
var matches: [ContactMatch] = []
95+
for contact in contacts where contact.name.lowercased().contains(normalizedQuery) {
96+
if let phone = contact.phones.first {
97+
matches.append(ContactMatch(name: contact.name, handle: phone))
98+
} else if let email = contact.emails.first {
99+
matches.append(ContactMatch(name: contact.name, handle: email))
100+
}
101+
}
102+
return matches
103+
}
104+
105+
private static func requestAccess(store: CNContactStore) async -> Bool {
106+
await withCheckedContinuation { continuation in
107+
store.requestAccess(for: .contacts) { granted, _ in
108+
continuation.resume(returning: granted)
109+
}
110+
}
111+
}
112+
113+
private static func load(store: CNContactStore, region: String) -> any ContactResolving {
114+
let keysToFetch: [CNKeyDescriptor] = [
115+
CNContactGivenNameKey as CNKeyDescriptor,
116+
CNContactFamilyNameKey as CNKeyDescriptor,
117+
CNContactNicknameKey as CNKeyDescriptor,
118+
CNContactPhoneNumbersKey as CNKeyDescriptor,
119+
CNContactEmailAddressesKey as CNKeyDescriptor,
120+
]
121+
let request = CNContactFetchRequest(keysToFetch: keysToFetch)
122+
let normalizer = PhoneNumberNormalizer()
123+
var phoneToName: [String: String] = [:]
124+
var emailToName: [String: String] = [:]
125+
var contacts: [ContactRecord] = []
126+
127+
do {
128+
try store.enumerateContacts(with: request) { contact, _ in
129+
guard let name = displayName(for: contact) else { return }
130+
var phones: [String] = []
131+
var emails: [String] = []
132+
133+
for number in contact.phoneNumbers {
134+
let normalized = normalizer.normalize(number.value.stringValue, region: region)
135+
phones.append(normalized)
136+
phoneToName[normalized] = phoneToName[normalized] ?? name
137+
}
138+
for email in contact.emailAddresses {
139+
let normalized = String(email.value).lowercased()
140+
emails.append(normalized)
141+
emailToName[normalized] = emailToName[normalized] ?? name
142+
}
143+
144+
if !phones.isEmpty || !emails.isEmpty {
145+
contacts.append(ContactRecord(name: name, phones: phones, emails: emails))
146+
}
147+
}
148+
} catch {
149+
return NoOpContactResolver(contactsUnavailable: true)
150+
}
151+
152+
return ContactResolver(
153+
phoneToName: phoneToName,
154+
emailToName: emailToName,
155+
contacts: contacts,
156+
region: region
157+
)
158+
}
159+
160+
private static func displayName(for contact: CNContact) -> String? {
161+
if !contact.nickname.isEmpty {
162+
return contact.nickname
163+
}
164+
let name = [contact.givenName, contact.familyName]
165+
.filter { !$0.isEmpty }
166+
.joined(separator: " ")
167+
return name.isEmpty ? nil : name
168+
}
169+
170+
private func normalizedLookupHandle(_ handle: String) -> String {
171+
let trimmed = handle.trimmingCharacters(in: .whitespacesAndNewlines)
172+
for prefix in ["iMessage;-;", "iMessage;+;", "SMS;-;", "SMS;+;", "any;-;", "any;+;"]
173+
where trimmed.hasPrefix(prefix) {
174+
return String(trimmed.dropFirst(prefix.count))
175+
}
176+
return trimmed
177+
}
178+
}
179+
180+
private struct ContactRecord: Sendable {
181+
let name: String
182+
let phones: [String]
183+
let emails: [String]
184+
}

Sources/imsg/ChatTargetResolver.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Foundation
12
import IMsgCore
23

34
struct ChatTargetInput: Sendable {
@@ -69,4 +70,38 @@ enum ChatTargetResolver {
6970
let prefix = service == .sms ? "SMS" : "iMessage"
7071
return "\(prefix);-;\(recipient)"
7172
}
73+
74+
static func looksLikeContactName(_ recipient: String) -> Bool {
75+
let trimmed = recipient.trimmingCharacters(in: .whitespacesAndNewlines)
76+
if trimmed.isEmpty { return false }
77+
if trimmed.contains("@") { return false }
78+
if trimmed.hasPrefix("+") { return false }
79+
let phoneCharacters = CharacterSet(charactersIn: "0123456789-(). ")
80+
if trimmed.unicodeScalars.allSatisfy({ phoneCharacters.contains($0) }) {
81+
return false
82+
}
83+
return true
84+
}
85+
86+
static func resolveRecipientName(
87+
_ recipient: String,
88+
contacts: any ContactResolving
89+
) throws -> String {
90+
guard looksLikeContactName(recipient) else { return recipient }
91+
let matches = contacts.searchByName(recipient)
92+
switch matches.count {
93+
case 0:
94+
return recipient
95+
case 1:
96+
return matches[0].handle
97+
default:
98+
let details =
99+
matches
100+
.map { " \($0.name): \($0.handle)" }
101+
.joined(separator: "\n")
102+
throw IMsgError.invalidChatTarget(
103+
"Multiple contacts match \"\(recipient)\":\n\(details)\nSpecify a phone number or email instead."
104+
)
105+
}
106+
}
72107
}

Sources/imsg/Commands/ChatsCommand.swift

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,72 @@ enum ChatsCommand {
1919
"imsg chats --limit 5 --json",
2020
]
2121
) { values, runtime in
22+
try await run(values: values, runtime: runtime)
23+
}
24+
25+
static func run(
26+
values: ParsedValues,
27+
runtime: RuntimeOptions,
28+
contactResolverFactory: @escaping () async -> any ContactResolving = {
29+
await ContactResolver.create()
30+
}
31+
) async throws {
2232
let dbPath = values.option("db") ?? MessageStore.defaultPath
2333
let limit = values.optionInt("limit") ?? 20
2434
let store = try MessageStore(path: dbPath)
2535
let chats = try store.listChats(limit: limit)
36+
let contacts = await contactResolverFactory()
2637

2738
if runtime.jsonOutput {
2839
for chat in chats {
2940
let chatInfo = try store.chatInfo(chatID: chat.id)
3041
let participants = try store.participants(chatID: chat.id)
42+
let contactName = contactNameForChat(
43+
chat: chat,
44+
chatInfo: chatInfo,
45+
participants: participants,
46+
contacts: contacts
47+
)
3148
try StdoutWriter.writeJSONLine(
32-
ChatPayload(chat: chat, chatInfo: chatInfo, participants: participants))
49+
ChatPayload(
50+
chat: chat,
51+
chatInfo: chatInfo,
52+
participants: participants,
53+
contactName: contactName
54+
))
3355
}
3456
return
3557
}
3658

3759
for chat in chats {
3860
let last = CLIISO8601.format(chat.lastMessageAt)
39-
StdoutWriter.writeLine("[\(chat.id)] \(chat.name) (\(chat.identifier)) last=\(last)")
61+
let participants = try store.participants(chatID: chat.id)
62+
let contactName = contactNameForChat(
63+
chat: chat,
64+
chatInfo: nil,
65+
participants: participants,
66+
contacts: contacts
67+
)
68+
let displayName = contactName ?? chat.name
69+
StdoutWriter.writeLine("[\(chat.id)] \(displayName) (\(chat.identifier)) last=\(last)")
70+
}
71+
}
72+
73+
private static func contactNameForChat(
74+
chat: Chat,
75+
chatInfo: ChatInfo?,
76+
participants: [String],
77+
contacts: any ContactResolving
78+
) -> String? {
79+
let identifier = chatInfo?.identifier ?? chat.identifier
80+
let guid = chatInfo?.guid ?? ""
81+
guard !isGroupHandle(identifier: identifier, guid: guid) else { return nil }
82+
if let name = contacts.displayName(for: identifier) {
83+
return name
84+
}
85+
if participants.count == 1 {
86+
return contacts.displayName(for: participants[0])
4087
}
88+
return nil
4189
}
4290
}

0 commit comments

Comments
 (0)