diff --git a/MeoAsstMac.xcodeproj/project.pbxproj b/MeoAsstMac.xcodeproj/project.pbxproj index d498427d9..58d2b6139 100644 --- a/MeoAsstMac.xcodeproj/project.pbxproj +++ b/MeoAsstMac.xcodeproj/project.pbxproj @@ -7,6 +7,14 @@ objects = { /* Begin PBXBuildFile section */ + 4505F96D2E7E9852006E7D28 /* DingTalkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4505F9672E7E9852006E7D28 /* DingTalkService.swift */; }; + 4505F96E2E7E9852006E7D28 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4505F9692E7E9852006E7D28 /* NotificationService.swift */; }; + 4505F96F2E7E9852006E7D28 /* DingTalkBotClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4505F9662E7E9852006E7D28 /* DingTalkBotClient.swift */; }; + 4505F9702E7E9852006E7D28 /* CustomWebhookService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4505F9652E7E9852006E7D28 /* CustomWebhookService.swift */; }; + 4505F9712E7E9852006E7D28 /* BarkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4505F9642E7E9852006E7D28 /* BarkService.swift */; }; + 4505F9722E7E9852006E7D28 /* QmsgService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4505F96A2E7E9852006E7D28 /* QmsgService.swift */; }; + 4505F9732E7E9852006E7D28 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4505F9682E7E9852006E7D28 /* NotificationManager.swift */; }; + 4505F9752E7E9884006E7D28 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4505F9742E7E9884006E7D28 /* NotificationSettingsView.swift */; }; 830C3D1C29BC7D8600B13A9F /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 830C3D1B29BC7D8600B13A9F /* Sparkle */; }; 830C3D1E29BC7DE200B13A9F /* CheckForUpdatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830C3D1D29BC7DE200B13A9F /* CheckForUpdatesView.swift */; }; 8315B0BD290E48F300AE7FDC /* StartupSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8315B0BC290E48F300AE7FDC /* StartupSettingsView.swift */; }; @@ -131,6 +139,14 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 4505F9642E7E9852006E7D28 /* BarkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarkService.swift; sourceTree = ""; }; + 4505F9652E7E9852006E7D28 /* CustomWebhookService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWebhookService.swift; sourceTree = ""; }; + 4505F9662E7E9852006E7D28 /* DingTalkBotClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DingTalkBotClient.swift; sourceTree = ""; }; + 4505F9672E7E9852006E7D28 /* DingTalkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DingTalkService.swift; sourceTree = ""; }; + 4505F9682E7E9852006E7D28 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; + 4505F9692E7E9852006E7D28 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + 4505F96A2E7E9852006E7D28 /* QmsgService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QmsgService.swift; sourceTree = ""; }; + 4505F9742E7E9884006E7D28 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; 830C3D1D29BC7DE200B13A9F /* CheckForUpdatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckForUpdatesView.swift; sourceTree = ""; }; 830C3D1F29BC86EE00B13A9F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 8315B0BC290E48F300AE7FDC /* StartupSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupSettingsView.swift; sourceTree = ""; }; @@ -244,6 +260,28 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 4505F96B2E7E9852006E7D28 /* Notification */ = { + isa = PBXGroup; + children = ( + 4505F9642E7E9852006E7D28 /* BarkService.swift */, + 4505F9652E7E9852006E7D28 /* CustomWebhookService.swift */, + 4505F9662E7E9852006E7D28 /* DingTalkBotClient.swift */, + 4505F9672E7E9852006E7D28 /* DingTalkService.swift */, + 4505F9682E7E9852006E7D28 /* NotificationManager.swift */, + 4505F9692E7E9852006E7D28 /* NotificationService.swift */, + 4505F96A2E7E9852006E7D28 /* QmsgService.swift */, + ); + path = Notification; + sourceTree = ""; + }; + 4505F96C2E7E9852006E7D28 /* Services */ = { + isa = PBXGroup; + children = ( + 4505F96B2E7E9852006E7D28 /* Notification */, + ); + path = Services; + sourceTree = ""; + }; 83245A0128F467C300ED6D4C /* Frameworks */ = { isa = PBXGroup; children = ( @@ -382,6 +420,7 @@ 92CDF6202A065E20005669EC /* Utils */, 83FF5A98297855E5000AE831 /* Settings */, 8337870728F1DA7B00D39D6F /* Resources */, + 4505F96C2E7E9852006E7D28 /* Services */, 833786D228F188D900D39D6F /* Assets.xcassets */, 830C3D1F29BC86EE00B13A9F /* Info.plist */, 833786D728F188D900D39D6F /* MeoAsstMac.entitlements */, @@ -458,6 +497,7 @@ 838FF82129FBB9E3000566A0 /* GameSettingsView.swift */, 83DD50B629BE385A000E84E7 /* UpdaterSettingsView.swift */, 92EB82862A0CD315006DD9D2 /* SystemSettingsView.swift */, + 4505F9742E7E9884006E7D28 /* NotificationSettingsView.swift */, ); path = Settings; sourceTree = ""; @@ -578,6 +618,13 @@ 834DEC122C4D94BD0073B5D4 /* ClosedownConfiguration.swift in Sources */, 83477CB429FEA7A500A5FF99 /* TaskButtons.swift in Sources */, 8355E7B829E9E2B2005D5A1C /* Sidebar.swift in Sources */, + 4505F96D2E7E9852006E7D28 /* DingTalkService.swift in Sources */, + 4505F96E2E7E9852006E7D28 /* NotificationService.swift in Sources */, + 4505F96F2E7E9852006E7D28 /* DingTalkBotClient.swift in Sources */, + 4505F9702E7E9852006E7D28 /* CustomWebhookService.swift in Sources */, + 4505F9712E7E9852006E7D28 /* BarkService.swift in Sources */, + 4505F9722E7E9852006E7D28 /* QmsgService.swift in Sources */, + 4505F9732E7E9852006E7D28 /* NotificationManager.swift in Sources */, 83661E2C29F77CFF007793CA /* VideoRecogView.swift in Sources */, 8337870228F1D95F00D39D6F /* Maa.swift in Sources */, 832985B929EF9D50009F7ED5 /* MAAContent.swift in Sources */, @@ -602,6 +649,7 @@ 83264A3E29EC22DE0074E33A /* MAAViewModel.swift in Sources */, 92F78D4D2A0A7B2E00999BD0 /* TaskTimerView.swift in Sources */, 8383EA2F28F2C5BD007DAB9D /* FightSettingsView.swift in Sources */, + 4505F9752E7E9884006E7D28 /* NotificationSettingsView.swift in Sources */, 83531D5F29F00F0A008A5DF8 /* CopilotView.swift in Sources */, 83477CB229FEA12300A5FF99 /* TaskCommands.swift in Sources */, 8329858C29EDA952009F7ED5 /* OpenLogFileView.swift in Sources */, diff --git a/MeoAsstMac/MeoAsstMacApp.swift b/MeoAsstMac/MeoAsstMacApp.swift index e416dbc1a..1e59fbd14 100644 --- a/MeoAsstMac/MeoAsstMacApp.swift +++ b/MeoAsstMac/MeoAsstMacApp.swift @@ -16,13 +16,30 @@ struct MeoAsstMacApp: App { private let updaterController: SPUStandardUpdaterController private let updaterDelegate = MaaUpdaterDelegate() + // 通知管理器 + private var notificationManager: NotificationManager + init() { + // 创建 MAAViewModel 的唯一实例 + let viewModel = MAAViewModel() + + // 使用创建的实例初始化 @StateObject 属性。 + // 注意在初始化器中为属性包装器赋值时使用的下划线语法。 + _appViewModel = StateObject(wrappedValue: viewModel) + + // 现在,使用同一个实例来初始化通知管理器 + let manager = NotificationManager(viewModel: viewModel) + self.notificationManager = manager #if DEBUG let isRelease = false #else let isRelease = true #endif updaterController = .init(startingUpdater: isRelease, updaterDelegate: updaterDelegate, userDriverDelegate: nil) + + // 立即开始监听日志 + self.notificationManager.startObserving() + } var body: some Scene { @@ -65,6 +82,11 @@ struct MeoAsstMacApp: App { .tabItem { Label("系统设置", systemImage: "wrench.adjustable") } + + NotificationSettingsView() + .tabItem { + Label("通知设置", systemImage: "ellipsis.message") + } } .environmentObject(appViewModel) .frame(maxWidth: 360, minHeight: 240) diff --git a/MeoAsstMac/Model/MAAViewModel.swift b/MeoAsstMac/Model/MAAViewModel.swift index 3517d6ce3..6b4743b53 100644 --- a/MeoAsstMac/Model/MAAViewModel.swift +++ b/MeoAsstMac/Model/MAAViewModel.swift @@ -9,6 +9,35 @@ import Combine import IOKit.pwr_mgt import SwiftUI +/// 用于存储通知触发器配置的结构体 +struct NotificationTriggers: Codable { + var onTaskCompletion: Bool = false + var sendAllLogs: Bool = true + var onTaskError: Bool = false + var onTaskTimeout: Bool = false + var sendAllLogsAfterFinish: Bool = true +} + +/// 自定义 Webhook 配置结构体 +struct CustomWebhookSettings: Codable { + var isEnabled: Bool = false + var url: String = "" + var bodyTemplate: String = """ + { + "content": "{title}\n{content}" + } + """ +} + +/// 用于存储 Qmsg 配置的结构体 +struct QmsgSettings: Codable { + var isEnabled: Bool = false + var server: String = "https://qmsg.zendee.cn/" + var key: String = "" + var userQQ: String = "" // 推送目标的 QQ + var botQQ: String = "" // 机器人的 QQ +} + @MainActor class MAAViewModel: ObservableObject { // MARK: - Core Status @@ -151,6 +180,90 @@ import SwiftUI } } + // MARK: - General Notification Settings + + @AppStorage("notificationSendingInterval") var notificationSendingInterval: Double = 1.0 + + @AppStorage("notificationTriggersData") private var notificationTriggersData: Data? + + var notificationTriggers: NotificationTriggers { + get { + if let data = notificationTriggersData, + let decodedSettings = try? JSONDecoder().decode(NotificationTriggers.self, from: data) + { + return decodedSettings + } + return NotificationTriggers() + } + set { + if let encodedData = try? JSONEncoder().encode(newValue) { + notificationTriggersData = encodedData + } + } + } + + // MARK: - DingTalk Settings + + @AppStorage("SendLogsInGUI") var showSendLogsInGUI: Bool = true + + @AppStorage("DingTalkBotURL") var DKwebhookURL = "" + + @AppStorage("DingTalkBotSecret") var DKsecret = "" + + @AppStorage("DingTalkBot") var DingTalkBot = false + + // MARK: - Bark Settings + + @AppStorage("Bark") var BarkBot: Bool = false + + @AppStorage("BarkKey") var BarkKey: String = "" + + @AppStorage("BarkServer") var BarkServer: String = "https://api.day.app/" + + // MARK: - Qmsg Settings + @AppStorage("qmsgSettingsData") private var qmsgSettingsData: Data? + + var qmsg: QmsgSettings { + get { + if let data = qmsgSettingsData, + let decodedSettings = try? JSONDecoder().decode(QmsgSettings.self, from: data) + { + return decodedSettings + } + return QmsgSettings() + } + set { + if let encodedData = try? JSONEncoder().encode(newValue) { + qmsgSettingsData = encodedData + } + } + } + + // MARK: - Custom Webhook Settings + + @AppStorage("customWebhookSettingsData") private var customWebhookSettingsData: Data? + /// 这是我们提供给 App 其他部分(比如 UI)使用的接口。 + /// 它是一个计算属性,自动处理编码和解码的复杂工作。 + var customWebhook: CustomWebhookSettings { + get { + // “get” - 当 UI 需要读取设置时: + // 尝试从 Data 中解码。如果失败或没有数据,就返回一个全新的默认设置。 + if let data = customWebhookSettingsData, + let decodedSettings = try? JSONDecoder().decode(CustomWebhookSettings.self, from: data) + { + return decodedSettings + } + return CustomWebhookSettings() + } + set { + // “set” - 当 UI 修改了设置时: + // 将新的设置编码成 Data,然后保存到 AppStorage 中。 + if let encodedData = try? JSONEncoder().encode(newValue) { + customWebhookSettingsData = encodedData + } + } + } + // MARK: - Initializer init() { diff --git a/MeoAsstMac/Services/Notification/BarkService.swift b/MeoAsstMac/Services/Notification/BarkService.swift new file mode 100644 index 000000000..a5486b56a --- /dev/null +++ b/MeoAsstMac/Services/Notification/BarkService.swift @@ -0,0 +1,123 @@ +// +// BarkService.swift +// MAA +// +// Created by RainYang on 2025/8/30. +// + +import Foundation + +/// Bark 通知服务,实现了 NotificationService 协议。 +/// +/// 这个类包含了发送 Bark 通知的具体逻辑,并内置了独立的失败重试和自动禁用机制。 +@MainActor +class BarkService: NotificationService { + + /// 用于记录连续发送失败的次数。 + private var consecutiveFailureCount = 0 + /// 连续失败多少次后自动禁用功能。 + private let failureThreshold = 3 + /// 所有发送失败日志的缓冲区。 + private var faillogBuffer: [MAALog] = [] + + /// 发送 Bark 通知。 + /// - Returns: 如果发送失败且需要重试,则返回原始的日志数组;否则返回 `nil`。 + func send(logs: [MAALog], using config: NotificationConfig, viewModel: MAAViewModel) async -> [MAALog]? { + // 1. 构建 Bark 请求 URLRequest (使用 JSON POST) + guard let request = buildRequest(for: logs, config: config, viewModel: viewModel) else { + if viewModel.showSendLogsInGUI { + viewModel.logError("构建 Bark POST 请求失败。") + } + viewModel.BarkBot = false + // 这是配置错误或 JSON 编码错误,不进行重试。 + return nil + } + + do { + // 2. 发送网络请求 + if viewModel.showSendLogsInGUI { + viewModel.logInfo("准备发送 Bark 日志摘要 (\(logs.count)条)...") + } + // 改为使用 URLSession.shared.upload(for:from:) 来发送 POST 请求体 + let (_, response) = try await URLSession.shared.data(for: request) + + // 3. 检查服务器响应 + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw URLError(.badServerResponse, userInfo: ["statusCode": statusCode]) + } + if viewModel.showSendLogsInGUI { + viewModel.logInfo("Bark 通知发送成功。") + } + // 发送成功,重置失败计数器并返回 nil (表示无需重试)。 + consecutiveFailureCount = 0 + return nil + + } catch { + // 4. 捕获错误,执行失败重试或自动禁用逻辑 + consecutiveFailureCount += 1 + if viewModel.showSendLogsInGUI { + viewModel.logError("发送 Bark 通知失败: \(error.localizedDescription)") + } + + if consecutiveFailureCount >= failureThreshold { + // 如果连续失败次数达到阈值,则禁用功能。 + viewModel.BarkBot = false + if viewModel.showSendLogsInGUI { + viewModel.logError("Bark 通知连续失败 \(consecutiveFailureCount) 次,已自动关闭此功能。请检查网络和 Bark Key/服务器配置。") + } + // 返回 nil,停止重试。 + return nil + } else { + // 如果失败次数未达到阈值,则请求重试。 + if viewModel.showSendLogsInGUI { + viewModel.logWarn("Bark 通知发送失败(第 \(consecutiveFailureCount) 次尝试),将在10s后重试。") + } + // 返回原始日志数组,通知上层管理器将它们加回缓冲区。 + return logs + } + } + } + + /// 根据配置和日志内容构建最终的 Bark URLRequest 和 JSON Body。 + /// 移除原 buildURL 方法,改为此 POST 请求构建方法。 + private func buildRequest(for logs: [MAALog], config: NotificationConfig, viewModel: MAAViewModel) -> URLRequest? { + // 1. 准备通知内容 + let title = (viewModel.notificationTriggers.sendAllLogsAfterFinish) ? "MAA 任务已全部完成" : "MAA 日志摘要 (\(logs.count)条)" + let bodyItems = logs.map { log in + let timeString = log.date.formatted(date: .omitted, time: .standard) + return "[\(timeString)] \(log.content)" + } + let body = bodyItems.joined(separator: "\n") + + // 2. 准备 Bark 服务器 URL + // 注意:POST 请求的 URL 路径与 GET 不同,只需要到 /push 即可。 + // 为了兼容旧的配置,我们从 config.barkServer 和 config.barkKey 构造基础 URL。 + let barkBaseURL = config.barkServer.hasSuffix("/") ? config.barkServer : "\(config.barkServer)/" + guard let url = URL(string: "\(barkBaseURL)push") else { + return nil + } + + // 3. 构建 JSON Payload + let jsonPayload: [String: Any] = [ + "title": title, + "body": body, + "device_key": config.barkKey, // 使用 device_key 字段传递 Bark Key + "group": "MAA", + "icon": "https://maa.plus/docs/images/maa-logo_512x512.png" + ] + + // 4. JSON 编码 + guard let httpBody = try? JSONSerialization.data(withJSONObject: jsonPayload, options: []) else { + return nil + } + + // 5. 构建 URLRequest + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = httpBody + + return request + } +} diff --git a/MeoAsstMac/Services/Notification/CustomWebhookService.swift b/MeoAsstMac/Services/Notification/CustomWebhookService.swift new file mode 100644 index 000000000..3871f1c73 --- /dev/null +++ b/MeoAsstMac/Services/Notification/CustomWebhookService.swift @@ -0,0 +1,84 @@ +// +// CustomWebhookService.swift +// MAA +// +// Created by RainYang on 2025/8/30. +// + +import Foundation + +@MainActor +class CustomWebhookService: NotificationService { + /// 用于记录连续发送失败的次数。 + private var consecutiveFailureCount = 0 + /// 连续失败多少次后自动禁用功能。 + private let failureThreshold = 3 + + /// 异步发送格式化后的日志。 + /// - Returns: 如果发送失败且需要重试,则返回原始的日志数组;如果发送成功或无需重试,则返回 `nil`。 + func send(logs: [MAALog], using config: NotificationConfig, viewModel: MAAViewModel) async -> [MAALog]? { + let settings = config.customWebhook + guard let url = URL(string: settings.url) else { + viewModel.logError("自定义 Webhook URL 无效。") + return nil // 配置错误,不重试 + } + + do { + var request = URLRequest(url: url) + // 默认使用 POST 和 application/json,因为这是 Webhook 最常见的配置 + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + // 1. 准备所有可用的模板变量 + let title = (viewModel.notificationTriggers.sendAllLogsAfterFinish) ? "MAA 任务已全部完成" : "MAA 日志摘要 (\(logs.count)条)" + let contentItems = logs.map { log -> String in + let timeString = log.date.formatted(date: .omitted, time: .standard) + // 为 JSON 字符串中的内容转义特殊字符 + let escapedContent = log.content + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + return "[\(timeString)] \(escapedContent)" + } + let content = contentItems.joined(separator: "\\n") // 在 JSON 中使用 \\n 表示换行 + + let time = Date().formatted(date: .omitted, time: .standard) + + // 2. 使用用户在 UI 中提供的完整 bodyTemplate 替换所有变量 + var processedBody = settings.bodyTemplate + processedBody = processedBody.replacingOccurrences(of: "{title}", with: title) + processedBody = processedBody.replacingOccurrences(of: "{content}", with: content) + processedBody = processedBody.replacingOccurrences(of: "{time}", with: time) + + request.httpBody = processedBody.data(using: .utf8) + + // 3. 发送网络请求 + viewModel.logInfo("准备发送自定义 Webhook...") + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw URLError(.badServerResponse, userInfo: ["statusCode": statusCode]) + } + + // 4. 处理成功响应 + viewModel.logInfo("自定义 Webhook 发送成功。") + consecutiveFailureCount = 0 + return nil + + } catch { + // 5. 处理失败响应,执行重试或禁用逻辑 + consecutiveFailureCount += 1 + viewModel.logError("发送自定义 Webhook 失败: \(error.localizedDescription)") + + if consecutiveFailureCount >= failureThreshold { + viewModel.customWebhook.isEnabled = false + viewModel.logError("自定义 Webhook 连续失败 \(consecutiveFailureCount) 次,已自动关闭。") + return nil + } else { + viewModel.logWarn("自定义 Webhook 发送失败(第 \(consecutiveFailureCount) 次尝试),将在10s后重试。") + return logs + } + } + } +} diff --git a/MeoAsstMac/Services/Notification/DingTalkBotClient.swift b/MeoAsstMac/Services/Notification/DingTalkBotClient.swift new file mode 100644 index 000000000..f15be5f10 --- /dev/null +++ b/MeoAsstMac/Services/Notification/DingTalkBotClient.swift @@ -0,0 +1,235 @@ +// +// DingTalkBotClient.swift +// MAA +// +// Created by RainYang on 2025/8/30. +// + + +import Foundation +import SwiftUI +import CryptoKit + +/// 一个用于与钉钉自定义机器人交互的客户端。 +/// +/// 这个类处理了与钉钉机器人API通信的所有细节,包括网络请求和安全签名(加签)。 +/// 它可以轻松地集成到任何Swift项目中,尤其适合在SwiftUI中使用。 +/// +/// ## 使用方法: +/// +/// 1. **初始化客户端**: +/// ``` +/// let botClient = DingTalkBotClient( +/// webhookURL: "YOUR_WEBHOOK_URL", +/// secret: "YOUR_SECRET_KEY" // 如果设置了“加签”,请提供密钥 +/// ) +/// ``` +/// +/// 2. **在 SwiftUI 视图的 `Task` 中发送消息**: +/// ``` +/// Task { +/// do { +/// try await botClient.sendTextMessage(content: "这是一条来自 SwiftUI 的测试消息") +/// print("消息发送成功!") +/// } catch { +/// print("消息发送失败: \(error.localizedDescription)") +/// } +/// } +/// ``` +class DingTalkBotClient { + + /// 钉钉机器人的 Webhook URL。 + private let webhookURL: String + + /// 用于“加签”安全设置的密钥。如果未设置,则为 `nil`。 + private let secret: String? + + /// 自定义错误类型,用于更好地描述发送过程中可能出现的问题。 + enum BotError: LocalizedError { + case invalidURL + case networkError(Error) + case apiError(code: Int, message: String) + case encodingError + case featureDisabled + case unknownError + + var errorDescription: String? { + switch self { + case .invalidURL: + return "无效的 Webhook URL。" + case .networkError(let error): + return "网络请求失败: \(error.localizedDescription)" + case .apiError(let code, let message): + return "钉钉 API 错误 (代码: \(code)): \(message)" + case .encodingError: + return "请求体编码失败。" + case .featureDisabled: + return "此功能已被禁用。" + case .unknownError: + return "发生未知错误。" + } + } + } + + /// 初始化一个钉钉机器人客户端实例。 + /// - Parameters: + /// - webhookURL: 钉钉机器人的完整 Webhook URL。 + /// - secret: (可选)如果你的机器人开启了“加签”安全设置,请提供此处。 + init(webhookURL: String, secret: String? = nil) { + self.webhookURL = webhookURL + self.secret = secret + } + + // MARK: - Public Methods + + /// 发送文本消息。 + /// - Parameters: + /// - content: 消息文本内容。**注意**: 如果机器人设置了“自定义关键词”,内容中必须包含其中一个关键词。 + /// - atMobiles: 需要@的用户的手机号列表。 + /// - isAtAll: 是否@所有人。 + @discardableResult + func sendTextMessage(content: String, atMobiles: [String] = [], isAtAll: Bool = false) async throws -> DingTalkResponse { + let payload = DingTalkPayload.text( + text: DingTalkPayload.TextPayload(content: content), + at: DingTalkPayload.AtPayload(atMobiles: atMobiles, isAtAll: isAtAll) + ) + return try await sendRequest(payload: payload) + } + + /// 发送 Markdown 格式消息。 + /// - Parameters: + /// - title: 消息标题,会显示在推送通知中。 + /// - text: Markdown 格式的消息内容。 + /// - atMobiles: 需要@的用户的手机号列表。 + /// - isAtAll: 是否@所有人。 + @discardableResult + func sendMarkdownMessage(title: String, text: String, atMobiles: [String] = [], isAtAll: Bool = false) async throws -> DingTalkResponse { + let payload = DingTalkPayload.markdown( + markdown: DingTalkPayload.MarkdownPayload(title: title, text: text), + at: DingTalkPayload.AtPayload(atMobiles: atMobiles, isAtAll: isAtAll) + ) + return try await sendRequest(payload: payload) + } + + // MARK: - Private Helpers + + /// 根据是否提供了 `secret` 来生成最终的请求 URL。 + /// 如果提供了 `secret`,则会计算签名并附加到 URL 参数中。 + private func getSignedURL() throws -> URL { + guard var components = URLComponents(string: webhookURL) else { + throw BotError.invalidURL + } + + // 如果没有 secret,直接返回原始 URL + guard let secret = secret, !secret.isEmpty else { + guard let url = components.url else { throw BotError.invalidURL } + return url + } + + // --- 开始计算签名 --- + let timestamp = String(Int(Date().timeIntervalSince1970 * 1000)) + let stringToSign = "\(timestamp)\n\(secret)" + + let secretData = Data(secret.utf8) + let stringToSignData = Data(stringToSign.utf8) + + let key = SymmetricKey(data: secretData) + let signature = HMAC.authenticationCode(for: stringToSignData, using: key) + let signatureBase64 = Data(signature).base64EncodedString() + + // URL-encode 签名结果 + guard let encodedSign = signatureBase64.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + throw BotError.encodingError + } + + components.queryItems = (components.queryItems ?? []) + [ + URLQueryItem(name: "timestamp", value: timestamp), + URLQueryItem(name: "sign", value: encodedSign) + ] + + guard let signedUrl = components.url else { + throw BotError.invalidURL + } + + return signedUrl + } + + /// 发送网络请求的核心方法。 + private func sendRequest(payload: T) async throws -> DingTalkResponse { + let url = try getSignedURL() + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + do { + request.httpBody = try JSONEncoder().encode(payload) + } catch { + throw BotError.encodingError + } + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { + throw BotError.networkError(NSError(domain: "HTTPError", code: (response as? HTTPURLResponse)?.statusCode ?? 500, userInfo: nil)) + } + + let apiResponse = try JSONDecoder().decode(DingTalkResponse.self, from: data) + + if apiResponse.errcode != 0 { + throw BotError.apiError(code: apiResponse.errcode, message: apiResponse.errmsg) + } + + return apiResponse + } +} + + +// MARK: - DingTalk Data Models + +/// 钉钉机器人消息的通用响应体。 +struct DingTalkResponse: Decodable { + let errcode: Int + let errmsg: String +} + +/// 钉钉机器人消息的载荷(Payload)结构体。 +/// 使用 `enum` 来区分不同的消息类型。 +private enum DingTalkPayload: Encodable { + case text(text: TextPayload, at: AtPayload) + case markdown(markdown: MarkdownPayload, at: AtPayload) + // 在这里可以添加更多消息类型,如 link, actionCard 等 + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .text(let text, let at): + try container.encode("text", forKey: .msgtype) + try container.encode(text, forKey: .text) + try container.encode(at, forKey: .at) + case .markdown(let markdown, let at): + try container.encode("markdown", forKey: .msgtype) + try container.encode(markdown, forKey: .markdown) + try container.encode(at, forKey: .at) + } + } + + enum CodingKeys: String, CodingKey { + case msgtype, text, markdown, at + } + + // MARK: - Nested Payload Structs + struct TextPayload: Encodable { + let content: String + } + + struct MarkdownPayload: Encodable { + let title: String + let text: String + } + + struct AtPayload: Encodable { + let atMobiles: [String] + let isAtAll: Bool + } +} diff --git a/MeoAsstMac/Services/Notification/DingTalkService.swift b/MeoAsstMac/Services/Notification/DingTalkService.swift new file mode 100644 index 000000000..30aa3a100 --- /dev/null +++ b/MeoAsstMac/Services/Notification/DingTalkService.swift @@ -0,0 +1,74 @@ +// +// DingTalkService.swift +// MAA +// +// Created by RainYang on 2025/8/30. +// + +import Foundation + +@MainActor +class DingTalkService: NotificationService { + + // 1. 将状态管理属性从旧的 Manager 移入此处 + /// 用于记录连续发送失败的次数。 + private var consecutiveFailureCount = 0 + /// 连续失败多少次后自动禁用功能。 + private let failureThreshold = 3 + + func send(logs: [MAALog], using config: NotificationConfig, viewModel: MAAViewModel) async -> [MAALog]? { + let botClient = DingTalkBotClient( + webhookURL: config.dingTalkWebhook, + secret: config.dingTalkSecret + ) + + let title = (viewModel.notificationTriggers.sendAllLogsAfterFinish) ? "MAA 任务已全部完成" : "MAA 日志摘要 (\(logs.count)条)" + let contentItems = logs.map { log in + let timeString = log.date.formatted(date: .omitted, time: .standard) + let escapedContent = log.content.replacingOccurrences(of: "\"", with: "\\\"") + return "- **[\(timeString)]** \(escapedContent)" + } + let fullContent = """ + ### \(title) + --- + \(contentItems.joined(separator: "\n\n")) + """ + + do { + if viewModel.showSendLogsInGUI { + viewModel.logInfo("准备发送 DingTalk 日志摘要 (\(logs.count)条)...") + } + _ = try await botClient.sendMarkdownMessage(title: title, text: fullContent) + if viewModel.showSendLogsInGUI { + viewModel.logInfo("钉钉通知发送成功。") + } + + // 2. 发送成功,重置失败计数器,并返回 nil 表示无需重试 + consecutiveFailureCount = 0 + return nil + } catch { + // 3. 发送失败,执行完整的重试或禁用逻辑 + consecutiveFailureCount += 1 + if viewModel.showSendLogsInGUI { + viewModel.logError("发送钉钉通知摘要失败: \(error.localizedDescription)") + } + + if consecutiveFailureCount >= failureThreshold { + // 如果连续失败次数达到阈值 + viewModel.DingTalkBot = false // 禁用功能 + if viewModel.showSendLogsInGUI { + viewModel.logError("钉钉通知连续失败 \(consecutiveFailureCount) 次,已自动关闭此功能。请检查网络和配置。") + } + // 返回 nil,表示任务结束,不要再重试了 + return nil + } else { + // 如果失败次数未达到阈值 + if viewModel.showSendLogsInGUI { + viewModel.logWarn("钉钉通知发送失败(第 \(consecutiveFailureCount) 次尝试),将在10s后重试。") + } + // 返回原始的日志数组,通知上层管理器需要将这些日志加回缓冲区 + return logs + } + } + } +} diff --git a/MeoAsstMac/Services/Notification/NotificationManager.swift b/MeoAsstMac/Services/Notification/NotificationManager.swift new file mode 100644 index 000000000..fb01148f1 --- /dev/null +++ b/MeoAsstMac/Services/Notification/NotificationManager.swift @@ -0,0 +1,238 @@ +// +// NotificationManager.swift +// MAA +// +// Created by RainYang on 2025/8/30. +// + +import Combine +import Foundation + +/// 通用通知管理器,负责调度所有启用的通知服务。 +/// +/// 这个管理器实现了核心的“缓冲”与“节流”逻辑: +/// 1. 监听全局日志流。 +/// 2. 将符合条件的日志存入一个共享的缓冲区 (`logBuffer`)。 +/// 3. 一个后台定时任务 (`processingTask`) 会周期性地唤醒。 +/// 4. 唤醒后,它会调用所有已启用的通知服务(如钉钉、Bark),并将当前缓冲区的日志交给它们发送。 +/// 5. 它还会处理具体服务返回的“重试请求”,将发送失败的日志重新放回缓冲区。 +@MainActor +class NotificationManager { + + // MARK: - 属性 (Properties) + + private let viewModel: MAAViewModel + + /// 所有新日志的共享缓冲区。 + private var logBuffer: [MAALog] = [] + + /// 用于定时处理缓冲区的后台任务。 + private var processingTask: Task? + + /// 用于存储 Combine 订阅。 + private var cancellables = Set() + + // 持有所有可用通知服务的实例。 + // 将它们作为属性持有,可以让他们各自维护自己的状态(例如失败计数)。 + private let dingTalkService = DingTalkService() + private let barkService = BarkService() + private let qmsgService = QmsgService() + private let customWebhookService = CustomWebhookService() + + init(viewModel: MAAViewModel) { + self.viewModel = viewModel + } + + // MARK: - 公开方法 (Public Methods) + + /// 开始监听日志流并启动定时发送任务。 + func startObserving() { + stopObserving() + + viewModel.$logs + .dropFirst() + .sink { [weak self] newLogs in + guard let self = self, let latestLog = newLogs.last else { return } + + if viewModel.notificationTriggers.sendAllLogs { + // 过滤掉管理器自身产生的日志,防止无限反馈循环。 + if !latestLog.content.contains("钉钉") && !latestLog.content.contains("DingTalk") + && !latestLog.content.contains("Bark") && !latestLog.content.contains("Webhook") + && !latestLog.content.contains("Qmsg") + { + self.logBuffer.append(latestLog) + } + } else { + if viewModel.notificationTriggers.onTaskCompletion + && (latestLog.content.contains("完成任务") || latestLog.content.contains("작업 완료") + || latestLog.content.contains("任务已全部完成!") || latestLog.content.contains("모든 작업이 완료되었습니다!")) + { + self.logBuffer.append(latestLog) + } else if viewModel.notificationTriggers.onTaskError + && (latestLog.content.contains("任务出错") || latestLog.content.contains("작업 오류")) + { + self.logBuffer.append(latestLog) + } + } + + // 检查“任务已全部完成!”日志,并检查是否启用了“完成后发送全部日志” + if latestLog.content.contains("已停止") { + if self.viewModel.notificationTriggers.sendAllLogsAfterFinish { + // 如果启用了此功能,将所有日志设置为待发送,并立即触发发送 + // self.logBuffer = newLogs + Task { @MainActor in + await self.processAndSendBuffer() + } + } + } + } + .store(in: &cancellables) + processingTask = Task { + while !Task.isCancelled { + // 从 ViewModel 动态获取用户设置的发送间隔(分钟) + let intervalInMinutes = viewModel.notificationSendingInterval + // 确保间隔至少为 1 分钟,防止设置过小导致问题 + let safeInterval = max(intervalInMinutes, 1) + try? await Task.sleep(nanoseconds: UInt64(safeInterval * 60 * 1_000_000_000)) + + if !viewModel.notificationTriggers.sendAllLogsAfterFinish { + await processAndSendBuffer() + } + } + } + } + + /// 停止监听并取消后台任务,释放资源。 + func stopObserving() { + cancellables.removeAll() + processingTask?.cancel() + processingTask = nil + } + + // MARK: - 私有方法 (Private Methods) + + /// 处理并发送缓冲区中的日志。 + /// 这个方法由后台任务定时调用。 + private func processAndSendBuffer() async { + // 如果缓冲区为空,则无需执行任何操作。 + guard !logBuffer.isEmpty else { return } + + // 创建一个日志副本用于发送,并立即清空主缓冲区,以接收新的日志。 + let logsToSend = logBuffer + logBuffer.removeAll() + + // 从 ViewModel 创建一个包含所有当前配置的快照。 + let currentConfig = NotificationConfig( + dingTalkWebhook: viewModel.DKwebhookURL, + dingTalkSecret: viewModel.DKsecret, + barkKey: viewModel.BarkKey, + barkServer: viewModel.BarkServer, + qmsg: viewModel.qmsg, + customWebhook: viewModel.customWebhook, + ) + + // --- 调度钉钉服务 --- + if viewModel.DingTalkBot && !currentConfig.dingTalkWebhook.isEmpty { + Task { + var Ding_retryCount = 0 + var Ding_logsToRetry: [MAALog]? = logsToSend + + while let currentLogs = Ding_logsToRetry, Ding_retryCount < 3 { + // 如果不是第一次尝试,则等待 10 秒 + if Ding_retryCount > 0 { + try? await Task.sleep(nanoseconds: UInt64(10 * 1_000_000_000)) + } + + // 调用 钉钉 服务发送 + Ding_logsToRetry = await dingTalkService.send( + logs: currentLogs, using: currentConfig, viewModel: viewModel) + + // 如果发送成功,`logsToRetry` 将变为 nil,循环自动结束 + if Ding_logsToRetry == nil { + break + } + + Ding_retryCount += 1 + } + } + } + + // --- 调度 Bark 服务 --- + if viewModel.BarkBot && !currentConfig.barkKey.isEmpty { + Task { + var Bark_retryCount = 0 + var Bark_logsToRetry: [MAALog]? = logsToSend + + while let currentLogs = Bark_logsToRetry, Bark_retryCount < 3 { + // 如果不是第一次尝试,则等待 10 秒 + if Bark_retryCount > 0 { + try? await Task.sleep(nanoseconds: UInt64(10 * 1_000_000_000)) + } + + // 调用 Bark 服务发送 + Bark_logsToRetry = await barkService.send( + logs: currentLogs, using: currentConfig, viewModel: viewModel) + + // 如果发送成功,`logsToRetry` 将变为 nil,循环自动结束 + if Bark_logsToRetry == nil { + break + } + + Bark_retryCount += 1 + } + } + } + + // --- 调度自定义 Webhook 服务 --- + if viewModel.customWebhook.isEnabled && !currentConfig.customWebhook.url.isEmpty { + Task { + var Webhook_retryCount = 0 + var Webhook_logsToRetry: [MAALog]? = logsToSend + + while let currentLogs = Webhook_logsToRetry, Webhook_retryCount < 3 { + // 如果不是第一次尝试,则等待 10 秒 + if Webhook_retryCount > 0 { + try? await Task.sleep(nanoseconds: UInt64(10 * 1_000_000_000)) + } + + // 调用 Webhook 服务发送 + Webhook_logsToRetry = await customWebhookService.send( + logs: currentLogs, using: currentConfig, viewModel: viewModel) + + // 如果发送成功,`logsToRetry` 将变为 nil,循环自动结束 + if Webhook_logsToRetry == nil { + break + } + + Webhook_retryCount += 1 + } + } + } + + // --- 调度 Qmsg 服务 --- + if viewModel.qmsg.isEnabled && !currentConfig.qmsg.key.isEmpty { + Task { + var Qmsg_retryCount = 0 + var Qmsg_logsToRetry: [MAALog]? = logsToSend + + while let currentLogs = Qmsg_logsToRetry, Qmsg_retryCount < 3 { + // 如果不是第一次尝试,则等待 10 秒 + if Qmsg_retryCount > 0 { + try? await Task.sleep(nanoseconds: UInt64(10 * 1_000_000_000)) + } + + // 调用 Qmsg 服务发送 + Qmsg_logsToRetry = await qmsgService.send( + logs: currentLogs, using: currentConfig, viewModel: viewModel) + + // 如果发送成功,`logsToRetry` 将变为 nil,循环自动结束 + if Qmsg_logsToRetry == nil { + break + } + + Qmsg_retryCount += 1 + } + } + } + } +} diff --git a/MeoAsstMac/Services/Notification/NotificationService.swift b/MeoAsstMac/Services/Notification/NotificationService.swift new file mode 100644 index 000000000..74e25ceee --- /dev/null +++ b/MeoAsstMac/Services/Notification/NotificationService.swift @@ -0,0 +1,28 @@ +// +// NotificationService.swift +// MAA +// +// Created by RainYang on 2025/8/30. +// + +import Foundation + +/// 一个通用的配置结构体,用于向服务传递所需的设置。 +struct NotificationConfig { + let dingTalkWebhook: String + let dingTalkSecret: String? + let barkKey: String + let barkServer: String + let qmsg: QmsgSettings + let customWebhook: CustomWebhookSettings +} + +/// 定义了所有通知服务都必须具备的功能。 +protocol NotificationService { + /// 异步发送格式化后的日志。 + /// - Parameters: + /// - logs: 需要发送的日志条目数组。 + /// - config: 包含所有通知服务配置的结构体。 + /// - viewModel: ViewModel 实例,用于记录发送状态日志。 + func send(logs: [MAALog], using config: NotificationConfig, viewModel: MAAViewModel) async -> [MAALog]? +} diff --git a/MeoAsstMac/Services/Notification/QmsgService.swift b/MeoAsstMac/Services/Notification/QmsgService.swift new file mode 100644 index 000000000..acc30d1b6 --- /dev/null +++ b/MeoAsstMac/Services/Notification/QmsgService.swift @@ -0,0 +1,90 @@ +// +// QmsgService.swift +// MAA +// +// Created by RainYang on 2025/8/31. +// + + +import Foundation + +@MainActor +class QmsgService: NotificationService { + private var consecutiveFailureCount = 0 + private let failureThreshold = 3 + + func send(logs: [MAALog], using config: NotificationConfig, viewModel: MAAViewModel) async -> [MAALog]? { + let settings = config.qmsg + // Qmsg API: server/send/{key} + guard let url = URL(string: "\(settings.server)send/\(settings.key)") else { + viewModel.logError("Qmsg URL 无效。请检查 Server 和 Key。") + return nil + } + + // 如果没有指定 QQ,则不发送 + guard !settings.userQQ.isEmpty else { + viewModel.logError("Qmsg 发送失败:未指定用户 QQ。") + return nil + } + + do { + var request = URLRequest(url: url) + request.httpMethod = "POST" + // Qmsg API 需要 'application/x-www-form-urlencoded' 格式 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + // 1. 格式化日志摘要作为消息内容 + let title = (viewModel.notificationTriggers.sendAllLogsAfterFinish) ? "MAA 任务已全部完成" : "MAA 日志摘要 (\(logs.count)条)" + let contentItems = logs.map { log -> String in + let timeString = log.date.formatted(date: .omitted, time: .standard) + return "[\(timeString)] \(log.content)" + } + let content = "\(title)\n--------------------\n\(contentItems.joined(separator: "\n"))" + + // 2. 构建 x-www-form-urlencoded 请求体 + var components = URLComponents() + components.queryItems = [ + URLQueryItem(name: "msg", value: content), + URLQueryItem(name: "qq", value: settings.userQQ) + ] + // botQQ 是可选的 + if !settings.botQQ.isEmpty { + components.queryItems?.append(URLQueryItem(name: "bot", value: settings.botQQ)) + } + + request.httpBody = components.query?.data(using: .utf8) + + // 3. 发送请求 + viewModel.logInfo("准备发送 Qmsg 通知...") + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw URLError(.badServerResponse, userInfo: ["statusCode": statusCode]) + } + + // 检查 Qmsg 的特定响应 + if let responseString = String(data: data, encoding: .utf8), responseString.contains("\"code\":0") { + viewModel.logInfo("Qmsg 通知发送成功。") + consecutiveFailureCount = 0 + return nil + } else { + let reason = String(data: data, encoding: .utf8) ?? "未知响应" + throw URLError(.cannotParseResponse, userInfo: [NSLocalizedDescriptionKey: "Qmsg API 错误: \(reason)"]) + } + + } catch { + consecutiveFailureCount += 1 + viewModel.logError("发送 Qmsg 通知失败: \(error.localizedDescription)") + + if consecutiveFailureCount >= failureThreshold { + viewModel.qmsg.isEnabled = false + viewModel.logError("Qmsg 通知连续失败 \(consecutiveFailureCount) 次,已自动关闭。") + return nil + } else { + viewModel.logWarn("Qmsg 通知发送失败(第 \(consecutiveFailureCount) 次尝试),将在10s后重试。") + return logs + } + } + } +} diff --git a/MeoAsstMac/Settings/NotificationSettingsView.swift b/MeoAsstMac/Settings/NotificationSettingsView.swift new file mode 100644 index 000000000..4825e8e62 --- /dev/null +++ b/MeoAsstMac/Settings/NotificationSettingsView.swift @@ -0,0 +1,506 @@ +// +// NotificationSettingsView.swift +// MAA +// +// Created by RainYang on 2025/8/21. +// + +import AppKit +import SwiftUI + +// 用于 Picker 的服务类型定义 +enum NotificationServiceType: String, CaseIterable, Identifiable { + case dingTalk = "DingTalkBot" + case bark = "Bark" + case qmsg = "Qmsg" + case custom = "自定义 Webhook" + var id: Self { self } +} + +struct NotificationSettingsView: View { + @EnvironmentObject private var viewModel: MAAViewModel + + @State private var testMessageContent: String = "这是 MAA 外部通知测试信息。如果你看到了这段内容,就说明通知发送成功了!" + @State private var statusMessage: String = "准备就绪" + @State private var isLoading: Bool = false + + // 新增状态,用于控制当前显示哪个服务的设置 + @State private var selectedService: NotificationServiceType = .dingTalk + + // MARK: 用于组合所有影响布局的状态 + private struct AnimationID: Equatable { + let service: NotificationServiceType + let isDingTalkEnabled: Bool + let isBarkEnabled: Bool + let isCustomWebhookEnabled: Bool + let isSendAllLogsEnabled: Bool + let isSendAllLogsAfterFinishEnabled: Bool + } + + // MARK: 计算属性来生成 AnimationID + private var animationID: AnimationID { + AnimationID( + service: selectedService, + isDingTalkEnabled: viewModel.DingTalkBot, + isBarkEnabled: viewModel.BarkBot, + isCustomWebhookEnabled: viewModel.customWebhook.isEnabled, + isSendAllLogsEnabled: viewModel.notificationTriggers.sendAllLogs, + isSendAllLogsAfterFinishEnabled: viewModel.notificationTriggers.sendAllLogsAfterFinish + ) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // MARK: - 通用设置 + Toggle("任务全部完成后发送", isOn: $viewModel.notificationTriggers.sendAllLogsAfterFinish) + Section { + if !viewModel.notificationTriggers.sendAllLogsAfterFinish { + HStack { + Text("发送间隔") + Spacer() + // 使用 TextField 允许用户直接输入 + TextField("分钟", value: $viewModel.notificationSendingInterval, formatter: NumberFormatter()) + .multilineTextAlignment(.trailing) + .frame(width: 80) // 限制输入框宽度 + Text("分钟") + } + } + + Toggle("发送所有日志", isOn: $viewModel.notificationTriggers.sendAllLogs) + + if !viewModel.notificationTriggers.sendAllLogs { + VStack(alignment: .leading, spacing: 10) { + SubToggleView(title: "任务完成后", isOn: $viewModel.notificationTriggers.onTaskCompletion) + SubToggleView(title: "任务出错时", isOn: $viewModel.notificationTriggers.onTaskError) + } + .padding(.leading, 5) + } + } + // MARK: - 主表单 + Form { + Toggle("记录发送日志", isOn: $viewModel.showSendLogsInGUI) + // MARK: - 服务选择器 + Section { + Picker("通知服务", selection: $selectedService.animation(.default)) { // 将动画应用到状态变更上 + ForEach(NotificationServiceType.allCases) { service in + Text(service.rawValue).tag(service) + } + } + .pickerStyle(.menu) // 使用下拉菜单样式,方便未来扩展 + } + + // MARK: - 动态设置区域 + // 根据选择的服务,动态显示对应的设置内容 + switch selectedService { + case .dingTalk: + DingTalkSettingsView(isLoading: $isLoading, sendAction: sendDingTalkTestMessage) + .transition(.opacity.combined(with: .offset(y: 10))) + case .bark: + BarkSettingsView(isLoading: $isLoading, sendAction: sendBarkTestMessage) + .transition(.opacity.combined(with: .offset(y: 10))) + case .qmsg: + QmsgSettingsView(isLoading: $isLoading, sendAction: sendQmsgTestMessage) + .transition(.opacity.combined(with: .offset(y: 10))) + case .custom: + CustomWebhookSettingsView(isLoading: $isLoading, sendAction: sendCustomWebhookTestMessage) + .transition(.opacity.combined(with: .offset(y: 10))) + + } + } + + Text("将在下一发送周期生效") + .font(.caption).foregroundColor(.secondary) + + // MARK: - 统一的状态栏页脚 + StatusFooter(isLoading: isLoading, statusMessage: statusMessage) + } + .padding() // 应用全局内边距,解决控件贴边问题 + //当 selectedService 变化时,所有相关的布局和视图变化都会有动画效果 + .animation(.default, value: animationID) + } + .frame(maxWidth: 360, maxHeight: 240) + } + // MARK: - 测试消息发送逻辑 + + private func sendDingTalkTestMessage() { + isLoading = true + statusMessage = "正在发送至钉钉..." + + let botClient = DingTalkBotClient( + webhookURL: viewModel.DKwebhookURL, + secret: viewModel.DKsecret + ) + + Task { + do { + _ = try await botClient.sendTextMessage(content: testMessageContent) + statusMessage = "钉钉发送成功!(\(Date().formatted(date: .omitted, time: .standard)))" + } catch { + statusMessage = "钉钉发送失败: \(error.localizedDescription)" + } + isLoading = false + } + + } + + private func sendBarkTestMessage() { + isLoading = true + statusMessage = "正在发送至 Bark..." + + Task { + do { + let title = "MAA 测试消息" + guard let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let encodedBody = testMessageContent.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) + else { + throw URLError(.badURL, userInfo: [NSLocalizedDescriptionKey: "消息内容编码失败"]) + } + + let urlString = + "\(viewModel.BarkServer)\(viewModel.BarkKey)/\(encodedTitle)/\(encodedBody)?group=MAA&icon=https://maa.plus/docs/images/maa-logo_512x512.png" + + guard let url = URL(string: urlString) else { + throw URLError(.badURL, userInfo: [NSLocalizedDescriptionKey: "无效的 Bark URL 或 Key"]) + } + + let (_, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw URLError( + .badServerResponse, + userInfo: [ + NSLocalizedDescriptionKey: + "服务器响应错误,状态码: \((response as? HTTPURLResponse)?.statusCode ?? -1)" + ]) + } + + statusMessage = "Bark 发送成功!(\(Date().formatted(date: .omitted, time: .standard)))" + + } catch { + statusMessage = "Bark 发送失败: \(error.localizedDescription)" + } + isLoading = false + } + } + + /// 发送自定义 Webhook 测试消息 + private func sendCustomWebhookTestMessage() { + isLoading = true + statusMessage = "正在发送至自定义 Webhook..." + + Task { + do { + let settings = viewModel.customWebhook + guard let url = URL(string: settings.url) else { + throw URLError(.badURL, userInfo: [NSLocalizedDescriptionKey: "无效的 Webhook URL"]) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let title = "MAA 测试消息" + let escapedContent = + testMessageContent + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + let time = Date().formatted(date: .omitted, time: .standard) + + var processedBody = settings.bodyTemplate + processedBody = processedBody.replacingOccurrences(of: "{title}", with: title) + processedBody = processedBody.replacingOccurrences(of: "{content}", with: escapedContent) + processedBody = processedBody.replacingOccurrences(of: "{time}", with: time) + + request.httpBody = processedBody.data(using: .utf8) + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) + else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw URLError( + .badServerResponse, userInfo: [NSLocalizedDescriptionKey: "服务器响应错误,状态码: \(statusCode)"]) + } + + statusMessage = "自定义 Webhook 发送成功!(\(Date().formatted(date: .omitted, time: .standard)))" + } catch { + statusMessage = "自定义 Webhook 发送失败: \(error.localizedDescription)" + } + isLoading = false + } + } + + /// 发送 Qmsg 测试消息 (新增) + private func sendQmsgTestMessage() { + isLoading = true + statusMessage = "正在发送至 Qmsg..." + + Task { + do { + let settings = viewModel.qmsg + guard var components = URLComponents(string: settings.server) else { + throw URLError(.badURL, userInfo: [NSLocalizedDescriptionKey: "无效的 Qmsg 服务器地址"]) + } + components.path = "/send/\(settings.key)" + + guard let url = components.url else { + throw URLError(.badURL, userInfo: [NSLocalizedDescriptionKey: "无效的 Qmsg URL 或 Key"]) + } + + guard !settings.userQQ.isEmpty else { + throw URLError(.badURL, userInfo: [NSLocalizedDescriptionKey: "用户 QQ 不能为空"]) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + var formComponents = URLComponents() + formComponents.queryItems = [ + URLQueryItem(name: "msg", value: testMessageContent), + URLQueryItem(name: "qq", value: settings.userQQ), + ] + if !settings.botQQ.isEmpty { + formComponents.queryItems?.append(URLQueryItem(name: "bot", value: settings.botQQ)) + } + + request.httpBody = formComponents.query?.data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw URLError( + .badServerResponse, userInfo: [NSLocalizedDescriptionKey: "服务器响应错误,状态码: \(statusCode)"]) + } + + if let responseString = String(data: data, encoding: .utf8), responseString.contains("\"code\":0") { + statusMessage = "Qmsg 发送成功!(\(Date().formatted(date: .omitted, time: .standard)))" + } else { + let reason = String(data: data, encoding: .utf8) ?? "未知响应" + throw URLError( + .cannotParseResponse, userInfo: [NSLocalizedDescriptionKey: "Qmsg API 错误: \(reason)"]) + } + + } catch { + statusMessage = "Qmsg 发送失败: \(error.localizedDescription)" + } + isLoading = false + } + } +} + +// MARK: - 带连接线的子 Toggle 视图 +struct SubToggleView: View { + let title: String + @Binding var isOn: Bool + + var body: some View { + HStack(spacing: 12) { + // 这就是我们的“连接线”,用一个圆角矩形或胶囊体模拟 + Capsule() + .fill(Color.gray.opacity(0.5)) // 设置颜色和透明度 + .frame(width: 2.5) // 线的宽度 + .frame(maxHeight: 25) // 线的高度,可以根据你的 Toggle 高度微调 + + Toggle(title, isOn: $isOn) + } + } +} + +// MARK: - 钉钉设置子视图 +struct DingTalkSettingsView: View { + @EnvironmentObject private var viewModel: MAAViewModel + @Binding var isLoading: Bool + let sendAction: () -> Void + + var body: some View { + // 使用 Group 包裹,以便应用统一的动画效果 + Group { + Section { + Toggle("启用 DingTalk 通知", isOn: $viewModel.DingTalkBot) + + if viewModel.DingTalkBot { + Text("若配置无效则将在正式运行中自动关闭") + .font(.caption).foregroundColor(.secondary) + TextField("Webhook", text: $viewModel.DKwebhookURL) + SecureField("密钥 (可选)", text: $viewModel.DKsecret) + } + } + + if viewModel.DingTalkBot { + Section { + Button(action: sendAction) { + Label("发送测试", systemImage: "paperplane.fill") + } + .disabled(viewModel.DKwebhookURL.isEmpty || isLoading) + } + } + } + // 添加动画修饰符,监听开关值的变化 + .animation(.default, value: viewModel.DingTalkBot) + } +} + +// MARK: - Bark 设置子视图 +struct BarkSettingsView: View { + @EnvironmentObject private var viewModel: MAAViewModel + @Binding var isLoading: Bool + let sendAction: () -> Void + + var body: some View { + // 使用 Group 包裹,以便应用统一的动画效果 + Group { + Section { + Toggle("启用 Bark 通知", isOn: $viewModel.BarkBot) + + if viewModel.BarkBot { + Text("若配置无效则将在正式运行中自动关闭") + .font(.caption).foregroundColor(.secondary) + TextField("服务器地址", text: $viewModel.BarkServer) + SecureField("Key", text: $viewModel.BarkKey) + } + } + + if viewModel.BarkBot { + Section { + Button(action: sendAction) { + Label("发送测试", systemImage: "paperplane.fill") + } + .disabled(viewModel.BarkKey.isEmpty || isLoading) + } + } + } + // 添加动画修饰符,监听开关值的变化 + .animation(.default, value: viewModel.BarkBot) + } +} + +// MARK: - Qmsg 设置子视图 (新增) +struct QmsgSettingsView: View { + @EnvironmentObject private var viewModel: MAAViewModel + @Binding var isLoading: Bool + let sendAction: () -> Void + + var body: some View { + Group { + Section { + Toggle("启用 Qmsg 通知", isOn: $viewModel.qmsg.isEnabled) + + if viewModel.qmsg.isEnabled { + Text("若配置无效则将在正式运行中自动关闭") + .font(.caption).foregroundColor(.secondary) + TextField("Server", text: $viewModel.qmsg.server) + .onAppear { + if viewModel.qmsg.server.isEmpty { + viewModel.qmsg.server = "https://qmsg.zendee.cn/" + } + } + + SecureField("Key", text: $viewModel.qmsg.key) + + TextField("用户 QQ (接收消息)", text: $viewModel.qmsg.userQQ) + + TextField("机器人 QQ (可选)", text: $viewModel.qmsg.botQQ) + } + } + + if viewModel.qmsg.isEnabled { + Section { + Button(action: sendAction) { + Label("发送测试", systemImage: "paperplane.fill") + } + .disabled(viewModel.qmsg.key.isEmpty || viewModel.qmsg.userQQ.isEmpty || isLoading) + } + } + } + .animation(.default, value: viewModel.qmsg.isEnabled) + } +} + +// MARK: - 自定义 Webhook 设置子视图 +struct CustomWebhookSettingsView: View { + @EnvironmentObject private var viewModel: MAAViewModel + @Binding var isLoading: Bool + let sendAction: () -> Void + + var body: some View { + Group { + Section { + Toggle("启用自定义 Webhook", isOn: $viewModel.customWebhook.isEnabled) + + if viewModel.customWebhook.isEnabled { + Text("若配置无效则将在正式运行中自动关闭") + .font(.caption).foregroundColor(.secondary) + TextField("Webhook URL", text: $viewModel.customWebhook.url) + + VStack(alignment: .leading) { + Text("请求体 (JSON Body Template)").font(.caption).foregroundColor(.secondary) + TextEditor(text: $viewModel.customWebhook.bodyTemplate) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 150) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray.opacity(0.2), lineWidth: 1)) + } + + DisclosureGroup("可用占位符说明") { + VStack(alignment: .leading, spacing: 4) { + Text("{title}").fontWeight(.bold) + Text(" - 消息标题") + Text("{content}").fontWeight(.bold) + Text(" - 格式化后的日志正文") + Text("{time}").fontWeight(.bold) + Text(" - 发送时间") + } + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + if viewModel.customWebhook.isEnabled { + Section { + Button(action: sendAction) { + Label("发送测试", systemImage: "paperplane.fill") + } + .disabled(viewModel.customWebhook.url.isEmpty || isLoading) + } + } + } + .animation(.default, value: viewModel.customWebhook.isEnabled) + } +} + +// MARK: - 可重用的状态页脚视图 +struct StatusFooter: View { + let isLoading: Bool + let statusMessage: String + + var body: some View { + HStack { + if isLoading { + ProgressView() + .scaleEffect(0.8) + Text("正在发送...") + .foregroundColor(.secondary) + } else { + Image(systemName: statusMessage.contains("失败") ? "xmark.circle.fill" : "checkmark.circle.fill") + .foregroundColor( + statusMessage.contains("失败") ? .red : (statusMessage == "准备就绪" ? .secondary : .green)) + + Text(statusMessage) + .foregroundColor(statusMessage.contains("失败") ? .red : .secondary) + .fixedSize() + } + Spacer() + } + .padding(.horizontal) + .frame(height: 20) + } +} + +struct NotificationSettingsView_Previews: PreviewProvider { + static var previews: some View { + NotificationSettingsView() + .environmentObject(MAAViewModel()) + .frame(width: 400, height: 500) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 3d321bbbd..afbee9e62 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -3,9 +3,27 @@ "strings" : { "" : { "shouldTranslate" : false + }, + " - 发送时间" : { + + }, + " - 格式化后的日志正文" : { + + }, + " - 消息标题" : { + }, "“开局干员”使用助战" : { + }, + "{content}" : { + + }, + "{time}" : { + + }, + "{title}" : { + }, "%@" : { "shouldTranslate" : false @@ -173,6 +191,15 @@ }, "AT-相谈室" : { + }, + "Bark 通知发送失败(第 %lld 次尝试),将在10s后重试。" : { + + }, + "Bark 通知发送成功。" : { + + }, + "Bark 通知连续失败 %lld 次,已自动关闭此功能。请检查网络和 Bark Key/服务器配置。" : { + }, "BattleFormation: %@" : { "localizations" : { @@ -945,6 +972,9 @@ }, "IP请求频率过快,请稍后再试" : { + }, + "Key" : { + }, "LabelsRefreshed" : { "localizations" : { @@ -1097,6 +1127,21 @@ } } } + }, + "Qmsg URL 无效。请检查 Server 和 Key。" : { + + }, + "Qmsg 发送失败:未指定用户 QQ。" : { + + }, + "Qmsg 通知发送失败(第 %lld 次尝试),将在10s后重试。" : { + + }, + "Qmsg 通知发送成功。" : { + + }, + "Qmsg 通知连续失败 %lld 次,已自动关闭。" : { + }, "Read item_index.json failed: %@" : { "localizations" : { @@ -1344,6 +1389,9 @@ } } } + }, + "Server" : { + }, "SSSGamePass" : { "localizations" : { @@ -1578,6 +1626,12 @@ } } } + }, + "Webhook" : { + + }, + "Webhook URL" : { + }, "一层远见密文板" : { @@ -1656,6 +1710,9 @@ } } } + }, + "任务全部完成后发送" : { + }, "企鹅物流" : { "localizations" : { @@ -1879,6 +1936,18 @@ }, "最高难度" : { + }, + "准备发送 Bark 日志摘要 (%lld条)" : { + + }, + "准备发送 DingTalk 日志摘要 (%lld条)..." : { + + }, + "准备发送 Qmsg 通知..." : { + + }, + "准备发送自定义 Webhook..." : { + }, "凹2构想开局" : { @@ -1898,6 +1967,9 @@ } } } + }, + "分钟" : { + }, "分队开局密文板" : { @@ -2016,6 +2088,27 @@ }, "发电站" : { + }, + "发送 Bark 通知失败: %@" : { + + }, + "发送 Qmsg 通知失败: %@" : { + + }, + "发送所有日志" : { + + }, + "发送测试" : { + + }, + "发送自定义 Webhook 失败: %@" : { + + }, + "发送钉钉通知摘要失败: %@" : { + + }, + "发送间隔" : { + }, "取消" : { "localizations" : { @@ -2032,6 +2125,9 @@ }, "可以使用非好友助战" : { + }, + "可用占位符说明" : { + }, "可能需要设置系统代理。" : { @@ -2041,6 +2137,18 @@ }, "吃理智药" : { + }, + "启用 Bark 通知" : { + + }, + "启用 DingTalk 通知" : { + + }, + "启用 Qmsg 通知" : { + + }, + "启用自定义 Webhook" : { + }, "周常任务" : { @@ -2126,6 +2234,9 @@ }, "宿舍空余位置蹭信赖" : { + }, + "密钥 (可选)" : { + }, "寻访一次" : { "localizations" : { @@ -2146,6 +2257,9 @@ } } } + }, + "将在下一发送周期生效" : { + }, "小游戏" : { @@ -2456,6 +2570,9 @@ }, "有存档,通过组装支援道具刷生息点数,组装完成后将会跳到下一个量定日并读取前一个量定日的存档" : { + }, + "服务器地址" : { + }, "未启用" : { "localizations" : { @@ -2472,6 +2589,12 @@ }, "未知排班" : { + }, + "机器人 QQ (可选)" : { + + }, + "构建 Bark POST 请求失败。" : { + }, "构想" : { @@ -2500,6 +2623,9 @@ }, "正在下载…" : { + }, + "正在发送..." : { + }, "正在解压…" : { @@ -2609,6 +2735,9 @@ }, "生息演算商店" : { + }, + "用户 QQ (接收消息)" : { + }, "界园" : { @@ -2832,12 +2961,27 @@ }, "自定义" : { + }, + "自定义 Webhook URL 无效。" : { + + }, + "自定义 Webhook 发送失败(第 %lld 次尝试),将在10s后重试。" : { + + }, + "自定义 Webhook 发送成功。" : { + + }, + "自定义 Webhook 连续失败 %lld 次,已自动关闭。" : { + }, "自定义基建配置" : { }, "自定义排班" : { + }, + "若配置无效则将在正式运行中自动关闭" : { + }, "萨卡兹" : { @@ -2866,6 +3010,9 @@ }, "训练室" : { + }, + "记录发送日志" : { + }, "设置" : { "localizations" : { @@ -2918,6 +3065,9 @@ } } } + }, + "请求体 (JSON Body Template)" : { + }, "请注意,这是真的抽卡!不是模拟!!!" : { "localizations" : { @@ -3016,6 +3166,12 @@ }, "选择本地文件…" : { + }, + "通知服务" : { + + }, + "通知设置" : { + }, "邮件" : { @@ -3025,6 +3181,15 @@ }, "重新打开应用后生效。" : { + }, + "钉钉通知发送失败(第 %lld 次尝试),将在10s后重试。" : { + + }, + "钉钉通知发送成功。" : { + + }, + "钉钉通知连续失败 %lld 次,已自动关闭此功能。请检查网络和配置。" : { + }, "钥匙" : {