feat(ios): add cross-instance notification center
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
import Foundation
|
||||
|
||||
public enum NotificationServiceError: Error, Equatable {
|
||||
case invalidInput
|
||||
case invalidResponse
|
||||
case unauthorized
|
||||
}
|
||||
|
||||
public struct NotificationService: 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 overview(_ instances: [Instance]) async throws -> NotificationOverview {
|
||||
var devices: [NotificationDevice] = []
|
||||
var configs: [NotificationConfigSummary] = []
|
||||
var logs: [NotificationLogSummary] = []
|
||||
var queues: [NotificationQueueSummary] = []
|
||||
|
||||
for instance in instances {
|
||||
do {
|
||||
let config = try Self.parseConfig(Self.data(of: try await request(.notificationConfig, instance)))
|
||||
let logPage = try Self.parseLogs(Self.data(of: try await request(
|
||||
.notificationLogs(limit: 50, offset: 0),
|
||||
instance
|
||||
)))
|
||||
let queue = try Self.parseQueue(
|
||||
Self.data(of: try await request(.notificationQueue(limit: 100), instance)),
|
||||
instance
|
||||
)
|
||||
configs.append(config)
|
||||
logs.append(logPage)
|
||||
queues.append(queue)
|
||||
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .ready))
|
||||
} catch UpstreamError.notSupported {
|
||||
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unavailable))
|
||||
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
||||
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unauthorized))
|
||||
} catch NotificationServiceError.unauthorized {
|
||||
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .unauthorized))
|
||||
} catch {
|
||||
devices.append(NotificationDevice(id: instance.id, name: instance.name, state: .failed))
|
||||
}
|
||||
}
|
||||
|
||||
return NotificationOverview(
|
||||
observedAt: Date(),
|
||||
devices: devices,
|
||||
config: Self.aggregateConfigs(configs),
|
||||
logs: Self.aggregateLogs(logs),
|
||||
queue: Self.aggregateQueues(queues)
|
||||
)
|
||||
}
|
||||
|
||||
public func retryAll(_ instances: [Instance]) async -> NotificationActionResult {
|
||||
var result = NotificationActionResult()
|
||||
for instance in instances {
|
||||
do {
|
||||
_ = try await request(.notificationRetryAllQueue, instance)
|
||||
result.succeeded += 1
|
||||
} catch UpstreamError.notSupported {
|
||||
result.skipped += 1
|
||||
} catch {
|
||||
result.failed += 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public func retryQueue(_ instance: Instance, id: String) async throws {
|
||||
try Self.validateQueueID(id)
|
||||
_ = try await request(.notificationRetryQueue(id: id), instance)
|
||||
}
|
||||
|
||||
public func deleteQueueItem(_ instance: Instance, id: String) async throws {
|
||||
try Self.validateQueueID(id)
|
||||
_ = try await request(.notificationDeleteQueue(id: id), instance)
|
||||
}
|
||||
|
||||
static func validateQueueID(_ id: String) throws {
|
||||
guard !id.isEmpty, id.count <= 128,
|
||||
id.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" || $0 == "." })
|
||||
else {
|
||||
throw NotificationServiceError.invalidInput
|
||||
}
|
||||
}
|
||||
|
||||
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 NotificationServiceError.unauthorized
|
||||
}
|
||||
let sessionService = InstanceSessionService(store: sessions, client: client)
|
||||
let result = try await sessionService.login(instance, password: password)
|
||||
guard result.authenticated,
|
||||
let cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
|
||||
else {
|
||||
throw NotificationServiceError.unauthorized
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
static func data(of response: UpstreamResponse) throws -> [String: Any] {
|
||||
guard let root = try JSONSerialization.jsonObject(with: response.data) as? [String: Any],
|
||||
let status = root["status"] as? String,
|
||||
["success", "ok", "OK"].contains(status),
|
||||
let data = root["data"] as? [String: Any]
|
||||
else {
|
||||
throw NotificationServiceError.invalidResponse
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func parseConfig(_ data: [String: Any]) throws -> NotificationConfigSummary {
|
||||
var channelCount = 0
|
||||
var channelEnabled = 0
|
||||
var typeOrder: [String] = []
|
||||
var typeTotals: [String: (total: Int, enabled: Int)] = [:]
|
||||
|
||||
func record(type: String, enabled: Bool) {
|
||||
if !typeOrder.contains(type) {
|
||||
typeOrder.append(type)
|
||||
}
|
||||
let current = typeTotals[type] ?? (0, 0)
|
||||
typeTotals[type] = (current.total + 1, current.enabled + (enabled ? 1 : 0))
|
||||
}
|
||||
|
||||
if let channels = data["channels"] as? [[String: Any]] {
|
||||
for raw in channels {
|
||||
guard let type = raw["type"] as? String, let enabled = raw["enabled"] as? Bool else { continue }
|
||||
channelCount += 1
|
||||
channelEnabled += enabled ? 1 : 0
|
||||
record(type: type, enabled: enabled)
|
||||
}
|
||||
} else {
|
||||
for type in ["webhook", "bark", "pushplus", "wecom_app", "wecom_robot", "dingtalk_robot", "dingtalk_app", "feishu_robot", "telegram", "email", "serverchan"] {
|
||||
guard let value = data[type] as? [String: Any], let enabled = value["enabled"] as? Bool else { continue }
|
||||
channelCount += 1
|
||||
channelEnabled += enabled ? 1 : 0
|
||||
record(type: type, enabled: enabled)
|
||||
}
|
||||
}
|
||||
|
||||
var ruleCount = 0
|
||||
var ruleEnabled = 0
|
||||
if let rules = data["rules"] as? [[String: Any]] {
|
||||
for raw in rules {
|
||||
guard let enabled = raw["enabled"] as? Bool else { continue }
|
||||
ruleCount += 1
|
||||
ruleEnabled += enabled ? 1 : 0
|
||||
}
|
||||
}
|
||||
let types = typeOrder.map {
|
||||
NotificationChannelTypeSummary(
|
||||
type: $0,
|
||||
total: typeTotals[$0]?.total ?? 0,
|
||||
enabled: typeTotals[$0]?.enabled ?? 0
|
||||
)
|
||||
}
|
||||
return NotificationConfigSummary(
|
||||
channelCount: channelCount,
|
||||
channelEnabled: channelEnabled,
|
||||
ruleCount: ruleCount,
|
||||
ruleEnabled: ruleEnabled,
|
||||
channelTypes: types
|
||||
)
|
||||
}
|
||||
|
||||
static func parseLogs(_ data: [String: Any]) throws -> NotificationLogSummary {
|
||||
guard let total = positive(data["total"]), let raw = data["logs"] as? [[String: Any]] else {
|
||||
throw NotificationServiceError.invalidResponse
|
||||
}
|
||||
var entries: [NotificationLogEntry] = []
|
||||
var counts = (
|
||||
success: 0, failed: 0, quietHours: 0, unmatched: 0, noAvailableChannel: 0, other: 0
|
||||
)
|
||||
for item in raw {
|
||||
guard let id = text(item["id"], 128),
|
||||
let eventType = text(item["event_type"], 80),
|
||||
let status = text(item["status"], 40),
|
||||
let createdAt = text(item["created_at"], 64)
|
||||
else {
|
||||
throw NotificationServiceError.invalidResponse
|
||||
}
|
||||
entries.append(NotificationLogEntry(
|
||||
id: id,
|
||||
eventType: eventType,
|
||||
status: status,
|
||||
ruleName: text(item["rule_name"], 160),
|
||||
channelName: text(item["channel_name"], 160),
|
||||
createdAt: createdAt
|
||||
))
|
||||
switch status {
|
||||
case "success": counts.success += 1
|
||||
case "failed": counts.failed += 1
|
||||
case "quiet_hours": counts.quietHours += 1
|
||||
case "unmatched": counts.unmatched += 1
|
||||
case "no_available_channel": counts.noAvailableChannel += 1
|
||||
default: counts.other += 1
|
||||
}
|
||||
}
|
||||
return NotificationLogSummary(
|
||||
total: total,
|
||||
success: counts.success,
|
||||
failed: counts.failed,
|
||||
quietHours: counts.quietHours,
|
||||
unmatched: counts.unmatched,
|
||||
noAvailableChannel: counts.noAvailableChannel,
|
||||
other: counts.other,
|
||||
entries: entries
|
||||
)
|
||||
}
|
||||
|
||||
static func parseQueue(_ data: [String: Any], _ instance: Instance) throws -> NotificationQueueSummary {
|
||||
guard let total = positive(data["total"]), let raw = data["items"] as? [[String: Any]] else {
|
||||
throw NotificationServiceError.invalidResponse
|
||||
}
|
||||
var items: [NotificationQueueEntry] = []
|
||||
var counts = (pending: 0, scheduled: 0, retrying: 0, sending: 0, failed: 0)
|
||||
for value in raw {
|
||||
guard let id = text(value["id"], 128),
|
||||
let eventType = text(value["event_type"], 80),
|
||||
let status = text(value["status"], 32),
|
||||
let createdAt = text(value["created_at"], 64)
|
||||
else {
|
||||
throw NotificationServiceError.invalidResponse
|
||||
}
|
||||
items.append(NotificationQueueEntry(
|
||||
id: id,
|
||||
instanceID: instance.id,
|
||||
instanceName: instance.name,
|
||||
eventType: eventType,
|
||||
status: status,
|
||||
ruleName: text(value["rule_name"], 160),
|
||||
channelName: text(value["channel_name"], 160),
|
||||
createdAt: createdAt
|
||||
))
|
||||
switch status {
|
||||
case "pending": counts.pending += 1
|
||||
case "scheduled": counts.scheduled += 1
|
||||
case "retrying": counts.retrying += 1
|
||||
case "sending": counts.sending += 1
|
||||
case "failed": counts.failed += 1
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return NotificationQueueSummary(
|
||||
total: total,
|
||||
pending: counts.pending,
|
||||
scheduled: counts.scheduled,
|
||||
retrying: counts.retrying,
|
||||
sending: counts.sending,
|
||||
failed: counts.failed,
|
||||
items: items
|
||||
)
|
||||
}
|
||||
|
||||
static func aggregateConfigs(_ values: [NotificationConfigSummary]) -> NotificationConfigSummary {
|
||||
NotificationConfigSummary(
|
||||
channelCount: values.reduce(0) { $0 + $1.channelCount },
|
||||
channelEnabled: values.reduce(0) { $0 + $1.channelEnabled },
|
||||
ruleCount: values.reduce(0) { $0 + $1.ruleCount },
|
||||
ruleEnabled: values.reduce(0) { $0 + $1.ruleEnabled },
|
||||
channelTypes: mergeChannelTypes(values.map(\.channelTypes))
|
||||
)
|
||||
}
|
||||
|
||||
static func aggregateLogs(_ values: [NotificationLogSummary]) -> NotificationLogSummary {
|
||||
NotificationLogSummary(
|
||||
total: values.reduce(0) { $0 + $1.total },
|
||||
success: values.reduce(0) { $0 + $1.success },
|
||||
failed: values.reduce(0) { $0 + $1.failed },
|
||||
quietHours: values.reduce(0) { $0 + $1.quietHours },
|
||||
unmatched: values.reduce(0) { $0 + $1.unmatched },
|
||||
noAvailableChannel: values.reduce(0) { $0 + $1.noAvailableChannel },
|
||||
other: values.reduce(0) { $0 + $1.other },
|
||||
entries: Array(values.flatMap(\.entries).prefix(20))
|
||||
)
|
||||
}
|
||||
|
||||
static func aggregateQueues(_ values: [NotificationQueueSummary]) -> NotificationQueueSummary {
|
||||
NotificationQueueSummary(
|
||||
total: values.reduce(0) { $0 + $1.total },
|
||||
pending: values.reduce(0) { $0 + $1.pending },
|
||||
scheduled: values.reduce(0) { $0 + $1.scheduled },
|
||||
retrying: values.reduce(0) { $0 + $1.retrying },
|
||||
sending: values.reduce(0) { $0 + $1.sending },
|
||||
failed: values.reduce(0) { $0 + $1.failed },
|
||||
items: Array(values.flatMap(\.items).prefix(20))
|
||||
)
|
||||
}
|
||||
|
||||
private static func text(_ value: Any?, _ maximum: Int) -> String? {
|
||||
guard let value, let text = value as? String,
|
||||
!text.isEmpty, text.count <= maximum,
|
||||
!text.unicodeScalars.contains(where: { $0.value < 9 || ($0.value > 13 && $0.value < 32) || $0.value == 127 })
|
||||
else { return nil }
|
||||
return text
|
||||
}
|
||||
|
||||
private static func positive(_ value: Any?) -> Int? {
|
||||
guard let number = value as? Int, number >= 0, number <= 10_000_000 else { return nil }
|
||||
return number
|
||||
}
|
||||
|
||||
private static func mergeChannelTypes(
|
||||
_ groups: [[NotificationChannelTypeSummary]]
|
||||
) -> [NotificationChannelTypeSummary] {
|
||||
var merged: [NotificationChannelTypeSummary] = []
|
||||
for group in groups {
|
||||
for summary in group {
|
||||
if let index = merged.firstIndex(where: { $0.type == summary.type }) {
|
||||
let current = merged[index]
|
||||
merged[index] = NotificationChannelTypeSummary(
|
||||
type: current.type,
|
||||
total: current.total + summary.total,
|
||||
enabled: current.enabled + summary.enabled
|
||||
)
|
||||
} else {
|
||||
merged.append(summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
public struct NotificationActionResult: Equatable, Sendable {
|
||||
public var succeeded = 0
|
||||
public var failed = 0
|
||||
public var skipped = 0
|
||||
|
||||
public init() {}
|
||||
}
|
||||
Reference in New Issue
Block a user