feat(ios): add cross-instance message center

This commit is contained in:
chick
2026-09-09 00:18:56 +08:00
parent 54eb8dcb86
commit 7778693807
8 changed files with 559 additions and 1 deletions
@@ -0,0 +1,31 @@
import Foundation
public enum SMSDirection: String, Equatable, Sendable {
case incoming
case outgoing
case unknown
public var label: String {
switch self {
case .incoming: return "收件"
case .outgoing: return "发件"
case .unknown: return "未知"
}
}
}
public struct SMSMessage: Equatable, Identifiable, Sendable {
public let id: String
public let instanceID: UUID
public let instanceName: String
public let phoneNumber: String
public let content: String
public let direction: SMSDirection
public let status: String
public let timestamp: String
}
public struct MessagePage: Equatable, Sendable {
public let messages: [SMSMessage]
public let total: Int
}
@@ -0,0 +1,146 @@
import Foundation
public enum MessageServiceError: Error, Equatable {
case invalidInput
case invalidResponse
case unauthorized
}
public struct MessageService: Sendable {
private let client: any SimAdminClientProtocol
private let sessions: InstanceSessionStore
private let credentials: (any CredentialStore)?
public init(
client: any SimAdminClientProtocol,
sessions: InstanceSessionStore,
credentials: (any CredentialStore)? = nil
) {
self.client = client
self.sessions = sessions
self.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 {
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
do {
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
cookie = try await login(instance)
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
}
}
private func login(_ instance: Instance) async throws -> String {
guard let credentials,
let password = try credentials.get(instanceID: instance.id),
!password.isEmpty
else {
throw MessageServiceError.unauthorized
}
let sessions = InstanceSessionService(store: sessions, client: client)
let result = try await sessions.login(instance, password: password)
guard result.authenticated,
let cookie = await sessions.cookie(for: instance)
else {
throw MessageServiceError.unauthorized
}
return cookie
}
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(
id: 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 ?? ""
)
}
}
@@ -0,0 +1,125 @@
import SwiftUI
struct MessagesView: View {
@StateObject private var model: MessagesViewModel
init(model: MessagesViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeMessagesModel())
}
var body: some View {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
LazyVStack(spacing: 12) {
filterBar
if let errorMessage = model.errorMessage {
AICard {
Text(errorMessage)
.font(.subheadline)
.foregroundStyle(AITheme.warning)
}
}
if model.filteredRows.isEmpty && !model.isLoading {
AICard {
VStack(spacing: 10) {
Image(systemName: "message")
.font(.system(size: 28))
.foregroundStyle(AITheme.muted)
Text("暂无消息")
.font(.headline)
Text("刷新后从实例同步短信。")
.font(.subheadline)
.foregroundStyle(AITheme.muted)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
}
}
ForEach(model.filteredRows) { message in
messageCard(message)
}
}
.padding(16)
}
}
.navigationTitle("消息中心")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
model.showingSendForm = true
} label: {
Label("发送", systemImage: "paperplane")
}
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
}
}
.refreshable {
await model.refresh()
}
.task {
await model.refresh()
}
.sheet(isPresented: $model.showingSendForm) {
SendMessageFormView(model: model)
}
}
private var filterBar: some View {
AICard {
VStack(spacing: 10) {
Picker("方向", selection: $model.direction) {
ForEach(MessagesViewModel.DirectionFilter.allCases) { item in
Text(item.rawValue).tag(item)
}
}
.pickerStyle(.segmented)
Picker("实例", selection: $model.selectedInstanceID) {
Text("全部实例").tag(Optional<UUID>.none)
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
TextField("搜索号码或内容", text: $model.searchText)
.textFieldStyle(.plain)
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
.overlay {
RoundedRectangle(cornerRadius: AITheme.buttonRadius)
.strokeBorder(AITheme.line)
}
}
}
}
private func messageCard(_ message: SMSMessage) -> some View {
AICard {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
.font(.headline)
Spacer()
AITag(message.direction.label, tint: message.direction == .outgoing ? AITheme.primary : AITheme.success)
}
Text(message.content)
.font(.subheadline)
.foregroundStyle(AITheme.ink)
HStack {
Text(message.instanceName)
.font(.caption)
.foregroundStyle(AITheme.muted)
Spacer()
Button("删除") {
Task { await model.delete(message) }
}
.buttonStyle(AIButtonStyle(kind: .danger, size: .compact))
}
}
}
}
}
@@ -0,0 +1,88 @@
import Foundation
@MainActor
final class MessagesViewModel: ObservableObject {
enum DirectionFilter: String, CaseIterable, Identifiable {
case all = "全部"
case incoming = "收件"
case outgoing = "发件"
var id: String { rawValue }
}
@Published var rows: [SMSMessage] = []
@Published var instances: [Instance] = []
@Published var selectedInstanceID: UUID?
@Published var searchText = ""
@Published var direction: DirectionFilter = .all
@Published var isLoading = false
@Published var errorMessage: String?
@Published var showingSendForm = false
private let repository: any InstanceRepository
private let service: MessageService
init(repository: any InstanceRepository, service: MessageService) {
self.repository = repository
self.service = service
}
var filteredRows: [SMSMessage] {
rows.filter { message in
selectedInstanceID == nil || message.instanceID == selectedInstanceID
}.filter { message in
switch direction {
case .all: return true
case .incoming: return message.direction == .incoming
case .outgoing: return message.direction == .outgoing
}
}.filter { message in
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return true }
return message.phoneNumber.localizedCaseInsensitiveContains(query)
|| message.content.localizedCaseInsensitiveContains(query)
|| message.instanceName.localizedCaseInsensitiveContains(query)
}
}
var selectedInstance: Instance? {
instances.first { $0.id == selectedInstanceID }
}
func refresh() async {
isLoading = true
errorMessage = nil
do {
let loaded = try await repository.list(InstanceQuery())
instances = loaded
if selectedInstanceID == nil || !loaded.contains(where: { $0.id == selectedInstanceID }) {
selectedInstanceID = nil
}
let page = try await service.messages(loaded, limit: 100)
rows = page.messages
} catch MessageServiceError.unauthorized {
errorMessage = "有实例需要登录,未获取受保护消息。"
} catch {
errorMessage = "无法加载消息。"
}
isLoading = false
}
func send(phoneNumber: String, content: String) async throws {
guard let instance = selectedInstance ?? instances.first else {
throw MessageServiceError.invalidInput
}
try await service.send(instance, phoneNumber: phoneNumber, content: content)
await refresh()
}
func delete(_ message: SMSMessage) async {
guard let instance = instances.first(where: { $0.id == message.instanceID }) else { return }
do {
try await service.delete([message.id], from: instance)
rows.removeAll { $0.id == message.id && $0.instanceID == message.instanceID }
} catch {
errorMessage = "删除消息失败。"
}
}
}
@@ -0,0 +1,78 @@
import SwiftUI
struct SendMessageFormView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var model: MessagesViewModel
@State private var phoneNumber = ""
@State private var content = ""
@State private var validationMessage: String?
var body: some View {
NavigationStack {
ZStack {
AITheme.canvas.ignoresSafeArea()
ScrollView {
VStack(spacing: 12) {
AICard {
VStack(alignment: .leading, spacing: 14) {
Picker("实例", selection: Binding(
get: { model.selectedInstanceID ?? model.instances.first?.id },
set: { model.selectedInstanceID = $0 }
)) {
ForEach(model.instances) { instance in
Text(instance.name).tag(Optional(instance.id))
}
}
.pickerStyle(.menu)
TextField("收件号码", text: $phoneNumber)
.padding(.horizontal, 12)
.frame(minHeight: AITheme.buttonHeight)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
TextField("消息内容", text: $content, axis: .vertical)
.lineLimit(4...8)
.padding(12)
.background(AITheme.canvas)
.clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
}
}
if let validationMessage {
Text(validationMessage)
.font(.subheadline)
.foregroundStyle(AITheme.danger)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(16)
}
}
.navigationTitle("发送消息")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular))
}
ToolbarItem(placement: .confirmationAction) {
Button("发送") { send() }
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular))
}
}
}
}
private func send() {
Task {
do {
try await model.send(phoneNumber: phoneNumber, content: content)
phoneNumber = ""
content = ""
validationMessage = nil
dismiss()
} catch {
validationMessage = "发送失败,请检查号码、内容和实例登录状态。"
}
}
}
}
@@ -9,7 +9,7 @@ struct MultiSimAdminApp: App {
.tabItem {
Label("节点", systemImage: "server.rack")
}
Text("消息")
MessagesView()
.tabItem {
Label("消息", systemImage: "message")
}
@@ -14,4 +14,16 @@ enum DefaultContainer {
credentials: KeychainCredentialStore()
)
}
@MainActor
static func makeMessagesModel() -> MessagesViewModel {
MessagesViewModel(
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
service: MessageService(
client: SimAdminClient(),
sessions: InstanceSessionStore(),
credentials: KeychainCredentialStore()
)
)
}
}
@@ -0,0 +1,78 @@
import XCTest
@testable import MultiSimAdmin
final class MessageServiceTests: XCTestCase {
func testParsesUpstreamEnvelopeAndNormalizesMessages() async throws {
let store = InstanceSessionStore()
let client = MessageTestClient()
let service = MessageService(client: client, sessions: store)
let instance = Self.instance()
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
let page = try await service.messages([instance], limit: 2)
XCTAssertEqual(client.routes.first, .smsList(limit: 2, offset: 0, direction: nil))
XCTAssertEqual(page.messages.count, 1)
let message = try XCTUnwrap(page.messages.first)
XCTAssertEqual(message.id, "42")
XCTAssertEqual(message.instanceID, instance.id)
XCTAssertEqual(message.instanceName, "Message Node")
XCTAssertEqual(message.phoneNumber, "13800138000")
XCTAssertEqual(message.content, "device ready")
XCTAssertEqual(message.direction, .outgoing)
XCTAssertEqual(message.status, "sent")
}
func testSendUsesStoredSessionAndRejectsInvalidInput() async throws {
let store = InstanceSessionStore()
let client = MessageTestClient()
let service = MessageService(client: client, sessions: store)
let instance = Self.instance()
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
try await service.send(instance, phoneNumber: "13800138000", content: "hello")
XCTAssertEqual(client.routes.last, .smsSend(phoneNumber: "13800138000", content: "hello"))
do {
try await service.send(instance, phoneNumber: "", content: "hello")
XCTFail("Empty phone number should fail.")
} catch {
XCTAssertEqual(error as? MessageServiceError, .invalidInput)
}
}
private static func instance() -> Instance {
Instance(
id: UUID(),
name: "Message Node",
origin: URL(string: "http://192.168.1.20:3000")!,
configRevision: 1,
createdAt: Date(),
updatedAt: Date()
)
}
}
private final class MessageTestClient: SimAdminClientProtocol, @unchecked Sendable {
var routes: [UpstreamRoute] = []
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
routes.append(route)
guard cookie == "simadmin_session=token" else {
throw UpstreamError.unauthorized
}
switch route {
case .smsList:
let json = """
{"status":"ok","data":{"total":1,"messages":[{"id":42,"phone_number":"13800138000",
"content":"device ready","direction":"outgoing","status":"sent","timestamp":"2026-09-08T10:00:00Z"}]}}
"""
return UpstreamResponse(status: 200, data: Data(json.utf8))
case .smsSend:
return UpstreamResponse(status: 200, data: Data(#"{"status":"ok"}"#.utf8))
default:
return UpstreamResponse(status: 200, data: Data("{}".utf8))
}
}
}