Skip to content
Closed
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
55 changes: 55 additions & 0 deletions Sources/IMsgCore/MessageStore+Messages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,41 @@ extension MessageStore {
}
}

public func messageSendStatus(guid: String) throws -> MessageSendStatus? {
let trimmed = guid.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }

return try withConnection { db in
let columns = MessageStore.tableColumns(connection: db, table: "message")
func column(_ name: String, defaultValue: String) -> String {
columns.contains(name.lowercased()) ? "m.\(name)" : defaultValue
}

let sql = """
SELECT m.ROWID AS message_rowid,
\(column("guid", defaultValue: "''")) AS guid,
\(column("service", defaultValue: "''")) AS service,
\(column("error", defaultValue: "0")) AS error,
\(column("date_delivered", defaultValue: "0")) AS date_delivered,
\(column("date_read", defaultValue: "0")) AS date_read,
\(column("is_sent", defaultValue: "0")) AS is_sent,
\(column("is_delivered", defaultValue: "0")) AS is_delivered,
\(column("is_finished", defaultValue: "0")) AS is_finished,
\(column("is_delayed", defaultValue: "0")) AS is_delayed,
\(column("is_prepared", defaultValue: "0")) AS is_prepared,
\(column("is_pending_satellite_send", defaultValue: "0")) AS is_pending_satellite_send,
\(column("was_downgraded", defaultValue: "0")) AS was_downgraded
FROM message m
WHERE \(column("guid", defaultValue: "''")) = ?
ORDER BY m.ROWID DESC
LIMIT 1
"""
let rows = try db.prepareRowIterator(sql, bindings: [trimmed])
guard let row = try rows.failableNext() else { return nil }
return try decodeMessageSendStatus(row)
}
}

func decodeMessageRow(
_ row: Row,
columns: MessageRowColumns,
Expand Down Expand Up @@ -378,4 +413,24 @@ extension MessageStore {
threadOriginatorGUID: threadOriginatorGUID
)
}

func decodeMessageSendStatus(_ row: Row) throws -> MessageSendStatus {
let deliveredRaw = try int64Value(row, "date_delivered")
let readRaw = try int64Value(row, "date_read")
return MessageSendStatus(
rowID: try int64Value(row, "message_rowid") ?? 0,
guid: try stringValue(row, "guid"),
service: try stringValue(row, "service"),
error: try intValue(row, "error") ?? 0,
dateDelivered: deliveredRaw.flatMap { $0 > 0 ? appleDate(from: $0) : nil },
dateRead: readRaw.flatMap { $0 > 0 ? appleDate(from: $0) : nil },
isSent: (try intValue(row, "is_sent") ?? 0) != 0,
isDelivered: (try intValue(row, "is_delivered") ?? 0) != 0,
isFinished: (try intValue(row, "is_finished") ?? 0) != 0,
isDelayed: (try intValue(row, "is_delayed") ?? 0) != 0,
isPrepared: (try intValue(row, "is_prepared") ?? 0) != 0,
isPendingSatelliteSend: (try intValue(row, "is_pending_satellite_send") ?? 0) != 0,
wasDowngraded: (try intValue(row, "was_downgraded") ?? 0) != 0
)
}
}
60 changes: 60 additions & 0 deletions Sources/IMsgCore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,66 @@ public struct Message: Sendable, Equatable {
}
}

public enum MessageSendState: String, Sendable, Equatable {
case pending
case sent
case delivered
case failed
}

public struct MessageSendStatus: Sendable, Equatable {
public let rowID: Int64
public let guid: String
public let service: String
public let error: Int
public let dateDelivered: Date?
public let dateRead: Date?
public let isSent: Bool
public let isDelivered: Bool
public let isFinished: Bool
public let isDelayed: Bool
public let isPrepared: Bool
public let isPendingSatelliteSend: Bool
public let wasDowngraded: Bool

public var state: MessageSendState {
if error != 0 { return .failed }
if isDelivered || dateDelivered != nil { return .delivered }
if isSent { return .sent }
return .pending
}

public init(
rowID: Int64,
guid: String,
service: String,
error: Int,
dateDelivered: Date?,
dateRead: Date?,
isSent: Bool,
isDelivered: Bool,
isFinished: Bool,
isDelayed: Bool,
isPrepared: Bool,
isPendingSatelliteSend: Bool,
wasDowngraded: Bool
) {
self.rowID = rowID
self.guid = guid
self.service = service
self.error = error
self.dateDelivered = dateDelivered
self.dateRead = dateRead
self.isSent = isSent
self.isDelivered = isDelivered
self.isFinished = isFinished
self.isDelayed = isDelayed
self.isPrepared = isPrepared
self.isPendingSatelliteSend = isPendingSatelliteSend
self.wasDowngraded = wasDowngraded
}
}

public struct AttachmentMeta: Sendable, Equatable {
public let filename: String
public let transferName: String
Expand Down
54 changes: 54 additions & 0 deletions Sources/imsg/RPCServer+MessageStatusHandlers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation
import IMsgCore

extension RPCServer {
func handleMessageSendStatus(params: [String: Any], id: Any?) async throws {
let guid = (stringParam(params["guid"]) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !guid.isEmpty else {
throw RPCError.invalidParams("guid is required")
}

let checkedAt = Date()
guard let status = try store.messageSendStatus(guid: guid) else {
respond(
id: id,
result: [
"ok": true,
"guid": guid,
"send_state": MessageSendState.pending.rawValue,
"service": NSNull(),
"checked_at": CLIISO8601.format(checkedAt),
"status_fields": NSNull(),
])
return
}

var result: [String: Any] = [
"ok": true,
"guid": status.guid,
"send_state": status.state.rawValue,
"service": status.service.isEmpty ? NSNull() : status.service,
"checked_at": CLIISO8601.format(checkedAt),
"status_fields": messageSendStatusFields(status),
]
if let deliveredAt = status.dateDelivered {
result["delivered_at"] = CLIISO8601.format(deliveredAt)
}
respond(id: id, result: result)
}

private func messageSendStatusFields(_ status: MessageSendStatus) -> [String: Any] {
return [
"is_sent": status.isSent,
"is_delivered": status.isDelivered,
"is_finished": status.isFinished,
"error": status.error,
"date_delivered": status.dateDelivered.map { CLIISO8601.format($0) } ?? NSNull(),
"date_read": status.dateRead.map { CLIISO8601.format($0) } ?? NSNull(),
"is_delayed": status.isDelayed,
"is_prepared": status.isPrepared,
"is_pending_satellite_send": status.isPendingSatelliteSend,
"was_downgraded": status.wasDowngraded,
]
}
}
3 changes: 3 additions & 0 deletions Sources/imsg/RPCServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ let kSupportedRPCMethods: [String] = [
"message.unsend",
"message.delete",
"message.notifyAnyways",
"message.send_status",
"group.rename",
"group.setIcon",
"group.addParticipant",
Expand Down Expand Up @@ -162,6 +163,8 @@ final class RPCServer {
try await handleMessageDelete(params: params, id: id)
case "message.notifyAnyways":
try await handleMessageNotifyAnyways(params: params, id: id)
case "message.send_status":
try await handleMessageSendStatus(params: params, id: id)
case "chats.create":
try await handleChatsCreate(id: id, params: params)
case "chats.delete":
Expand Down
85 changes: 80 additions & 5 deletions Tests/IMsgCoreTests/MessageStoreSentMessageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,63 @@ func latestUnjoinedSentMessageRowIDMatchesAnyGroupTargetVariants() throws {
#expect(rowID == 20)
}

@Test
func messageSendStatusMapsFailedSentDeliveredAndPendingRows() throws {
let db = try makeSentMessageDatabase()
let deliveredAt = Date()
try insertSentMessageFixture(
db,
rowID: 30,
chatID: 1,
text: "failed",
guid: "failed-guid",
date: deliveredAt,
isFromMe: true,
error: 22,
isSent: false
)
try insertSentMessageFixture(
db,
rowID: 31,
chatID: 1,
text: "sent",
guid: "sent-guid",
date: deliveredAt,
isFromMe: true,
isSent: true
)
try insertSentMessageFixture(
db,
rowID: 32,
chatID: 1,
text: "delivered",
guid: "delivered-guid",
date: deliveredAt,
isFromMe: true,
isSent: true,
isDelivered: true,
dateDelivered: deliveredAt
)
try insertSentMessageFixture(
db,
rowID: 33,
chatID: 1,
text: "pending",
guid: "pending-guid",
date: deliveredAt,
isFromMe: true
)
let store = try MessageStore(connection: db, path: ":memory:")

#expect(try store.messageSendStatus(guid: "failed-guid")?.state == .failed)
#expect(try store.messageSendStatus(guid: "sent-guid")?.state == .sent)
let delivered = try store.messageSendStatus(guid: "delivered-guid")
#expect(delivered?.state == .delivered)
#expect(delivered?.dateDelivered != nil)
#expect(try store.messageSendStatus(guid: "pending-guid")?.state == .pending)
#expect(try store.messageSendStatus(guid: "missing-guid") == nil)
}

private func makeSentMessageDatabase() throws -> Connection {
let db = try Connection(.inMemory)
try db.execute(
Expand All @@ -147,8 +204,18 @@ private func makeSentMessageDatabase() throws -> Connection {
associated_message_guid TEXT,
associated_message_type INTEGER,
date INTEGER,
date_delivered INTEGER,
date_read INTEGER,
is_from_me INTEGER,
service TEXT
service TEXT,
error INTEGER DEFAULT 0,
is_sent INTEGER DEFAULT 0,
is_delivered INTEGER DEFAULT 0,
is_finished INTEGER DEFAULT 0,
is_delayed INTEGER DEFAULT 0,
is_prepared INTEGER DEFAULT 0,
is_pending_satellite_send INTEGER DEFAULT 0,
was_downgraded INTEGER DEFAULT 0
);
"""
)
Expand Down Expand Up @@ -185,21 +252,29 @@ private func insertSentMessageFixture(
text: String,
guid: String,
date: Date,
isFromMe: Bool
isFromMe: Bool,
error: Int = 0,
isSent: Bool = false,
isDelivered: Bool = false,
dateDelivered: Date? = nil
) throws {
try db.run(
"""
INSERT INTO message(
ROWID, handle_id, text, guid, associated_message_guid, associated_message_type,
date, is_from_me, service
date, date_delivered, is_from_me, service, error, is_sent, is_delivered, is_finished
)
VALUES (?, 1, ?, ?, NULL, 0, ?, ?, 'iMessage')
VALUES (?, 1, ?, ?, NULL, 0, ?, ?, ?, 'iMessage', ?, ?, ?, 1)
""",
rowID,
text,
guid,
TestDatabase.appleEpoch(date),
isFromMe ? 1 : 0
dateDelivered.map { TestDatabase.appleEpoch($0) } ?? 0,
isFromMe ? 1 : 0,
error,
isSent ? 1 : 0,
isDelivered ? 1 : 0
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (?, ?)", chatID, rowID)
}
2 changes: 2 additions & 0 deletions Tests/imsgTests/RPCBridgeMessageHandlersTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import Testing

@testable import IMsgCore
Expand All @@ -15,6 +16,7 @@ func rpcStatusAdvertisesBridgeMessageMethods() {
"message.unsend",
"message.delete",
"message.notifyAnyways",
"message.send_status",
] {
#expect(methods.contains(method))
}
Expand Down
Loading