diff --git a/KMART/Commands/Kmart.swift b/KMART/Commands/Kmart.swift index 0a8f19b..503a729 100644 --- a/KMART/Commands/Kmart.swift +++ b/KMART/Commands/Kmart.swift @@ -54,7 +54,8 @@ struct Kmart: AsyncParsableCommand { PrettyPrint.print(apiString) PrettyPrint.printHeader("GENERATING REPORTS") - let reportPrefix: PrettyPrint.Prefix = configuration.slack.enabled ? .default : .ending + let notificationsEnabled: Bool = configuration.slack.enabled || configuration.discord.enabled + let reportPrefix: PrettyPrint.Prefix = notificationsEnabled ? .default : .ending let reportStart: Date = Date() let reports: Reports = Reporter.generateReports(from: objects, using: configuration) reports.saveToDisk(using: configuration) @@ -68,7 +69,17 @@ struct Kmart: AsyncParsableCommand { await Slacker.send(reports, using: configuration) let slackEnd: Date = Date() let slackString: String = String(format: "Total Time: %.1f seconds", slackEnd.timeIntervalSince(slackStart)) - PrettyPrint.print(slackString, prefix: .ending) + let slackPrefix: PrettyPrint.Prefix = configuration.discord.enabled ? .default : .ending + PrettyPrint.print(slackString, prefix: slackPrefix) + } + + if configuration.discord.enabled { + PrettyPrint.printHeader("SENDING VIA DISCORD") + let discordStart: Date = Date() + await Discorder.send(reports, using: configuration) + let discordEnd: Date = Date() + let discordString: String = String(format: "Total Time: %.1f seconds", discordEnd.timeIntervalSince(discordStart)) + PrettyPrint.print(discordString, prefix: .ending) } } } diff --git a/KMART/Helpers/Discorder.swift b/KMART/Helpers/Discorder.swift new file mode 100644 index 0000000..70fb2f2 --- /dev/null +++ b/KMART/Helpers/Discorder.swift @@ -0,0 +1,150 @@ +// +// Discorder.swift +// KMART +// + +import Foundation + +/// Struct used to perform all **Discord** operations. +struct Discorder { + + /// Send a Discord message with attached reports. + /// + /// - Parameters: + /// - reports: The reports to send via Discord. + /// - configuration: The configuration containing Discord settings. + static func send(_ reports: Reports, using configuration: Configuration) async { + let discord: DiscordConfiguration = configuration.discord + + PrettyPrint.print("Sending Report(s) via Discord") + + await message(discord) + + for attachment in discord.attachments { + if let data: Data = reports.data(type: attachment, using: configuration) { + await upload(data, of: attachment, for: discord) + } + } + } + + /// Send a Discord message. + /// + /// - Parameters: + /// - discord: The Discord configuration. + private static func message(_ discord: DiscordConfiguration) async { + + guard let url: URL = URL(string: discord.webhookURL) else { + PrettyPrint.print("Invalid Discord webhook URL: \(discord.webhookURL)", prefixColor: .red) + return + } + + var request: URLRequest = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json; charset=UTF-8", forHTTPHeaderField: "Content-Type") + request.httpBody = messageData(for: discord) + + do { + let (_, response): (Data, URLResponse) = try await URLSession.shared.data(for: request) + + guard let httpResponse: HTTPURLResponse = response as? HTTPURLResponse else { + PrettyPrint.print("Unable to get response from Discord webhook URL: \(url)", prefixColor: .red) + return + } + + // Discord returns 204 No Content on success for message-only posts + guard httpResponse.statusCode == 200 || httpResponse.statusCode == 204 else { + let string: String = HTTP.errorMessage(httpResponse.statusCode, for: url) + PrettyPrint.print(string, prefixColor: .red) + return + } + } catch { + PrettyPrint.print(error.localizedDescription, prefixColor: .red) + } + } + + /// Generate HTTP body data for the Discord message. + /// + /// - Parameters: + /// - discord: The Discord configuration. + /// - Returns: A `Data` object if successful, otherwise `nil`. + private static func messageData(for discord: DiscordConfiguration) -> Data? { + let dictionary: [String: Any] = [ + "content": discord.text + ] + + guard let data: Data = try? JSONSerialization.data(withJSONObject: dictionary, options: []) else { + PrettyPrint.print("Invalid Discord message dictionary", prefixColor: .red) + return nil + } + + return data + } + + /// Upload a file to Discord via webhook. + /// + /// - Parameters: + /// - data: The file data to upload. + /// - outputType: The output type of the file to upload. + /// - discord: The Discord configuration. + private static func upload(_ data: Data, of outputType: OutputType, for discord: DiscordConfiguration) async { + + guard let url: URL = URL(string: discord.webhookURL) else { + PrettyPrint.print("Invalid Discord webhook URL: \(discord.webhookURL)", prefixColor: .red) + return + } + + let boundary: String = "\(String.identifier).\(UUID().uuidString)" + var request: URLRequest = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = uploadData(from: data, of: outputType, boundary: boundary) + + PrettyPrint.print("Uploading \(outputType.description) report via Discord") + + do { + let (_, response): (Data, URLResponse) = try await URLSession.shared.data(for: request) + + guard let httpResponse: HTTPURLResponse = response as? HTTPURLResponse else { + PrettyPrint.print("Unable to get response from Discord webhook URL: \(url)", prefixColor: .red) + return + } + + guard httpResponse.statusCode == 200 else { + let string: String = HTTP.errorMessage(httpResponse.statusCode, for: url) + PrettyPrint.print(string, prefixColor: .red) + return + } + } catch { + PrettyPrint.print(error.localizedDescription, prefixColor: .red) + } + } + + /// Generate multipart form data for a Discord file upload. + /// + /// - Parameters: + /// - data: The file data to upload. + /// - outputType: The output type of the file to upload. + /// - boundary: A unique string used as a boundary to separate form parts. + /// - Returns: A `Data` object if successful, otherwise `nil`. + private static func uploadData(from data: Data, of outputType: OutputType, boundary: String) -> Data? { + + guard let file: String = String(data: data, encoding: .utf8) else { + PrettyPrint.print("Invalid report file data", prefixColor: .red) + return nil + } + + let dateFormatter: DateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let dateString: String = dateFormatter.string(from: Date()) + let filename: String = "KMART Report \(dateString).\(outputType.fileExtension)" + + var string: String = "" + string.append("--\(boundary)\r\n") + string.append("Content-Disposition: form-data; name=\"files[0]\"; filename=\"\(filename)\"\r\n") + string.append("Content-Type: application/octet-stream\r\n\r\n") + string.append(file) + string.append("\r\n--\(boundary)--\r\n") + + return string.data(using: .utf8) + } +} \ No newline at end of file diff --git a/KMART/Model/Configuration.swift b/KMART/Model/Configuration.swift index 6107514..75b99f2 100644 --- a/KMART/Model/Configuration.swift +++ b/KMART/Model/Configuration.swift @@ -28,6 +28,8 @@ struct Configuration { var output: [OutputType: String] = [:] /// Slack configuration object. var slack: SlackConfiguration = SlackConfiguration([:]) + /// Discord configuration object. + var discord: DiscordConfiguration = DiscordConfiguration([:]) /// List of endpoints to report on, derived from the requested report types. var endpoints: [Endpoint] { let endpoints: [Endpoint] = reports.flatMap { $0.endpoints } @@ -75,7 +77,8 @@ struct Configuration { configureReports(dictionary["reports"] as? [String: Bool] ?? [:]) configureReportOptions(dictionary["reports_options"] as? [String: Int] ?? [:]) configureOutput(dictionary["output"] as? [String: String] ?? [:]) - configureSlack(dictionary["slack"] as? [String: Any] ?? [: ]) + configureSlack(dictionary["slack"] as? [String: Any] ?? [:]) + configureDiscord(dictionary["discord"] as? [String: Any] ?? [:]) } /// Set the default Jamf API configuration. @@ -175,4 +178,12 @@ struct Configuration { private mutating func configureSlack(_ dictionary: [String: Any]) { slack = SlackConfiguration(dictionary) } + + /// Set the **Discord** configuration. + /// + /// - Parameters: + /// - dictionary: The dictionary containing the **Discord** configuration options. + private mutating func configureDiscord(_ dictionary: [String: Any]) { + discord = DiscordConfiguration(dictionary) + } } diff --git a/KMART/Model/DiscordConfiguration.swift b/KMART/Model/DiscordConfiguration.swift new file mode 100644 index 0000000..35a9dc8 --- /dev/null +++ b/KMART/Model/DiscordConfiguration.swift @@ -0,0 +1,37 @@ +// +// DiscordConfiguration.swift +// KMART +// + +import Foundation + +/// Struct used to hold Discord configuration. +struct DiscordConfiguration { + /// Default message text. + static let defaultText: String = "KMART Report(s) have been uploaded." + /// Set to `true` to enable reporting via Discord. + var enabled: Bool = false + /// Discord webhook URL. + var webhookURL: String = "" + /// Message text. + var text: String = "" + /// Discord attachments. + var attachments: [OutputType] = [] + + /// Initialize a DiscordConfiguration struct by passing in a custom dictionary. + /// + /// - Parameters: + /// - dictionary: Custom dictionary containing Discord configuration properties. + init(_ dictionary: [String: Any] = [:]) { + enabled = dictionary["enabled"] as? Bool ?? false + webhookURL = dictionary["webhook_url"] as? String ?? "" + text = dictionary["text"] as? String ?? DiscordConfiguration.defaultText + if let array: [String] = dictionary["attachments"] as? [String] { + for item in array { + if let attachment: OutputType = OutputType(rawValue: item) { + attachments.append(attachment) + } + } + } + } +} \ No newline at end of file