fix: return RPC send message identifiers
This commit is contained in:
parent
60ed8a9f02
commit
af6fd822c1
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
162
Tests/IMsgCoreTests/MessageStoreSentMessageTests.swift
Normal file
162
Tests/IMsgCoreTests/MessageStoreSentMessageTests.swift
Normal 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)
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user