Skip to content

Commit 8dcb9d0

Browse files
steipetemfzeidan
andcommitted
feat: convert unsupported attachment metadata
Co-authored-by: Mark Zeidan <[email protected]>
1 parent 5203f74 commit 8dcb9d0

20 files changed

Lines changed: 509 additions & 34 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
## Unreleased
4+
- feat: optionally expose model-compatible converted attachment files for CAF/GIF metadata (#73, thanks @mfzeidan)
45
- feat: add `imsg group` chat metadata lookup and group fields to `chats --json` (#88, thanks @mryanb)
56
- fix: return best-effort message `id` and `guid` from RPC `send` responses (#85)
67
- fix: keep watch streams alive with a periodic poll fallback when filesystem events are missed (#78)

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ make build
3939
## Commands
4040
- `imsg chats [--limit 20] [--json]` — list recent conversations.
4141
- `imsg group --chat-id <id> [--json]` — show identity and participants for one chat.
42-
- `imsg history --chat-id <id> [--limit 50] [--attachments] [--participants +15551234567,...] [--start 2025-01-01T00:00:00Z] [--end 2025-02-01T00:00:00Z] [--json]`
43-
- `imsg watch [--chat-id <id>] [--since-rowid <n>] [--debounce 250ms] [--attachments] [--reactions] [--participants …] [--start …] [--end …] [--json]`
42+
- `imsg history --chat-id <id> [--limit 50] [--attachments] [--convert-attachments] [--participants +15551234567,...] [--start 2025-01-01T00:00:00Z] [--end 2025-02-01T00:00:00Z] [--json]`
43+
- `imsg watch [--chat-id <id>] [--since-rowid <n>] [--debounce 250ms] [--attachments] [--convert-attachments] [--reactions] [--participants …] [--start …] [--end …] [--json]`
4444
- `imsg send --to <handle-or-contact-name> [--text "hi"] [--file /path/file] [--service imessage|sms|auto] [--region US]`
4545
- `imsg react --chat-id <id> --reaction love|like|dislike|laugh|emphasis|question`
4646
- `imsg read --to <handle> [--chat-id <id> | --chat-identifier <id> | --chat-guid <guid>]`
@@ -91,7 +91,13 @@ imsg typing --to "+14155551212" --duration 5s
9191
```
9292

9393
## Attachment notes
94-
`--attachments` prints per-attachment lines with name, MIME, missing flag, and resolved path (tilde expanded). Only metadata is shown; files aren’t copied.
94+
`--attachments` prints per-attachment lines with name, MIME, missing flag, and resolved path (tilde expanded). By default only metadata is shown; files aren’t copied.
95+
96+
`--convert-attachments` uses `ffmpeg`, when available on `PATH`, to cache
97+
model-compatible copies for unsupported receive-side attachments: CAF audio is
98+
converted to M4A and GIF images to first-frame PNGs. The original attachment
99+
path remains unchanged; converted output is reported separately as
100+
`converted_path` / `converted_mime_type`.
95101

96102
`imsg send --file` can send regular file attachments, including audio files such as
97103
`.m4a`, through Messages.app AppleScript. Before handing the file to Messages,
@@ -111,7 +117,7 @@ send/read/typing/reaction commands that control Messages.app.
111117

112118
## JSON output
113119
`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`.
120+
`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`, `converted_path`, `converted_mime_type`, `missing`), `reactions`.
115121
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`.
116122

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

Sources/IMsgCore/AttachmentResolver.swift

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
import CryptoKit
12
import Foundation
23

34
enum AttachmentResolver {
5+
private struct ConversionPlan {
6+
let targetExtension: String
7+
let mimeType: String
8+
let arguments: (_ input: String, _ output: String) -> [String]
9+
}
10+
411
static func resolve(_ path: String) -> (resolved: String, missing: Bool) {
512
guard !path.isEmpty else { return ("", true) }
613
let expanded = (path as NSString).expandingTildeInPath
@@ -9,9 +16,159 @@ enum AttachmentResolver {
916
return (expanded, !(exists && !isDir.boolValue))
1017
}
1118

19+
static func metadata(
20+
filename: String,
21+
transferName: String,
22+
uti: String,
23+
mimeType: String,
24+
totalBytes: Int64,
25+
isSticker: Bool,
26+
options: AttachmentQueryOptions = .default
27+
) -> AttachmentMeta {
28+
let resolved = resolve(filename)
29+
let converted =
30+
options.convertUnsupported && !resolved.missing
31+
? convertUnsupportedAttachment(path: resolved.resolved, uti: uti, mimeType: mimeType)
32+
: nil
33+
return AttachmentMeta(
34+
filename: filename,
35+
transferName: transferName,
36+
uti: uti,
37+
mimeType: mimeType,
38+
totalBytes: totalBytes,
39+
isSticker: isSticker,
40+
originalPath: resolved.resolved,
41+
convertedPath: converted?.path,
42+
convertedMimeType: converted?.mimeType,
43+
missing: resolved.missing
44+
)
45+
}
46+
1247
static func displayName(filename: String, transferName: String) -> String {
1348
if !transferName.isEmpty { return transferName }
1449
if !filename.isEmpty { return filename }
1550
return "(unknown)"
1651
}
52+
53+
static func convertedURL(for sourcePath: String, targetExtension: String) -> URL {
54+
let sourceURL = URL(fileURLWithPath: sourcePath)
55+
let values = try? sourceURL.resourceValues(forKeys: [
56+
.contentModificationDateKey, .fileSizeKey,
57+
])
58+
let modification = values?.contentModificationDate?.timeIntervalSince1970 ?? 0
59+
let size = values?.fileSize ?? 0
60+
let token = "\(sourceURL.path)|\(size)|\(modification)"
61+
let digest = SHA256.hash(data: Data(token.utf8))
62+
.map { String(format: "%02x", $0) }
63+
.joined()
64+
let base = sourceURL.deletingPathExtension().lastPathComponent
65+
.components(separatedBy: CharacterSet.alphanumerics.inverted)
66+
.filter { !$0.isEmpty }
67+
.joined(separator: "-")
68+
let prefix = base.isEmpty ? "attachment" : String(base.prefix(48))
69+
return conversionCacheDirectory()
70+
.appendingPathComponent("\(prefix)-\(digest.prefix(16)).\(targetExtension)")
71+
}
72+
73+
private static func convertUnsupportedAttachment(
74+
path: String,
75+
uti: String,
76+
mimeType: String
77+
) -> (path: String, mimeType: String)? {
78+
guard let plan = conversionPlan(path: path, uti: uti, mimeType: mimeType) else {
79+
return nil
80+
}
81+
let outputURL = convertedURL(for: path, targetExtension: plan.targetExtension)
82+
if FileManager.default.fileExists(atPath: outputURL.path) {
83+
return (outputURL.path, plan.mimeType)
84+
}
85+
guard let ffmpegURL = executableURL(named: "ffmpeg") else {
86+
return nil
87+
}
88+
89+
do {
90+
try FileManager.default.createDirectory(
91+
at: outputURL.deletingLastPathComponent(),
92+
withIntermediateDirectories: true
93+
)
94+
} catch {
95+
return nil
96+
}
97+
98+
let temporaryURL = outputURL.deletingLastPathComponent()
99+
.appendingPathComponent(".\(UUID().uuidString).\(plan.targetExtension)")
100+
let process = Process()
101+
process.executableURL = ffmpegURL
102+
process.arguments = plan.arguments(path, temporaryURL.path)
103+
process.standardOutput = Pipe()
104+
process.standardError = Pipe()
105+
106+
do {
107+
try process.run()
108+
process.waitUntilExit()
109+
guard process.terminationStatus == 0,
110+
FileManager.default.fileExists(atPath: temporaryURL.path)
111+
else {
112+
try? FileManager.default.removeItem(at: temporaryURL)
113+
return nil
114+
}
115+
try? FileManager.default.removeItem(at: outputURL)
116+
try FileManager.default.moveItem(at: temporaryURL, to: outputURL)
117+
return (outputURL.path, plan.mimeType)
118+
} catch {
119+
try? FileManager.default.removeItem(at: temporaryURL)
120+
return nil
121+
}
122+
}
123+
124+
private static func conversionPlan(
125+
path: String,
126+
uti: String,
127+
mimeType: String
128+
) -> ConversionPlan? {
129+
let lowerPath = path.lowercased()
130+
let lowerUTI = uti.lowercased()
131+
let lowerMime = mimeType.lowercased()
132+
if lowerUTI == "com.apple.coreaudio-format"
133+
|| lowerPath.hasSuffix(".caf")
134+
|| lowerMime == "audio/x-caf"
135+
{
136+
return ConversionPlan(targetExtension: "m4a", mimeType: "audio/mp4") { input, output in
137+
["-nostdin", "-y", "-i", input, "-c:a", "aac", "-b:a", "128k", output]
138+
}
139+
}
140+
if lowerUTI == "com.compuserve.gif"
141+
|| lowerPath.hasSuffix(".gif")
142+
|| lowerMime == "image/gif"
143+
{
144+
return ConversionPlan(targetExtension: "png", mimeType: "image/png") { input, output in
145+
["-nostdin", "-y", "-i", input, "-vframes", "1", output]
146+
}
147+
}
148+
return nil
149+
}
150+
151+
private static func conversionCacheDirectory() -> URL {
152+
if let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
153+
return caches.appendingPathComponent("imsg/converted-attachments", isDirectory: true)
154+
}
155+
return FileManager.default.temporaryDirectory.appendingPathComponent(
156+
"imsg/converted-attachments",
157+
isDirectory: true
158+
)
159+
}
160+
161+
private static func executableURL(named name: String) -> URL? {
162+
let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
163+
let candidates =
164+
path.split(separator: ":").map(String.init)
165+
+ ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
166+
for directory in candidates {
167+
let url = URL(fileURLWithPath: directory).appendingPathComponent(name)
168+
if FileManager.default.isExecutableFile(atPath: url.path) {
169+
return url
170+
}
171+
}
172+
return nil
173+
}
17174
}

Sources/IMsgCore/MessageStore+HistoryMetadata.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ extension MessageStore {
55
private static let bulkAttachmentBatchSize = 500
66
private static let bulkReactionBatchSize = 200
77

8-
public func attachments(for messageIDs: [Int64]) throws -> [Int64: [AttachmentMeta]] {
8+
public func attachments(
9+
for messageIDs: [Int64],
10+
options: AttachmentQueryOptions = .default
11+
) throws -> [Int64: [AttachmentMeta]] {
912
let uniqueIDs = Array(Set(messageIDs)).sorted()
1013
guard !uniqueIDs.isEmpty else { return [:] }
1114

@@ -31,17 +34,15 @@ extension MessageStore {
3134
let mimeType = stringValue(row[4])
3235
let totalBytes = int64Value(row[5]) ?? 0
3336
let isSticker = boolValue(row[6])
34-
let resolved = AttachmentResolver.resolve(filename)
3537
metasByMessageID[messageID, default: []].append(
36-
AttachmentMeta(
38+
AttachmentResolver.metadata(
3739
filename: filename,
3840
transferName: transferName,
3941
uti: uti,
4042
mimeType: mimeType,
4143
totalBytes: totalBytes,
4244
isSticker: isSticker,
43-
originalPath: resolved.resolved,
44-
missing: resolved.missing
45+
options: options
4546
))
4647
}
4748
}

Sources/IMsgCore/MessageStore.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,10 @@ public final class MessageStore: @unchecked Sendable {
262262
}
263263

264264
extension MessageStore {
265-
public func attachments(for messageID: Int64) throws -> [AttachmentMeta] {
265+
public func attachments(
266+
for messageID: Int64,
267+
options: AttachmentQueryOptions = .default
268+
) throws -> [AttachmentMeta] {
266269
let sql = """
267270
SELECT a.filename, a.transfer_name, a.uti, a.mime_type, a.total_bytes, a.is_sticker
268271
FROM message_attachment_join maj
@@ -278,17 +281,15 @@ extension MessageStore {
278281
let mimeType = stringValue(row[3])
279282
let totalBytes = int64Value(row[4]) ?? 0
280283
let isSticker = boolValue(row[5])
281-
let resolved = AttachmentResolver.resolve(filename)
282284
metas.append(
283-
AttachmentMeta(
285+
AttachmentResolver.metadata(
284286
filename: filename,
285287
transferName: transferName,
286288
uti: uti,
287289
mimeType: mimeType,
288290
totalBytes: totalBytes,
289291
isSticker: isSticker,
290-
originalPath: resolved.resolved,
291-
missing: resolved.missing
292+
options: options
292293
))
293294
}
294295
return metas

Sources/IMsgCore/Models.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,8 @@ public struct AttachmentMeta: Sendable, Equatable {
365365
public let totalBytes: Int64
366366
public let isSticker: Bool
367367
public let originalPath: String
368+
public let convertedPath: String?
369+
public let convertedMimeType: String?
368370
public let missing: Bool
369371

370372
public init(
@@ -375,6 +377,8 @@ public struct AttachmentMeta: Sendable, Equatable {
375377
totalBytes: Int64,
376378
isSticker: Bool,
377379
originalPath: String,
380+
convertedPath: String? = nil,
381+
convertedMimeType: String? = nil,
378382
missing: Bool
379383
) {
380384
self.filename = filename
@@ -384,6 +388,18 @@ public struct AttachmentMeta: Sendable, Equatable {
384388
self.totalBytes = totalBytes
385389
self.isSticker = isSticker
386390
self.originalPath = originalPath
391+
self.convertedPath = convertedPath
392+
self.convertedMimeType = convertedMimeType
387393
self.missing = missing
388394
}
389395
}
396+
397+
public struct AttachmentQueryOptions: Sendable, Equatable {
398+
public static let `default` = AttachmentQueryOptions()
399+
400+
public let convertUnsupported: Bool
401+
402+
public init(convertUnsupported: Bool = false) {
403+
self.convertUnsupported = convertUnsupported
404+
}
405+
}

Sources/imsg/AttachmentDisplay.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,14 @@ func displayName(for meta: AttachmentMeta) -> String {
99
if !meta.filename.isEmpty { return meta.filename }
1010
return "(unknown)"
1111
}
12+
13+
func attachmentMetadataLine(for meta: AttachmentMeta) -> String {
14+
let name = displayName(for: meta)
15+
var line =
16+
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
17+
if let convertedPath = meta.convertedPath {
18+
let convertedMime = meta.convertedMimeType ?? ""
19+
line += " converted_mime=\(convertedMime) converted_path=\(convertedPath)"
20+
}
21+
return line
22+
}

Sources/imsg/Commands/HistoryCommand.swift

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ enum HistoryCommand {
2121
flags: [
2222
.make(
2323
label: "attachments", names: [.long("attachments")], help: "include attachment metadata"
24-
)
24+
),
25+
.make(
26+
label: "convertAttachments", names: [.long("convert-attachments")],
27+
help: "convert CAF/GIF attachments to model-compatible cached files"
28+
),
2529
]
2630
)
2731
),
@@ -46,6 +50,8 @@ enum HistoryCommand {
4650
let dbPath = values.option("db") ?? MessageStore.defaultPath
4751
let limit = values.optionInt("limit") ?? 50
4852
let showAttachments = values.flag("attachments")
53+
let attachmentOptions = AttachmentQueryOptions(
54+
convertUnsupported: values.flag("convertAttachments"))
4955
let participants = values.optionValues("participants")
5056
.flatMap { $0.split(separator: ",").map { String($0) } }
5157
.filter { !$0.isEmpty }
@@ -61,7 +67,10 @@ enum HistoryCommand {
6167

6268
if runtime.jsonOutput {
6369
let cache = ChatCache(store: store)
64-
let attachmentsByMessageID = try store.attachments(for: filtered.map(\.rowID))
70+
let attachmentsByMessageID = try store.attachments(
71+
for: filtered.map(\.rowID),
72+
options: attachmentOptions
73+
)
6574
let reactionsByMessageID = try store.reactions(for: filtered)
6675
for message in filtered {
6776
let payload = try await buildMessagePayload(
@@ -72,6 +81,7 @@ enum HistoryCommand {
7281
includeReactions: true,
7382
prefetchedAttachments: attachmentsByMessageID[message.rowID] ?? [],
7483
prefetchedReactions: reactionsByMessageID[message.rowID] ?? [],
84+
attachmentOptions: attachmentOptions,
7585
contactResolver: contacts
7686
)
7787
try JSONLines.printObject(payload)
@@ -88,12 +98,9 @@ enum HistoryCommand {
8898
StdoutWriter.writeLine("\(timestamp) [\(direction)] \(sender): \(message.text)")
8999
if message.attachmentsCount > 0 {
90100
if showAttachments {
91-
let metas = try store.attachments(for: message.rowID)
101+
let metas = try store.attachments(for: message.rowID, options: attachmentOptions)
92102
for meta in metas {
93-
let name = displayName(for: meta)
94-
StdoutWriter.writeLine(
95-
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
96-
)
103+
StdoutWriter.writeLine(attachmentMetadataLine(for: meta))
97104
}
98105
} else {
99106
StdoutWriter.writeLine(

0 commit comments

Comments
 (0)