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