import Foundation import OpenClawKit enum WatchMessagingPayloadCodec { private static let durableSnapshotTypes = [ OpenClawWatchPayloadType.appSnapshot.rawValue, OpenClawWatchPayloadType.execApprovalSnapshot.rawValue, ] static let completedChatReplyTextLimit = 4000 static func nowMs() -> Int64 { Int64(Date().timeIntervalSince1970 * 1000) } static func nonEmpty(_ value: String?) -> String? { let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } static func exactNonEmpty(_ value: String?) -> String? { guard let value, !value.isEmpty else { return nil } return value } static func encodeNotificationPayload( id: String, params: OpenClawWatchNotifyParams, gatewayStableID: String?) -> [String: Any] { var payload: [String: Any] = [ "type": OpenClawWatchPayloadType.notify.rawValue, "id": id, "title": params.title, "body": params.body, "priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue, "sentAtMs": self.nowMs(), ] if let promptId = nonEmpty(params.promptId) { payload["promptId"] = promptId } if let sessionKey = nonEmpty(params.sessionKey) { payload["sessionKey"] = sessionKey } if let gatewayStableID = GatewayStableIdentifier.exact(gatewayStableID) { payload["gatewayStableID"] = gatewayStableID } if let kind = nonEmpty(params.kind) { payload["kind"] = kind } if let details = nonEmpty(params.details) { payload["details"] = details } if let expiresAtMs = params.expiresAtMs { payload["expiresAtMs"] = expiresAtMs } if let risk = params.risk { payload["risk"] = risk.rawValue } if let actions = params.actions, !actions.isEmpty { payload["actions"] = actions.map { action in var encoded: [String: Any] = [ "id": action.id, "label": action.label, ] if let style = nonEmpty(action.style) { encoded["style"] = style } return encoded } } return payload } static func encodeDirectNodeSetupPayload(setupCode: String) -> [String: Any] { [ "type": OpenClawWatchPayloadType.directNodeSetup.rawValue, "setupCode": setupCode, "sentAtMs": self.nowMs(), ] } static func encodeExecApprovalItem(_ item: OpenClawWatchExecApprovalItem) -> [String: Any] { var payload: [String: Any] = [ "id": item.id, "commandText": item.commandText, "allowedDecisions": item.allowedDecisions.map(\.rawValue), ] if let gatewayStableID = GatewayStableIdentifier.exact(item.gatewayStableID) { payload["gatewayStableID"] = gatewayStableID } if let commandPreview = nonEmpty(item.commandPreview) { payload["commandPreview"] = commandPreview } if let warningText = nonEmpty(item.warningText) { payload["warningText"] = warningText } if let host = nonEmpty(item.host) { payload["host"] = host } if let nodeId = nonEmpty(item.nodeId) { payload["nodeId"] = nodeId } if let agentId = nonEmpty(item.agentId) { payload["agentId"] = agentId } if let expiresAtMs = item.expiresAtMs { payload["expiresAtMs"] = expiresAtMs } if let risk = item.risk { payload["risk"] = risk.rawValue } return payload } static func encodeExecApprovalPromptPayload( _ message: OpenClawWatchExecApprovalPromptMessage) -> [String: Any] { var payload: [String: Any] = [ "type": OpenClawWatchPayloadType.execApprovalPrompt.rawValue, "approval": self.encodeExecApprovalItem(message.approval), ] if let sentAtMs = message.sentAtMs { payload["sentAtMs"] = sentAtMs } if let resetResolutionAttemptId = exactNonEmpty(message.resetResolutionAttemptId) { payload["resetResolutionAttemptId"] = resetResolutionAttemptId } return payload } static func encodeExecApprovalResolvedPayload( _ message: OpenClawWatchExecApprovalResolvedMessage) -> [String: Any] { var payload: [String: Any] = [ "type": OpenClawWatchPayloadType.execApprovalResolved.rawValue, "approvalId": message.approvalId, ] if let gatewayStableID = GatewayStableIdentifier.exact(message.gatewayStableID) { payload["gatewayStableID"] = gatewayStableID } if let decision = message.decision { payload["decision"] = decision.rawValue } if let outcome = message.outcome { payload["outcome"] = outcome.rawValue } if let resolvedAtMs = message.resolvedAtMs { payload["resolvedAtMs"] = resolvedAtMs } if let source = nonEmpty(message.source) { payload["source"] = source } if let outcomeText = nonEmpty(message.outcomeText) { payload["outcomeText"] = outcomeText } return payload } static func encodeExecApprovalExpiredPayload( _ message: OpenClawWatchExecApprovalExpiredMessage) -> [String: Any] { var payload: [String: Any] = [ "type": OpenClawWatchPayloadType.execApprovalExpired.rawValue, "approvalId": message.approvalId, "reason": message.reason.rawValue, ] if let gatewayStableID = GatewayStableIdentifier.exact(message.gatewayStableID) { payload["gatewayStableID"] = gatewayStableID } if let expiredAtMs = message.expiredAtMs { payload["expiredAtMs"] = expiredAtMs } return payload } static func encodeExecApprovalSnapshotPayload( _ message: OpenClawWatchExecApprovalSnapshotMessage) -> [String: Any] { var payload: [String: Any] = [ "type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue, "approvals": message.approvals.map(self.encodeExecApprovalItem), ] if let gatewayStableID = GatewayStableIdentifier.exact(message.gatewayStableID) { payload["gatewayStableID"] = gatewayStableID } if let sentAtMs = message.sentAtMs { payload["sentAtMs"] = sentAtMs } if let snapshotId = nonEmpty(message.snapshotId) { payload["snapshotId"] = snapshotId } if let requestId = exactNonEmpty(message.requestId) { payload["requestId"] = requestId } if let requestGatewayStableID = GatewayStableIdentifier.exact(message.requestGatewayStableID) { payload["requestGatewayStableID"] = requestGatewayStableID } return payload } static func encodeAppSnapshotPayload( _ message: OpenClawWatchAppSnapshotMessage) -> [String: Any] { var payload: [String: Any] = [ "type": OpenClawWatchPayloadType.appSnapshot.rawValue, "gatewayStatus": self.encodeAppStatus(message.gatewayStatus), "gatewayStatusText": message.gatewayStatusText, "gatewayConnected": message.gatewayConnected, "agentName": message.agentName, "sessionKey": message.sessionKey, "talkStatus": self.encodeAppStatus(message.talkStatus), "talkStatusText": message.talkStatusText, "talkEnabled": message.talkEnabled, "talkListening": message.talkListening, "talkSpeaking": message.talkSpeaking, "pendingApprovalCount": message.pendingApprovalCount, ] if let agentAvatarURL = nonEmpty(message.agentAvatarURL) { payload["agentAvatarUrl"] = agentAvatarURL } if let agentAvatarText = nonEmpty(message.agentAvatarText) { payload["agentAvatarText"] = agentAvatarText } if let gatewayStableID = GatewayStableIdentifier.exact(message.gatewayStableID) { payload["gatewayStableID"] = gatewayStableID } if let sentAtMs = message.sentAtMs { payload["sentAtMs"] = sentAtMs } if let chatItems = message.chatItems { payload["chatItems"] = chatItems.map { item in var encoded: [String: Any] = [ "id": item.id, "role": item.role, "text": item.text, ] if let timestampMs = item.timestampMs { encoded["timestampMs"] = timestampMs } return encoded } } if let chatStatus = message.chatStatus { payload["chatStatus"] = self.encodeAppStatus(chatStatus) } if let chatStatusText = nonEmpty(message.chatStatusText) { payload["chatStatusText"] = chatStatusText } if let snapshotId = nonEmpty(message.snapshotId) { payload["snapshotId"] = snapshotId } return payload } private static func encodeAppStatus(_ status: OpenClawWatchAppStatus) -> [String: Any] { var payload: [String: Any] = ["code": status.code.rawValue] if let localizationKey = exactNonEmpty(status.localizationKey) { payload["localizationKey"] = localizationKey } if !status.arguments.isEmpty { payload["arguments"] = status.arguments } if let verbatim = exactNonEmpty(status.verbatim) { payload["verbatim"] = verbatim } return payload } static func encodeSnapshotApplicationContext( _ payload: [String: Any], merging existingContext: [String: Any]) -> [String: Any] { guard let payloadType = payload["type"] as? String, self.durableSnapshotTypes.contains(payloadType) else { return payload } // updateApplicationContext retains one dictionary. Nest both logical snapshots while // keeping the newest one at the top level for older Watch app versions. var context = payload for snapshotType in self.durableSnapshotTypes { if snapshotType == payloadType { context[snapshotType] = payload } else if let previous = existingContext[snapshotType] as? [String: Any] { context[snapshotType] = previous } else if existingContext["type"] as? String == snapshotType { context[snapshotType] = existingContext } } return context } static func encodeChatCompletionPayload( _ message: OpenClawWatchChatCompletionMessage) -> [String: Any] { [ "type": message.type.rawValue, "commandId": message.commandId, "replyText": self.truncatedCompletedChatReplyText(message.replyText), "sentAtMs": message.sentAtMs ?? self.nowMs(), ] } private static func truncatedCompletedChatReplyText(_ text: String) -> String { guard text.count > self.completedChatReplyTextLimit else { return text } return "\(text.prefix(self.completedChatReplyTextLimit - 3))..." } static func parseQuickReplyPayload( _ payload: [String: Any], transport: String) -> WatchQuickReplyEvent? { guard (payload["type"] as? String) == OpenClawWatchPayloadType.reply.rawValue else { return nil } guard let actionId = nonEmpty(payload["actionId"] as? String) else { return nil } let promptId = self.nonEmpty(payload["promptId"] as? String) ?? "unknown" let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString let actionLabel = self.nonEmpty(payload["actionLabel"] as? String) let sessionKey = self.nonEmpty(payload["sessionKey"] as? String) let gatewayStableID = GatewayStableIdentifier.exact(payload["gatewayStableID"] as? String) let note = self.nonEmpty(payload["note"] as? String) let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value return WatchQuickReplyEvent( replyId: replyId, promptId: promptId, actionId: actionId, actionLabel: actionLabel, sessionKey: sessionKey, gatewayStableID: gatewayStableID, note: note, sentAtMs: sentAtMs, transport: transport) } static func parseExecApprovalResolvePayload( _ payload: [String: Any], transport: String) -> WatchExecApprovalResolveEvent? { guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalResolve.rawValue else { return nil } guard let approvalId = ExecApprovalIdentifier.exact(payload["approvalId"] as? String), let rawDecision = nonEmpty(payload["decision"] as? String), let decision = OpenClawWatchExecApprovalDecision(rawValue: rawDecision) else { return nil } let replyId = self.exactNonEmpty(payload["replyId"] as? String) ?? UUID().uuidString let gatewayStableID = GatewayStableIdentifier.exact(payload["gatewayStableID"] as? String) let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value return WatchExecApprovalResolveEvent( replyId: replyId, approvalId: approvalId, gatewayStableID: gatewayStableID, decision: decision, sentAtMs: sentAtMs, transport: transport) } static func parseExecApprovalSnapshotRequestPayload( _ payload: [String: Any], transport: String) -> WatchExecApprovalSnapshotRequestEvent? { guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue else { return nil } // Version-skew compat: shipped Watch binaries request snapshots without requestId or // heldApprovals. A missing key decodes as the shipped shape (present-but-malformed // still rejects); remove once the minimum paired Watch app version sends heldApprovals. let requestId = self.exactNonEmpty(payload["requestId"] as? String) ?? UUID().uuidString let rawHeldApprovals: [Any] if let rawHeldApprovalsValue = payload["heldApprovals"] { guard let heldApprovalsArray = rawHeldApprovalsValue as? [Any] else { return nil } rawHeldApprovals = heldApprovalsArray } else { rawHeldApprovals = [] } var heldApprovals: [WatchExecApprovalSnapshotRequestItem] = [] heldApprovals.reserveCapacity(rawHeldApprovals.count) for rawItem in rawHeldApprovals { guard let item = rawItem as? [String: Any], let approvalId = ExecApprovalIdentifier.exact(item["approvalId"] as? String) else { return nil } let activeResolutionAttemptId: String? if let rawAttemptId = item["activeResolutionAttemptId"] { guard let attemptId = exactNonEmpty(rawAttemptId as? String) else { return nil } activeResolutionAttemptId = attemptId } else { activeResolutionAttemptId = nil } heldApprovals.append(WatchExecApprovalSnapshotRequestItem( approvalId: approvalId, activeResolutionAttemptId: activeResolutionAttemptId)) } let gatewayStableID = GatewayStableIdentifier.exact(payload["gatewayStableID"] as? String) let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value return WatchExecApprovalSnapshotRequestEvent( requestId: requestId, gatewayStableID: gatewayStableID, heldApprovals: heldApprovals, sentAtMs: sentAtMs, transport: transport) } static func parseAppSnapshotRequestPayload( _ payload: [String: Any], transport: String) -> WatchAppSnapshotRequestEvent? { guard (payload["type"] as? String) == OpenClawWatchPayloadType.appSnapshotRequest.rawValue else { return nil } let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value return WatchAppSnapshotRequestEvent( requestId: requestId, sentAtMs: sentAtMs, transport: transport) } static func parseAppCommandPayload( _ payload: [String: Any], transport: String) -> WatchAppCommandEvent? { guard (payload["type"] as? String) == OpenClawWatchPayloadType.appCommand.rawValue else { return nil } guard let rawCommand = nonEmpty(payload["command"] as? String), let command = OpenClawWatchAppCommand(rawValue: rawCommand) else { return nil } let commandId = self.nonEmpty(payload["commandId"] as? String) ?? UUID().uuidString let sessionKey = self.nonEmpty(payload["sessionKey"] as? String) let gatewayStableID = GatewayStableIdentifier.exact(payload["gatewayStableID"] as? String) let text = self.nonEmpty(payload["text"] as? String) let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value return WatchAppCommandEvent( commandId: commandId, command: command, sessionKey: sessionKey, gatewayStableID: gatewayStableID, text: text, sentAtMs: sentAtMs, transport: transport) } }