Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions KMART/Commands/Kmart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}
}
150 changes: 150 additions & 0 deletions KMART/Helpers/Discorder.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 12 additions & 1 deletion KMART/Model/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
}
37 changes: 37 additions & 0 deletions KMART/Model/DiscordConfiguration.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
}