mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 23:06:05 +00:00
* fix(ios): persist queued watch replies Route Apple Watch quick replies through the durable, gateway-scoped Watch message outbox with restart-safe routing and deduplication. Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com> * fix(ios): preserve pre-upgrade Watch replies Bind shipped notification prompts without routing metadata to the current Gateway once, while keeping explicit and persisted Gateway owners fail-closed. --------- Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>
231 lines
9.8 KiB
Swift
231 lines
9.8 KiB
Swift
import Foundation
|
|
|
|
@MainActor
|
|
final class WatchMessageOutbox {
|
|
enum Decision {
|
|
case dropMissingFields
|
|
case dropMissingTarget
|
|
case deduped(messageID: String)
|
|
case queue(messageID: String)
|
|
case forward
|
|
}
|
|
|
|
// Keep the shipped chat key so upgrades retain messages already queued by the Watch.
|
|
private static let persistedQueueKey = "watch.chat.command.queue.v1"
|
|
private static let persistedMetadataKey = "watch.message.outbox.metadata.v1"
|
|
private static let maxRecentMessageIDs = 128
|
|
private static let maxPromptRoutes = 128
|
|
|
|
private struct QueuedMessage: Codable, Equatable {
|
|
var gatewayStableID: String
|
|
var event: WatchAppCommandEvent
|
|
}
|
|
|
|
private struct PromptRoute: Codable, Equatable {
|
|
var promptID: String
|
|
var gatewayStableID: String
|
|
}
|
|
|
|
private struct PersistedMetadata: Codable, Equatable {
|
|
var recentMessageIDs: [String]
|
|
var promptRoutes: [PromptRoute]
|
|
}
|
|
|
|
private let defaults: UserDefaults
|
|
private var queuedMessages: [QueuedMessage] = []
|
|
private var recentMessageIDs: [String] = []
|
|
private var seenMessageIDs = Set<String>()
|
|
private var promptRoutes: [PromptRoute] = []
|
|
|
|
init(defaults: UserDefaults = .standard) {
|
|
self.defaults = defaults
|
|
self.restoreMetadata()
|
|
self.restoreQueue()
|
|
}
|
|
|
|
func ingest(
|
|
_ event: WatchAppCommandEvent,
|
|
isAvailable: Bool,
|
|
gatewayStableID: String?) -> Decision
|
|
{
|
|
let messageID = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if messageID.isEmpty || text.isEmpty {
|
|
return .dropMissingFields
|
|
}
|
|
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !owner.isEmpty else { return .dropMissingTarget }
|
|
if self.seenMessageIDs.contains(messageID) {
|
|
return .deduped(messageID: messageID)
|
|
}
|
|
// Persist before network delivery; iOS may suspend a background callback at any await.
|
|
self.queuedMessages.append(
|
|
QueuedMessage(gatewayStableID: owner, event: self.message(event, taggedFor: owner)))
|
|
self.rebuildSeenMessageIDs()
|
|
self.persistQueue()
|
|
return isAvailable ? .forward : .queue(messageID: messageID)
|
|
}
|
|
|
|
func recordPromptRoute(promptID: String?, gatewayStableID: String?) {
|
|
let promptID = promptID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let gatewayStableID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !promptID.isEmpty, promptID != "unknown", !gatewayStableID.isEmpty else { return }
|
|
self.promptRoutes.removeAll { $0.promptID == promptID }
|
|
self.promptRoutes.append(PromptRoute(promptID: promptID, gatewayStableID: gatewayStableID))
|
|
if self.promptRoutes.count > Self.maxPromptRoutes {
|
|
self.promptRoutes.removeFirst(self.promptRoutes.count - Self.maxPromptRoutes)
|
|
}
|
|
self.persistMetadata()
|
|
}
|
|
|
|
func gatewayStableID(forPromptID promptID: String) -> String? {
|
|
let promptID = promptID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !promptID.isEmpty, promptID != "unknown" else { return nil }
|
|
return self.promptRoutes.last { $0.promptID == promptID }?.gatewayStableID
|
|
}
|
|
|
|
func nextQueuedMessage(isAvailable: Bool, gatewayStableID: String?) -> WatchAppCommandEvent? {
|
|
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard isAvailable, !owner.isEmpty else { return nil }
|
|
// Replies are time-sensitive; a retrying chat must not strand them behind it.
|
|
if let reply = self.queuedMessages.first(where: {
|
|
$0.gatewayStableID == owner && self.kind(of: $0.event) == .quickReply
|
|
}) {
|
|
return reply.event
|
|
}
|
|
return self.queuedMessages.first { $0.gatewayStableID == owner }?.event
|
|
}
|
|
|
|
func removeQueuedMessage(messageID: String, gatewayStableID: String?) {
|
|
let messageID = messageID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !messageID.isEmpty, !owner.isEmpty else { return }
|
|
guard let index = self.queuedMessages.firstIndex(where: {
|
|
$0.gatewayStableID == owner && $0.event.commandId == messageID
|
|
}) else { return }
|
|
self.queuedMessages.remove(at: index)
|
|
self.rememberRecentMessageID(messageID)
|
|
self.persistQueue()
|
|
}
|
|
|
|
func requeueFront(_ event: WatchAppCommandEvent, gatewayStableID: String?) {
|
|
let messageID = event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let owner = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !messageID.isEmpty, !owner.isEmpty else { return }
|
|
self.queuedMessages.removeAll { $0.event.commandId == messageID }
|
|
self.queuedMessages.insert(
|
|
QueuedMessage(gatewayStableID: owner, event: self.message(event, taggedFor: owner)),
|
|
at: 0)
|
|
self.rebuildSeenMessageIDs()
|
|
self.persistQueue()
|
|
}
|
|
|
|
func queuedCount(kind: WatchMessageKind? = nil) -> Int {
|
|
guard let kind else { return self.queuedMessages.count }
|
|
return self.queuedMessages.count(where: { self.kind(of: $0.event) == kind })
|
|
}
|
|
|
|
func queuedMessageIDs(kind: WatchMessageKind? = nil) -> [String] {
|
|
self.queuedMessages.compactMap { queued in
|
|
guard kind == nil || self.kind(of: queued.event) == kind else { return nil }
|
|
return queued.event.commandId
|
|
}
|
|
}
|
|
|
|
private func restoreQueue() {
|
|
guard let data = defaults.data(forKey: Self.persistedQueueKey),
|
|
let persisted = try? JSONDecoder().decode([QueuedMessage].self, from: data)
|
|
else {
|
|
return
|
|
}
|
|
|
|
var seenSet = Set<String>()
|
|
self.queuedMessages = persisted.compactMap { queued in
|
|
let owner = queued.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let messageID = queued.event.commandId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let text = queued.event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !owner.isEmpty, !messageID.isEmpty, !text.isEmpty, seenSet.insert(messageID).inserted else {
|
|
return nil
|
|
}
|
|
return QueuedMessage(gatewayStableID: owner, event: self.message(queued.event, taggedFor: owner))
|
|
}
|
|
self.rebuildSeenMessageIDs()
|
|
if self.queuedMessages.count != persisted.count {
|
|
self.persistQueue()
|
|
}
|
|
}
|
|
|
|
private func rememberRecentMessageID(_ messageID: String) {
|
|
guard !messageID.isEmpty else { return }
|
|
self.recentMessageIDs.removeAll { $0 == messageID }
|
|
self.recentMessageIDs.append(messageID)
|
|
if self.recentMessageIDs.count > Self.maxRecentMessageIDs {
|
|
self.recentMessageIDs.removeFirst(self.recentMessageIDs.count - Self.maxRecentMessageIDs)
|
|
}
|
|
self.rebuildSeenMessageIDs()
|
|
self.persistMetadata()
|
|
}
|
|
|
|
private func restoreMetadata() {
|
|
guard let data = self.defaults.data(forKey: Self.persistedMetadataKey),
|
|
let metadata = try? JSONDecoder().decode(PersistedMetadata.self, from: data)
|
|
else { return }
|
|
|
|
for rawMessageID in metadata.recentMessageIDs {
|
|
let messageID = rawMessageID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !messageID.isEmpty else { continue }
|
|
self.recentMessageIDs.removeAll { $0 == messageID }
|
|
self.recentMessageIDs.append(messageID)
|
|
}
|
|
self.recentMessageIDs = Array(self.recentMessageIDs.suffix(Self.maxRecentMessageIDs))
|
|
|
|
for rawRoute in metadata.promptRoutes {
|
|
let promptID = rawRoute.promptID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let gatewayStableID = rawRoute.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !promptID.isEmpty, promptID != "unknown", !gatewayStableID.isEmpty else { continue }
|
|
self.promptRoutes.removeAll { $0.promptID == promptID }
|
|
self.promptRoutes.append(PromptRoute(promptID: promptID, gatewayStableID: gatewayStableID))
|
|
}
|
|
self.promptRoutes = Array(self.promptRoutes.suffix(Self.maxPromptRoutes))
|
|
self.rebuildSeenMessageIDs()
|
|
}
|
|
|
|
private func rebuildSeenMessageIDs() {
|
|
var ids = Set(self.recentMessageIDs)
|
|
ids.formUnion(self.queuedMessages.map(\.event.commandId))
|
|
self.seenMessageIDs = ids
|
|
}
|
|
|
|
private func persistQueue() {
|
|
if self.queuedMessages.isEmpty {
|
|
self.defaults.removeObject(forKey: Self.persistedQueueKey)
|
|
return
|
|
}
|
|
guard let data = try? JSONEncoder().encode(self.queuedMessages) else { return }
|
|
self.defaults.set(data, forKey: Self.persistedQueueKey)
|
|
}
|
|
|
|
private func persistMetadata() {
|
|
let metadata = PersistedMetadata(
|
|
recentMessageIDs: self.recentMessageIDs,
|
|
promptRoutes: self.promptRoutes)
|
|
guard let data = try? JSONEncoder().encode(metadata) else { return }
|
|
self.defaults.set(data, forKey: Self.persistedMetadataKey)
|
|
}
|
|
|
|
private func message(_ event: WatchAppCommandEvent, taggedFor gatewayStableID: String) -> WatchAppCommandEvent {
|
|
var tagged = event
|
|
tagged.gatewayStableID = gatewayStableID
|
|
return tagged
|
|
}
|
|
|
|
private func kind(of event: WatchAppCommandEvent) -> WatchMessageKind {
|
|
event.messageKind ?? .chat
|
|
}
|
|
|
|
static func resetPersistedQueue(defaults: UserDefaults = .standard) {
|
|
defaults.removeObject(forKey: self.persistedQueueKey)
|
|
defaults.removeObject(forKey: self.persistedMetadataKey)
|
|
}
|
|
}
|