fix: return RPC send message identifiers

This commit is contained in:
Peter Steinberger 2026-05-04 07:25:33 +01:00
parent 60ed8a9f02
commit af6fd822c1
No known key found for this signature in database
7 changed files with 363 additions and 14 deletions

View File

@ -2,6 +2,7 @@
## Unreleased
- feat: add `imsg group` chat metadata lookup and group fields to `chats --json` (#88, thanks @mryanb)
- fix: return best-effort message `id` and `guid` from RPC `send` responses (#85)
- fix: expose RPC watch debounce and default it to 500ms to reduce outbound echo races (#72, #80)
- fix: speed up chat listing by using `chat_message_join.message_date` when available (#76, thanks @tmad4000)
- fix: speed up JSON history metadata lookups by batching attachments and reactions (#81, thanks @kacy)

View File

@ -282,6 +282,88 @@ extension MessageStore {
}
}
public func latestSentMessage(matchingText text: String, chatID: Int64?, since date: Date)
throws -> Message?
{
guard !text.isEmpty else { return nil }
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
let guidColumn = hasReactionColumns ? "m.guid" : "NULL"
let associatedGuidColumn = hasReactionColumns ? "m.associated_message_guid" : "NULL"
let associatedTypeColumn = hasReactionColumns ? "m.associated_message_type" : "NULL"
let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
let audioMessageColumn = hasAudioMessageColumn ? "m.is_audio_message" : "0"
let threadOriginatorColumn =
hasThreadOriginatorGUIDColumn ? "m.thread_originator_guid" : "NULL"
var sql = """
SELECT m.ROWID, cmj.chat_id, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
\(audioMessageColumn) AS is_audio_message, \(destinationCallerColumn) AS destination_caller_id,
\(guidColumn) AS guid, \(associatedGuidColumn) AS associated_guid, \(associatedTypeColumn) AS associated_type,
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
\(bodyColumn) AS body,
\(threadOriginatorColumn) AS thread_originator_guid
FROM message m
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE m.is_from_me = 1
AND IFNULL(m.text, '') = ?
AND m.date >= ?
"""
var bindings: [Binding?] = [text, MessageStore.appleEpoch(date)]
if let chatID {
sql += " AND cmj.chat_id = ?"
bindings.append(chatID)
}
sql += " ORDER BY m.date DESC, m.ROWID DESC LIMIT 1"
let columns = MessageRowColumns(
rowID: 0,
chatID: 1,
handleID: 2,
sender: 3,
text: 4,
date: 5,
isFromMe: 6,
service: 7,
isAudioMessage: 8,
destinationCallerID: 9,
guid: 10,
associatedGUID: 11,
associatedType: 12,
attachments: 13,
body: 14,
threadOriginatorGUID: 15
)
return try withConnection { db in
guard let row = try db.prepare(sql, bindings).makeIterator().next() else { return nil }
let decoded = try decodeMessageRow(row, columns: columns, fallbackChatID: chatID)
let replyToGUID = replyToGUID(
associatedGuid: decoded.associatedGUID,
associatedType: decoded.associatedType
)
return Message(
rowID: decoded.rowID,
chatID: decoded.chatID,
sender: decoded.sender,
text: decoded.text,
date: decoded.date,
isFromMe: decoded.isFromMe,
service: decoded.service,
handleID: decoded.handleID,
attachmentsCount: decoded.attachments,
guid: decoded.guid,
routing: Message.RoutingMetadata(
replyToGUID: replyToGUID,
threadOriginatorGUID: decoded.threadOriginatorGUID.isEmpty
? nil : decoded.threadOriginatorGUID,
destinationCallerID: decoded.destinationCallerID.isEmpty
? nil : decoded.destinationCallerID
)
)
}
}
private func decodeMessageRow(
_ row: [Binding?],
columns: MessageRowColumns,

View File

@ -172,18 +172,27 @@ extension RPCServer {
throw RPCError.invalidParams("missing chat identifier or guid")
}
try sendMessage(
MessageSendOptions(
recipient: input.recipient,
text: text,
attachmentPath: file,
service: service,
region: region,
chatIdentifier: resolvedTarget.chatIdentifier,
chatGUID: resolvedTarget.chatGUID
)
let options = MessageSendOptions(
recipient: input.recipient,
text: text,
attachmentPath: file,
service: service,
region: region,
chatIdentifier: resolvedTarget.chatIdentifier,
chatGUID: resolvedTarget.chatGUID
)
respond(id: id, result: ["ok": true])
let sentAt = Date()
try sendMessage(options)
let sentMessage = try? await resolveSentMessage(store, options, input.chatID, sentAt)
var result: [String: Any] = ["ok": true]
if let sentMessage {
result["id"] = sentMessage.rowID
if !sentMessage.guid.isEmpty {
result["guid"] = sentMessage.guid
}
}
respond(id: id, result: result)
}
}

View File

@ -1,6 +1,13 @@
import Foundation
import IMsgCore
typealias SentMessageResolver = (
_ store: MessageStore,
_ options: MessageSendOptions,
_ chatID: Int64?,
_ sentAt: Date
) async throws -> Message?
protocol RPCOutput: Sendable {
func sendResponse(id: Any, result: Any)
func sendError(id: Any?, error: RPCError)
@ -15,12 +22,14 @@ final class RPCServer {
let subscriptions = SubscriptionStore()
let verbose: Bool
let sendMessage: (MessageSendOptions) throws -> Void
let resolveSentMessage: SentMessageResolver
init(
store: MessageStore,
verbose: Bool,
output: RPCOutput = RPCWriter(),
sendMessage: @escaping (MessageSendOptions) throws -> Void = { try MessageSender().send($0) }
sendMessage: @escaping (MessageSendOptions) throws -> Void = { try MessageSender().send($0) },
resolveSentMessage: @escaping SentMessageResolver = RPCServer.resolveSentMessage
) {
self.store = store
self.watcher = MessageWatcher(store: store)
@ -28,6 +37,7 @@ final class RPCServer {
self.verbose = verbose
self.output = output
self.sendMessage = sendMessage
self.resolveSentMessage = resolveSentMessage
}
func run() async throws {
@ -107,4 +117,28 @@ final class RPCServer {
output.sendError(id: id, error: RPCError.internalError(error.localizedDescription))
}
}
static func resolveSentMessage(
store: MessageStore,
options: MessageSendOptions,
chatID: Int64?,
sentAt: Date
) async throws -> Message? {
guard !options.text.isEmpty else { return nil }
let lowerBound = sentAt.addingTimeInterval(-2)
let deadline = Date().addingTimeInterval(2)
repeat {
if Task.isCancelled { return nil }
if let message = try store.latestSentMessage(
matchingText: options.text,
chatID: chatID,
since: lowerBound
) {
return message
}
try await Task.sleep(nanoseconds: 100_000_000)
} while Date() < deadline
return nil
}
}

View File

@ -0,0 +1,162 @@
import Foundation
import SQLite
import Testing
@testable import IMsgCore
@Test
func latestSentMessageMatchesNewestOutgoingTextInChat() throws {
let db = try makeSentMessageDatabase()
let now = Date()
try insertSentMessageFixture(
db,
rowID: 1,
chatID: 1,
text: "same",
guid: "old-guid",
date: now.addingTimeInterval(-20),
isFromMe: true
)
try insertSentMessageFixture(
db,
rowID: 2,
chatID: 1,
text: "same",
guid: "incoming-guid",
date: now.addingTimeInterval(-5),
isFromMe: false
)
try insertSentMessageFixture(
db,
rowID: 3,
chatID: 1,
text: "same",
guid: "chat-guid",
date: now,
isFromMe: true
)
try insertSentMessageFixture(
db,
rowID: 4,
chatID: 2,
text: "same",
guid: "other-chat-guid",
date: now.addingTimeInterval(5),
isFromMe: true
)
let store = try MessageStore(connection: db, path: ":memory:")
let message = try store.latestSentMessage(
matchingText: "same",
chatID: 1,
since: now.addingTimeInterval(-10)
)
#expect(message?.rowID == 3)
#expect(message?.chatID == 1)
#expect(message?.guid == "chat-guid")
}
@Test
func latestSentMessageFallsBackToNewestOutgoingTextWithoutChatFilter() throws {
let db = try makeSentMessageDatabase()
let now = Date()
try insertSentMessageFixture(
db,
rowID: 1,
chatID: 1,
text: "same",
guid: "chat-one-guid",
date: now,
isFromMe: true
)
try insertSentMessageFixture(
db,
rowID: 2,
chatID: 2,
text: "same",
guid: "chat-two-guid",
date: now.addingTimeInterval(5),
isFromMe: true
)
let store = try MessageStore(connection: db, path: ":memory:")
let message = try store.latestSentMessage(
matchingText: "same",
chatID: nil,
since: now.addingTimeInterval(-1)
)
#expect(message?.rowID == 2)
#expect(message?.chatID == 2)
#expect(message?.guid == "chat-two-guid")
}
private func makeSentMessageDatabase() throws -> Connection {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
guid TEXT,
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute(
"""
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
guid TEXT,
display_name TEXT,
service_name TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);")
try db.run(
"""
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
VALUES (1, 'iMessage;+;one', 'iMessage;+;one', 'One', 'iMessage'),
(2, 'iMessage;+;two', 'iMessage;+;two', 'Two', 'iMessage')
"""
)
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, 'me@icloud.com')")
return db
}
private func insertSentMessageFixture(
_ db: Connection,
rowID: Int64,
chatID: Int64,
text: String,
guid: String,
date: Date,
isFromMe: Bool
) throws {
try db.run(
"""
INSERT INTO message(
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
date, is_from_me, service
)
VALUES (?, 1, ?, ?, NULL, 0, ?, ?, 'iMessage')
""",
rowID,
text,
guid,
TestDatabase.appleEpoch(date),
isFromMe ? 1 : 0
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (?, ?)", chatID, rowID)
}

View File

@ -89,7 +89,8 @@ func rpcSendResolvesChatID() async throws {
store: store,
verbose: false,
output: output,
sendMessage: { options in captured = options }
sendMessage: { options in captured = options },
resolveSentMessage: { _, _, _, _ in nil }
)
let line = #"{"jsonrpc":"2.0","id":"3","method":"send","params":{"chat_id":1,"text":"yo"}}"#
@ -101,6 +102,61 @@ func rpcSendResolvesChatID() async throws {
#expect(output.responses.first?["result"] as? [String: Any] != nil)
}
@Test
func rpcSendReturnsSentMessageIdentifiersWhenResolved() async throws {
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(
store: store,
verbose: false,
output: output,
sendMessage: { _ in },
resolveSentMessage: { _, options, chatID, _ in
Message(
rowID: 1_979,
chatID: chatID ?? 0,
sender: "me@icloud.com",
text: options.text,
date: Date(),
isFromMe: true,
service: "iMessage",
handleID: nil,
attachmentsCount: 0,
guid: "8DF1B3D7"
)
}
)
let line = #"{"jsonrpc":"2.0","id":"3b","method":"send","params":{"chat_id":1,"text":"yo"}}"#
await server.handleLineForTesting(line)
let result = output.responses.first?["result"] as? [String: Any]
#expect(result?["ok"] as? Bool == true)
#expect(int64Value(result?["id"]) == 1_979)
#expect(result?["guid"] as? String == "8DF1B3D7")
}
@Test
func rpcSendKeepsOkResponseWhenSentMessageIsNotResolved() async throws {
let store = try CommandTestDatabase.makeStoreForRPC()
let output = TestRPCOutput()
let server = RPCServer(
store: store,
verbose: false,
output: output,
sendMessage: { _ in },
resolveSentMessage: { _, _, _, _ in nil }
)
let line = #"{"jsonrpc":"2.0","id":"3c","method":"send","params":{"chat_id":1,"text":"yo"}}"#
await server.handleLineForTesting(line)
let result = output.responses.first?["result"] as? [String: Any]
#expect(result?["ok"] as? Bool == true)
#expect(result?["id"] == nil)
#expect(result?["guid"] == nil)
}
@Test
func rpcSendRejectsMissingTextAndFile() async throws {
let store = try CommandTestDatabase.makeStoreForRPC()

View File

@ -67,7 +67,12 @@ Params (group):
- `text` / `file` as above
Result:
- `{ "ok": true }`
- `{ "ok": true, "id": 1979, "guid": "8DF..." }`
`id` and `guid` are best-effort. `send` returns them when the sent row can be
observed in `chat.db` after Messages accepts the send. Attachment-only sends,
delayed database writes, or ambiguous direct sends may still return only
`{ "ok": true }`.
## Objects