- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>) - RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器, 因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写) - NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter; DirectTransport 是薄适配,直通路径行为不变 - RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃, 四类以上失败各有独立 UpstreamError 分支与用户文案 - DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet - 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成 - scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫 - 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、 传输往返与各类拒绝路径
125 lines
4.6 KiB
Swift
125 lines
4.6 KiB
Swift
import Foundation
|
|
|
|
public enum MessageServiceError: Error, Equatable {
|
|
case invalidInput
|
|
case invalidResponse
|
|
case unauthorized
|
|
}
|
|
|
|
public struct MessageService: Sendable {
|
|
private let transport: any NodeTransport
|
|
|
|
public init(transport: any NodeTransport) {
|
|
self.transport = transport
|
|
}
|
|
|
|
/// Direct-mode construction, kept for the existing call sites and tests.
|
|
public init(
|
|
client: any SimAdminClientProtocol,
|
|
sessions: InstanceSessionStore,
|
|
credentials: (any CredentialStore)? = nil
|
|
) {
|
|
self.init(transport: DirectTransport(client: client, sessions: sessions, credentials: credentials))
|
|
}
|
|
|
|
public func messages(
|
|
_ instances: [Instance],
|
|
limit: Int = 50,
|
|
direction: SMSDirection? = nil
|
|
) async throws -> MessagePage {
|
|
let directionText: String?
|
|
switch direction {
|
|
case .incoming: directionText = "incoming"
|
|
case .outgoing: directionText = "outgoing"
|
|
default: directionText = nil
|
|
}
|
|
|
|
var collected: [SMSMessage] = []
|
|
var total = 0
|
|
var lastError: Error?
|
|
for instance in instances {
|
|
do {
|
|
let response = try await request(.smsList(limit: limit, offset: 0, direction: directionText), instance)
|
|
let page = try Self.parsePage(response, instance)
|
|
collected.append(contentsOf: page.messages)
|
|
total += page.total
|
|
} catch {
|
|
lastError = error
|
|
}
|
|
}
|
|
if collected.isEmpty && total == 0, let lastError {
|
|
throw lastError
|
|
}
|
|
collected.sort { $0.timestamp > $1.timestamp }
|
|
return MessagePage(messages: collected, total: total)
|
|
}
|
|
|
|
public func send(
|
|
_ instance: Instance,
|
|
phoneNumber rawPhoneNumber: String,
|
|
content rawContent: String
|
|
) async throws {
|
|
let phoneNumber = rawPhoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let content = rawContent.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !phoneNumber.isEmpty, !content.isEmpty else {
|
|
throw MessageServiceError.invalidInput
|
|
}
|
|
_ = try await request(.smsSend(phoneNumber: phoneNumber, content: content), instance)
|
|
}
|
|
|
|
public func delete(_ ids: [String], from instance: Instance) async throws {
|
|
let numericIDs = ids.compactMap(Int.init)
|
|
guard !numericIDs.isEmpty else {
|
|
throw MessageServiceError.invalidInput
|
|
}
|
|
_ = try await request(.smsBatchDelete(ids: numericIDs), instance)
|
|
}
|
|
|
|
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
|
|
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
|
|
}
|
|
|
|
static func parsePage(_ response: UpstreamResponse, _ instance: Instance) throws -> MessagePage {
|
|
let root = try JSONSerialization.jsonObject(with: response.data)
|
|
guard let container = Self.dataContainer(root),
|
|
let data = container["data"] as? [String: Any] ?? container["response"] as? [String: Any]
|
|
else {
|
|
throw MessageServiceError.invalidResponse
|
|
}
|
|
let rawMessages = (data["messages"] as? [Any]) ?? []
|
|
let messages = rawMessages.compactMap { Self.message($0, instance) }
|
|
let total = (data["total"] as? Int) ?? messages.count
|
|
return MessagePage(messages: messages, total: total)
|
|
}
|
|
|
|
private static func dataContainer(_ root: Any) -> [String: Any]? {
|
|
root as? [String: Any]
|
|
}
|
|
|
|
private static func message(_ raw: Any, _ instance: Instance) -> SMSMessage? {
|
|
guard let value = raw as? [String: Any] else { return nil }
|
|
let id: String
|
|
if let number = value["id"] as? Int {
|
|
id = String(number)
|
|
} else if let text = value["id"] as? String {
|
|
id = text
|
|
} else {
|
|
return nil
|
|
}
|
|
let directionText = (value["direction"] as? String)?.lowercased() ?? ""
|
|
let direction: SMSDirection = directionText == "incoming" ? .incoming
|
|
: directionText == "outgoing" ? .outgoing
|
|
: .unknown
|
|
return SMSMessage(
|
|
upstreamID: id,
|
|
instanceID: instance.id,
|
|
instanceName: instance.name,
|
|
phoneNumber: value["phone_number"] as? String ?? "",
|
|
content: value["content"] as? String ?? "",
|
|
direction: direction,
|
|
status: value["status"] as? String ?? "",
|
|
timestamp: value["timestamp"] as? String ?? ""
|
|
)
|
|
}
|
|
}
|