feat(ios): add cross-instance message center
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user