357 lines
14 KiB
Swift
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() {}
|
|
}
|