From 18ea1e8e6fdc3d3f4c7c0723adcc452afd5f5cd0 Mon Sep 17 00:00:00 2001 From: Rain Yang Date: Sat, 20 Sep 2025 16:21:25 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=B6=88=E6=81=AF=E9=80=9A?= =?UTF-8?q?=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MeoAsstMac.xcodeproj/project.pbxproj | 48 ++ MeoAsstMac/MeoAsstMacApp.swift | 22 + MeoAsstMac/Model/MAAViewModel.swift | 112 ++++ .../Services/Notification/BarkService.swift | 101 ++++ .../Notification/CustomWebhookService.swift | 85 +++ .../Notification/DingTalkBotClient.swift | 235 ++++++++ .../Notification/DingTalkService.swift | 74 +++ .../Notification/NotificationManager.swift | 222 ++++++++ .../Notification/NotificationService.swift | 28 + .../Services/Notification/QmsgService.swift | 90 ++++ .../Settings/NotificationSettingsView.swift | 502 ++++++++++++++++++ Resources/Localizable.xcstrings | 165 ++++++ 12 files changed, 1684 insertions(+) create mode 100644 MeoAsstMac/Services/Notification/BarkService.swift create mode 100644 MeoAsstMac/Services/Notification/CustomWebhookService.swift create mode 100644 MeoAsstMac/Services/Notification/DingTalkBotClient.swift create mode 100644 MeoAsstMac/Services/Notification/DingTalkService.swift create mode 100644 MeoAsstMac/Services/Notification/NotificationManager.swift create mode 100644 MeoAsstMac/Services/Notification/NotificationService.swift create mode 100644 MeoAsstMac/Services/Notification/QmsgService.swift create mode 100644 MeoAsstMac/Settings/NotificationSettingsView.swift diff --git a/MeoAsstMac.xcodeproj/project.pbxproj b/MeoAsstMac.xcodeproj/project.pbxproj index 8689284ec..3100a261d 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 */; }; @@ -128,6 +136,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 = ""; }; @@ -239,6 +255,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 = ( @@ -376,6 +414,7 @@ 92CDF6202A065E20005669EC /* Utils */, 83FF5A98297855E5000AE831 /* Settings */, 8337870728F1DA7B00D39D6F /* Resources */, + 4505F96C2E7E9852006E7D28 /* Services */, 833786D228F188D900D39D6F /* Assets.xcassets */, 830C3D1F29BC86EE00B13A9F /* Info.plist */, 833786D728F188D900D39D6F /* MeoAsstMac.entitlements */, @@ -452,6 +491,7 @@ 838FF82129FBB9E3000566A0 /* GameSettingsView.swift */, 83DD50B629BE385A000E84E7 /* UpdaterSettingsView.swift */, 92EB82862A0CD315006DD9D2 /* SystemSettingsView.swift */, + 4505F9742E7E9884006E7D28 /* NotificationSettingsView.swift */, ); path = Settings; sourceTree = ""; @@ -572,6 +612,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 */, @@ -596,6 +643,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..ec4c56bfe 100644 --- a/MeoAsstMac/Model/MAAViewModel.swift +++ b/MeoAsstMac/Model/MAAViewModel.swift @@ -9,6 +9,34 @@ 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 +} + +/// 自定义 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 +179,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..ce5c6407c --- /dev/null +++ b/MeoAsstMac/Services/Notification/BarkService.swift @@ -0,0 +1,101 @@ +// +// 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 请求 URL + guard let url = buildURL(for: logs, config: config) else { + if viewModel.showSendLogsInGUI { + viewModel.logError("构建 Bark URL 失败。") + } + viewModel.BarkBot = false + // 这是配置错误,不是网络错误,因此不进行重试。 + return nil + } + + do { + // 2. 发送网络请求 + if viewModel.showSendLogsInGUI { + viewModel.logInfo("准备发送 Bark 日志摘要 (\(logs.count)条)...") + } + let (_, response) = try await URLSession.shared.data(from: url) + + // 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 URL。 + private func buildURL(for logs: [MAALog], config: NotificationConfig) -> URL? { + let title = "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") + + guard let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let encodedBody = body.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) + else { + return nil + } + + let fullURLString = + "\(config.barkServer)\(config.barkKey)/\(encodedTitle)/\(encodedBody)?group=MAA&&icon=https://maa.plus/docs/images/maa-logo_512x512.png" + + return URL(string: fullURLString) + } +} diff --git a/MeoAsstMac/Services/Notification/CustomWebhookService.swift b/MeoAsstMac/Services/Notification/CustomWebhookService.swift new file mode 100644 index 000000000..9d5a80028 --- /dev/null +++ b/MeoAsstMac/Services/Notification/CustomWebhookService.swift @@ -0,0 +1,85 @@ +// +// 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 = "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..3d85202a3 --- /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 = "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 = """ + ### MAA 日志摘要 (\(logs.count)条新消息) + --- + \(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..54bd36e9b --- /dev/null +++ b/MeoAsstMac/Services/Notification/NotificationManager.swift @@ -0,0 +1,222 @@ +// +// 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) + } + } + } + .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)) + 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..472179fa0 --- /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 = "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..b4c59cd55 --- /dev/null +++ b/MeoAsstMac/Settings/NotificationSettingsView.swift @@ -0,0 +1,502 @@ +// +// 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 + } + + // MARK: 计算属性来生成 AnimationID + private var animationID: AnimationID { + AnimationID( + service: selectedService, + isDingTalkEnabled: viewModel.DingTalkBot, + isBarkEnabled: viewModel.BarkBot, + isCustomWebhookEnabled: viewModel.customWebhook.isEnabled, + isSendAllLogsEnabled: viewModel.notificationTriggers.sendAllLogs + ) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // MARK: - 通用设置 + Section { + HStack { + Text("发送间隔") + Spacer() + // 使用 TextField 允许用户直接输入 + TextField("分钟", value: $viewModel.notificationSendingInterval, formatter: NumberFormatter()) + .multilineTextAlignment(.trailing) + .frame(width: 80) // 限制输入框宽度 + Text("分钟") + } + .help("设置合并日志并发送通知的时间间隔,单位为分钟(1-120)。") + + 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 340f30854..8aaeb3cb0 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" : { @@ -1341,6 +1386,9 @@ } } } + }, + "Server" : { + }, "SSSGamePass" : { "localizations" : { @@ -1575,6 +1623,12 @@ } } } + }, + "Webhook" : { + + }, + "Webhook URL" : { + }, "一层远见密文板" : { @@ -1876,6 +1930,18 @@ }, "最高难度" : { + }, + "准备发送 Bark 日志摘要 (%lld条)..." : { + + }, + "准备发送 DingTalk 日志摘要 (%lld条)..." : { + + }, + "准备发送 Qmsg 通知..." : { + + }, + "准备发送自定义 Webhook..." : { + }, "凹2构想开局" : { @@ -1895,6 +1961,9 @@ } } } + }, + "分钟" : { + }, "分队开局密文板" : { @@ -2013,6 +2082,27 @@ }, "发电站" : { + }, + "发送 Bark 通知失败: %@" : { + + }, + "发送 Qmsg 通知失败: %@" : { + + }, + "发送所有日志" : { + + }, + "发送测试" : { + + }, + "发送自定义 Webhook 失败: %@" : { + + }, + "发送钉钉通知摘要失败: %@" : { + + }, + "发送间隔" : { + }, "取消" : { "localizations" : { @@ -2029,6 +2119,9 @@ }, "可以使用非好友助战" : { + }, + "可用占位符说明" : { + }, "可能需要设置系统代理。" : { @@ -2038,6 +2131,18 @@ }, "吃理智药" : { + }, + "启用 Bark 通知" : { + + }, + "启用 DingTalk 通知" : { + + }, + "启用 Qmsg 通知" : { + + }, + "启用自定义 Webhook" : { + }, "周常任务" : { @@ -2123,6 +2228,9 @@ }, "宿舍空余位置蹭信赖" : { + }, + "密钥 (可选)" : { + }, "寻访一次" : { "localizations" : { @@ -2143,6 +2251,9 @@ } } } + }, + "将在下一发送周期生效" : { + }, "小游戏" : { @@ -2453,6 +2564,9 @@ }, "有存档,通过组装支援道具刷生息点数,组装完成后将会跳到下一个量定日并读取前一个量定日的存档" : { + }, + "服务器地址" : { + }, "未启用" : { "localizations" : { @@ -2469,6 +2583,12 @@ }, "未知排班" : { + }, + "机器人 QQ (可选)" : { + + }, + "构建 Bark URL 失败。" : { + }, "构想" : { @@ -2497,6 +2617,9 @@ }, "正在下载…" : { + }, + "正在发送..." : { + }, "正在解压…" : { @@ -2606,6 +2729,9 @@ }, "生息演算商店" : { + }, + "用户 QQ (接收消息)" : { + }, "界园" : { @@ -2829,12 +2955,27 @@ }, "自定义" : { + }, + "自定义 Webhook URL 无效。" : { + + }, + "自定义 Webhook 发送失败(第 %lld 次尝试),将在10s后重试。" : { + + }, + "自定义 Webhook 发送成功。" : { + + }, + "自定义 Webhook 连续失败 %lld 次,已自动关闭。" : { + }, "自定义基建配置" : { }, "自定义排班" : { + }, + "若配置无效则将在正式运行中自动关闭" : { + }, "萨卡兹" : { @@ -2863,6 +3004,9 @@ }, "训练室" : { + }, + "记录发送日志" : { + }, "设置" : { "localizations" : { @@ -2873,6 +3017,9 @@ } } } + }, + "设置合并日志并发送通知的时间间隔,单位为分钟(1-120)。" : { + }, "访问被禁止,请检查IP或CDK" : { @@ -2915,6 +3062,9 @@ } } } + }, + "请求体 (JSON Body Template)" : { + }, "请注意,这是真的抽卡!不是模拟!!!" : { "localizations" : { @@ -3010,6 +3160,12 @@ }, "选择本地文件…" : { + }, + "通知服务" : { + + }, + "通知设置" : { + }, "邮件" : { @@ -3019,6 +3175,15 @@ }, "重新打开应用后生效。" : { + }, + "钉钉通知发送失败(第 %lld 次尝试),将在10s后重试。" : { + + }, + "钉钉通知发送成功。" : { + + }, + "钉钉通知连续失败 %lld 次,已自动关闭此功能。请检查网络和配置。" : { + }, "钥匙" : { From 3b2814f432970ae4aada46faca150ade1f831620 Mon Sep 17 00:00:00 2001 From: Rain Yang Date: Tue, 30 Sep 2025 21:25:40 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E5=85=A8=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=90=8E=E5=86=8D=E5=8F=91=E9=80=81=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MeoAsstMac/Model/MAAViewModel.swift | 1 + .../Services/Notification/BarkService.swift | 6 +-- .../Notification/CustomWebhookService.swift | 3 +- .../Notification/DingTalkService.swift | 4 +- .../Notification/NotificationManager.swift | 30 ++++++++++---- .../Services/Notification/QmsgService.swift | 2 +- .../Settings/NotificationSettingsView.swift | 40 ++++++++++--------- MeoAsstMac/Views/MiniGameView.swift | 2 +- Resources/Localizable.xcstrings | 12 ++++-- 9 files changed, 63 insertions(+), 37 deletions(-) diff --git a/MeoAsstMac/Model/MAAViewModel.swift b/MeoAsstMac/Model/MAAViewModel.swift index ec4c56bfe..6b4743b53 100644 --- a/MeoAsstMac/Model/MAAViewModel.swift +++ b/MeoAsstMac/Model/MAAViewModel.swift @@ -15,6 +15,7 @@ struct NotificationTriggers: Codable { var sendAllLogs: Bool = true var onTaskError: Bool = false var onTaskTimeout: Bool = false + var sendAllLogsAfterFinish: Bool = true } /// 自定义 Webhook 配置结构体 diff --git a/MeoAsstMac/Services/Notification/BarkService.swift b/MeoAsstMac/Services/Notification/BarkService.swift index ce5c6407c..eb8e587b6 100644 --- a/MeoAsstMac/Services/Notification/BarkService.swift +++ b/MeoAsstMac/Services/Notification/BarkService.swift @@ -24,7 +24,7 @@ class BarkService: NotificationService { /// - Returns: 如果发送失败且需要重试,则返回原始的日志数组;否则返回 `nil`。 func send(logs: [MAALog], using config: NotificationConfig, viewModel: MAAViewModel) async -> [MAALog]? { // 1. 构建 Bark 请求 URL - guard let url = buildURL(for: logs, config: config) else { + guard let url = buildURL(for: logs, config: config, viewModel: viewModel) else { if viewModel.showSendLogsInGUI { viewModel.logError("构建 Bark URL 失败。") } @@ -79,8 +79,8 @@ class BarkService: NotificationService { } /// 根据配置和日志内容构建最终的 Bark URL。 - private func buildURL(for logs: [MAALog], config: NotificationConfig) -> URL? { - let title = "MAA 日志摘要 (\(logs.count)条)" + private func buildURL(for logs: [MAALog], config: NotificationConfig, viewModel: MAAViewModel) -> URL? { + 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)" diff --git a/MeoAsstMac/Services/Notification/CustomWebhookService.swift b/MeoAsstMac/Services/Notification/CustomWebhookService.swift index 9d5a80028..3871f1c73 100644 --- a/MeoAsstMac/Services/Notification/CustomWebhookService.swift +++ b/MeoAsstMac/Services/Notification/CustomWebhookService.swift @@ -30,8 +30,7 @@ class CustomWebhookService: NotificationService { request.setValue("application/json", forHTTPHeaderField: "Content-Type") // 1. 准备所有可用的模板变量 - let title = "MAA 日志摘要 (\(logs.count)条)" - + 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 字符串中的内容转义特殊字符 diff --git a/MeoAsstMac/Services/Notification/DingTalkService.swift b/MeoAsstMac/Services/Notification/DingTalkService.swift index 3d85202a3..30aa3a100 100644 --- a/MeoAsstMac/Services/Notification/DingTalkService.swift +++ b/MeoAsstMac/Services/Notification/DingTalkService.swift @@ -22,14 +22,14 @@ class DingTalkService: NotificationService { secret: config.dingTalkSecret ) - let title = "MAA 日志摘要 (\(logs.count)条)" + 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 = """ - ### MAA 日志摘要 (\(logs.count)条新消息) + ### \(title) --- \(contentItems.joined(separator: "\n\n")) """ diff --git a/MeoAsstMac/Services/Notification/NotificationManager.swift b/MeoAsstMac/Services/Notification/NotificationManager.swift index 54bd36e9b..fb01148f1 100644 --- a/MeoAsstMac/Services/Notification/NotificationManager.swift +++ b/MeoAsstMac/Services/Notification/NotificationManager.swift @@ -74,18 +74,30 @@ class NotificationManager { 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)) - await processAndSendBuffer() + + if !viewModel.notificationTriggers.sendAllLogsAfterFinish { + await processAndSendBuffer() + } } } } @@ -132,7 +144,8 @@ class NotificationManager { } // 调用 钉钉 服务发送 - Ding_logsToRetry = await dingTalkService.send(logs: currentLogs, using: currentConfig, viewModel: viewModel) + Ding_logsToRetry = await dingTalkService.send( + logs: currentLogs, using: currentConfig, viewModel: viewModel) // 如果发送成功,`logsToRetry` 将变为 nil,循环自动结束 if Ding_logsToRetry == nil { @@ -157,7 +170,8 @@ class NotificationManager { } // 调用 Bark 服务发送 - Bark_logsToRetry = await barkService.send(logs: currentLogs, using: currentConfig, viewModel: viewModel) + Bark_logsToRetry = await barkService.send( + logs: currentLogs, using: currentConfig, viewModel: viewModel) // 如果发送成功,`logsToRetry` 将变为 nil,循环自动结束 if Bark_logsToRetry == nil { @@ -182,7 +196,8 @@ class NotificationManager { } // 调用 Webhook 服务发送 - Webhook_logsToRetry = await customWebhookService.send(logs: currentLogs, using: currentConfig, viewModel: viewModel) + Webhook_logsToRetry = await customWebhookService.send( + logs: currentLogs, using: currentConfig, viewModel: viewModel) // 如果发送成功,`logsToRetry` 将变为 nil,循环自动结束 if Webhook_logsToRetry == nil { @@ -207,7 +222,8 @@ class NotificationManager { } // 调用 Qmsg 服务发送 - Qmsg_logsToRetry = await qmsgService.send(logs: currentLogs, using: currentConfig, viewModel: viewModel) + Qmsg_logsToRetry = await qmsgService.send( + logs: currentLogs, using: currentConfig, viewModel: viewModel) // 如果发送成功,`logsToRetry` 将变为 nil,循环自动结束 if Qmsg_logsToRetry == nil { diff --git a/MeoAsstMac/Services/Notification/QmsgService.swift b/MeoAsstMac/Services/Notification/QmsgService.swift index 472179fa0..acc30d1b6 100644 --- a/MeoAsstMac/Services/Notification/QmsgService.swift +++ b/MeoAsstMac/Services/Notification/QmsgService.swift @@ -34,7 +34,7 @@ class QmsgService: NotificationService { request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") // 1. 格式化日志摘要作为消息内容 - let title = "MAA 日志摘要 (\(logs.count)条)" + 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)" diff --git a/MeoAsstMac/Settings/NotificationSettingsView.swift b/MeoAsstMac/Settings/NotificationSettingsView.swift index b4c59cd55..4825e8e62 100644 --- a/MeoAsstMac/Settings/NotificationSettingsView.swift +++ b/MeoAsstMac/Settings/NotificationSettingsView.swift @@ -34,6 +34,7 @@ struct NotificationSettingsView: View { let isBarkEnabled: Bool let isCustomWebhookEnabled: Bool let isSendAllLogsEnabled: Bool + let isSendAllLogsAfterFinishEnabled: Bool } // MARK: 计算属性来生成 AnimationID @@ -43,7 +44,8 @@ struct NotificationSettingsView: View { isDingTalkEnabled: viewModel.DingTalkBot, isBarkEnabled: viewModel.BarkBot, isCustomWebhookEnabled: viewModel.customWebhook.isEnabled, - isSendAllLogsEnabled: viewModel.notificationTriggers.sendAllLogs + isSendAllLogsEnabled: viewModel.notificationTriggers.sendAllLogs, + isSendAllLogsAfterFinishEnabled: viewModel.notificationTriggers.sendAllLogsAfterFinish ) } @@ -51,24 +53,26 @@ struct NotificationSettingsView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { // MARK: - 通用设置 + Toggle("任务全部完成后发送", isOn: $viewModel.notificationTriggers.sendAllLogsAfterFinish) Section { - HStack { - Text("发送间隔") - Spacer() - // 使用 TextField 允许用户直接输入 - TextField("分钟", value: $viewModel.notificationSendingInterval, formatter: NumberFormatter()) - .multilineTextAlignment(.trailing) - .frame(width: 80) // 限制输入框宽度 - Text("分钟") + if !viewModel.notificationTriggers.sendAllLogsAfterFinish { + HStack { + Text("发送间隔") + Spacer() + // 使用 TextField 允许用户直接输入 + TextField("分钟", value: $viewModel.notificationSendingInterval, formatter: NumberFormatter()) + .multilineTextAlignment(.trailing) + .frame(width: 80) // 限制输入框宽度 + Text("分钟") + } } - .help("设置合并日志并发送通知的时间间隔,单位为分钟(1-120)。") - + 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) + SubToggleView(title: "任务完成后", isOn: $viewModel.notificationTriggers.onTaskCompletion) + SubToggleView(title: "任务出错时", isOn: $viewModel.notificationTriggers.onTaskError) } .padding(.leading, 5) } @@ -104,7 +108,7 @@ struct NotificationSettingsView: View { } } - + Text("将在下一发送周期生效") .font(.caption).foregroundColor(.secondary) @@ -297,9 +301,9 @@ struct SubToggleView: View { HStack(spacing: 12) { // 这就是我们的“连接线”,用一个圆角矩形或胶囊体模拟 Capsule() - .fill(Color.gray.opacity(0.5)) // 设置颜色和透明度 - .frame(width: 2.5) // 线的宽度 - .frame(maxHeight: 25) // 线的高度,可以根据你的 Toggle 高度微调 + .fill(Color.gray.opacity(0.5)) // 设置颜色和透明度 + .frame(width: 2.5) // 线的宽度 + .frame(maxHeight: 25) // 线的高度,可以根据你的 Toggle 高度微调 Toggle(title, isOn: $isOn) } diff --git a/MeoAsstMac/Views/MiniGameView.swift b/MeoAsstMac/Views/MiniGameView.swift index 71553453b..dff865960 100644 --- a/MeoAsstMac/Views/MiniGameView.swift +++ b/MeoAsstMac/Views/MiniGameView.swift @@ -99,7 +99,7 @@ enum MiniGameOption: CaseIterable { var instructions: String { switch self { - case .rebuildingMandate + case .rebuildingMandate: NSLocalizedString( """ 过完新手教程后进入前哨支点,滑动到界面最左侧。 diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 8aaeb3cb0..f7edc32d5 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -1280,6 +1280,9 @@ } } } + }, + "RM-次生预案" : { + }, "RoguelikeGamePass" : { "localizations" : { @@ -1707,6 +1710,9 @@ } } } + }, + "任务全部完成后发送" : { + }, "企鹅物流" : { "localizations" : { @@ -3017,9 +3023,6 @@ } } } - }, - "设置合并日志并发送通知的时间间隔,单位为分钟(1-120)。" : { - }, "访问被禁止,请检查IP或CDK" : { @@ -3125,6 +3128,9 @@ }, "资源更新来源" : { + }, + "过完新手教程后进入前哨支点,滑动到界面最左侧。" : { + }, "运行日志" : { From 62389486831561300ecfd5167cb9246f1b118e3c Mon Sep 17 00:00:00 2001 From: Rain Yang Date: Wed, 1 Oct 2025 12:25:56 +0800 Subject: [PATCH 3/3] feat: Switch Bark Notifications to JSON POST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change resolves the issue where Bark notifications fail to send due to URL length limitations when the log content (body) is too long. The original HTTP GET request method places all content in the URL path, which easily exceeds maximum allowed URL lengths (typically 2KB to 8KB). ✨ Key Changes Protocol Switch: Changed the Bark notification sending method from HTTP GET to HTTP POST request. Data Format: Content (title, body, key, etc.) is now packaged as a JSON Payload and sent in the request body (httpBody), following the official Bark JSON POST documentation. Code Refactoring: Removed the old buildURL(for:config:viewModel:) method from BarkService. Introduced a new buildRequest(for:config:viewModel:) method to construct a URLRequest object containing the JSON data. Modified the send(...) method to use URLSession.shared.data(for: request) for sending the POST request. 解决了当日志内容 (body) 过长时,Bark 通知因 URL 长度限制而发送失败的问题。原有的 GET 请求方式将所有通知内容(包括正文)都放在 URL 路径中,容易超过服务器或客户端限制的最大 URL 长度。 ✨ 主要改动 协议切换:将 Bark 通知的发送方式从 HTTP GET 请求切换为 HTTP POST 请求。 数据格式:将通知内容 (title, body, key 等) 封装为 JSON Payload,并通过请求体 (httpBody) 发送,遵循 Bark 官方文档的 JSON POST 格式。 代码重构: 移除 BarkService 中构建 URL 的 buildURL(for:config:viewModel:) 方法。 新增 buildRequest(for:config:viewModel:) 方法,用于构建包含 JSON 数据的 URLRequest 对象。 修改 send(...) 方法,使用 URLSession.shared.data(for: request) 发送 POST 请求。 --- .../Services/Notification/BarkService.swift | 48 ++++++++++++++----- Resources/Localizable.xcstrings | 4 +- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/MeoAsstMac/Services/Notification/BarkService.swift b/MeoAsstMac/Services/Notification/BarkService.swift index eb8e587b6..a5486b56a 100644 --- a/MeoAsstMac/Services/Notification/BarkService.swift +++ b/MeoAsstMac/Services/Notification/BarkService.swift @@ -23,13 +23,13 @@ class BarkService: NotificationService { /// 发送 Bark 通知。 /// - Returns: 如果发送失败且需要重试,则返回原始的日志数组;否则返回 `nil`。 func send(logs: [MAALog], using config: NotificationConfig, viewModel: MAAViewModel) async -> [MAALog]? { - // 1. 构建 Bark 请求 URL - guard let url = buildURL(for: logs, config: config, viewModel: viewModel) else { + // 1. 构建 Bark 请求 URLRequest (使用 JSON POST) + guard let request = buildRequest(for: logs, config: config, viewModel: viewModel) else { if viewModel.showSendLogsInGUI { - viewModel.logError("构建 Bark URL 失败。") + viewModel.logError("构建 Bark POST 请求失败。") } viewModel.BarkBot = false - // 这是配置错误,不是网络错误,因此不进行重试。 + // 这是配置错误或 JSON 编码错误,不进行重试。 return nil } @@ -38,7 +38,8 @@ class BarkService: NotificationService { if viewModel.showSendLogsInGUI { viewModel.logInfo("准备发送 Bark 日志摘要 (\(logs.count)条)...") } - let (_, response) = try await URLSession.shared.data(from: url) + // 改为使用 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 { @@ -78,8 +79,10 @@ class BarkService: NotificationService { } } - /// 根据配置和日志内容构建最终的 Bark URL。 - private func buildURL(for logs: [MAALog], config: NotificationConfig, viewModel: MAAViewModel) -> URL? { + /// 根据配置和日志内容构建最终的 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) @@ -87,15 +90,34 @@ class BarkService: NotificationService { } let body = bodyItems.joined(separator: "\n") - guard let encodedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), - let encodedBody = body.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) - else { + // 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" + ] - let fullURLString = - "\(config.barkServer)\(config.barkKey)/\(encodedTitle)/\(encodedBody)?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 URL(string: fullURLString) + return request } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f7edc32d5..20011423e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -1937,7 +1937,7 @@ "最高难度" : { }, - "准备发送 Bark 日志摘要 (%lld条)..." : { + "准备发送 Bark 日志摘要 (%lld条)" : { }, "准备发送 DingTalk 日志摘要 (%lld条)..." : { @@ -2593,7 +2593,7 @@ "机器人 QQ (可选)" : { }, - "构建 Bark URL 失败。" : { + "构建 Bark POST 请求失败。" : { }, "构想" : {