Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Features/Notifications/NotificationService.swift
T

357 lines
14 KiB
Swift

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() {}
}