diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index de205b533..000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,13 +0,0 @@ -# These are supported funding model platforms - -github: cryptoAlgorithm -patreon: # Replace with a single Patreon username -open_collective: # Replace with a single Open Collective username -ko_fi: # Replace with a single Ko-fi username -tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -liberapay: # Replace with a single Liberapay username -issuehunt: # Replace with a single IssueHunt username -otechie: # Replace with a single Otechie username -lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry -custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index a5f794e22..b5254ddba 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -1,8 +1,5 @@ name: Lint and Test -env: - GH_USER: cryptoAlgorithm - on: push: branches: [ main ] # Running on every branch causes double runs for PR commits @@ -20,9 +17,15 @@ jobs: - uses: actions/checkout@v3 - name: Xcode Select - uses: devbotsxyz/xcode-select@v1.1.0 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '14.1' + + - name: Cache Swift Build + uses: actions/cache@v3 with: - version: 13.3.1 + path: .build + key: swift-build-cache # Runs a single command using the runners shell - name: Build @@ -36,9 +39,15 @@ jobs: - uses: actions/checkout@v3 - name: Xcode Select - uses: devbotsxyz/xcode-select@v1.1.0 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '14.1' + + - name: Cache Swift Build + uses: actions/cache@v3 with: - version: 13.3.1 + path: .build + key: swift-build-cache # -${{ hashFiles('**/*.swift') }} - name: Test run: swift test diff --git a/.swiftlint.yml b/.swiftlint.yml index 81eadb2b0..a4e041e97 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -3,6 +3,7 @@ disabled_rules: - unused_closure_parameter - multiple_closures_with_trailing_closure - large_tuple + - todo # TODOs are precisely for reminding me of tasks I'll have to do in the future. If they are flagged as violations, it completely defeats the point. opt_in_rules: force_cast: warning @@ -10,6 +11,7 @@ force_try: warning excluded: - Sources/DiscordKit/protos + - .build identifier_name: min_length: @@ -19,4 +21,11 @@ identifier_name: warning: 40 error: 50 allowed_symbols: ["_"] +cyclomatic_complexity: + ignores_case_statements: true +nesting: + type_level: + warning: 5 + error: 8 + reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, junit, html, emoji, sonarqube, markdown) diff --git a/Package.resolved b/Package.resolved index d09701a3b..62eed67f1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -18,6 +18,15 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c", + "version" : "1.4.4" + } + }, { "identity" : "swift-protobuf", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index bed319c99..b4e065a7e 100644 --- a/Package.swift +++ b/Package.swift @@ -10,13 +10,14 @@ let package = Package( ], products: [ .library(name: "DiscordKitCore", targets: ["DiscordKitCore"]), - .library(name: "DiscordKit", targets: ["DiscordKit"]), - .library(name: "DiscordKitCommon", targets: ["DiscordKitCommon"]), + .library(name: "DiscordKit", targets: ["DiscordKit"]), // User-oriented module, simplifies use of API for UI apps + .library(name: "DiscordKitBot", targets: ["DiscordKitBot"]) // Bot-oriented module, for use in bots ], dependencies: [ .package(url: "https://github.com/ashleymills/Reachability.swift", from: "5.1.0"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.6.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0") ], targets: [ .target( @@ -24,22 +25,29 @@ let package = Package( dependencies: [ .product(name: "Reachability", package: "Reachability.swift", condition: .when(platforms: [.macOS])), .product(name: "SwiftProtobuf", package: "swift-protobuf"), - .target(name: "DiscordKitCommon"), + .product(name: "Logging", package: "swift-log") ], exclude: [ "REST/README.md", - "Gateway/README.md" + "Gateway/README.md", + "Objects/README.md" ] ), .target( name: "DiscordKit", - dependencies: [.target(name: "DiscordKitCore")] + dependencies: [ + .target(name: "DiscordKitCore"), + .product(name: "Logging", package: "swift-log") + ] ), - .target( - name: "DiscordKitCommon", - exclude: ["Objects/README.md"] + .target( + name: "DiscordKitBot", + dependencies: [ + .target(name: "DiscordKitCore"), + .product(name: "SwiftProtobuf", package: "swift-protobuf") + ] ), - .testTarget(name: "DiscordKitCommonTests", dependencies: ["DiscordKitCommon"]) + .testTarget(name: "DiscordKitCommonTests", dependencies: ["DiscordKitCore"]) ], swiftLanguageVersions: [.v5] ) diff --git a/README.md b/README.md index c3e58ac30..de0af64a8 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,112 @@ -# DiscordKit +

+ +

-![Discord](https://img.shields.io/discord/964741354112577557?style=for-the-badge) +

DiscordKit

-The Discord API implementation that powers [Swiftcord](https://github.com/SwiftcordApp/Swiftcord), -a native Discord client for macOS also written in Swift. +

+ + + -A (mainly) fully functional Discord API library written from scratch fully in Swift! -Currently only supports user accounts, bot support coming soon. Check out the -[`bot-support`](https://github.com/SwiftcordApp/DiscordKit/tree/bot-support) branch -and SwiftcordApp/DiscordKit#18 for a quick peep into what we've been up to ;D + + -**If you like DiscordKit, please give it a ⭐ star, or consider sponsoring! It helps motivate -me to continue developing it** + + + +

-## Documentation +

Package for interacting with Discord's API to build Swift bots

-WIP Developer Documentation is available [here](https://swiftcordapp.github.io/DiscordKit/documentation/discordkit/). +> DiscordKit for Bots is now released! Use DiscordKit to create that bot you've been +> looking to make, in the Swift that you know and love! -## Platform Support +## About + +DiscordKit is a Swift package for creating Discord bots in Swift. + +**If DiscordKit has helped you, please give it a ⭐ star, or consider sponsoring! It +keeps me motivated to continue developing this and other projects.** + +## Installation + +### Swift Package Manager (SPM): + +
+ Package.swift + + Add the following to your `Package.swift`: + ```swift + .package(url: "https://github.com/SwiftcordApp/DiscordKit", branch: "bot-support") + ``` +
+
+ Xcode Project -Currently, DiscordKit only offically supports macOS versions 12 and up. Theoretically, You should be able to compile and use DiscordKit on i(Pad)OS/tvOS, however this has not been tested and is considered an unsupported setup. + Add a package dependancy in your Xcode project with the following parameters: -Linux and Windows is not supported at the moment, due to our reliance on Apple's `Security` and `SystemConfiguration` frameworks. We have not blocked building DiscordKit on Linux and Windows in the event that support for those frameworks is added in the future. We may rework the code to add support for linux/windows in the future. + **Package URL:** + ``` + https://github.com/SwiftcordApp/DiscordKit + ``` + + **Branch:** + ``` + bot-support + ``` + + **Product:** + - [x] DiscordKitBot +
+ +For more detailed instructions, refer to [this page](https://app.gitbook.com/o/bq2pyf3PEDPf2CURHt4z/s/WJuHiYLW9jKqPb7h8D7t/getting-started/installation) +in the DiscordKit guide. + +## Example Usage + +Create a simple bot with a **/ping** command: -## Adding DiscordKit to your project -### SPM: -Add the following to your `Package.swift`: ```swift -.package(url: "https://github.com/SwiftcordApp/DiscordKit", branch: "main"), + +import DiscordKitBot + +let bot = Client(intents: .unprivileged) + +bot.ready.listen { + print("Logged in as \(bot.user!.username)#\(bot.user!.discriminator)!") + + print("Started refreshing application (/) commands.") + try? await bot.registerApplicationCommands(guild: ProcessInfo.processInfo.environment["COMMAND_GUILD_ID"]) { + NewAppCommand("ping", description: "Ping me!") { interaction in + try? await interaction.reply("Pong!") + } + } + print("Successfully reloaded application (/) commands.") +} + +bot.login() // Reads the bot token from the DISCORD_TOKEN environment variable and logs in with the token + +// Run the main RunLoop to prevent the program from exiting +RunLoop.main.run() ``` -Currently, DiscordKit is in alpha, so it's recommended to use the latest commit on the `main` branch. +_(Yes, that's really the whole code, no messing with registering commands with the REST +API or anything!)_ + +Not sure what to do next? Check out the guide below, which walks you through +all the steps to create your own Discord bot! + +## Resources + +Here are some (WIP) resources that might be useful while developing with DiscordKit. + +* [DiscordKit Guide](https://swiftcord.gitbook.io/discordkit-guide/) +* [Developer Documentation](https://swiftcordapp.github.io/DiscordKit/documentation/discordkit/) + +## Platform Support + +Currently, DiscordKit only offically supports macOS versions 11 and up. Theoretically, you should be able to compile and use DiscordKit on any Apple platform with equivalent APIs, however this has not been tested and is considered an unsupported setup. + +Linux and Windows is not supported at the moment, primarily due to our reliance on Apple's `Combine` framework. We have not blocked building DiscordKit on other platforms in the event that support for those frameworks is added to Swift's corelibs in the future. + +Linux support is planned, and will arrive sometime in the future. Unfortunately, we do not have a timeline for that at the moment. diff --git a/Sources/DiscordKit/Extensions/Presence+.swift b/Sources/DiscordKit/Extensions/Presence+.swift index 235ada2de..47f316201 100644 --- a/Sources/DiscordKit/Extensions/Presence+.swift +++ b/Sources/DiscordKit/Extensions/Presence+.swift @@ -1,12 +1,12 @@ // -// File.swift +// Presence+.swift // // // Created by Vincent Kwok on 7/9/22. // import Foundation -import DiscordKitCommon +import DiscordKitCore extension Presence { init(protoStatus: StatusSettings, id: Snowflake) { diff --git a/Sources/DiscordKit/Gateway/DiscordGateway.swift b/Sources/DiscordKit/Gateway/DiscordGateway.swift index e6621a78c..de3075f34 100644 --- a/Sources/DiscordKit/Gateway/DiscordGateway.swift +++ b/Sources/DiscordKit/Gateway/DiscordGateway.swift @@ -6,8 +6,7 @@ // import Foundation -import os -import DiscordKitCommon +import Logging import DiscordKitCore /// Higher-level Gateway manager, mainly for handling and dispatching @@ -22,7 +21,7 @@ public class DiscordGateway: ObservableObject { // Events /// An ``EventDispatch`` that is notified when an event is dispatched /// from the Gateway - public let onEvent = EventDispatch<(GatewayEvent, GatewayData?)>() + public let onEvent = EventDispatch() /// Proxies ``RobustWebSocket/onAuthFailure`` public let onAuthFailure = EventDispatch() @@ -57,9 +56,9 @@ public class DiscordGateway: ObservableObject { /// Includes both single DMs and group DMs @Published public var privateChannels: [Channel] = [] - private var evtListenerID: EventDispatch.HandlerIdentifier? = nil, - authFailureListenerID: EventDispatch.HandlerIdentifier? = nil, - connStateChangeListenerID: EventDispatch.HandlerIdentifier? = nil + private var evtListenerID: EventDispatch.HandlerIdentifier?, + authFailureListenerID: EventDispatch.HandlerIdentifier?, + connStateChangeListenerID: EventDispatch.HandlerIdentifier? /// If the Gateway socket is connected /// @@ -72,8 +71,8 @@ public class DiscordGateway: ObservableObject { // Logger private let log = Logger( - subsystem: Bundle.main.bundleIdentifier ?? DiscordKitConfig.subsystem, - category: "DiscordGateway" + label: "DiscordGateway", + level: nil ) // Event subscribing state @@ -89,15 +88,15 @@ public class DiscordGateway: ObservableObject { /// - Parameter token: Token to use to authenticate with the Gateway public func connect(token: String) { socket = RobustWebSocket(token: token) - evtListenerID = socket!.onEvent.addHandler { [weak self] (t, d) in - self?.handleEvent(t, data: d) + evtListenerID = socket!.onEvent.addHandler { [weak self] data in + self?.handleEvent(data) } authFailureListenerID = socket!.onAuthFailure.addHandler { [weak self] in self?.onAuthFailure.notify() } - connStateChangeListenerID = socket!.onConnStateChange.addHandler { [weak self] (c, r) in - self?.connected = c - self?.reachable = r + connStateChangeListenerID = socket!.onConnStateChange.addHandler { [weak self] (connected, reachable) in + self?.connected = connected + self?.reachable = reachable } socket!.open() } @@ -111,12 +110,12 @@ public class DiscordGateway: ObservableObject { socket?.close(code: .normalClosure) } - public func send(op: GatewayOutgoingOpcodes, data: T) { + public func send(_ opcode: GatewayOutgoingOpcodes, data: T) { guard let socket = socket else { log.warning("Not sending data to a non existant socket") return } - socket.send(op: op, data: data) + socket.send(opcode, data: data) } /// Subscribe to guild events or member updates @@ -133,7 +132,7 @@ public class DiscordGateway: ObservableObject { if subscribedGuildID != id { subscribedMemberIDs.removeAll() } subscribedMemberIDs.append(memberID) send( - op: .subscribeGuildEvents, + .subscribeGuildEvents, data: SubscribeGuildEvts(guild_id: id, members: subscribedMemberIDs) ) // Unsubscribe from events from previous guild after subscribing to the new guild @@ -141,7 +140,7 @@ public class DiscordGateway: ObservableObject { // but who am I to judge. if subscribedGuildID != id, let previousGuild = subscribedGuildID { send( - op: .subscribeGuildEvents, + .subscribeGuildEvents, data: SubscribeGuildEvts(guild_id: previousGuild, members: []) ) } @@ -149,7 +148,7 @@ public class DiscordGateway: ObservableObject { } else if !subscribedTypingGuildIDs.contains(id) { // Subscribe to typing events from members in the guild if we haven't already send( - op: .subscribeGuildEvents, + .subscribeGuildEvents, data: SubscribeGuildEvts(guild_id: id, typing: true) ) subscribedTypingGuildIDs.append(id) @@ -224,7 +223,9 @@ public class DiscordGateway: ObservableObject { // Update current user presence if let currentID = cache.user?.id { presences[currentID] = Presence(protoStatus: settings.status, id: currentID) - log.debug("Updated presence for current user to \(self.presences[currentID]?.status.rawValue ?? "nil", privacy: .public)") + log.debug("Updated presence for current user", metadata: [ + "newPresence": "\(self.presences[currentID]?.status.rawValue ?? "nil")"] + ) } else { log.error("User ID is unset in cache!") } @@ -238,9 +239,9 @@ public class DiscordGateway: ObservableObject { ) } } - private func handleEvent(_ event: GatewayEvent, data: GatewayData?) { - switch (event, data) { - case let (.ready, event as ReadyEvt): + private func handleEvent(_ data: GatewayIncoming.Data) { + switch data { + case .userReady(let event): cache.configure(using: event) presences.removeAll() if let proto = event.user_settings_proto { @@ -248,30 +249,23 @@ public class DiscordGateway: ObservableObject { } else { log.warning("No user settings proto, is this a bot account?") } log.info("[READY] Gateway wrapper ready") - case let (.readySupplemental, evt as ReadySuppEvt): + case .readySupplemental(let evt): let flatPresences = evt.merged_presences.guilds.flatMap { $0 } + evt.merged_presences.friends for presence in flatPresences { presences.updateValue(presence, forKey: presence.user_id) } // Guild events - case let (.guildCreate, guild as Guild): - cache.appendOrReplace(guild) + case .guildCreate(let guild): cache.appendOrReplace(guild) - case let (.guildDelete, guild as GuildUnavailable): - cache.remove(guild) + case .guildDelete(let guild): cache.remove(guild) - case let (.guildUpdate, guild as Guild): - handleGuildUpdate(guild) + case .guildUpdate(let guild): handleGuildUpdate(guild) - // User updates - case let (.userUpdate, currentUser as CurrentUser): - cache.user = currentUser + // User updates + case .userUpdate(let currentUser): cache.user = currentUser - case let (.userSettingsUpdate, settings as UserSettings): - cache.mergeOrReplace(settings) - - case let (.userSettingsProtoUpdate, protoUpdate as GatewaySettingsProtoUpdate): + case .settingsProtoUpdate(let protoUpdate): guard !protoUpdate.partial else { log.warning("Cannot handle partial proto update yet") break @@ -283,28 +277,23 @@ public class DiscordGateway: ObservableObject { handleProtoUpdate(proto: protoUpdate.settings.proto) // Channel events - case let (.channelCreate, channel as Channel): - cache.append(channel) + case .channelCreate(let channel): cache.append(channel) - case let (.channelDelete, channel as Channel): - cache.remove(channel) + case .channelDelete(let channel): cache.remove(channel) - case let (.channelUpdate, channel as Channel): - cache.replace(channel) + case .channelUpdate(let channel): cache.replace(channel) - case let (.messageCreate, message as DiscordKitCommon.Message): - cache.appendOrReplace(message) + case .messageCreate(let message): cache.appendOrReplace(message) - case let (.presenceUpdate, update as PresenceUpdate): + case .presenceUpdate(let update): presences.updateValue(Presence(update: update), forKey: update.user.id) - log.debug("Updating presence for user ID: \(update.user.id, privacy: .public)") + log.debug("Updating presence", metadata: ["user.id": "\(update.user.id)"]) - default: - break + default: break } cache.objectWillChange.send() - onEvent.notify(event: (event, data)) - log.debug("[EVENT] Dispatched event <\(event.rawValue, privacy: .public)>") + onEvent.notify(event: data) + log.trace("[EVENT] Dispatched event") } } diff --git a/Sources/DiscordKit/Gateway/GatewayCachedState.swift b/Sources/DiscordKit/Gateway/GatewayCachedState.swift index 2fb09dd69..c899ed5aa 100644 --- a/Sources/DiscordKit/Gateway/GatewayCachedState.swift +++ b/Sources/DiscordKit/Gateway/GatewayCachedState.swift @@ -7,7 +7,6 @@ import Foundation import DiscordKitCore -import DiscordKitCommon /// A struct for storing cached data from the Gateway /// @@ -28,11 +27,6 @@ public class CachedState: ObservableObject { /// grow over time public private(set) var users: [Snowflake: User] = [:] - /// User settings - /// - /// View ``UserSettings`` for information about each entry. - public var userSettings: UserSettings? - /// Populates the cache using the provided event. /// - Parameter event: An incoming Gateway "ready" event. func configure(using event: ReadyEvt) { @@ -40,7 +34,6 @@ public class CachedState: ObservableObject { dms = event.private_channels user = event.user event.users.forEach(appendOrReplace(_:)) - userSettings = event.user_settings } // MARK: Guilds @@ -111,16 +104,4 @@ public class CachedState: ObservableObject { func appendOrReplace(_ user: User) { users.updateValue(user, forKey: user.id) } - - // MARK: User Settings - - /// If the cache does not contain user settings, the provided settings will be set directly. If the cache already contains user settings, the provided settings will be merged with the existing settings. - /// - Parameter updatedSettings: The user settings to cache. - func mergeOrReplace(_ updatedSettings: UserSettings) { - if let existingSettings = userSettings { - userSettings = existingSettings.merged(with: updatedSettings) - } else { - userSettings = updatedSettings - } - } } diff --git a/Sources/DiscordKit/REST/APIUser+.swift b/Sources/DiscordKit/REST/APIUser+.swift index 9fef08cba..18ca0a497 100644 --- a/Sources/DiscordKit/REST/APIUser+.swift +++ b/Sources/DiscordKit/REST/APIUser+.swift @@ -7,18 +7,16 @@ import Foundation import DiscordKitCore -import DiscordKitCommon public extension DiscordREST { /// Update user settings proto /// /// `PATCH /users/@me/settings-proto/{id}` - @discardableResult func updateSettingsProto( proto: Data, type: Int = 1 // Always 1 for now - ) async -> Bool { - return await patchReq( + ) async throws { + return try await patchReq( path: "users/@me/settings-proto/\(type)", body: UserSettingsProtoUpdate(settings: proto.base64EncodedString()) ) diff --git a/Sources/DiscordKitBot/ApplicationCommand/AppCommandBuilder.swift b/Sources/DiscordKitBot/ApplicationCommand/AppCommandBuilder.swift new file mode 100644 index 000000000..2d9db1ee0 --- /dev/null +++ b/Sources/DiscordKitBot/ApplicationCommand/AppCommandBuilder.swift @@ -0,0 +1,19 @@ +// +// AppCommandBuilder.swift +// +// +// Created by Vincent Kwok on 26/11/22. +// + +import Foundation +import DiscordKitCore + +/// A `resultBuilder` which allows constructing ``NewAppCommand``s with blocks +/// +/// This provides syntactic sugar for constructing application commands. +@resultBuilder +public struct AppCommandBuilder { + public static func buildBlock(_ components: NewAppCommand...) -> [NewAppCommand] { + components + } +} diff --git a/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift b/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift new file mode 100644 index 000000000..360766262 --- /dev/null +++ b/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift @@ -0,0 +1,144 @@ +// +// CommandData.swift +// +// +// Created by Vincent Kwok on 12/12/22. +// + +import Foundation +import DiscordKitCore + +/// Provides methods to get parameters of and respond to application command interactions +public class CommandData { + internal init( + optionValues: [OptionData], + rest: DiscordREST, applicationID: String, interactionID: Snowflake, token: String + ) { + self.rest = rest + self.token = token + self.interactionID = interactionID + self.applicationID = applicationID + + self.optionValues = Self.unwrapOptionDatas(optionValues) + } + + /// A private reference to the active rest handler for handling actions + /// + /// This is a `weak` reference to prevent retain cycles if the backing ``Client`` + /// instance gets deallocated. + private weak var rest: DiscordREST? + + /// The ID of this bot application + private let applicationID: String + + /// Values of options in this command + private let optionValues: [String: OptionData] + + /// If this reply has already been deferred + fileprivate var hasReplied = false + + // MARK: Parameters for executing callbacks + /// The token to use when carrying out actions with this interaction + let token: String + /// The ID of this interaction + public let interactionID: Snowflake + + fileprivate static func unwrapOptionDatas(_ options: [OptionData]) -> [String: OptionData] { + var optValues: [String: OptionData] = [:] + for optionValue in options { + optValues[optionValue.name] = optionValue + } + return optValues + } +} + +// MARK: - Getter functions for option values +public extension CommandData { + /// Get the unboxed `String` value of an option + func optionValue(of name: String) -> String? { + optionValues[name]?.value?.value() + } + /// Get the unboxed `Int` value of an option + func optionValue(of name: String) -> Int? { + optionValues[name]?.value?.value() + } + /// Get the unboxed `Double` value of an option + func optionValue(of name: String) -> Double? { + optionValues[name]?.value?.value() + } + /// Get the unboxed `Bool` value of an option + func optionValue(of name: String) -> Bool? { + optionValues[name]?.value?.value() + } + + /// Check if a sub-option is selected, and get its nested options + /// + /// This will probably be reworked in the future to provide a more friendly way of + /// handling sub-options, sub-option groups and nested options. + func subOption(name: String) -> [String: OptionData.Value]? { + guard let option = optionValues[name], option.type == .subCommand else { return nil } + // Throw away options without values as they aren't useful in this situation + return Self.unwrapOptionDatas(option.options ?? []).compactMapValues { opt in + opt.value + } + } + + /// The wrapped value of an option + typealias OptionData = Interaction.Data.AppCommandData.OptionData +} + +// MARK: - Callback APIs +public extension CommandData { + /// Wrapper function to send an interaction response with the current interaction's ID and token + private func sendResponse( + _ response: InteractionResponse.ResponseData?, type: InteractionResponse.ResponseType + ) async throws { + try await rest?.sendInteractionResponse(.init(type: type, data: response), interactionID: interactionID, token: token) + } + + /// Reply to this interaction + /// + /// If a prior call to ``deferReply()`` was made, this function automatically + /// calls ``followUp(_:)`` instead. + func reply(content: String?, embeds: [BotEmbed]?, components: [Component]?, ephemeral: Bool = false) async throws { + if hasReplied { + _ = try await followUp(content: content, embeds: embeds, components: components) + return + } + try await sendResponse( + .message( + .init(content: content, embeds: embeds, flags: ephemeral ? .ephemeral : nil, components: components) + ), + type: .interactionReply + ) + } + /// Reply to this interaction with plain text content + func reply(_ content: String, ephemeral: Bool = false) async throws { + try await reply(content: content, embeds: nil, components: nil, ephemeral: ephemeral) + } + func reply(_ content: String, ephemeral: Bool = false, @ComponentBuilder _ components: () -> [Component]) async throws { + try await reply(content: content, embeds: nil, components: components(), ephemeral: ephemeral) + } + /// Reply to this interaction with embeds + func reply(ephemeral: Bool = false, @EmbedBuilder _ embeds: () -> [BotEmbed]) async throws { + try await reply(content: nil, embeds: embeds(), components: nil, ephemeral: ephemeral) + } + func reply(ephemeral: Bool = false, @EmbedBuilder _ embeds: () -> [BotEmbed], @ComponentBuilder components: () -> [Component]) async throws { + try await reply(content: nil, embeds: embeds(), components: components(), ephemeral: ephemeral) + } + + /// Send a follow up response to this interaction + /// + /// By default, this creates a second reply to this interaction, appearing as a + /// reply in clients. However, if a call to ``deferReply()`` was made, this + /// edits the loading message with the content provided. + func followUp(content: String?, embeds: [BotEmbed]?, components: [Component]?) async throws -> Message { + try await rest!.sendInteractionFollowUp(.init(content: content, embeds: embeds, components: components), applicationID: applicationID, token: token) + } + + /// Defer the reply to this interaction - the user sees a loading state + func deferReply(ephemeral: Bool = false) async throws { + try await sendResponse(.message(.init(flags: ephemeral ? .ephemeral : nil)), type: .deferredInteractionReply) + hasReplied = true + } +} diff --git a/Sources/DiscordKitBot/ApplicationCommand/NewAppCommand.swift b/Sources/DiscordKitBot/ApplicationCommand/NewAppCommand.swift new file mode 100644 index 000000000..ef2e96aa8 --- /dev/null +++ b/Sources/DiscordKitBot/ApplicationCommand/NewAppCommand.swift @@ -0,0 +1,75 @@ +// +// NewAppCommand.swift +// +// +// Created by Vincent Kwok on 10/12/22. +// + +import Foundation +import DiscordKitCore + +/// A block to build a new application command with the ``AppCommandBuilder`` +/// +/// > This struct is not designed to be constructed outside of the ``AppCommandBuilder``. +/// > Use methods like ``Client/registerApplicationCommands(guild:_:)-3vqy0`` +/// > which allow you to construct commands with an ``AppCommandBuilder``. +public struct NewAppCommand: Encodable { + public let type: AppCommand.CommandType + /// Name of this application command + public let name: String + /// Description of this application command + public let description: String? + /// Options of this application command + public let options: [CommandOption]? + /// Interaction handler that will be called upon interactions with this command + let handler: Handler + + enum CodingKeys: CodingKey { + case type + case name + case description + case options + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encode(name, forKey: .name) + try container.encode(description, forKey: .description) + + // Workaround to encode array of protocols + if let options = options { + var optContainer = container.nestedUnkeyedContainer(forKey: .options) + for option in options { + try optContainer.encode(option) + } + } + } + + /// Create an instance of a ``NewAppCommand``, with options provided as an array without an ``OptionBuilder`` + public init( + _ name: String, description: String? = nil, + type: AppCommand.CommandType = .slash, + options: [CommandOption]? = nil, + handler: @escaping Handler + ) { + self.name = name + self.description = description + self.type = type + self.options = options + self.handler = handler + } + + /// Create an instance of a ``NewAppCommand``, adding options with an ``OptionBuilder`` + public init( + _ name: String, description: String? = nil, + type: AppCommand.CommandType = .slash, + @OptionBuilder options: () -> [CommandOption], + handler: @escaping Handler + ) { + self.init(name, description: description, type: type, options: options(), handler: handler) + } + + /// An application command handler that will be called on invocation of the command + public typealias Handler = (_ interaction: CommandData) async -> Void +} diff --git a/Sources/DiscordKitBot/ApplicationCommand/Option/BooleanOption.swift b/Sources/DiscordKitBot/ApplicationCommand/Option/BooleanOption.swift new file mode 100644 index 000000000..561c9b2c9 --- /dev/null +++ b/Sources/DiscordKitBot/ApplicationCommand/Option/BooleanOption.swift @@ -0,0 +1,29 @@ +// +// BooleanOption.swift +// +// +// Created by Vincent Kwok on 13/12/22. +// + +import Foundation +import DiscordKitCore + +/// An option accepting `Bool` values for an application command +/// +/// To be used with the ``OptionBuilder`` from the ``NewAppCommand`` initialiser +public struct BooleanOption: CommandOption { + public init(_ name: String, description: String) { + type = .boolean + + self.name = name + self.description = description + } + + public let type: CommandOptionType + + public let name: String + + public let description: String + + public var required: Bool? +} diff --git a/Sources/DiscordKitBot/ApplicationCommand/Option/CommandOption.swift b/Sources/DiscordKitBot/ApplicationCommand/Option/CommandOption.swift new file mode 100644 index 000000000..664006df4 --- /dev/null +++ b/Sources/DiscordKitBot/ApplicationCommand/Option/CommandOption.swift @@ -0,0 +1,81 @@ +// +// CmdOption.swift +// +// +// Created by Vincent Kwok on 12/12/22. +// + +import Foundation +import DiscordKitCore + +/// An option in an application command +public protocol CommandOption: Encodable { + /// The type of this option + var type: CommandOptionType { get } + + /// Name of this command + /// + /// > Important: Must be 1-32 characters long, matching the following Regex: `^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$` + var name: String { get } + + /// Description of this command + /// + /// > Important: Must be 1-100 characters long + var description: String { get } + /// If this command is required + var required: Bool? { get set } + + /// Channel types to restrict visibility of command to + // var channel_types: ChannelType? { get } +} + +// MARK: Modifiers +public extension CommandOption { + func required() -> Self { + var opt = self + opt.required = true + return opt + } +} + +public struct AppCommandOptionChoice: Encodable { + public init(name: String, value: Interaction.Data.AppCommandData.OptionData.Value) { + self.name = name + self.value = value + } + + public let name: String + public let value: Interaction.Data.AppCommandData.OptionData.Value // Trust me it makes more sense nested like this +} + +/// An enum to store either a `Double` or `Int` value for setting the minimum or maximum value of an option +enum MinMaxValue: Codable { + /// Min or max value for an option of ``CommandOptionType/number`` type + case number(Double) + /// Min or max value for an option of ``CommandOptionType/integer`` type + case integer(Int) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let val = try? container.decode(Double.self) { + self = .number(val) + } else if let val = try? container.decode(Int.self) { + self = .integer(val) + } else { + throw DecodingError.typeMismatch( + Int.self, + .init(codingPath: [], debugDescription: "Expected either Int or Double, found neither") + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .number(let value): try container.encode(value) + case .integer(let value): try container.encode(value) + } + } +} diff --git a/Sources/DiscordKitBot/ApplicationCommand/Option/IntegerOption.swift b/Sources/DiscordKitBot/ApplicationCommand/Option/IntegerOption.swift new file mode 100644 index 000000000..b9ddd9c45 --- /dev/null +++ b/Sources/DiscordKitBot/ApplicationCommand/Option/IntegerOption.swift @@ -0,0 +1,60 @@ +// +// IntegerOption.swift +// +// +// Created by Vincent Kwok on 13/12/22. +// + +import Foundation +import DiscordKitCore + +/// An option accepting `Int` values for an application command +/// +/// To be used with the ``OptionBuilder`` from the ``NewAppCommand`` initialiser +public struct IntegerOption: CommandOption { + public init(_ name: String, description: String, choices: [AppCommandOptionChoice]? = nil, autocomplete: Bool? = nil) { + type = .integer + + self.name = name + self.description = description + self.choices = choices + self.autocomplete = autocomplete + } + + public let type: CommandOptionType + + public let name: String + + public let description: String + + public var required: Bool? + + /// Choices for the user to pick from + /// + /// > Important: There can be a max of 25 choices. + public let choices: [AppCommandOptionChoice]? + + /// Minimium value permitted for this option + fileprivate(set) var min_value: Int? + /// Maximum value permitted for this option + fileprivate(set) var max_value: Int? + + /// If autocomplete interactions are enabled for this option + public let autocomplete: Bool? +} + +extension IntegerOption { + /// Require the value of this option to be greater than or equal to this value + public func min(_ min: Int) -> Self { + var opt = self + opt.min_value = min + return opt + } + + /// Require the value of this option to be smaller than or equal to this value + public func max(_ max: Int) -> Self { + var opt = self + opt.max_value = max + return opt + } +} diff --git a/Sources/DiscordKitBot/ApplicationCommand/Option/NumberOption.swift b/Sources/DiscordKitBot/ApplicationCommand/Option/NumberOption.swift new file mode 100644 index 000000000..780c5af82 --- /dev/null +++ b/Sources/DiscordKitBot/ApplicationCommand/Option/NumberOption.swift @@ -0,0 +1,46 @@ +// +// NumberOption.swift +// +// +// Created by Vincent Kwok on 13/12/22. +// + +import Foundation +import DiscordKitCore + +/// An option accepting `Double` values for an application command +/// +/// To be used with the ``OptionBuilder`` from the ``NewAppCommand`` initialiser +public struct NumberOption: CommandOption { + public init(_ name: String, description: String, choices: [AppCommandOptionChoice]? = nil, min: Double? = nil, max: Double? = nil, autocomplete: Bool? = nil) { + type = .number + + self.name = name + self.description = description + self.choices = choices + self.min_value = min + self.max_value = max + self.autocomplete = autocomplete + } + + public let type: CommandOptionType + + public let name: String + + public let description: String + + public var required: Bool? + + /// Choices for the user to pick from + /// + /// > Important: There can be a max of 25 choices. + public let choices: [AppCommandOptionChoice]? + + /// Minimium value permitted for this option + public let min_value: Double? + /// Maximum value permitted for this option + public let max_value: Double? + + /// If autocomplete interactions are enabled for this option + public let autocomplete: Bool? +} diff --git a/Sources/DiscordKitBot/ApplicationCommand/Option/OptionBuilder.swift b/Sources/DiscordKitBot/ApplicationCommand/Option/OptionBuilder.swift new file mode 100644 index 000000000..5fa75fbb6 --- /dev/null +++ b/Sources/DiscordKitBot/ApplicationCommand/Option/OptionBuilder.swift @@ -0,0 +1,16 @@ +// +// OptionBuilder.swift +// +// +// Created by Vincent Kwok on 12/12/22. +// + +import Foundation +import DiscordKitCore + +@resultBuilder +public struct OptionBuilder { + public static func buildBlock(_ components: CommandOption...) -> [CommandOption] { + components + } +} diff --git a/Sources/DiscordKitBot/ApplicationCommand/Option/StringOption.swift b/Sources/DiscordKitBot/ApplicationCommand/Option/StringOption.swift new file mode 100644 index 000000000..dedd63990 --- /dev/null +++ b/Sources/DiscordKitBot/ApplicationCommand/Option/StringOption.swift @@ -0,0 +1,48 @@ +// +// StringOption.swift +// +// +// Created by Vincent Kwok on 12/12/22. +// + +import Foundation +import DiscordKitCore + +/// An option for an application command that accepts a string value +public struct StringOption: CommandOption { + public init(_ name: String, description: String, `required`: Bool? = nil, choices: [AppCommandOptionChoice]? = nil, minLength: Int? = nil, maxLength: Int? = nil, autocomplete: Bool? = nil) { + type = .string + + self.required = `required` + self.choices = choices + self.name = name + self.description = description + self.min_length = minLength + self.max_length = maxLength + self.autocomplete = autocomplete + } + + public var type: CommandOptionType + + public var required: Bool? + + /// Choices for the user to pick from + /// + /// > Important: There can be a max of 25 choices. + public let choices: [AppCommandOptionChoice]? + + public let name: String + public let description: String + + /// The minimum allowed length of the value + /// + /// This parameter has a minimum of 0 and maximum of 6000 + public let min_length: Int? + /// The maximum allowed length + /// + /// This parameter has a minimum of 1 and maximum of 6000 + public let max_length: Int? + + /// If autocomplete interactions are enabled for this option + public let autocomplete: Bool? +} diff --git a/Sources/DiscordKitBot/ApplicationCommand/Option/SubCommand.swift b/Sources/DiscordKitBot/ApplicationCommand/Option/SubCommand.swift new file mode 100644 index 000000000..58a4287de --- /dev/null +++ b/Sources/DiscordKitBot/ApplicationCommand/Option/SubCommand.swift @@ -0,0 +1,59 @@ +// +// SubCommand.swift +// +// +// Created by Vincent Kwok on 13/12/22. +// + +import Foundation +import DiscordKitCore + +public struct SubCommand: CommandOption { + /// Create a sub-command, optionally with an array of options + public init(_ name: String, description: String, options: [CommandOption]? = nil) { + type = .subCommand + + self.name = name + self.description = description + self.options = options + } + + /// Create a sub-command with options built by an ``OptionBuilder`` + public init(_ name: String, description: String, @OptionBuilder options: () -> [CommandOption]) { + self.init(name, description: description, options: options()) + } + + public let type: CommandOptionType + + public let name: String + + public let description: String + + public var required: Bool? + + /// If this command is a subcommand or subcommand group type, these nested options will be its parameters + public let options: [CommandOption]? + + enum CodingKeys: CodingKey { + case type + case name + case description + case required + case options + } + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: SubCommand.CodingKeys.self) + + try container.encode(self.type, forKey: SubCommand.CodingKeys.type) + try container.encode(self.name, forKey: SubCommand.CodingKeys.name) + try container.encode(self.description, forKey: SubCommand.CodingKeys.description) + try container.encodeIfPresent(self.required, forKey: SubCommand.CodingKeys.required) + if let options = options { + var optContainer = container.nestedUnkeyedContainer(forKey: .options) + for option in options { + try optContainer.encode(option) + } + } + } +} diff --git a/Sources/DiscordKitBot/BotMessage.swift b/Sources/DiscordKitBot/BotMessage.swift new file mode 100644 index 000000000..847aa1158 --- /dev/null +++ b/Sources/DiscordKitBot/BotMessage.swift @@ -0,0 +1,40 @@ +// +// BotMessage.swift +// +// +// Created by Vincent Kwok on 22/11/22. +// + +import Foundation +import DiscordKitCore + +/// A Discord message, with convenience methods +/// +/// This struct represents a message on Discord, +/// > Internally, `Message`s are converted to and from this type +/// > for easier use +public struct BotMessage { + public let content: String + public let channelID: Snowflake // This will be changed very soon + public let id: Snowflake // This too + + // The REST handler associated with this message, used for message actions + fileprivate weak var rest: DiscordREST? + + internal init(from message: Message, rest: DiscordREST) { + content = message.content + channelID = message.channel_id + id = message.id + + self.rest = rest + } +} + +public extension BotMessage { + func reply(_ content: String) async throws -> Message { + return try await rest!.createChannelMsg( + message: .init(content: content, message_reference: .init(message_id: id), components: []), + id: channelID + ) + } +} diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift new file mode 100644 index 000000000..d95a411dc --- /dev/null +++ b/Sources/DiscordKitBot/Client.swift @@ -0,0 +1,184 @@ +// +// Client.swift +// +// +// Created by Vincent Kwok on 21/11/22. +// + +import Foundation +import Logging +import DiscordKitCore + +/// The main client class for bots to interact with Discord's API +public final class Client { + // REST handler + private let rest = DiscordREST() + + // MARK: Gateway vars + fileprivate var gateway: RobustWebSocket? + private var evtHandlerID: EventDispatch.HandlerIdentifier? + + // MARK: Application Command Handlers + fileprivate var appCommandHandlers: [Snowflake: NewAppCommand.Handler] = [:] + + // MARK: Event publishers + private let notificationCenter = NotificationCenter() + public let ready: NCWrapper<()> + public let messageCreate: NCWrapper + + // MARK: Configuration Members + public let intents: Intents + + // Logger + private static let logger = Logger(label: "Client", level: nil) + + // MARK: Information about the bot + /// The user object of the bot + public fileprivate(set) var user: User? + /// The application ID of the bot + /// + /// This is used for registering application commands, among other actions. + public fileprivate(set) var applicationID: String? + + public init(intents: Intents = .unprivileged) { + self.intents = intents + // Override default config for bots + DiscordKitConfig.default = .init( + properties: .init(browser: DiscordKitConfig.libraryName, device: DiscordKitConfig.libraryName), + intents: intents + ) + + // Init event wrappers + ready = .init(.ready, notificationCenter: notificationCenter) + messageCreate = .init(.messageCreate, notificationCenter: notificationCenter) + } + + deinit { + disconnect() + } + + /// Login to the Discord API with a token + /// + /// Calling this function will cause a connection to the Gateway to be attempted. + /// + /// > Warning: Ensure this function is called _before_ any calls to the API are made, + /// > and _after_ all event sinks have been registered. API calls made before this call + /// > will fail, and no events will be received while the gateway is disconnected. + public func login(token: String) { + rest.setToken(token: token) + gateway = .init(token: token) + evtHandlerID = gateway?.onEvent.addHandler { [weak self] data in + self?.handleEvent(data) + } + } + /// Login to the Discord API with a token from the environment + /// + /// This method attempts to retrieve the token from the `DISCORD_TOKEN` environment + /// variable, and calls ``login(token:)`` if it was found. + /// + /// ## See Also + /// - ``login(token:)`` If you'd like to manually provide a token instead + public func login() { + let token = ProcessInfo.processInfo.environment["DISCORD_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines) + precondition(token != nil, "The \"DISCORD_TOKEN\" environment variable was not found.") + precondition(!token!.isEmpty, "The \"DISCORD_TOKEN\" environment variable is empty.") + // We force unwrap here since that's the best way to inform the developer that they're missing a token + login(token: token!) + } + + /// Disconnect from the gateway, undoes ``login(token:)`` + /// + /// Request that the gateway connection be gracefully closed. Also destroys + /// the REST hander. Following this call, none of the APIs would function. Subsequently, + /// connection can be restored by calling ``login(token:)`` again. + public func disconnect() { + // Remove event listeners and gracefully disconnect from Gateway + gateway?.close(code: .normalClosure) + if let evtHandlerID = evtHandlerID { _ = gateway?.onEvent.removeHandler(handler: evtHandlerID) } + // Clear member vars + gateway = nil + rest.setToken(token: nil) + applicationID = nil + user = nil + } +} + +// Gateway API +extension Client { + public var isReady: Bool { gateway?.sessionOpen == true } + + /// Invoke the handler associated with the respective commands + private func invokeCommandHandler(_ commandData: Interaction.Data.AppCommandData, id: Snowflake, token: String) { + if let handler = appCommandHandlers[commandData.id] { + Self.logger.trace("Invoking application handler", metadata: ["command.name": "\(commandData.name)"]) + Task { + await handler(.init( + optionValues: commandData.options ?? [], + rest: rest, applicationID: applicationID!, interactionID: id, token: token + )) + } + } + } + + /// Handle a subset of gateway events + private func handleEvent(_ data: GatewayIncoming.Data) { + switch data { + case .botReady(let readyEvt): + let firstTime = applicationID == nil + // Set several members with info about the bot + applicationID = readyEvt.application.id + user = readyEvt.user + if firstTime { + Self.logger.info("Bot client ready", metadata: [ + "user.id": "\(readyEvt.user.id)", + "application.id": "\(readyEvt.application.id)" + ]) + ready.emit() + } + case .messageCreate(let message): + let botMessage = BotMessage(from: message, rest: rest) + messageCreate.emit(value: botMessage) + case .interaction(let interaction): + Self.logger.trace("Received interaction", metadata: ["interaction.id": "\(interaction.id)"]) + // Handle interactions based on type + switch interaction.data { + case .applicationCommand(let commandData): + invokeCommandHandler(commandData, id: interaction.id, token: interaction.token) + case .messageComponent(let componentData): + print("Component interaction: \(componentData.custom_id)") + default: break + } + default: + break + } + } +} + +// MARK: - REST-related API +public extension Client { + // MARK: Interactions + /// Register Application Commands with a result builder + func registerApplicationCommands( + guild: Snowflake? = nil, @AppCommandBuilder _ commands: () -> [NewAppCommand] + ) async throws { + try await registerApplicationCommands(guild: guild, commands()) + } + /// Register Application Commands with the provided application command create structs + func registerApplicationCommands(guild: Snowflake? = nil, _ commands: [NewAppCommand]) async throws { + let registeredCommands = try await rest.bulkOverwriteCommands(commands, applicationID: applicationID!, guildID: guild) + for command in commands { + // Find the actual registered command + // By comparing both the type and name, we ensure there is no ambiguity. + guard let registeredCommand = registeredCommands.first(where: { + $0.type == command.type && $0.name == command.name + }) else { + Self.logger.warning("Could not find registered command corresponding to new command", metadata: [ + "command.name": "\(command.name)", + "command.type": "\(command.type)" + ]) + continue + } + appCommandHandlers[registeredCommand.id] = command.handler + } + } +} diff --git a/Sources/DiscordKitBot/Embed/BotEmbed.swift b/Sources/DiscordKitBot/Embed/BotEmbed.swift new file mode 100644 index 000000000..015ad16a3 --- /dev/null +++ b/Sources/DiscordKitBot/Embed/BotEmbed.swift @@ -0,0 +1,106 @@ +// +// BotEmbed.swift +// +// +// Created by Vincent Kwok on 16/12/22. +// + +import Foundation +import DiscordKitCore + +public struct BotEmbed: Codable { + /// An embed field + public struct Field: Codable { + /// Create an embed field + public init(_ name: String, value: String, inline: Bool = false) { + assert(!name.isEmpty, "Name cannot be empty") + assert(!value.isEmpty, "Value cannot be empty") + + self.name = name + self.value = value + self.inline = inline + } + /// Construct an empty field + /// + /// This populates both the name and inline field values with `\u{200b}`, as + /// [recommended in the Discord.JS Guide](https://discordjs.guide/popular-topics/embeds.html#using-the-embed-constructor) + public init(inline: Bool = false) { + self.init("\u{200b}", value: "\u{200b}", inline: inline) + } + + public let name: String + public let value: String + public let inline: Bool + } + + enum CodingKeys: CodingKey { + case type + case title + case description + case url + case timestamp + case color + case fields + case footer + } + + // Always rich as that's the only type supported + private let type = EmbedType.rich + + // Fields are implicitly internal(get) as we do not want them appearing in autocomplete + fileprivate(set) var title: String? + fileprivate(set) var description: String? + fileprivate(set) var url: URL? + fileprivate(set) var timestamp: Date? + fileprivate(set) var color: Int? + fileprivate(set) var footer: EmbedFooter? + private let fields: [Field]? + + public init(fields: [Field]? = nil) { + self.fields = fields + } + public init(@EmbedFieldBuilder fields: () -> [Field]) { + self.init(fields: fields()) + } +} + +public extension BotEmbed { + func title(_ title: String?) -> Self { + var embed = self + embed.title = title + return embed + } + + func description(_ description: String?) -> Self { + var embed = self + embed.description = description + return embed + } + + func footer(_ text: String) -> Self { + var embed = self + embed.footer = .init(text: text) + return embed + } + + func url(_ url: URL?) -> Self { + var embed = self + embed.url = url + return embed + } + func url(_ newURL: String?) -> Self { + url(newURL != nil ? URL(string: newURL!) : nil) + } + + func timestamp(_ timestamp: Date?) -> Self { + var embed = self + embed.timestamp = timestamp + return embed + } + + func color(_ color: Int?) -> Self { + var embed = self + embed.color = color + return embed + } +} diff --git a/Sources/DiscordKitBot/Embed/BotEmbedBuilder.swift b/Sources/DiscordKitBot/Embed/BotEmbedBuilder.swift new file mode 100644 index 000000000..f43274190 --- /dev/null +++ b/Sources/DiscordKitBot/Embed/BotEmbedBuilder.swift @@ -0,0 +1,16 @@ +// +// EmbedBuilder.swift +// +// +// Created by Vincent Kwok on 16/12/22. +// + +import Foundation +import DiscordKitCore + +@resultBuilder +public struct EmbedBuilder { + public static func buildBlock(_ components: BotEmbed...) -> [BotEmbed] { + components + } +} diff --git a/Sources/DiscordKitBot/Embed/Field/EmbedFieldBuilder.swift b/Sources/DiscordKitBot/Embed/Field/EmbedFieldBuilder.swift new file mode 100644 index 000000000..7e860642e --- /dev/null +++ b/Sources/DiscordKitBot/Embed/Field/EmbedFieldBuilder.swift @@ -0,0 +1,15 @@ +// +// EmbedFieldBuilder.swift +// +// +// Created by Vincent Kwok on 16/12/22. +// + +import Foundation + +@resultBuilder +public struct EmbedFieldBuilder { + public static func buildBlock(_ components: BotEmbed.Field...) -> [BotEmbed.Field] { + components + } +} diff --git a/Sources/DiscordKitBot/MessageComponent/ActionRow.swift b/Sources/DiscordKitBot/MessageComponent/ActionRow.swift new file mode 100644 index 000000000..e41018cca --- /dev/null +++ b/Sources/DiscordKitBot/MessageComponent/ActionRow.swift @@ -0,0 +1,35 @@ +// +// ActionRow.swift +// +// +// Created by Vincent Kwok on 27/1/23. +// + +import Foundation +import DiscordKitCore + +public struct ActionRow: Component { + public let type: MessageComponentTypes = .actionRow + public let components: [Component] + + public init(@ComponentBuilder _ components: () -> [Component]) { + self.components = components() + assert(self.components.count <= 5, "An action row can contain up to 5 buttons") + } + + enum CodingKeys: CodingKey { + case type + case components + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(1, forKey: .type) + var componentContainer = container.nestedUnkeyedContainer(forKey: .components) + for component in components { + try componentContainer.encode(component) + } + } +} + diff --git a/Sources/DiscordKitBot/MessageComponent/Button.swift b/Sources/DiscordKitBot/MessageComponent/Button.swift new file mode 100644 index 000000000..4f817e5cd --- /dev/null +++ b/Sources/DiscordKitBot/MessageComponent/Button.swift @@ -0,0 +1,62 @@ +// +// Button.swift +// +// +// Created by Vincent Kwok on 27/1/23. +// + +import Foundation +import DiscordKitCore + +public struct Button: Component { + public enum ButtonType: Int, Codable { + /// An action button with a blurple background + case primary = 1 + /// An action button with a grey background + case secondary = 2 + /// An action button with a green background + case success = 3 + /// An action button with a red background + case danger = 4 + /// A grey link button + case link = 5 + } + + public let type: MessageComponentTypes = .button + fileprivate(set) var style: ButtonType = .primary + public let label: String? + public let emoji: Emoji? + public let custom_id: String? + public let url: URL? + fileprivate(set) var disabled: Bool? + + public init(_ label: String? = nil, emoji: Emoji? = nil, id: String) { + assert(label != nil || emoji != nil, "One of label or emoji must be provided") + self.label = label + self.custom_id = id + self.emoji = emoji + self.url = nil + } + + public init(_ label: String? = nil, emoji: Emoji? = nil, url: URL) { + assert(label != nil || emoji != nil, "One of label or emoji must be provided") + self.label = label + self.custom_id = nil + self.emoji = emoji + self.url = url + } +} + +public extension Button { + func buttonStyle(_ style: ButtonType) -> Self { + var opt = self + opt.style = style + return opt + } + + func disabled(_ disabled: Bool = true) -> Self { + var opt = self + opt.disabled = disabled + return opt + } +} diff --git a/Sources/DiscordKitBot/MessageComponent/ComponentBuilder.swift b/Sources/DiscordKitBot/MessageComponent/ComponentBuilder.swift new file mode 100644 index 000000000..ed7cd9ce3 --- /dev/null +++ b/Sources/DiscordKitBot/MessageComponent/ComponentBuilder.swift @@ -0,0 +1,34 @@ +// +// ComponentBuilder.swift +// +// +// Created by Vincent Kwok on 27/1/23. +// + +import Foundation +import DiscordKitCore + +@resultBuilder +public struct ComponentBuilder { + public static func buildBlock(_ components: [Component]...) -> [Component] { + components.flatMap { $0 } + } + + public static func buildArray(_ components: [[Component]]) -> [Component] { + components.flatMap { $0 } + } + + public static func buildExpression(_ expression: Component) -> [Component] { + [expression] + } + + public static func buildOptional(_ component: [Component]?) -> [Component] { + component ?? [] + } + public static func buildEither(first component: [Component]) -> [Component] { + component + } + public static func buildEither(second component: [Component]) -> [Component] { + component + } +} diff --git a/Sources/DiscordKitBot/NCWrapper.swift b/Sources/DiscordKitBot/NCWrapper.swift new file mode 100644 index 000000000..cf4e56d90 --- /dev/null +++ b/Sources/DiscordKitBot/NCWrapper.swift @@ -0,0 +1,49 @@ +// +// NCWrapper.swift +// +// +// Created by Vincent Kwok on 24/11/22. +// Credits: Helloyunho for original iteration +// + +import Foundation + +public struct NCWrapper { + private let notificationCenter: NotificationCenter + + private let name: NSNotification.Name + + init(_ name: NSNotification.Name, notificationCenter: NotificationCenter = .default) { + self.name = name + self.notificationCenter = notificationCenter + } + + func emit(value: Data) { + notificationCenter.post(name: name, object: value) + } + + public func listen(listener: @escaping (Data) -> Void) { + notificationCenter.addObserver(forName: name, object: nil, queue: nil) { notif in + guard let obj = notif.object as? Data else { return } + listener(obj) + } + } + + public func listen(listener: @escaping (Data) async -> Void) { + listen { data in Task { await listener(data) } } + } +} + +// Wrapper functions if the data is of type void +extension NCWrapper where Data == Void { + func emit() { + emit(value: ()) + } + + public func listen(listener: @escaping () -> Void) { + listen { _ in listener() } + } + public func listen(listener: @escaping () async -> Void) { + listen { _ in await listener() } + } +} diff --git a/Sources/DiscordKitBot/NotificationNames.swift b/Sources/DiscordKitBot/NotificationNames.swift new file mode 100644 index 000000000..e87757f9d --- /dev/null +++ b/Sources/DiscordKitBot/NotificationNames.swift @@ -0,0 +1,14 @@ +// +// NotificationNames.swift +// +// +// Created by Vincent Kwok on 23/11/22. +// + +import Foundation + +public extension NSNotification.Name { + static let ready = Self("dk-ready") + + static let messageCreate = Self("dk-msg-create") +} diff --git a/Sources/DiscordKitBot/Objects/InteractionResponse.swift b/Sources/DiscordKitBot/Objects/InteractionResponse.swift new file mode 100644 index 000000000..6932c628d --- /dev/null +++ b/Sources/DiscordKitBot/Objects/InteractionResponse.swift @@ -0,0 +1,98 @@ +// +// InteractionResponse.swift +// +// +// Created by Vincent Kwok on 17/12/22. +// + +import Foundation +import DiscordKitCore + +// MARK: - Interaction Response +public struct InteractionResponse: Encodable { + public init(type: InteractionResponse.ResponseType, data: InteractionResponse.ResponseData?) { + self.type = type + self.data = data + } + + public enum ResponseType: Int, Codable { + case pong = 1 + case interactionReply = 4 + case deferredInteractionReply = 5 + case deferredUpdateMessage = 6 + case updateMessage = 7 + case appCommandAutocompleteResult = 8 + case modal = 9 + } + + public enum ResponseData: Encodable { + public struct Message: Encodable { + public init( + content: String? = nil, tts: String? = nil, embeds: [BotEmbed]? = nil, + allowed_mentions: AllowedMentions? = nil, + flags: DiscordKitCore.Message.Flags? = nil, + components: [Component]? = nil, + attachments: [NewAttachment]? = nil + ) { + self.content = content + self.tts = tts + self.embeds = embeds + self.allowed_mentions = allowed_mentions + self.flags = flags + self.components = components + self.attachments = attachments + } + + public let content: String? + public let tts: String? + public let embeds: [BotEmbed]? + public let allowed_mentions: AllowedMentions? + public let flags: DiscordKitCore.Message.Flags? + public let components: [Component]? + public let attachments: [NewAttachment]? + + enum CodingKeys: CodingKey { + case content + case tts + case embeds + case allowed_mentions + case flags + case attachments + case components + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(self.content, forKey: .content) + try container.encodeIfPresent(self.tts, forKey: .tts) + try container.encodeIfPresent(self.embeds, forKey: .embeds) + try container.encodeIfPresent(self.allowed_mentions, forKey: .allowed_mentions) + try container.encodeIfPresent(self.flags, forKey: .flags) + try container.encodeIfPresent(self.attachments, forKey: .attachments) + + if let components { + var componentContainer = container.nestedUnkeyedContainer(forKey: .components) + for component in components { + try componentContainer.encode(component) + } + } + } + } + + case message(Message) + // case autocompleteResult + // case modal + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .message(let message): try container.encode(message) + } + } + } + + public let type: ResponseType + public let data: ResponseData? +} diff --git a/Sources/DiscordKitBot/Objects/WebhookResponse.swift b/Sources/DiscordKitBot/Objects/WebhookResponse.swift new file mode 100644 index 000000000..58c07d65e --- /dev/null +++ b/Sources/DiscordKitBot/Objects/WebhookResponse.swift @@ -0,0 +1,79 @@ +// +// WebhookResponse.swift +// +// +// Created by Vincent Kwok on 17/12/22. +// + +import Foundation +import DiscordKitCore + +public struct WebhookResponse: Encodable { + public init( + content: String? = nil, embeds: [BotEmbed]? = nil, tts: Bool? = nil, + attachments: [NewAttachment]? = nil, + components: [Component]? = nil, + username: String? = nil, avatarURL: URL? = nil, + allowedMentions: AllowedMentions? = nil, + flags: Message.Flags? = nil, + threadName: String? = nil + ) { + assert(content != nil || embeds != nil, "Must have at least one of content or embeds (files unsupported)") + + self.content = content + self.username = username + self.avatar_url = avatarURL + self.tts = tts + self.embeds = embeds + self.allowed_mentions = allowedMentions + self.components = components + self.attachments = attachments + self.flags = flags + self.thread_name = threadName + } + + public let content: String? + public let username: String? + public let avatar_url: URL? + public let tts: Bool? + public let embeds: [BotEmbed]? + public let allowed_mentions: AllowedMentions? + public let components: [Component]? + public let attachments: [NewAttachment]? + public let flags: Message.Flags? + public let thread_name: String? + + enum CodingKeys: CodingKey { + case content + case username + case avatar_url + case tts + case embeds + case allowed_mentions + case components + case attachments + case flags + case thread_name + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(content, forKey: .content) + try container.encodeIfPresent(username, forKey: .username) + try container.encodeIfPresent(avatar_url, forKey: .avatar_url) + try container.encodeIfPresent(tts, forKey: .tts) + try container.encodeIfPresent(embeds, forKey:.embeds) + try container.encodeIfPresent(allowed_mentions, forKey: .allowed_mentions) + try container.encodeIfPresent(attachments, forKey: .attachments) + try container.encodeIfPresent(flags, forKey: .flags) + try container.encodeIfPresent(thread_name, forKey: .thread_name) + + if let components { + var componentContainer = container.nestedUnkeyedContainer(forKey: .components) + for component in components { + try componentContainer.encode(component) + } + } + } +} diff --git a/Sources/DiscordKitBot/REST/APICommand.swift b/Sources/DiscordKitBot/REST/APICommand.swift new file mode 100644 index 000000000..655563576 --- /dev/null +++ b/Sources/DiscordKitBot/REST/APICommand.swift @@ -0,0 +1,105 @@ +// +// APICommand.swift +// +// +// Created by Vincent Kwok on 26/11/22. +// + +import Foundation +import DiscordKitCore + +public extension DiscordREST { + /// Create global application command + /// + /// > POST: `/applications/{application.id}/commands` + /// This creates a global application command available in all guilds. + func createGlobalCommand(_ command: NewAppCommand, applicationID: Snowflake) async throws -> AppCommand { + try await postReq(path: "applications/\(applicationID)/commands", body: command) + } + + /// Create guild application command + /// + /// > POST: `/applications/{application.id}/guilds/{guild.id}/commands` + /// + /// This creates a global application command scoped to a specific guild. + /// + /// > Tip: This is useful for testing as guild commands update immediately, + /// > while updates to global commands take some time to propagate. + func createGuildCommand( + _ command: NewAppCommand, + applicationID: Snowflake, guildID: Snowflake + ) async throws -> AppCommand { + try await postReq(path: "applications/\(applicationID)/guilds/\(guildID)/commands", body: command) + } + + /// Utility method to conditionally create a guild or global command depending on parameters + func createCommand( + _ command: NewAppCommand, + applicationID: Snowflake, guildID: Snowflake? + ) async throws -> AppCommand { + if let guildID = guildID { + return try await createGuildCommand(command, applicationID: applicationID, guildID: guildID) + } else { + return try await createGlobalCommand(command, applicationID: applicationID) + } + } + + /// Builk overwrite global application command + /// + /// > PUT: `/applications/{application.id}/commands` + /// + /// Overwrite global application commands with those provided. + /// + /// > Warning: + /// > This will overwrite **all** types of application commands: slash commands, user + /// > commands, and message commands. + func bulkOverwriteGlobalCommands( + _ commands: [NewAppCommand], applicationID: Snowflake + ) async throws -> [AppCommand] { + try await putReq(path: "applications/\(applicationID)/commands", body: commands) + } + + /// Builk overwrite guild application command + /// + /// > PUT: `/applications/{application.id}/guilds/{guild.id}/commands` + /// + /// Overwrite the application commands scoped to a certain guild with those provided. + /// + /// > Warning: + /// > This will overwrite **all** types of application commands: slash commands, user + /// > commands, and message commands. + /// + /// > Tip: This is useful for testing as guild commands update immediately, + /// > while updates to global commands take some time to propagate. + func bulkOverwriteGuildCommands( + _ commands: [NewAppCommand], + applicationID: Snowflake, + guildID: Snowflake + ) async throws -> [AppCommand] { + try await putReq(path: "applications/\(applicationID)/guilds/\(guildID)/commands", body: commands) + } + + /// Utility method to conditionally bulk overwrite guild or global commands depending on parameters + func bulkOverwriteCommands( + _ commands: [NewAppCommand], + applicationID: Snowflake, guildID: Snowflake? + ) async throws -> [AppCommand] { + if let guildID = guildID { + return try await bulkOverwriteGuildCommands(commands, applicationID: applicationID, guildID: guildID) + } else { + return try await bulkOverwriteGlobalCommands(commands, applicationID: applicationID) + } + } + + /// Send a response to an interaction + func sendInteractionResponse(_ response: InteractionResponse, interactionID: Snowflake, token: String) async throws { + try await postReq(path: "interactions/\(interactionID)/\(token)/callback", body: response) + } + + /// Send a follow up response to an interaction + /// + /// > POST: `/webhooks/{application.id}/{interaction.token}` + func sendInteractionFollowUp(_ response: WebhookResponse, applicationID: Snowflake, token: String) async throws -> Message { + try await postReq(path: "webhooks/\(applicationID)/\(token)", body: response) + } +} diff --git a/Sources/DiscordKitCommon/DiscordKitConfig.swift b/Sources/DiscordKitCommon/DiscordKitConfig.swift deleted file mode 100644 index 17ee9251f..000000000 --- a/Sources/DiscordKitCommon/DiscordKitConfig.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// File.swift -// -// -// Created by Vincent Kwok on 7/6/22. -// - -import Foundation - -// Target official Discord client version for feature parity -enum ClientReleaseChannel: String { - case canary - case beta - case stable -} - -/// Information about the target official client to emulate -/// -/// These values can be found by inspecting requests from the official -/// desktop client in its DevTools. -public struct ClientParityVersion { - let version: String - let buildNumber: Int - let releaseCh: ClientReleaseChannel - let electronVersion: String -} - -/// Configuration used throughout DiscordKit -/// -/// Contains various info related to gateway connection, URLs, -/// and the official client version that is emulated. -/// -/// > Warning: Do not modify these values unless you know what you're -/// > doing. You risk sending invalid data to the endpoints and getting -/// > your account flagged and banned if values are set incorrectly. -public struct DiscordKitConfig { - /// Base Discord URL - public let baseURL: String - /// CDN URL for retrieving attachments, avatars etc. - public let cdnURL: String - /// Discord API endpoint version; Only version 9 is implemented & - /// supported - public let version: Int - /// Client version that this implementation aims to emulate - /// - /// Currently the only missing piece of emulating the official - /// desktop client completely is ETK packing/unpacking. - public let parity: ClientParityVersion - - /// Base REST endpoint URL - public let restBase: String - /// Gateway WebSocket URL - public let gateway: String - - /// DiscordKit subsystem constant - public static let subsystem = "com.cryptoalgo.DiscordKit" - - /// Populate struct values with provided parameters - public init( - baseURL: String, - version: Int, - clientParity: ClientParityVersion - ) { - self.cdnURL = "https://cdn.discordapp.com/" - self.baseURL = "https://\(baseURL)/" - self.version = version - parity = clientParity - gateway = "wss://gateway.discord.gg/?v=\(version)&encoding=json&compress=zlib-stream" - restBase = "\(self.baseURL)api/v\(version)/" - } - - /// Target official client version to emulate - public static let clientParity = ClientParityVersion( - version: "0.0.283", - buildNumber: 115689, - releaseCh: .canary, - electronVersion: "13.6.6" - ) - public static let `default` = DiscordKitConfig( - baseURL: "canary.discord.com", - version: 9, - clientParity: Self.clientParity - ) -} diff --git a/Sources/DiscordKitCommon/Objects/Gateway/DataStructs.swift b/Sources/DiscordKitCommon/Objects/Gateway/DataStructs.swift deleted file mode 100644 index 5edb15cde..000000000 --- a/Sources/DiscordKitCommon/Objects/Gateway/DataStructs.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// DataStructs.swift -// DiscordAPI -// -// Created by Vincent Kwok on 20/2/22. -// - -import Foundation - -public protocol GatewayData: Decodable {} -public protocol OutgoingGatewayData: Encodable {} - -/// Presence Update -/// -/// Sent to update the presence of the current client. -/// -/// > Outgoing Gateway data struct for opcode 3 -public struct GatewayPresenceUpdate: OutgoingGatewayData { - public init(since: Int, activities: [ActivityOutgoing], status: PresenceStatus, afk: Bool) { - self.since = since - self.activities = activities - self.status = status - self.afk = afk - } - - public let since: Int // Unix time (in milliseconds) of when the client went idle, or null if the client is not idle - public let activities: [ActivityOutgoing] - public let status: PresenceStatus - public let afk: Bool -} - -/// Voice State Update -/// -/// Sent to update the client's voice, deaf and video state. -/// -/// > Outgoing Gateway data struct for opcode 4 -public struct GatewayVoiceStateUpdate: OutgoingGatewayData, GatewayData { - public let guild_id: Snowflake? - public let channel_id: Snowflake? // ID of the voice channel client wants to join (null if disconnecting) - public let self_mute: Bool - public let self_deaf: Bool - public let self_video: Bool? - - public init(guild_id: Snowflake?, channel_id: Snowflake?, self_mute: Bool, self_deaf: Bool, self_video: Bool?) { - self.guild_id = guild_id - self.channel_id = channel_id - self.self_mute = self_mute - self.self_deaf = self_deaf - self.self_video = self_video - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - // Encoding containers directly so nil optionals get encoded as "null" and not just removed - try container.encode(self_mute, forKey: .self_mute) - try container.encode(self_deaf, forKey: .self_deaf) - try container.encode(self_video, forKey: .self_video) - try container.encode(channel_id, forKey: .channel_id) - try container.encode(guild_id, forKey: .guild_id) - } -} - -/// Guild Request Members -/// -/// > Outgoing Gateway data struct for opcode 8 -public struct GatewayGuildRequestMembers: GatewayData { - public let guild_id: Snowflake - public let query: String? - public let limit: Int - public let presences: Bool? // Used to specify if we want the presences of the matched members - public let user_ids: [Snowflake]? // Used to specify which users you wish to fetch - public let nonce: String? // Nonce to identify the Guild Members Chunk response -} - -/// Gateway Hello -/// -/// > Incoming Gateway data struct for opcode 10 -public struct GatewayHello: GatewayData { - /// Interval between outgoing heartbeats, in ms - /// - /// The Gateway has an approx 25% time tolerance to delayed heartbeats, - /// that is, it will close the connection if no heartbeat is received after - /// ``heartbeat_interval``\*125% ms from the last received heartbeat. - /// As per official docs, the first heartbeat should be sent - /// ``heartbeat_interval``\*[0...1] ms after receiving the ``GatewayHello`` - /// payload, where [0...1] is a random double from 0-1. - public let heartbeat_interval: Int -} - -/// Subscribe Guild Events -/// -/// > Outgoing Gateway data struct for opcode 11 -public struct SubscribeGuildEvts: OutgoingGatewayData { - public let guild_id: Snowflake - public let typing: Bool? - public let activities: Bool? - public let threads: Bool? - public let members: [Snowflake]? - - public init(guild_id: Snowflake, typing: Bool? = nil, activities: Bool? = nil, threads: Bool? = nil, members: [Snowflake]? = nil) { - self.guild_id = guild_id - self.typing = typing - self.activities = activities - self.threads = threads - self.members = members - } -} diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/ReadyEvt.swift b/Sources/DiscordKitCommon/Objects/Gateway/Event/ReadyEvt.swift deleted file mode 100644 index 446cb8119..000000000 --- a/Sources/DiscordKitCommon/Objects/Gateway/Event/ReadyEvt.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// ReadyEvt.swift -// DiscordAPI -// -// Created by Vincent Kwok on 21/2/22. -// - -import Foundation - -public struct ReadyEvt: Decodable, GatewayData { - public let v: Int - public let user: CurrentUser - public let users: [User] - public let guilds: [Guild] - public let session_id: String - public let shard: [Int]? // Included for inclusivity, will not be used - public let application: PartialApplication? // Discord doesn't send this to human clients - public let user_settings: UserSettings? // Depreciated, no longer sent - public let user_settings_proto: String? // Protobuf of user settings - public let private_channels: [Channel] // Basically DMs -} diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Gateway.swift b/Sources/DiscordKitCommon/Objects/Gateway/Gateway.swift deleted file mode 100644 index d56c7a667..000000000 --- a/Sources/DiscordKitCommon/Objects/Gateway/Gateway.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// Gateway.swift -// DiscordAPI -// -// Created by Vincent Kwok on 20/2/22. -// - -import Foundation - -/* - Contains structs to decode JSON sent back by Gateway. May not - include a complete list of data structs for all opcodes/events, - but enough for what this app needs to do. - */ - -public enum GatewayCloseCode: Int { - case unknown = 4000 - case unknownOpcode = 4001 - case decodeErr = 4002 - case notAuthenthicated = 4003 - case authenthicationFail = 4004 - case alreadyAuthenthicated = 4005 - case invalidSeq = 4007 - case rateLimited = 4008 - case timedOut = 4009 - case invalidVersion = 4012 - case invalidIntent = 4013 - case disallowedIntent = 4014 -} - -// MARK: - Gateway Opcode enums -public enum GatewayOutgoingOpcodes: Int, Codable { - case heartbeat = 1 - case identify = 2 - case presenceUpdate = 3 - case voiceStateUpdate = 4 - case resume = 6 // Attempt to resume disconnected session - case requestGuildMembers = 8 - case subscribeGuildEvents = 14 -} - -public enum GatewayIncomingOpcodes: Int, Codable { - case dispatchEvent = 0 // Event dispatched - case heartbeat = 1 - case reconnect = 7 // Server is closing connection, should disconnect and resume - case invalidSession = 9 - case hello = 10 - case heartbeatAck = 11 -} diff --git a/Sources/DiscordKitCommon/Objects/Interaction.swift b/Sources/DiscordKitCommon/Objects/Interaction.swift deleted file mode 100644 index c5af1b429..000000000 --- a/Sources/DiscordKitCommon/Objects/Interaction.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Interaction.swift -// DiscordAPI -// -// Created by Vincent Kwok on 19/2/22. -// - -import Foundation - -// TODO: Impliment other interaction structs - -public enum InteractionType: Int, Codable { - case ping = 1 - case applicationCmd = 2 - case messageComponent = 3 - case applicationCmdAutocomplete = 4 - case modalSubmit = 5 -} - -public struct MessageInteraction: Codable { - public let id: Snowflake - public let type: InteractionType - public let name: String - public let user: User - public let member: Member? -} diff --git a/Sources/DiscordKitCommon/Objects/REST/NewMessage.swift b/Sources/DiscordKitCommon/Objects/REST/NewMessage.swift deleted file mode 100644 index 65c67e3f3..000000000 --- a/Sources/DiscordKitCommon/Objects/REST/NewMessage.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// NewMessage.swift -// DiscordAPI -// -// Created by Vincent Kwok on 25/2/22. -// - -import Foundation - -public struct NewAttachment: Codable { - public let id: String // Will not be a valid snowflake for new attachments - public let filename: String - - public init(id: String, filename: String) { - self.id = id - self.filename = filename - } -} - -public struct NewMessage: Codable { - public let content: String? - public let tts: Bool? - public let embeds: [Embed]? - public let allowed_mentions: AllowedMentions? - public let message_reference: MessageReference? - public let components: [MessageComponent]? - public let sticker_ids: [Snowflake]? - public let attachments: [NewAttachment]? - // file[n] // Handle file uploading later - // attachments - // let payload_json: Codable? // Handle this later - public let flags: Int? - - public init(content: String?, tts: Bool? = false, embeds: [Embed]? = nil, allowed_mentions: AllowedMentions? = nil, message_reference: MessageReference? = nil, components: [MessageComponent]? = nil, sticker_ids: [Snowflake]? = nil, attachments: [NewAttachment]? = nil, flags: Int? = nil) { - self.content = content - self.tts = tts - self.embeds = embeds - self.allowed_mentions = allowed_mentions - self.message_reference = message_reference - self.components = components - self.sticker_ids = sticker_ids - self.attachments = attachments - self.flags = flags - } -} diff --git a/Sources/DiscordKitCore/APIUtils.swift b/Sources/DiscordKitCore/APIUtils.swift index 265a3a69b..ead4223f6 100644 --- a/Sources/DiscordKitCore/APIUtils.swift +++ b/Sources/DiscordKitCore/APIUtils.swift @@ -6,7 +6,6 @@ // import Foundation -import DiscordKitCommon let iso8601 = { () -> ISO8601DateFormatter in let fmt = ISO8601DateFormatter() @@ -21,47 +20,6 @@ let iso8601WithFractionalSeconds = { () -> ISO8601DateFormatter in }() public extension DiscordREST { - /// Populate a ``GatewayConnProperties`` struct with some constant - /// values + some dynamic versions - /// - /// - Returns: Populated ``GatewayConnProperties`` - internal static func getSuperProperties() -> GatewayConnProperties { - var systemInfo = utsname() - uname(&systemInfo) - - // Ugly method to turn C char array into String - func parseUname(ptr: UnsafePointer) -> String { - ptr.withMemoryRebound( - to: UInt8.self, - capacity: MemoryLayout.size(ofValue: ptr) - ) { return String(cString: $0) } - } - - let release = withUnsafePointer(to: systemInfo.release) { - parseUname(ptr: $0) - } - // This should be called arch instead - let machine = withUnsafePointer(to: systemInfo.machine) { parseUname(ptr: $0) } - - return GatewayConnProperties( - os: "Mac OS X", - browser: "Discord Client", - release_channel: GatewayConfig.default.parity.releaseCh.rawValue, - client_version: GatewayConfig.default.parity.version, - os_version: release, - os_arch: machine, - system_locale: Locale.englishUS.rawValue, - client_build_number: GatewayConfig.default.parity.buildNumber - ) - } - - /// User agent to be sent along with all requests - /// - /// This is mainly to emulate the official clients to evade bans. - static var userAgent: String { - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) discord/\(GatewayConfig.default.parity.version) Chrome/91.0.4472.164 Electron/\(GatewayConfig.default.parity.electronVersion) Safari/537.36" - } - // Encoders and decoders with custom date en/decoders static let encoder: JSONEncoder = { let enc = JSONEncoder() @@ -77,14 +35,14 @@ public extension DiscordREST { dec.dateDecodingStrategy = .custom({ decoder in let container = try decoder.singleValueContainer() let dateString = try container.decode(String.self) - + if let date = iso8601.date(from: dateString) { return date } if let date = iso8601WithFractionalSeconds.date(from: dateString) { return date } - + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)") }) return dec diff --git a/Sources/DiscordKitCore/Config.swift b/Sources/DiscordKitCore/Config.swift deleted file mode 100644 index 22b884ce4..000000000 --- a/Sources/DiscordKitCore/Config.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// Config.swift -// DiscordAPI -// -// Created by Vincent Kwok on 20/2/22. -// -// Base config for many parts in Discord API - -import Foundation - -// Target official Discord client version for feature parity -public enum ClientReleaseChannel: String { - case canary - case beta - case stable -} - -/// Information about the target official client to emulate -/// -/// These values can be found by inspecting requests from the official -/// desktop client in its DevTools. -public struct ClientParityVersion { - public let version: String - public let buildNumber: Int - public let releaseCh: ClientReleaseChannel - public let electronVersion: String -} - -/// Configuration used throughout this package -/// -/// Contains various info related to gateway connection, URLs, -/// and the official client version that is emulated. -/// -/// > Warning: Do not modify these values unless you know what you're -/// > doing. You risk sending invalid data to the endpoints and getting -/// > your account flagged and banned if values are set incorrectly. -public struct GatewayConfig { - /// Base Discord URL - public let baseURL: String - /// CDN URL for retrieving attachments, avatars etc. - public let cdnURL: String - /// Discord API endpoint version; Only version 9 is implemented & - /// supported - public let version: Int - /// Client version that this implementation aims to emulate - /// - /// Currently the only missing piece of emulating the official - /// desktop client completely is ETK packing/unpacking. - public let parity: ClientParityVersion - - /// Base REST endpoint URL - public let restBase: String - /// Gateway WebSocket URL - public let gateway: String - - /// Populate struct values with provided parameters - public init( - baseURL: String, - version: Int, - clientParity: ClientParityVersion - ) { - self.cdnURL = "https://cdn.discordapp.com/" - self.baseURL = "https://\(baseURL)/" - self.version = version - parity = clientParity - gateway = "wss://gateway.discord.gg/?v=\(version)&encoding=json&compress=zlib-stream" - restBase = "\(self.baseURL)api/v\(version)/" - } - - /// Target official client version to emulate - public static let clientParity = ClientParityVersion( - version: "0.0.283", - buildNumber: 115689, - releaseCh: .canary, - electronVersion: "13.6.6" - ) - public static let `default` = GatewayConfig( - baseURL: "canary.discord.com", - version: 9, - clientParity: Self.clientParity - ) -} diff --git a/Sources/DiscordKitCore/DiscordKitConfig.swift b/Sources/DiscordKitCore/DiscordKitConfig.swift new file mode 100644 index 000000000..7faafd7e7 --- /dev/null +++ b/Sources/DiscordKitCore/DiscordKitConfig.swift @@ -0,0 +1,259 @@ +// +// DiscordKitConfig.swift +// +// +// Created by Vincent Kwok on 7/6/22. +// + +import Foundation + +// Target official Discord client version for feature parity +enum ClientReleaseChannel: String { + case canary + case beta + case stable +} + +/// Information about the target official client to emulate +/// +/// These values can be found by inspecting requests from the official +/// desktop client in its DevTools. +public struct ClientParityVersion { + let version: String + let buildNumber: Int + let releaseCh: ClientReleaseChannel + let electronVersion: String +} + +public enum PropertiesOS: String, Codable { + case macOS = "Mac OS X" + case linux + + public static var current: Self { + #if os(macOS) + .macOS + #else + .linux + #endif + } +} + +/// Connection properties used to construct client info that is sent +/// in the ``GatewayIdentify`` payload +public struct GatewayConnProperties: OutgoingGatewayData { + public init( + os: PropertiesOS = .current, // swiftlint:disable:this identifier_name + browser: String, + device: String? = nil, + release_channel: String? = nil, + client_version: String? = nil, + os_version: String? = nil, + os_arch: String? = nil, + system_locale: String? = nil, + client_build_number: Int? = nil + ) { + self.os = os + self.browser = browser + self.device = device + self.release_channel = release_channel + self.client_version = client_version + self.os_version = os_version + self.os_arch = os_arch + self.system_locale = system_locale + self.client_build_number = client_build_number + } + + /// OS the client is running on + /// + /// This should be set according to the OS the library is running on + /// + /// ## See Also + /// - ``PropertiesOS`` + let os: PropertiesOS // swiftlint:disable:this identifier_name + + /// Browser name + /// + /// Observed values were `Chrome` when running on Google Chrome and + /// `Discord Client` when running in the desktop client. + /// + /// > For now, this value is hardcoded to `Discord Client` in the + /// > ``DiscordAPI/getSuperProperties()`` method. Customisability + /// > might be added in a future release. + let browser: String + + /// Device (for bots) + /// + /// This should be set to the name of the device for bots, or left nil for user accounts. + let device: String? + + /// Release channel of target official client + /// + /// Refer to ``GatewayConfig/clientParity`` for more details. + let release_channel: String? + + /// Version of target official client + /// + /// Refer to ``GatewayConfig/clientParity`` for more details. + let client_version: String? + + /// OS version + /// + /// The version of the OS the client is running on. This is dynamically + /// retrieved in ``DiscordAPI/getSuperProperties()`` by calling `uname()`. + /// For macOS, it is the version of the Darwin Kernel, which is `21.4.0` + /// as of macOS `12.3`. + let os_version: String? + + /// Machine arch + /// + /// The arch of the machine the client is running on. This is dynamically + /// retrieved in ``DiscordAPI/getSuperProperties()`` by calling `uname()`. + /// For macOS, it could be either `x86_64` (Intel) or `arm64` (Apple Silicon). + let os_arch: String? + + /// System locale + /// + /// The locale (language) of the system. This is hardcoded to be `en-US` for now. + let system_locale: String? + + /// Build number of target official client + /// + /// Refer to ``GatewayConfig/clientParity`` for more details. + let client_build_number: Int? +} + +/// Configuration used throughout DiscordKit +/// +/// Contains various info related to gateway connection, URLs, +/// and the official client version that is emulated. +/// +/// > Warning: Do not modify these values unless you know what you're +/// > doing. You risk sending invalid data to the endpoints and getting +/// > your account flagged and banned if values are set incorrectly. +public struct DiscordKitConfig { + /// Base Discord URL + public let baseURL: URL + /// CDN URL for retrieving attachments, avatars etc. + public let cdnURL: String + /// Discord API endpoint version; Only version 9 is implemented & + /// supported + public let version: Int + + /// Properties of the client + public let properties: GatewayConnProperties + + /// The user agent to be sent with requests to the Discord API + public let userAgent: String + + /// If zlib stream compression is enabled for communication with the gateway + public let streamCompression: Bool + + /// Base REST endpoint URL + public let restBase: URL + /// Gateway WebSocket URL + public var gateway: String { + "wss://gateway.discord.gg/?v=\(version)&encoding=json\(streamCompression ? "&compress=zlib-stream" : "")" + } + + // The token to use to authenticate with both the Discord REST and Gateway APIs + // public let token: String + + /// The ``Intents`` to provide when identifying with the Gateway + /// + /// This is used for event filtering, reducing WebSocket traffic by choosing "buckets" of + /// events to receive. + /// + /// > Important: Some intents are "privileged" and must be enabled in the Developer Portal. + public let intents: Intents? + + /// If the current configuration is for a bot account + public var isBot: Bool { intents != nil } + + // MARK: Configuration constants + public static let libraryName = "DiscordKit" + public static let discordKitBuild = 1 + + /// Populate struct values with provided parameters + public init( + baseURL: String = "canary.discord.com", + version: Int? = nil, + properties: GatewayConnProperties? = nil, + intents: Intents? = nil, + streamCompression: Bool = true + ) { + self.cdnURL = "https://cdn.discordapp.com/" + self.baseURL = URL(string: "https://\(baseURL)/")! + self.intents = intents + self.streamCompression = streamCompression + + if let version { + self.version = version + } else { + self.version = intents == nil ? 9 : 10 + } + if intents == nil { + userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) discord/\(self.version) Chrome/91.0.4472.164 Electron/\(Self.clientParity.electronVersion) Safari/537.36" + } else { + userAgent = "DiscordBot (https://github.com/SwiftcordApp/DiscordKit, \(Self.discordKitBuild))" + } + if let properties = properties { + self.properties = properties + } else { + self.properties = Self.getSuperProperties( + releaseCh: Self.clientParity.releaseCh, + clientVersion: Self.clientParity.version, + buildNumber: Self.clientParity.buildNumber + ) + } + restBase = self.baseURL.appendingPathComponent("api").appendingPathComponent("v\(self.version)") + } + + /// Populate a ``GatewayConnProperties`` struct with some constant + /// values + some dynamic versions + /// + /// - Returns: Populated ``GatewayConnProperties`` + internal static func getSuperProperties( + releaseCh: ClientReleaseChannel, + clientVersion: String, + buildNumber: Int + ) -> GatewayConnProperties { + var systemInfo = utsname() + uname(&systemInfo) + + // Ugly method to turn C char array into String + func parseUname(ptr: UnsafePointer) -> String { + ptr.withMemoryRebound( + to: UInt8.self, + capacity: MemoryLayout.size(ofValue: ptr) + ) { return String(cString: $0) } + } + + let release = withUnsafePointer(to: systemInfo.release) { + parseUname(ptr: $0) + } + // This should be called arch instead + let machine = withUnsafePointer(to: systemInfo.machine) { parseUname(ptr: $0) } + + return GatewayConnProperties( + browser: "Discord Client", + release_channel: releaseCh.rawValue, + client_version: clientVersion, + os_version: release, + os_arch: machine, + system_locale: Locale.englishUS.rawValue, + client_build_number: buildNumber + ) + } + + /// Client version that this implementation aims to emulate + /// + /// Currently the only missing piece of emulating the official + /// desktop client completely is ETF packing/unpacking. + public static let clientParity = ClientParityVersion( + version: "0.0.283", + buildNumber: 115689, + releaseCh: .canary, + electronVersion: "13.6.6" + ) + public static var `default` = Self() +} diff --git a/Sources/DiscordKitCore/DiscordKitCore.swift b/Sources/DiscordKitCore/DiscordREST.swift similarity index 65% rename from Sources/DiscordKitCore/DiscordKitCore.swift rename to Sources/DiscordKitCore/DiscordREST.swift index 74d723d41..9927b10ec 100644 --- a/Sources/DiscordKitCore/DiscordKitCore.swift +++ b/Sources/DiscordKitCore/DiscordREST.swift @@ -1,17 +1,15 @@ // -// File.swift +// DiscordREST.swift // // // Created by Vincent Kwok on 5/6/22. // import Foundation -import os +import Logging public class DiscordREST { - static let subsystem = "com.cryptoalgo.discordapi" - - static let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? DiscordREST.subsystem, category: "DiscordREST") + static let log = Logger(label: "DiscordREST", level: nil) // How empty, everything is broken into smaller files (for now xD) static let session: URLSession = { @@ -26,11 +24,9 @@ public class DiscordREST { internal var token: String? - public init(token: String? = nil) { - self.token = token - } + public init() {} - public func setToken(token: String) { + public func setToken(token: String?) { self.token = token } } diff --git a/Sources/DiscordKitCore/Documentation.docc/DiscordKit.md b/Sources/DiscordKitCore/Documentation.docc/DiscordKit.md index 4f9a66f2b..7ee9bc937 100644 --- a/Sources/DiscordKitCore/Documentation.docc/DiscordKit.md +++ b/Sources/DiscordKitCore/Documentation.docc/DiscordKit.md @@ -97,7 +97,7 @@ endpoints. These are named "objects" in the official Discord Developer docs. - ``Guild`` - ``GuildBan`` - ``GuildEmojisUpdate`` -- ``GuildFolder`` +- ``GuildFolderItem`` - ``GuildIntegrationsUpdate`` - ``GuildMemberRemove`` - ``GuildMemberUpdate`` diff --git a/Sources/DiscordKitCommon/Extensions/Collection+Identifiable.swift b/Sources/DiscordKitCore/Extensions/Collection+Identifiable.swift similarity index 100% rename from Sources/DiscordKitCommon/Extensions/Collection+Identifiable.swift rename to Sources/DiscordKitCore/Extensions/Collection+Identifiable.swift diff --git a/Sources/DiscordKitCommon/Extensions/Int+decodeFlags.swift b/Sources/DiscordKitCore/Extensions/Int+decodeFlags.swift similarity index 71% rename from Sources/DiscordKitCommon/Extensions/Int+decodeFlags.swift rename to Sources/DiscordKitCore/Extensions/Int+decodeFlags.swift index fbc2efd2a..6e17d83f9 100644 --- a/Sources/DiscordKitCommon/Extensions/Int+decodeFlags.swift +++ b/Sources/DiscordKitCore/Extensions/Int+decodeFlags.swift @@ -12,10 +12,10 @@ extension Int { /// and returns an array of flags where the /// corrosponding bit in the Int is true func decodeFlags(flags: T) -> [T] where T.RawValue == Int { - var a: [T] = [] - T.allCases.forEach { c in - if (self & (1 << c.rawValue)) != 0 { a.append(c) } + var decoded: [T] = [] + T.allCases.forEach { flag in + if (self & (1 << flag.rawValue)) != 0 { decoded.append(flag) } } - return a + return decoded } } diff --git a/Sources/DiscordKitCore/Extensions/Logger+.swift b/Sources/DiscordKitCore/Extensions/Logger+.swift new file mode 100644 index 000000000..f6fa272b9 --- /dev/null +++ b/Sources/DiscordKitCore/Extensions/Logger+.swift @@ -0,0 +1,23 @@ +// +// Logger+.swift +// +// +// Created by Vincent Kwok on 25/11/22. +// + +import Foundation +import Logging + +public extension Logger { + /// Create a Logger instance at a specific log level + init(label: String, level: Level?) { + self.init(label: label) + if let level = level { + logLevel = level + } else { +#if DEBUG + logLevel = .trace +#endif + } + } +} diff --git a/Sources/DiscordKitCommon/Extensions/Snowflake+decode.swift b/Sources/DiscordKitCore/Extensions/Snowflake+decode.swift similarity index 100% rename from Sources/DiscordKitCommon/Extensions/Snowflake+decode.swift rename to Sources/DiscordKitCore/Extensions/Snowflake+decode.swift diff --git a/Sources/DiscordKitCommon/Extensions/String+random.swift b/Sources/DiscordKitCore/Extensions/String+random.swift similarity index 100% rename from Sources/DiscordKitCommon/Extensions/String+random.swift rename to Sources/DiscordKitCore/Extensions/String+random.swift diff --git a/Sources/DiscordKitCommon/Extensions/URL+.swift b/Sources/DiscordKitCore/Extensions/URL+.swift similarity index 100% rename from Sources/DiscordKitCommon/Extensions/URL+.swift rename to Sources/DiscordKitCore/Extensions/URL+.swift diff --git a/Sources/DiscordKitCommon/Extensions/URLSession+.swift b/Sources/DiscordKitCore/Extensions/URLSession+.swift similarity index 100% rename from Sources/DiscordKitCommon/Extensions/URLSession+.swift rename to Sources/DiscordKitCore/Extensions/URLSession+.swift diff --git a/Sources/DiscordKitCore/Gateway/DecompressionEngine.swift b/Sources/DiscordKitCore/Gateway/DecompressionEngine.swift index def88f73c..afeb7ae09 100644 --- a/Sources/DiscordKitCore/Gateway/DecompressionEngine.swift +++ b/Sources/DiscordKitCore/Gateway/DecompressionEngine.swift @@ -7,7 +7,7 @@ import Foundation import Compression -import OSLog +import Logging /// Decompresses `zlib-stream`-compressed payloads received /// from the Gateway @@ -23,7 +23,7 @@ import OSLog public class DecompressionEngine { private static let ZLIB_SUFFIX = Data([0x00, 0x00, 0xff, 0xff]), BUFFER_SIZE = 32_768 - private static let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? DiscordREST.subsystem, category: "DecompressionEngine") + private static let log = Logger(label: "DecompressionEngine", level: nil) private var buf = Data(), stream: compression_stream, status: compression_status, decompressing = false @@ -58,13 +58,13 @@ public class DecompressionEngine { buf.append(data) guard buf.count >= 4, buf.suffix(4) == DecompressionEngine.ZLIB_SUFFIX else { - DecompressionEngine.log.debug("Appending to buf, current buf len: \(self.buf.count, privacy: .public)") + Self.log.debug("Appending to buf", metadata: ["buf.count": "\(buf.count)"]) return nil } let output = decompress(buf) - buf.removeAll() + return String(decoding: output, as: UTF8.self) } } @@ -72,7 +72,7 @@ public class DecompressionEngine { public extension DecompressionEngine { fileprivate func decompress(_ data: Data) -> Data { guard !decompressing else { - DecompressionEngine.log.warning("Another decompression is currently taking place, skipping") + Self.log.warning("Another decompression is currently taking place, skipping") return Data() } decompressing = true @@ -114,12 +114,10 @@ public extension DecompressionEngine { // Perform compression or decompression. if let srcChunk = srcChunk { - let count = srcChunk.count - srcChunk.withUnsafeBytes { let baseAddress = $0.bindMemory(to: UInt8.self).baseAddress! - stream.src_ptr = baseAddress.advanced(by: count - stream.src_size) + stream.src_ptr = baseAddress.advanced(by: $0.count - stream.src_size) status = compression_stream_process(&stream, flags) } } @@ -130,19 +128,22 @@ public extension DecompressionEngine { // stream.dst_size before the call (here bufferSize), and stream.dst_size after the call. let count = bufferSize - stream.dst_size - let outputData = Data(bytesNoCopy: destinationBufferPointer, - count: count, - deallocator: .none) + let outputData = Data(bytesNoCopy: destinationBufferPointer, count: count, deallocator: .none) decompressed.append(contentsOf: outputData) // Reset the stream to receive the next batch of output. stream.dst_ptr = destinationBufferPointer stream.dst_size = bufferSize - case COMPRESSION_STATUS_ERROR: return decompressed - // This "error" happens when decompression is done, what a hack + case COMPRESSION_STATUS_ERROR: break // This "error" occurs when decompression is done, what a hack default: break } } while status == COMPRESSION_STATUS_OK + + Self.log.trace("Decompressed data", metadata: [ + "original.count": "\(buf.count)", + "decompressed.count": "\(decompressed.count)" + ]) + return decompressed } } diff --git a/Sources/DiscordKitCore/Gateway/GatewayIdentify.swift b/Sources/DiscordKitCore/Gateway/GatewayIdentify.swift index 8d8c111d3..753c8971b 100644 --- a/Sources/DiscordKitCore/Gateway/GatewayIdentify.swift +++ b/Sources/DiscordKitCore/Gateway/GatewayIdentify.swift @@ -6,7 +6,6 @@ // import Foundation -import DiscordKitCommon public extension RobustWebSocket { /// Returns a `GatewayIdentify` struct for identification @@ -19,23 +18,22 @@ public extension RobustWebSocket { /// - Returns: A `GatewayIdentify` struct, or nil if the Discord token is /// not present in the keychain internal func getIdentify() -> GatewayIdentify? { - // Keychain.save(key: "token", data: "token goes here") - // Keychain.remove(key: "token") // For testing return GatewayIdentify( token: self.token, - properties: DiscordREST.getSuperProperties(), + properties: DiscordKitConfig.default.properties, compress: false, large_threshold: nil, shard: nil, presence: GatewayPresenceUpdate(since: 0, activities: [], status: .online, afk: false), - client_state: ClientState( // Just a dummy client_state + client_state: DiscordKitConfig.default.isBot ? nil : ClientState( // Just a dummy client_state guild_hashes: GuildHashes(), highest_last_message_id: "0", read_state_version: 0, user_guild_settings_version: -1, user_settings_version: -1 ), - capabilities: 0b1111111101 // TODO: Reverse engineer this + capabilities: DiscordKitConfig.default.isBot ? nil : 0b1111111101, // TODO: Reverse engineer this + intents: DiscordKitConfig.default.intents ) } diff --git a/Sources/DiscordKitCore/Gateway/Intents.swift b/Sources/DiscordKitCore/Gateway/Intents.swift new file mode 100644 index 000000000..6375339d5 --- /dev/null +++ b/Sources/DiscordKitCore/Gateway/Intents.swift @@ -0,0 +1,75 @@ +// +// Intents.swift +// +// +// Created by Vincent Kwok on 21/11/22. +// + +import Foundation + +/// List of intents to select the events that should be sent back from the gateway +/// +/// +public struct Intents: OptionSet, Encodable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + /// Guilds + static public let guilds = Self(rawValue: 1 << 0) + /// Guild members + /// + /// > Warning: This is a privileged intent + static public let guildMembers = Self(rawValue: 1 << 1) + /// Guild bans + static public let guildBans = Self(rawValue: 1 << 2) + /// Guild emote and stickers + static public let emoteSticker = Self(rawValue: 1 << 3) + /// Guild integrations + static public let integrations = Self(rawValue: 1 << 4) + /// Guild webhooks + static public let webhooks = Self(rawValue: 1 << 5) + /// Guild invites + static public let guildInvites = Self(rawValue: 1 << 6) + /// Guild voice states + static public let voiceStates = Self(rawValue: 1 << 7) + /// Guild presences + /// + /// > Warning: This is a privileged intent + static public let presences = Self(rawValue: 1 << 8) + /// Guild messages + static public let messages = Self(rawValue: 1 << 9) + /// Guild message reactions + static public let reactions = Self(rawValue: 1 << 10) + /// Guild message typing + static public let msgTyping = Self(rawValue: 1 << 11) + /// Direct messages + static public let directMsgs = Self(rawValue: 1 << 12) + /// DM reactions + static public let dmReactions = Self(rawValue: 1 << 13) + /// DM message typing + static public let dmMsgTyping = Self(rawValue: 1 << 14) + /// Message content + /// + /// This intent does not represent individual events, but rather affects what data + /// is present for events that could contain message content fields. + /// > Warning: This is a privileged intent + static public let messageContent = Self(rawValue: 1 << 15) + /// Guild scheduled events + static public let scheduledEvt = Self(rawValue: 1 << 16) + /// Auto moderation configuration + static public let autoModCfg = Self(rawValue: 1 << 20) + /// Auto moderation execution + static public let autoModExec = Self(rawValue: 1 << 20) + + static public let unprivileged: Self = [.guilds, .guildBans, .emoteSticker, .integrations, .webhooks, .guildInvites, .voiceStates, .messages, .reactions, .msgTyping, .directMsgs, .dmReactions, .dmMsgTyping, .scheduledEvt, .autoModCfg, .autoModExec] + static public let privileged: Self = [.guildMembers, .presences, .messageContent] + static public let all: Self = [.unprivileged, .privileged] +} diff --git a/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift b/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift index 3f0835595..95c6d5d50 100644 --- a/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift +++ b/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift @@ -6,10 +6,9 @@ // import Foundation -import DiscordKitCommon import Reachability -import OSLog import Combine +import Logging /// A robust WebSocket that handles resuming, reconnection and heartbeats /// with the Discord Gateway @@ -25,10 +24,10 @@ import Combine /// /// > Use ``DiscordGateway`` instead of this class - it uses ``RobustWebSocket`` /// > underlyingly and is higher-level for more ease of use. -public class RobustWebSocket: NSObject, ObservableObject { +public class RobustWebSocket: NSObject { /// An ``EventDispatch`` that is notified when an event dispatch /// is received from the Gateway - public let onEvent = EventDispatch<(GatewayEvent, GatewayData?)>() + public let onEvent = EventDispatch() /// An ``EventDispatch`` that is notified when the gateway closes /// with an auth failure, or when the token is not present @@ -50,18 +49,24 @@ public class RobustWebSocket: NSObject, ObservableObject { private let reachability = try! Reachability() // Logger instance - private static let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? DiscordREST.subsystem, category: "RobustWebSocket") + private static let log = Logger(label: "RobustWebSocket", level: nil) - private let queue: OperationQueue + // Operation queue for the URLSessionWebSocketTask + private let queue: OperationQueue = { + let queue = OperationQueue() + queue.qualityOfService = .utility + return queue + }() - private let timeout: TimeInterval, maxMsgSize: Int, - reconnectInterval: (URLSessionWebSocketTask.CloseCode?, Int) -> TimeInterval? + private let timeout: TimeInterval, maxMsgSize: Int private var attempts = 0, reconnects = -1, explicitlyClosed = false, - seq: Int? = nil, canResume = false, sessionID: String? = nil, - pendingReconnect: Timer? = nil, connTimeout: Timer? = nil + seq: Int?, canResume = false, sessionID: String?, + pendingReconnect: Timer?, connTimeout: Timer? + // MARK: - Configuration internal let token: String + private let reconnectInterval: ReconnectDelayClosure /// The gateway close codes that signal a fatal error, and reconnection shouldn't be attempted private static let fatalCloseCodes = [4004] + Array(4010...4014) @@ -71,13 +76,13 @@ public class RobustWebSocket: NSObject, ObservableObject { /// This is set to `true` immediately after the socket connection /// is established, but the connection is most likely not ready. /// No events will be received until ``sessionOpen`` is `true`. - private(set) var connected = false { + private(set) final var connected = false { didSet { if !connected { sessionOpen = false }} } /// If the network is reachable (has network connectivity) /// /// ``onConnStateChange`` is notified when this changes. - public var reachable = false { + public final var reachable = false { didSet { onConnStateChange.notify(event: (sessionOpen, reachable)) } } /// If a session with the Gateway is established @@ -86,7 +91,7 @@ public class RobustWebSocket: NSObject, ObservableObject { /// The socket is then considered "fully opened" once this is `true`. /// /// ``onConnStateChange`` is notified when this changes. - public var sessionOpen = false { + public final var sessionOpen = false { didSet { onConnStateChange.notify(event: (sessionOpen, reachable)) } } @@ -102,7 +107,7 @@ public class RobustWebSocket: NSObject, ObservableObject { private func invalidateConnTimeout(reason: String = "") { if let timer = connTimeout { - Self.log.debug("Invalidating conn timeout, reason: \(reason)") + Self.log.debug("Invalidating conn timeout", metadata: ["reason": "\(reason)"]) timer.invalidate() connTimeout = nil } @@ -142,9 +147,13 @@ public class RobustWebSocket: NSObject, ObservableObject { let delay = reconnectInterval(code, attempts) if let delay = delay { - Self.log.info("Retrying connection in \(delay)s, attempt \(String(self.attempts))") + Self.log.info("Retrying connection", metadata: [ + "connectIn": "\(delay)", + "attempt": "\(attempts)" + ]) DispatchQueue.main.async { [weak self] in self?.pendingReconnect = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in + Self.log.trace("Attempting reconnection now", metadata: ["attempt": "\(self?.attempts ?? 0)"]) self?.connect() } } @@ -163,17 +172,17 @@ public class RobustWebSocket: NSObject, ObservableObject { case .data(let data): if let decompressed = self?.decompressor.push_data(data) { try self?.handleMessage(with: decompressed) - } else { Self.log.debug("Data has not ended yet") } + } else { Self.log.trace("Decompression did not return any result - compressed packet is not complete") } case .string(let str): try self?.handleMessage(with: str) @unknown default: Self.log.warning("Unknown sock message case!") } } catch { - Self.log.warning("Error decoding message: \(error.localizedDescription, privacy: .public)") + Self.log.warning("Error decoding message", metadata: ["error": "\(error.localizedDescription)"]) } self?.attachSockReceiveListener() case .failure(let error): // If an error is encountered here, the connection is probably broken - Self.log.error("Error when receiving: \(error.localizedDescription, privacy: .public)") + Self.log.error("Receive error", metadata: ["error": "\(error.localizedDescription)"]) self?.forceClose() } } @@ -185,12 +194,15 @@ public class RobustWebSocket: NSObject, ObservableObject { socket.cancel() } - Self.log.info("[CONNECT] \(GatewayConfig.default.gateway), version: \(GatewayConfig.default.version)") + Self.log.info("[CONNECT]", metadata: [ + "ws": "\(DiscordKitConfig.default.gateway)", + "version": "\(DiscordKitConfig.default.version)" + ]) pendingReconnect = nil - var gatewayReq = URLRequest(url: URL(string: GatewayConfig.default.gateway)!) + var gatewayReq = URLRequest(url: URL(string: DiscordKitConfig.default.gateway)!) // The difference in capitalisation is intentional - gatewayReq.setValue(DiscordREST.userAgent, forHTTPHeaderField: "User-Agent") + gatewayReq.setValue(DiscordKitConfig.default.userAgent, forHTTPHeaderField: "User-Agent") socket = session.webSocketTask(with: gatewayReq) socket!.maximumMessageSize = maxMsgSize @@ -199,7 +211,7 @@ public class RobustWebSocket: NSObject, ObservableObject { self?.connTimeout = nil guard self?.connected != true else { return } // reachability.stopNotifier() - Self.log.warning("Connection timed out after \(self!.timeout)s") + Self.log.warning("Connection timed out", metadata: ["timeout": "\(self?.timeout ?? -1)"]) self?.forceClose() Self.log.info("[RECONNECT] Preemptively attempting reconnection") self?.reconnect(code: nil) @@ -229,7 +241,7 @@ public class RobustWebSocket: NSObject, ObservableObject { } catch let DecodingError.valueNotFound(value, context) { print("Value '\(value)' not found:", context.debugDescription) print("codingPath:", context.codingPath) - } catch let DecodingError.typeMismatch(type, context) { + } catch let DecodingError.typeMismatch(type, context) { print("Type '\(type)' mismatch:", context.debugDescription) print("codingPath:", context.codingPath) print(message) @@ -241,86 +253,90 @@ public class RobustWebSocket: NSObject, ObservableObject { guard let msgData = message.data(using: .utf8) else { return } let decoded = try DiscordREST.decoder.decode(GatewayIncoming.self, from: msgData) - if let sequence = decoded.s { seq = sequence } + if let sequence = decoded.seq { seq = sequence } - switch decoded.op { + switch decoded.data { case .heartbeat: Self.log.debug("[HEARTBEAT] Sending expedited heartbeat as requested") - send(op: .heartbeat, data: GatewayHeartbeat(seq)) + send(.heartbeat, data: GatewayHeartbeat(seq)) case .heartbeatAck: hbTimeout?.invalidate() - case .hello: + case .hello(let hello): onHello() // Start heartbeating and send identify - guard let d = decoded.d as? GatewayHello else { return } - Self.log.debug("[HELLO] heartbeat interval: \(d.heartbeat_interval, privacy: .public)") - startHeartbeating(interval: Double(d.heartbeat_interval) / 1000.0) + Self.log.info("[HELLO]", metadata: ["heartbeat_interval": "\(hello.heartbeat_interval)"]) + startHeartbeating(interval: Double(hello.heartbeat_interval) / 1000.0) // Check if we're attempting to and can resume if canResume, let sessionID = sessionID { - Self.log.info("[RESUME] Resuming session \(sessionID, privacy: .public), seq: \(String(describing: self.seq), privacy: .public)") + Self.log.info("[RESUME] Resuming session", metadata: [ + "sessionID": "\(sessionID)", + "seq": "\(self.seq ?? -1)" + ]) guard let resume = getResume(seq: seq, sessionID: sessionID) else { return } - send(op: .resume, data: resume) + send(.resume, data: resume) return } - Self.log.debug("[IDENTIFY]") + Self.log.info("[IDENTIFY]", metadata: [ + "intents": "\(String(describing: DiscordKitConfig.default.intents))" + ]) // Send identify seq = nil // Clear sequence # - // isReconnecting = false // Resuming failed/not attempted guard let identify = getIdentify() else { - Self.log.debug("Token not in keychain") - // authFailed = true - // socket.disconnect(closeCode: 1000) + Self.log.error("Could not get identify!") close(code: .normalClosure) onAuthFailure.notify() return } - send(op: .identify, data: identify) - case .invalidSession: + send(.identify, data: identify) + case .invalidSession(canResume: let shouldResume): // Check if the session can be resumed - let shouldResume = (decoded.primitiveData as? Bool) ?? false if !shouldResume { - Self.log.warning("Session is invalid, reconnecting without resuming") + Self.log.warning("[RECONNECT] Session is invalid, reconnecting without resuming") onSessionInvalid.notify() canResume = false } - /// Close the connection immediately and reconnect after 1-5s, as per Discord docs - /// Unfortunately Discord seems to reject the new identify no matter how long I - /// wait before sending it, so sometimes there will be 2 identify attempts before - /// the Gateway session is reestablished + // Close the connection immediately and reconnect after 1-5s, as per Discord docs + // Unfortunately Discord seems to reject the new identify no matter how long I + // wait before sending it, so sometimes there will be 2 identify attempts before + // the Gateway session is reestablished close(code: .normalClosure) DispatchQueue.main.asyncAfter(deadline: .now() + Double.random(in: 1...5)) { [weak self] in - Self.log.debug("Attempting to reconnect now") + Self.log.debug("[RECONNECT] Attempting to reconnect now") self?.open() } // attemptReconnect(resume: shouldResume) - case .dispatchEvent: - guard let type = decoded.t else { - Self.log.warning("Event has nil type") - return - } - switch type { - case .ready: - guard let d = decoded.d as? ReadyEvt else { return } - sessionID = d.session_id - canResume = true - fallthrough - case .resumed: - sessionOpen = true - default: break - } - onEvent.notify(event: (type, decoded.d)) + case .userReady(let ready): + sessionID = ready.session_id + canResume = true + sessionOpen = true + case .botReady(let ready): + sessionID = ready.session_id + canResume = true + Self.log.info("[READY]", metadata: ["session": "\(ready.session_id)"]) + fallthrough + case .resumed: + sessionOpen = true + // onEvent.notify(event: (type, decoded.data)) case .reconnect: Self.log.warning("Gateway-requested reconnect: disconnecting and reconnecting immediately") forceClose() + default: break + } + + if decoded.opcode == .dispatchEvent { + onEvent.notify(event: decoded.data) } } // MARK: - Initializers - /// Inits an instance of ``RobustWebSocket`` with provided parameters + /// Inits an instance of ``RobustWebSocket`` with provided parameters or defaults /// - /// A convenience init is also provided that uses reasonable defaults instead. + /// Defaults are also provided for some parameters: + /// - Connection timeout: 4s + /// - Maximum socket payload size: 10MiB + /// - Reconnection delay: `1.4^reconnectionTimes * 5 - 5` /// /// - Parameters: /// - token: Discord token used for authentication @@ -329,41 +345,28 @@ public class RobustWebSocket: NSObject, ObservableObject { /// - maxMessageSize: The maximum outgoing and incoming payload size for the socket. /// - reconnectIntClosure: A closure called with `(closecode, reconnectionTimes)` /// used to determine the reconnection delay. + /// - intents: Gateway intents to send in identify payload, should be set to nil for user accounts. public init( token: String, - timeout: TimeInterval, - maxMessageSize: Int, - reconnectIntClosure: @escaping (URLSessionWebSocketTask.CloseCode?, Int) -> TimeInterval? + timeout: TimeInterval = 4, + maxMessageSize: Int = 1024*1024*10, // 10MB + reconnectIntClosure: @escaping ReconnectDelayClosure = { code, times in + guard code != .policyViolation, code != .internalServerError, code?.rawValue != 4004 else { return nil } + return min(pow(2, Double(times))*1.1 + 1.6, 60) + } ) { self.timeout = timeout self.token = token - queue = OperationQueue() - queue.qualityOfService = .utility reconnectInterval = reconnectIntClosure maxMsgSize = maxMessageSize super.init() session = URLSession(configuration: .default, delegate: self, delegateQueue: queue) connect() } - - /// Inits an instance of ``RobustWebSocket`` with all parameters set - /// to reasonable defaults. - /// - /// The following are the default parameters: - /// - Connection timeout: 4s - /// - Maximum socket payload size: 10MiB - /// - Reconnection delay: `1.4^reconnectionTimes * 5 - 5` - /// - /// - Parameter token: Discord token used for authentication - public convenience init(token: String) { - self.init(token: token, timeout: TimeInterval(4), maxMessageSize: 1024*1024*10) { code, times in - guard code != .policyViolation, code != .internalServerError, code?.rawValue != 4004 else { return nil } - - return min(pow(2, Double(times))*1.1 + 1.6, 60) - } - } } +public typealias ReconnectDelayClosure = (URLSessionWebSocketTask.CloseCode?, Int) -> TimeInterval? + // MARK: - WebSocketTask delegate functions extension RobustWebSocket: URLSessionWebSocketDelegate { public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { @@ -390,14 +393,14 @@ public extension RobustWebSocket { private func setupReachability() { reachability.whenReachable = { [weak self] _ in self?.reachable = true - Self.log.info("Reset backoff for reason: connection is reachable") + Self.log.debug("Reset backoff", metadata: ["reason": "connection is reachable"]) self?.clearPendingReconnectIfNeeded() self?.attempts = 0 self?.reconnect(code: nil) } reachability.whenUnreachable = { [weak self] _ in self?.reachable = false - Self.log.info("Connection unreachable, sending expedited heartbeat") + Self.log.warning("Connection unreachable, sending expedited heartbeat") self?.sendHeartbeat(4*4) } do { try reachability.startNotifier() } catch { Self.log.error("Starting reachability notifier failed!") } @@ -409,24 +412,24 @@ public extension RobustWebSocket { @objc private func sendHeartbeat(_ interval: TimeInterval) { guard connected else { return } if let hbTimeout = hbTimeout, hbTimeout.isValid { - Self.log.warning("Skipping sending heartbeat - already waiting for one") + Self.log.warning("Skipping sending heartbeat", metadata: ["reason": "already waiting for one"]) return } Self.log.debug("[HEARTBEAT] Sending heartbeat") - send(op: .heartbeat, data: GatewayHeartbeat(seq)) + send(.heartbeat, data: GatewayHeartbeat(seq)) hbTimeout?.invalidate() DispatchQueue.main.async { [weak self] in self?.hbTimeout = Timer.scheduledTimer(withTimeInterval: interval * 0.25, repeats: false) { [weak self] _ in - Self.log.warning("[HEARTBEAT] Force-closing connection, reason: socket timed out") + Self.log.warning("[HEARTBEAT] Force-closing connection", metadata: ["reason": "socket timed out"]) self?.forceClose() } } } private func startHeartbeating(interval: TimeInterval) { - Self.log.debug("Sending heartbeats every \(interval)s") + Self.log.debug("Start heartbeating", metadata: ["interval": "\(interval)"]) if let hbCancellable = hbCancellable { Self.log.debug("Cancelling existing heartbeat timer") @@ -466,7 +469,7 @@ public extension RobustWebSocket { /// - code: A custom code to close the socket with (defaults to `.abnormalClosure`) /// - shouldReconnect: If reconnection should be attempted after the connection /// is closed. Defaults to `true` - func forceClose( + final func forceClose( code: URLSessionWebSocketTask.CloseCode = .abnormalClosure, shouldReconnect: Bool = true ) { @@ -491,7 +494,7 @@ public extension RobustWebSocket { /// called. To reconnect, recreate the ``RobustWebSocket`` instance. /// /// - Parameter code: The close code to close the socket with. - func close(code: URLSessionWebSocketTask.CloseCode) { + final func close(code: URLSessionWebSocketTask.CloseCode) { clearPendingReconnectIfNeeded() explicitlyClosed = true connected = false @@ -509,7 +512,7 @@ public extension RobustWebSocket { /// the init method, and can be used to explicitly open the connection after /// it has been closed with `close()`. This method has no effect if the socket /// is already opened. - func open() { + final func open() { guard socket.state != .running else { return } clearPendingReconnectIfNeeded() explicitlyClosed = false @@ -526,21 +529,27 @@ public extension RobustWebSocket { /// - data: A outgoing data struct that conforms to OutgoingGatewayData /// - completionHandler: Called when the send completes, with an error if any. /// Not called if set to `nil` (defaults to `nil`) - func send( - op: GatewayOutgoingOpcodes, + final func send( + _ opcode: GatewayOutgoingOpcodes, data: T, completionHandler: ((Error?) -> Void)? = nil ) { guard connected else { return } - let sendPayload = GatewayOutgoing(op: op, d: data, s: seq) + let sendPayload = GatewayOutgoing(opcode: opcode, data: data, seq: seq) guard let encoded = try? DiscordREST.encoder.encode(sendPayload) else { return } - Self.log.debug("Outgoing Payload: <\(String(describing: op), privacy: .public)> \(String(describing: data), privacy: .sensitive(mask: .hash)) [seq: \(String(describing: self.seq), privacy: .public)]") + Self.log.trace("Outgoing Payload", metadata: [ + "opcode": "\(opcode)", + "data": "\(data)", + "seq": "\(seq ?? -1)" + ]) socket.send(.data(encoded), completionHandler: completionHandler ?? { err in - if let err = err { Self.log.error("Socket send error: \(err.localizedDescription, privacy: .public)") } + if let err = err { Self.log.error("Socket send error", metadata: [ + "error": "\(err.localizedDescription)" + ]) } }) } } diff --git a/Sources/DiscordKitCommon/Objects/Activity.swift b/Sources/DiscordKitCore/Objects/Data/Activity.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Activity.swift rename to Sources/DiscordKitCore/Objects/Data/Activity.swift diff --git a/Sources/DiscordKitCore/Objects/Data/AppCommand.swift b/Sources/DiscordKitCore/Objects/Data/AppCommand.swift new file mode 100644 index 000000000..f6173c4b5 --- /dev/null +++ b/Sources/DiscordKitCore/Objects/Data/AppCommand.swift @@ -0,0 +1,67 @@ +// +// AppCommand.swift +// +// +// Created by Vincent Kwok on 12/12/22. +// + +import Foundation + +/// An application command +/// +/// > This is pretty incomplete at the moment since it was added as part +/// > of another feature +public struct AppCommand: Codable { + public enum CommandType: Int, Codable { + case slash = 1 + case user = 2 + case message = 3 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .slash + return + } + guard let type = Self(rawValue: try container.decode(Int.self)) else { + throw DecodingError.dataCorrupted(.init( + codingPath: [], debugDescription: "Int value could not be cast to a valid command type" + )) + } + self = type + } + } + + public let id: Snowflake + public let type: CommandType + public let application_id: Snowflake + public let guild_id: Snowflake? + public let name: String + public let description: String +} + +/// The type of an option +public enum CommandOptionType: Int, Codable { + /// A "sub-command" with no options + case subCommand = 1 + /// A group for nesting other options + case subCommandGroup = 2 + /// An option accepting a `String` value + case string = 3 + /// An option accepting an `Int` value + case integer = 4 + /// An option accepting a `Bool` value + case boolean = 5 + /// An option accepting a user as its value + case user = 6 + /// An option accepting a channel as its value + case channel = 7 + /// An option accepting a role as its value + case role = 8 + /// An option accepting a @mention as its value + case mentionable = 9 + /// An option accepting a `Double` value + case number = 10 + /// An option accepting a file attachment as its value + case attachment = 11 +} diff --git a/Sources/DiscordKitCommon/Objects/Application.swift b/Sources/DiscordKitCore/Objects/Data/Application.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Application.swift rename to Sources/DiscordKitCore/Objects/Data/Application.swift diff --git a/Sources/DiscordKitCommon/Objects/Attachment.swift b/Sources/DiscordKitCore/Objects/Data/Attachment.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Attachment.swift rename to Sources/DiscordKitCore/Objects/Data/Attachment.swift diff --git a/Sources/DiscordKitCommon/Objects/Channel.swift b/Sources/DiscordKitCore/Objects/Data/Channel.swift similarity index 98% rename from Sources/DiscordKitCommon/Objects/Channel.swift rename to Sources/DiscordKitCore/Objects/Data/Channel.swift index b87efba29..5c241317a 100644 --- a/Sources/DiscordKitCommon/Objects/Channel.swift +++ b/Sources/DiscordKitCore/Objects/Data/Channel.swift @@ -14,7 +14,7 @@ public enum VideoQualityMode: Int, Codable { public enum ChannelType: Int, Codable { case text = 0 - case dm = 1 + case dm = 1 // swiftlint:disable:this identifier_name case voice = 2 case groupDM = 3 case category = 4 diff --git a/Sources/DiscordKitCommon/Objects/Connection.swift b/Sources/DiscordKitCore/Objects/Data/Connection.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Connection.swift rename to Sources/DiscordKitCore/Objects/Data/Connection.swift diff --git a/Sources/DiscordKitCommon/Objects/Embed.swift b/Sources/DiscordKitCore/Objects/Data/Embed.swift similarity index 56% rename from Sources/DiscordKitCommon/Objects/Embed.swift rename to Sources/DiscordKitCore/Objects/Data/Embed.swift index f8deaf0d8..e9f98ebf3 100644 --- a/Sources/DiscordKitCommon/Objects/Embed.swift +++ b/Sources/DiscordKitCore/Objects/Data/Embed.swift @@ -18,25 +18,48 @@ public enum EmbedType: String, Codable { } public struct Embed: Codable, Identifiable { - public let title: String? + public init(title: String? = nil, type: EmbedType? = nil, description: String? = nil, url: String? = nil, timestamp: Date? = nil, color: Int? = nil, footer: EmbedFooter? = nil, image: EmbedMedia? = nil, thumbnail: EmbedMedia? = nil, video: EmbedMedia? = nil, provider: EmbedProvider? = nil, author: EmbedAuthor? = nil, fields: [EmbedField]? = nil) { + self.title = title + self.type = type + self.description = description + self.url = url + self.timestamp = timestamp + self.color = color + self.footer = footer + self.image = image + self.thumbnail = thumbnail + self.video = video + self.provider = provider + self.author = author + self.fields = fields + } + + public var title: String? public let type: EmbedType? - public let description: String? - public let url: String? - public let timestamp: Date? - public let color: Int? - public let footer: EmbedFooter? + public var description: String? + public var url: String? + public var timestamp: Date? + public var color: Int? + public var footer: EmbedFooter? public let image: EmbedMedia? public let thumbnail: EmbedMedia? public let video: EmbedMedia? public let provider: EmbedProvider? - public let author: EmbedAuthor? - public let fields: [EmbedField]? + public var author: EmbedAuthor? + public var fields: [EmbedField]? + public var id: String { "\(title ?? "")\(description ?? "")\(url ?? "")\(String(color ?? 0))\(String(timestamp?.timeIntervalSince1970 ?? 0))" } } public struct EmbedFooter: Codable { + public init(text: String, icon_url: String? = nil, proxy_icon_url: String? = nil) { + self.text = text + self.icon_url = icon_url + self.proxy_icon_url = proxy_icon_url + } + public let text: String public let icon_url: String? public let proxy_icon_url: String? diff --git a/Sources/DiscordKitCommon/Objects/Emoji.swift b/Sources/DiscordKitCore/Objects/Data/Emoji.swift similarity index 56% rename from Sources/DiscordKitCommon/Objects/Emoji.swift rename to Sources/DiscordKitCore/Objects/Data/Emoji.swift index 67a8f1361..f797f23e1 100644 --- a/Sources/DiscordKitCommon/Objects/Emoji.swift +++ b/Sources/DiscordKitCore/Objects/Data/Emoji.swift @@ -8,6 +8,17 @@ import Foundation public struct Emoji: Codable { + public init(id: Snowflake? = nil, name: String? = nil, roles: [Role]? = nil, user: User? = nil, require_colons: Bool? = nil, managed: Bool? = nil, animated: Bool? = nil, available: Bool? = nil) { + self.id = id + self.name = name + self.roles = roles + self.user = user + self.require_colons = require_colons + self.managed = managed + self.animated = animated + self.available = available + } + public let id: Snowflake? public let name: String? // Can be null only in reaction emoji objects public let roles: [Role]? diff --git a/Sources/DiscordKitCommon/Objects/Guild.swift b/Sources/DiscordKitCore/Objects/Data/Guild.swift similarity index 98% rename from Sources/DiscordKitCommon/Objects/Guild.swift rename to Sources/DiscordKitCore/Objects/Data/Guild.swift index 34483c30e..38695c16c 100644 --- a/Sources/DiscordKitCommon/Objects/Guild.swift +++ b/Sources/DiscordKitCore/Objects/Data/Guild.swift @@ -38,6 +38,8 @@ public enum GuildFeature: String, Codable { } public struct Guild: GatewayData, Equatable, Identifiable { + // There's nothing I can do to reduce function length here... + // swiftlint:disable:next function_body_length public init(id: Snowflake, name: String, icon: String? = nil, icon_hash: String? = nil, splash: String? = nil, discovery_splash: String? = nil, owner: Bool? = nil, owner_id: Snowflake, permissions: String? = nil, region: String? = nil, afk_channel_id: Snowflake? = nil, afk_timeout: Int, widget_enabled: Bool? = nil, widget_channel_id: Snowflake? = nil, verification_level: VerificationLevel, default_message_notifications: MessageNotifLevel, explicit_content_filter: ExplicitContentFilterLevel, roles: [DecodableThrowable], emojis: [DecodableThrowable], features: [DecodableThrowable], mfa_level: MFALevel, application_id: Snowflake? = nil, system_channel_id: Snowflake? = nil, system_channel_flags: Int, rules_channel_id: Snowflake? = nil, joined_at: Date? = nil, large: Bool? = nil, unavailable: Bool? = nil, member_count: Int? = nil, voice_states: [VoiceState]? = nil, members: [Member]? = nil, channels: [Channel]? = nil, threads: [Channel]? = nil, presences: [PresenceUpdate]? = nil, max_presences: Int? = nil, max_members: Int? = nil, vanity_url_code: String? = nil, description: String? = nil, banner: String? = nil, premium_tier: PremiumLevel, premium_subscription_count: Int? = nil, preferred_locale: Locale, public_updates_channel_id: Snowflake? = nil, max_video_channel_users: Int? = nil, approximate_member_count: Int? = nil, approximate_presence_count: Int? = nil, welcome_screen: GuildWelcomeScreen? = nil, nsfw_level: NSFWLevel, stage_instances: [StageInstance]? = nil, stickers: [Sticker]? = nil, guild_scheduled_events: [GuildScheduledEvent]? = nil, premium_progress_bar_enabled: Bool) { self.id = id self.name = name diff --git a/Sources/DiscordKitCommon/Objects/Integration.swift b/Sources/DiscordKitCore/Objects/Data/Integration.swift similarity index 94% rename from Sources/DiscordKitCommon/Objects/Integration.swift rename to Sources/DiscordKitCore/Objects/Data/Integration.swift index a8abf0a88..5049cac33 100644 --- a/Sources/DiscordKitCommon/Objects/Integration.swift +++ b/Sources/DiscordKitCore/Objects/Data/Integration.swift @@ -8,9 +8,9 @@ import Foundation public enum IntegrationType: String, Codable { - case youtube = "youtube" - case twitch = "twitch" - case discord = "discord" + case youtube + case twitch + case discord } public enum InteractionExpireBehaviour: Int, Codable { diff --git a/Sources/DiscordKitCore/Objects/Data/Interaction.swift b/Sources/DiscordKitCore/Objects/Data/Interaction.swift new file mode 100644 index 000000000..415398048 --- /dev/null +++ b/Sources/DiscordKitCore/Objects/Data/Interaction.swift @@ -0,0 +1,218 @@ +// +// Interaction.swift +// DiscordAPI +// +// Created by Vincent Kwok on 19/2/22. +// + +import Foundation + +// TODO: Impliment other interaction structs + +public enum InteractionType: Int, Codable { + case ping = 1 + case applicationCmd = 2 + case messageComponent = 3 + case applicationCmdAutocomplete = 4 + case modalSubmit = 5 +} + +/// An interaction struct, sent in response to an interaction +public struct Interaction: Decodable { + /// ID of the interaction + public let id: Snowflake + /// ID of the application this interaction is for + public let applicationID: Snowflake + + /// Type of interaction + public let type: InteractionType + + public let data: Data? + + public let guildID: Snowflake? + public let channelID: Snowflake? + public let member: Member? + public let user: User? + + /// Continuation token for responding to the interaction + public let token: String + + /// Interaction version - always 1 + public let version: Int + + /// For components, the message they were attached to + public let message: Message? + + public let appPermissions: String? + + public let locale: String? + + public let guildLocale: String? + + /// The data payload of an interaction + public enum Data { + /// The data payload for application command interactions + /// + /// This contains information about the executed command and its options (if present) + public struct AppCommandData: Codable { + /// ID of the invoked application command + public let id: Snowflake + /// Name of command + public let name: String + /// Type of command + public let type: Int + /// Options of command (present if the command has options) + public let options: [OptionData]? + + /// The data representing one option + public struct OptionData: Codable { + /// The value of an option + public enum Value: Encodable { + case string(String) + case integer(Int) + case double(Double) + case boolean(Bool) // Discord docs are disappointing + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let val): try container.encode(val) + case .integer(let val): try container.encode(val) + case .double(let val): try container.encode(val) + case .boolean(let val): try container.encode(val) + } + } + + /// Get the wrapped `String` value + /// + /// - Returns: The string value of a certain option if it is present and is of type `String`, otherwise `nil` + public func value() -> String? { + guard case let .string(val) = self else { return nil } + return val + } + /// Get the wrapped `Int` value + /// + /// - Returns: The string value of a certain option if it is present and is of type `Int`, otherwise `nil` + public func value() -> Int? { + guard case let .integer(val) = self else { return nil } + return val + } + /// Get the wrapped `Double` value + /// + /// - Returns: The string value of a certain option if it is present and is of type `Double`, otherwise `nil` + public func value() -> Double? { + guard case let .double(val) = self else { return nil } + return val + } + /// Get the wrapped `Bool` value + /// + /// - Returns: The string value of a certain option if it is present and is of type `Bool`, otherwise `nil` + public func value() -> Bool? { + guard case let .boolean(val) = self else { return nil } + return val + } + } + + /// Name of the option + public let name: String + /// Data type of the option + public let type: CommandOptionType + /// User-specified value of the option + public let value: Value? + /// Nested options, if this is a sub-option + public let options: [OptionData]? + /// If this option is focused (only sent for autocomplete options) + public let focused: Bool? + + enum CodingKeys: CodingKey { + case name + case type + case value + case options + case focused + } + + // Custom decodable conformance to "smartly" decode typed data based on provided type + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + name = try container.decode(String.self, forKey: .name) + type = try container.decode(CommandOptionType.self, forKey: .type) + options = try container.decodeIfPresent([Self].self, forKey: .options) + focused = try container.decodeIfPresent(Bool.self, forKey: .focused) + switch type { + case .integer: value = .integer(try container.decode(Int.self, forKey: .value)) + case .number: value = .double(try container.decode(Double.self, forKey: .value)) + case .boolean: value = .boolean(try container.decode(Bool.self, forKey: .value)) + case .string: value = .string(try container.decode(String.self, forKey: .value)) + default: value = nil + } + } + } + } + + /// The data payload for message component interactions + /// + /// This will be sent for all interactions with message components except link buttons + public struct MessageComponentData: Codable { + public let custom_id: String + public let component_type: MessageComponentTypes + } + + case applicationCommand(AppCommandData) + case messageComponent(MessageComponentData) + } + + enum CodingKeys: String, CodingKey { + case id + case applicationID = "application_id" + case type + case data + case guildID = "guild_id" + case channelID = "channel_id" + case member + case user + case token + case version + case message + case appPermissions = "app_permissions" + case locale + case guildLocale = "guild_locale" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Interaction.CodingKeys.self) + + id = try container.decode(Snowflake.self, forKey: .id) + applicationID = try container.decode(Snowflake.self, forKey: .applicationID) + type = try container.decode(InteractionType.self, forKey: .type) + guildID = try container.decodeIfPresent(Snowflake.self, forKey: .guildID) + channelID = try container.decodeIfPresent(Snowflake.self, forKey: .channelID) + member = try container.decodeIfPresent(Member.self, forKey: .member) + user = try container.decodeIfPresent(User.self, forKey: .user) + token = try container.decode(String.self, forKey: .token) + version = try container.decode(Int.self, forKey: .version) + message = try container.decodeIfPresent(Message.self, forKey: .message) + appPermissions = try container.decodeIfPresent(String.self, forKey: .appPermissions) + locale = try container.decodeIfPresent(String.self, forKey: .locale) + guildLocale = try container.decodeIfPresent(String.self, forKey: .guildLocale) + + // Conditionally decode data based on type + // Data will always be present for every type except PING + switch type { + case .applicationCmd, .applicationCmdAutocomplete: + data = .applicationCommand(try container.decode(Data.AppCommandData.self, forKey: .data)) + case .messageComponent: + data = .messageComponent(try container.decode(Data.MessageComponentData.self, forKey: .data)) + default: data = nil + } + } +} + +public struct MessageInteraction: Codable { + public let id: Snowflake + public let type: InteractionType + public let name: String + public let user: User + public let member: Member? +} diff --git a/Sources/DiscordKitCommon/Objects/Levels.swift b/Sources/DiscordKitCore/Objects/Data/Levels.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Levels.swift rename to Sources/DiscordKitCore/Objects/Data/Levels.swift diff --git a/Sources/DiscordKitCommon/Objects/Locale.swift b/Sources/DiscordKitCore/Objects/Data/Locale.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Locale.swift rename to Sources/DiscordKitCore/Objects/Data/Locale.swift diff --git a/Sources/DiscordKitCommon/Objects/Member.swift b/Sources/DiscordKitCore/Objects/Data/Member.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Member.swift rename to Sources/DiscordKitCore/Objects/Data/Member.swift diff --git a/Sources/DiscordKitCommon/Objects/Mention.swift b/Sources/DiscordKitCore/Objects/Data/Mention.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Mention.swift rename to Sources/DiscordKitCore/Objects/Data/Mention.swift diff --git a/Sources/DiscordKitCommon/Objects/Message.swift b/Sources/DiscordKitCore/Objects/Data/Message.swift similarity index 91% rename from Sources/DiscordKitCommon/Objects/Message.swift rename to Sources/DiscordKitCore/Objects/Data/Message.swift index b23164bc0..5521775d6 100644 --- a/Sources/DiscordKitCommon/Objects/Message.swift +++ b/Sources/DiscordKitCore/Objects/Data/Message.swift @@ -57,6 +57,31 @@ public enum MessageType: Int, Codable { /// Represents a message sent in a channel within Discord public class Message: Codable, GatewayData, Equatable, Identifiable { + public struct Flags: OptionSet, Codable { + public init(rawValue: UInt8) { + self.rawValue = rawValue + } + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + rawValue = try container.decode(UInt8.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public let rawValue: UInt8 + + public static let crossposted = Self(rawValue: 1 << 0) + public static let isCrosspost = Self(rawValue: 1 << 1) + public static let suppressEmbeds = Self(rawValue: 1 << 2) + public static let sourceMessageDeleted = Self(rawValue: 1 << 3) + public static let urgent = Self(rawValue: 1 << 4) + public static let hasThread = Self(rawValue: 1 << 5) + public static let ephemeral = Self(rawValue: 1 << 6) + } + public init(id: Snowflake, channel_id: Snowflake, guild_id: Snowflake? = nil, author: User, member: Member? = nil, content: String, timestamp: Date, edited_timestamp: Date? = nil, tts: Bool, mention_everyone: Bool, mentions: [User], mention_roles: [Snowflake], mention_channels: [ChannelMention]? = nil, attachments: [Attachment], embeds: [Embed], reactions: [Reaction]? = nil, pinned: Bool, webhook_id: Snowflake? = nil, type: MessageType, activity: MessageActivity? = nil, application: Application? = nil, application_id: Snowflake? = nil, message_reference: MessageReference? = nil, flags: Int? = nil, referenced_message: Message? = nil, interaction: MessageInteraction? = nil, thread: Channel? = nil, components: [MessageComponent]? = nil, sticker_items: [StickerItem]? = nil) { self.id = id self.channel_id = channel_id @@ -309,6 +334,10 @@ public struct MessageComponent: Codable { public let type: MessageComponentTypes } +public protocol Component: Encodable { + var type: MessageComponentTypes { get } +} + /// Call message component /// Representation of a call message shown in DMs public struct CallMessageComponent: Codable { diff --git a/Sources/DiscordKitCommon/Objects/Permission.swift b/Sources/DiscordKitCore/Objects/Data/Permission.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Permission.swift rename to Sources/DiscordKitCore/Objects/Data/Permission.swift diff --git a/Sources/DiscordKitCommon/Objects/Presence.swift b/Sources/DiscordKitCore/Objects/Data/Presence.swift similarity index 93% rename from Sources/DiscordKitCommon/Objects/Presence.swift rename to Sources/DiscordKitCore/Objects/Data/Presence.swift index 3776edbee..abc63b506 100644 --- a/Sources/DiscordKitCommon/Objects/Presence.swift +++ b/Sources/DiscordKitCore/Objects/Data/Presence.swift @@ -30,11 +30,11 @@ public struct Presence: GatewayData { } public enum PresenceStatus: String, Codable { - case idle = "idle" - case dnd = "dnd" - case online = "online" - case offline = "offline" - case invisible = "invisible" + case idle + case dnd + case online + case offline + case invisible } public struct PresenceUser: Codable, GatewayData { diff --git a/Sources/DiscordKitCommon/Objects/Reaction.swift b/Sources/DiscordKitCore/Objects/Data/Reaction.swift similarity index 74% rename from Sources/DiscordKitCommon/Objects/Reaction.swift rename to Sources/DiscordKitCore/Objects/Data/Reaction.swift index 6045afeb1..779dfbcc8 100644 --- a/Sources/DiscordKitCommon/Objects/Reaction.swift +++ b/Sources/DiscordKitCore/Objects/Data/Reaction.swift @@ -9,6 +9,6 @@ import Foundation public struct Reaction: Codable { public let count: Int - public let me: Bool + public let me: Bool // swiftlint:disable:this identifier_name public let emoji: Emoji } diff --git a/Sources/DiscordKitCommon/Objects/Snowflake.swift b/Sources/DiscordKitCore/Objects/Data/Snowflake.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Snowflake.swift rename to Sources/DiscordKitCore/Objects/Data/Snowflake.swift diff --git a/Sources/DiscordKitCommon/Objects/Stage.swift b/Sources/DiscordKitCore/Objects/Data/Stage.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Stage.swift rename to Sources/DiscordKitCore/Objects/Data/Stage.swift diff --git a/Sources/DiscordKitCommon/Objects/Sticker.swift b/Sources/DiscordKitCore/Objects/Data/Sticker.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Sticker.swift rename to Sources/DiscordKitCore/Objects/Data/Sticker.swift diff --git a/Sources/DiscordKitCommon/Objects/Team.swift b/Sources/DiscordKitCore/Objects/Data/Team.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Team.swift rename to Sources/DiscordKitCore/Objects/Data/Team.swift diff --git a/Sources/DiscordKitCommon/Objects/User+Flags.swift b/Sources/DiscordKitCore/Objects/Data/User+Flags.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/User+Flags.swift rename to Sources/DiscordKitCore/Objects/Data/User+Flags.swift diff --git a/Sources/DiscordKitCommon/Objects/User+PremiumType.swift b/Sources/DiscordKitCore/Objects/Data/User+PremiumType.swift similarity index 72% rename from Sources/DiscordKitCommon/Objects/User+PremiumType.swift rename to Sources/DiscordKitCore/Objects/Data/User+PremiumType.swift index 0fa779cf1..48d1ecabf 100644 --- a/Sources/DiscordKitCommon/Objects/User+PremiumType.swift +++ b/Sources/DiscordKitCore/Objects/Data/User+PremiumType.swift @@ -21,14 +21,14 @@ public extension User { public var description: String { switch self { - case .none: - return "None" + case .none: + return "None" - case .nitroClassic: - return "Nitro Classic" + case .nitroClassic: + return "Nitro Classic" - case .nitro: - return "Nitro" + case .nitro: + return "Nitro" } } } diff --git a/Sources/DiscordKitCommon/Objects/User.swift b/Sources/DiscordKitCore/Objects/Data/User.swift similarity index 98% rename from Sources/DiscordKitCommon/Objects/User.swift rename to Sources/DiscordKitCore/Objects/Data/User.swift index 9481f97c4..28ee462c8 100644 --- a/Sources/DiscordKitCommon/Objects/User.swift +++ b/Sources/DiscordKitCore/Objects/Data/User.swift @@ -118,11 +118,11 @@ public struct CurrentUser: Codable, GatewayData, Equatable { /// The user's purchased flags /// - /// Temporarily removed to prevent decoding errors. + /// Temporarily changed to a string to prevent decoding errors. /// /// > Experiment: If anyone figures out the possible values and function of /// > this property, please make a PR with relevant changes :D - // public let purchased_flags: User.PremiumType? + public let purchased_flags: Int? /// If this user is a premium (nitro) user public let premium: Bool diff --git a/Sources/DiscordKitCommon/Objects/Voice.swift b/Sources/DiscordKitCore/Objects/Data/Voice.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Voice.swift rename to Sources/DiscordKitCore/Objects/Data/Voice.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/ApplicationObj.swift b/Sources/DiscordKitCore/Objects/Gateway/ApplicationObj.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/ApplicationObj.swift rename to Sources/DiscordKitCore/Objects/Gateway/ApplicationObj.swift diff --git a/Sources/DiscordKitCore/Objects/Gateway/DataStructs.swift b/Sources/DiscordKitCore/Objects/Gateway/DataStructs.swift index 69473a194..b7cf340ea 100644 --- a/Sources/DiscordKitCore/Objects/Gateway/DataStructs.swift +++ b/Sources/DiscordKitCore/Objects/Gateway/DataStructs.swift @@ -8,60 +8,104 @@ // and not meant for external use import Foundation -import DiscordKitCommon -/// Connection properties used to construct client info that is sent -/// in the ``GatewayIdentify`` payload -struct GatewayConnProperties: OutgoingGatewayData { - /// OS the client is running on - /// - /// Always `Mac OS X` - let os: String +public protocol GatewayData: Decodable {} +public protocol OutgoingGatewayData: Encodable {} - /// Browser name - /// - /// Observed values were `Chrome` when running on Google Chrome and - /// `Discord Client` when running in the desktop client. - /// - /// > For now, this value is hardcoded to `Discord Client` in the - /// > ``DiscordAPI/getSuperProperties()`` method. Customisability - /// > might be added in a future release. - let browser: String +/// Presence Update +/// +/// Sent to update the presence of the current client. +/// +/// > Outgoing Gateway data struct for opcode 3 +public struct GatewayPresenceUpdate: OutgoingGatewayData { + public init(since: Int, activities: [ActivityOutgoing], status: PresenceStatus, afk: Bool) { + self.since = since + self.activities = activities + self.status = status + self.afk = afk + } - /// Release channel of target official client - /// - /// Refer to ``GatewayConfig/clientParity`` for more details. - let release_channel: String? + public let since: Int // Unix time (in milliseconds) of when the client went idle, or null if the client is not idle + public let activities: [ActivityOutgoing] + public let status: PresenceStatus + public let afk: Bool +} - /// Version of target official client - /// - /// Refer to ``GatewayConfig/clientParity`` for more details. - let client_version: String? +/// Voice State Update +/// +/// Sent to update the client's voice, deaf and video state. +/// +/// > Outgoing Gateway data struct for opcode 4 +public struct GatewayVoiceStateUpdate: OutgoingGatewayData, GatewayData { + public let guild_id: Snowflake? + public let channel_id: Snowflake? // ID of the voice channel client wants to join (null if disconnecting) + public let self_mute: Bool + public let self_deaf: Bool + public let self_video: Bool? + + public init(guild_id: Snowflake?, channel_id: Snowflake?, self_mute: Bool, self_deaf: Bool, self_video: Bool?) { + self.guild_id = guild_id + self.channel_id = channel_id + self.self_mute = self_mute + self.self_deaf = self_deaf + self.self_video = self_video + } - /// OS version - /// - /// The version of the OS the client is running on. This is dynamically - /// retrieved in ``DiscordAPI/getSuperProperties()`` by calling `uname()`. - /// For macOS, it is the version of the Darwin Kernel, which is `21.4.0` - /// as of macOS `12.3`. - let os_version: String? + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + // Encoding containers directly so nil optionals get encoded as "null" and not just removed + try container.encode(self_mute, forKey: .self_mute) + try container.encode(self_deaf, forKey: .self_deaf) + try container.encode(self_video, forKey: .self_video) + try container.encode(channel_id, forKey: .channel_id) + try container.encode(guild_id, forKey: .guild_id) + } +} - /// Machine arch - /// - /// The arch of the machine the client is running on. This is dynamically - /// retrieved in ``DiscordAPI/getSuperProperties()`` by calling `uname()`. - /// For macOS, it could be either `x86_64` (Intel) or `arm64` (Apple Silicon). - let os_arch: String? +/// Guild Request Members +/// +/// > Outgoing Gateway data struct for opcode 8 +public struct GatewayGuildRequestMembers: GatewayData { + public let guild_id: Snowflake + public let query: String? + public let limit: Int + public let presences: Bool? // Used to specify if we want the presences of the matched members + public let user_ids: [Snowflake]? // Used to specify which users you wish to fetch + public let nonce: String? // Nonce to identify the Guild Members Chunk response +} - /// System locale +/// Gateway Hello +/// +/// > Incoming Gateway data struct for opcode 10 +public struct GatewayHello: GatewayData { + /// Interval between outgoing heartbeats, in ms /// - /// The locale (language) of the system. This is hardcoded to be `en-US` for now. - let system_locale: String? + /// The Gateway has an approx 25% time tolerance to delayed heartbeats, + /// that is, it will close the connection if no heartbeat is received after + /// ``heartbeat_interval``\*125% ms from the last received heartbeat. + /// As per official docs, the first heartbeat should be sent + /// ``heartbeat_interval``\*[0...1] ms after receiving the ``GatewayHello`` + /// payload, where [0...1] is a random double from 0-1. + public let heartbeat_interval: Int +} - /// Build number of target official client - /// - /// Refer to ``GatewayConfig/clientParity`` for more details. - let client_build_number: Int? +/// Subscribe Guild Events +/// +/// > Outgoing Gateway data struct for opcode 11 +public struct SubscribeGuildEvts: OutgoingGatewayData { + public let guild_id: Snowflake + public let typing: Bool? + public let activities: Bool? + public let threads: Bool? + public let members: [Snowflake]? + + public init(guild_id: Snowflake, typing: Bool? = nil, activities: Bool? = nil, threads: Bool? = nil, members: [Snowflake]? = nil) { + self.guild_id = guild_id + self.typing = typing + self.activities = activities + self.threads = threads + self.members = members + } } /// Current client state, sent with the ``GatewayIdentify`` payload @@ -102,7 +146,7 @@ struct GatewayHeartbeat: OutgoingGatewayData { /// Gateway Identify /// /// Sent every ``GatewayHello/heartbeat_interval``, to prevent the Gateway -/// from closing the connection and +/// from closing the connection /// /// > Outgoing Gateway data struct for opcode 1 struct GatewayIdentify: OutgoingGatewayData { @@ -113,7 +157,8 @@ struct GatewayIdentify: OutgoingGatewayData { let shard: [Int]? // Array of two integers (shard_id, num_shards) let presence: GatewayPresenceUpdate? let client_state: ClientState? - let capabilities: Int + let capabilities: Int? // Must be set for user accounts + let intents: Intents? } /// Gateway Resume diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/ChUnreadUpdate.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/ChUnreadUpdate.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/ChUnreadUpdate.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/ChUnreadUpdate.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/ChannelPinsUpdate.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/ChannelPinsUpdate.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/ChannelPinsUpdate.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/ChannelPinsUpdate.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/GatewayEvent.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/GatewayEvent.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/GatewayEvent.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/GatewayEvent.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/GatewaySettingsProtoUpdate.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/GatewaySettingsProtoUpdate.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/GatewaySettingsProtoUpdate.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/GatewaySettingsProtoUpdate.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/GuildBan.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/GuildBan.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/GuildBan.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/GuildBan.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/GuildMemberEvt.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/GuildMemberEvt.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/GuildMemberEvt.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/GuildMemberEvt.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/GuildMiscUpdate.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/GuildMiscUpdate.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/GuildMiscUpdate.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/GuildMiscUpdate.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/GuildRoleEvt.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/GuildRoleEvt.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/GuildRoleEvt.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/GuildRoleEvt.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/GuildSchEvtUserEvt.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/GuildSchEvtUserEvt.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/GuildSchEvtUserEvt.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/GuildSchEvtUserEvt.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/MessageACKEvt.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/MessageACKEvt.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/MessageACKEvt.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/MessageACKEvt.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/MessageDelete.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/MessageDelete.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/MessageDelete.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/MessageDelete.swift diff --git a/Sources/DiscordKitCore/Objects/Gateway/Event/ReadyEvt.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/ReadyEvt.swift new file mode 100644 index 000000000..202129b25 --- /dev/null +++ b/Sources/DiscordKitCore/Objects/Gateway/Event/ReadyEvt.swift @@ -0,0 +1,35 @@ +// +// ReadyEvt.swift +// DiscordAPI +// +// Created by Vincent Kwok on 21/2/22. +// + +import Foundation + +/// The ready event palyoad for user accounts +public struct ReadyEvt: Decodable, GatewayData { + // swiftlint:disable:next identifier_name + public let v: Int + public let user: CurrentUser + public let users: [User] + public let guilds: [Guild] + public let session_id: String + public let user_settings: UserSettings? // Depreciated, no longer sent + /// Protobuf of user settings + public let user_settings_proto: String? + /// DMs for this user + public let private_channels: [Channel] +} + +/// The ready event payload for bot accounts +public struct BotReadyEvt: Decodable, GatewayData { + // swiftlint:disable:next identifier_name + public let v: Int + public let user: User + public let guilds: [GuildUnavailable] + public let session_id: String + public let shard: [Int]? // Included for inclusivity, will not be used + public let application: PartialApplication + public let resume_gateway_url: String +} diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/ReadySuppEvt.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/ReadySuppEvt.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/ReadySuppEvt.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/ReadySuppEvt.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/ThreadListSync.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/ThreadListSync.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/ThreadListSync.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/ThreadListSync.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/ThreadMembersUpdate.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/ThreadMembersUpdate.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/ThreadMembersUpdate.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/ThreadMembersUpdate.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/TypingStart.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/TypingStart.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/TypingStart.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/TypingStart.swift diff --git a/Sources/DiscordKitCommon/Objects/Gateway/Event/TypingStartEvt.swift b/Sources/DiscordKitCore/Objects/Gateway/Event/TypingStartEvt.swift similarity index 100% rename from Sources/DiscordKitCommon/Objects/Gateway/Event/TypingStartEvt.swift rename to Sources/DiscordKitCore/Objects/Gateway/Event/TypingStartEvt.swift diff --git a/Sources/DiscordKitCore/Objects/Gateway/Gateway.swift b/Sources/DiscordKitCore/Objects/Gateway/Gateway.swift index cd16fdcc8..eeabab96a 100644 --- a/Sources/DiscordKitCore/Objects/Gateway/Gateway.swift +++ b/Sources/DiscordKitCore/Objects/Gateway/Gateway.swift @@ -1,96 +1,47 @@ // // Gateway.swift -// +// DiscordAPI // -// Created by Vincent Kwok on 7/6/22. +// Created by Vincent Kwok on 20/2/22. // import Foundation -import DiscordKitCommon -// MARK: - Main Gateway Sending/Receiving Structs - -public struct GatewayIncoming: Decodable { - public let op: GatewayIncomingOpcodes - public var d: GatewayData? - public let s: Int? // Sequence # - public let t: GatewayEvent? - public var primitiveData: Any? - - private enum CodingKeys: String, CodingKey { - case op - case d - case s - case t - } - - // swiftlint:disable cyclomatic_complexity - public init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - let action = try values.decode(GatewayIncomingOpcodes.self, forKey: .op) - - op = action - s = try values.decodeIfPresent(Int.self, forKey: .s) - t = try values.decodeIfPresent(GatewayEvent.self, forKey: .t) - - switch action { - case .hello: d = try values.decode(GatewayHello.self, forKey: .d) - case .invalidSession: primitiveData = try values.decode(Bool.self, forKey: .d) // Parse data as bool - case .dispatchEvent: - // Cue the long switch case to parse every single event - switch t { - case .ready: d = try values.decode(ReadyEvt.self, forKey: .d) - case .readySupplemental: d = try values.decode(ReadySuppEvt.self, forKey: .d) - case .resumed: d = nil - case .channelCreate, .channelUpdate, .channelDelete, .threadCreate, .threadUpdate, .threadDelete: - d = try values.decode(Channel.self, forKey: .d) - case .channelPinUpdate: d = try values.decode(ChannelPinsUpdate.self, forKey: .d) - - case .threadListSync: d = try values.decode(ThreadListSync.self, forKey: .d) - case .threadMemberUpdate: d = try values.decode(ThreadMember.self, forKey: .d) - case .threadMembersUpdate: d = try values.decode(ThreadMembersUpdate.self, forKey: .d) - - case .guildUpdate, .guildCreate: d = try values.decode(Guild.self, forKey: .d) - case .guildDelete: d = try values.decode(GuildUnavailable.self, forKey: .d) - case .guildBanAdd, .guildBanRemove: d = try values.decode(GuildBan.self, forKey: .d) - case .guildEmojisUpdate: d = try values.decode(GuildEmojisUpdate.self, forKey: .d) - case .guildStickersUpdate: d = try values.decode(GuildStickersUpdate.self, forKey: .d) - case .guildIntegrationsUpdate: d = try values.decode(GuildIntegrationsUpdate.self, forKey: .d) - case .guildMemberAdd: d = try values.decode(Member.self, forKey: .d) - case .guildMemberRemove: d = try values.decode(GuildMemberRemove.self, forKey: .d) - case .guildMemberUpdate: d = try values.decode(GuildMemberUpdate.self, forKey: .d) - case .guildRoleCreate: d = try values.decode(GuildRoleEvt.self, forKey: .d) - case .guildRoleUpdate: d = try values.decode(GuildRoleEvt.self, forKey: .d) - case .guildRoleDelete: d = try values.decode(GuildRoleDelete.self, forKey: .d) - case .guildSchEvtCreate, .guildSchEvtUpdate, .guildSchEvtDelete: d = try values.decode(GuildScheduledEvent.self, forKey: .d) - case .guildSchEvtUserAdd, .guildSchEvtUserRemove: d = try values.decode(GuildSchEvtUserEvt.self, forKey: .d) - - // More events go here - case .messageCreate: d = try values.decode(Message.self, forKey: .d) - case .messageUpdate: d = try values.decode(PartialMessage.self, forKey: .d) - case .messageDelete: d = try values.decode(MessageDelete.self, forKey: .d) - case .messageACK: d = try values.decode(MessageACKEvt.self, forKey: .d) - case .messageDeleteBulk: d = try values.decode(MessageDeleteBulk.self, forKey: .d) - case .presenceUpdate: d = try values.decode(PresenceUpdate.self, forKey: .d) - // Add the remaining like 100 events - - case .userUpdate: d = try values.decode(CurrentUser.self, forKey: .d) - case .typingStart: d = try values.decode(TypingStart.self, forKey: .d) +/* + but enough for what this app needs to do. + */ + +public enum GatewayCloseCode: Int { + case unknown = 4000 + case unknownOpcode = 4001 + case decodeErr = 4002 + case notAuthenthicated = 4003 + case authenthicationFail = 4004 + case alreadyAuthenthicated = 4005 + case invalidSeq = 4007 + case rateLimited = 4008 + case timedOut = 4009 + case invalidVersion = 4012 + case invalidIntent = 4013 + case disallowedIntent = 4014 +} - // User-specific events - case .channelUnreadUpdate: d = try values.decode(ChannelUnreadUpdate.self, forKey: .d) - case .userSettingsUpdate: d = try values.decode(UserSettings.self, forKey: .d) - case .userSettingsProtoUpdate: d = try values.decode(GatewaySettingsProtoUpdate.self, forKey: .d) - default: d = nil - } - default: - d = nil - } - } +// MARK: - Gateway Opcode enums +public enum GatewayOutgoingOpcodes: Int, Codable { + case heartbeat = 1 + case identify = 2 + case presenceUpdate = 3 + case voiceStateUpdate = 4 + case resume = 6 // Attempt to resume disconnected session + case requestGuildMembers = 8 + case subscribeGuildEvents = 14 } -public struct GatewayOutgoing: Encodable { - public let op: GatewayOutgoingOpcodes - public let d: T? - public let s: Int? // Sequence # +public enum GatewayIncomingOpcodes: Int, Codable { + case dispatchEvent = 0 // Event dispatched + case heartbeat = 1 + case reconnect = 7 // Server is closing connection, should disconnect and resume + case invalidSession = 9 + case hello = 10 + case heartbeatAck = 11 } diff --git a/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift b/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift new file mode 100644 index 000000000..9ed082f1c --- /dev/null +++ b/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift @@ -0,0 +1,288 @@ +// +// GatewayIO.swift +// Contains structs to decode JSON sent back by Gateway. May not +// include a complete list of data structs for all opcodes/events, +// but that is being worked on gradually. +// +// Created by Vincent Kwok on 7/6/22. +// + +import Foundation + +// MARK: - Main Gateway Sending/Receiving Structs + +public struct GatewayIncoming: Decodable { + public let opcode: GatewayIncomingOpcodes + public var data: Data + public let seq: Int? // Sequence # + public let type: GatewayEvent? + + private enum CodingKeys: String, CodingKey { + case opcode = "op" + case data = "d" + case seq = "s" + case type = "t" + } + + /// An enum representing possible payloads + public enum Data { + // MARK: - Gateway lifecycle + + /// Invalid session payload + /// + /// This signals that the current Gateway session has been invalidated + /// and that the client should attempt to reconnect or resume depending on + /// the `canResume` associated value. + case invalidSession(canResume: Bool) + /// Hello payload + /// + /// - Parameter hello: The hello payload, containing (most importantly) the `heartbeat_interval` + case hello(GatewayHello) + /// Ready event for users + /// + /// - Parameter ready: The ready event payload for users + case userReady(ReadyEvt) + /// Ready event for bots + /// + /// - Parameter ready: The ready event payload for bots + case botReady(BotReadyEvt) + /// Ready supplemental event + /// + /// - Parameter readySupp: + case readySupplemental(ReadySuppEvt) + /// Heartbeat payload + /// + /// Should be sent from the client every `heartbeat_interval`. + /// > This payload may also be sent from the server. In that event, + /// > the client should respond with a ``heartbeat`` payload + /// > as soon as possible. + case heartbeat + /// Heartbeat acknowledge payload + /// + /// Sent from the server in response to a ``heartbeat`` payload. + case heartbeatAck + /// Reconnect payload + /// + /// Upon receiving this payload, the client should disconnect and attempt + /// to reconnect, resuming if possible. This could be used for migration off + /// old Gateway server instances. + case reconnect + /// Resumed event + /// + /// Informs the client that all missed events have been replayed after resuming. + case resumed + + // MARK: - Guilds + + /// Guild create event + case guildCreate(Guild) + /// Guild update event + case guildUpdate(Guild) + /// Guild delete event + /// + /// > This event may also be dispatched when a guild becomes unavailable due to a + /// > server outage. + case guildDelete(GuildUnavailable) + + // MARK: - Channels + + /// Channel create event + /// + /// - Parameter channel: The channel that was created + case channelCreate(Channel) + /// Channel update event + /// + /// - Parameter channel: The channel that was updated + case channelUpdate(Channel) + /// Channel delete event + /// + /// - Parameter channel: The channel that was deleted + case channelDelete(Channel) + /// Thread create event + /// + /// - Parameter channel: The thread that was created + case threadCreate(Channel) + /// Thread update event + /// + /// - Parameter channel: The thread that was updated + case threadUpdate(Channel) + /// Thread delete event + /// + /// - Parameter channel: The thread that was deleted + case threadDelete(Channel) + + /// Channel pin update + /// + /// Sent when a pin in a channel was updated (added, removed). + /// + /// - Parameter channelPinsUpdate: Some information about the updated pin + case channelPinUpdate(ChannelPinsUpdate) + + // MARK: - Messages + + /// Message create event + /// + /// This event would be dispatched when a message is sent. + /// + /// - Parameter message: Created message + case messageCreate(Message) + /// Message update event + /// + /// This event would be dispatched when a message is edited, among other actions. + /// + /// - Parameter partialMessage: Partial message with the message `id` and updated fields + case messageUpdate(PartialMessage) + /// Message delete event + /// + /// - Parameter messageDelete: Information about the deleted message + case messageDelete(MessageDelete) + /// Bulk message delete event + /// + /// This can only be dispatched in response to bulk deletes by bots and the system, + /// and cannot be initiated by users. + /// + /// - Parameter messageDeleteBulk: Information about the bulk-deleted messages + case messageDeleteBulk(MessageDeleteBulk) + /// Message read ACK event + /// + /// Sent to update message reading state. This is to acknowledge messages read + /// from other clients. + /// + /// - Parameter messageACKEvt: Information about the acknowledged messages + case messageACK(MessageACKEvt) + + // MARK: - Users + + /// User update event + /// + /// Dispatched for updates to the current user. + case userUpdate(CurrentUser) + + /// Presence update event + /// + /// Dispatched when the presence of a user has changed, including for the current user. + /// > (For user accounts only) By default, such updates are only dispatched for users in + /// > open DMs, and require a ``SubscribeGuildEvts`` outgoing payload to enable + /// > presence updates for users in a certain guild. + case presenceUpdate(PresenceUpdate) + + // MARK: - Interactions + + /// Interaction create event + /// + /// + case interaction(Interaction) + + // MARK: - User account-specific events + + /// User settings proto update event + /// + /// Dispatched when the user settings proto changes. + case settingsProtoUpdate(GatewaySettingsProtoUpdate) + + /// Handling this payload/event isn't implemented yet + case unknown + } + + // Nothing I can do here too, really. + // swiftlint:disable:next function_body_length + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + let action = try values.decode(GatewayIncomingOpcodes.self, forKey: .opcode) + + opcode = action + seq = try values.decodeIfPresent(Int.self, forKey: .seq) + type = try values.decodeIfPresent(GatewayEvent.self, forKey: .type) + + switch action { + // MARK: Gateway lifecycle + case .hello: data = .hello(try values.decode(GatewayHello.self, forKey: .data)) + case .invalidSession: data = .invalidSession(canResume: try values.decode(Bool.self, forKey: .data)) + case .heartbeat: data = .heartbeat + case .heartbeatAck: data = .heartbeatAck + case .reconnect: data = .reconnect + case .dispatchEvent: + // Cue the long switch case to parse every single event + switch type { + case .ready: + if let userReady = try? values.decode(ReadyEvt.self, forKey: .data) { + data = .userReady(userReady) + } else { + data = .botReady(try values.decode(BotReadyEvt.self, forKey: .data)) + } + case .readySupplemental: data = .readySupplemental(try values.decode(ReadySuppEvt.self, forKey: .data)) + case .resumed: data = .resumed + + // MARK: Channels + case .channelCreate: data = .channelCreate(try values.decode(Channel.self, forKey: .data)) + case .channelUpdate: data = .channelUpdate(try values.decode(Channel.self, forKey: .data)) + case .channelDelete: data = .channelDelete(try values.decode(Channel.self, forKey: .data)) + case .threadCreate: data = .threadCreate(try values.decode(Channel.self, forKey: .data)) + case .threadUpdate: data = .threadUpdate(try values.decode(Channel.self, forKey: .data)) + case .threadDelete: data = .threadDelete(try values.decode(Channel.self, forKey: .data)) + + case .channelPinUpdate: data = .channelPinUpdate(try values.decode(ChannelPinsUpdate.self, forKey: .data)) +/* + case .threadListSync: data = try values.decode(ThreadListSync.self, forKey: .data) + case .threadMemberUpdate: data = try values.decode(ThreadMember.self, forKey: .data) + case .threadMembersUpdate: data = try values.decode(ThreadMembersUpdate.self, forKey: .data) +*/ + // MARK: Guilds + case .guildCreate: data = .guildCreate(try values.decode(Guild.self, forKey: .data)) + case .guildUpdate: data = .guildUpdate(try values.decode(Guild.self, forKey: .data)) + case .guildDelete: data = .guildDelete(try values.decode(GuildUnavailable.self, forKey: .data)) +/* + case .guildBanAdd, .guildBanRemove: data = try values.decode(GuildBan.self, forKey: .data) + case .guildEmojisUpdate: data = try values.decode(GuildEmojisUpdate.self, forKey: .data) + case .guildStickersUpdate: data = try values.decode(GuildStickersUpdate.self, forKey: .data) + case .guildIntegrationsUpdate: data = try values.decode(GuildIntegrationsUpdate.self, forKey: .data) + case .guildMemberAdd: data = try values.decode(Member.self, forKey: .data) + case .guildMemberRemove: data = try values.decode(GuildMemberRemove.self, forKey: .data) + case .guildMemberUpdate: data = try values.decode(GuildMemberUpdate.self, forKey: .data) + case .guildRoleCreate: data = try values.decode(GuildRoleEvt.self, forKey: .data) + case .guildRoleUpdate: data = try values.decode(GuildRoleEvt.self, forKey: .data) + case .guildRoleDelete: data = try values.decode(GuildRoleDelete.self, forKey: .data) + case .guildSchEvtCreate, .guildSchEvtUpdate, .guildSchEvtDelete: data = try values.decode(GuildScheduledEvent.self, forKey: .data) + case .guildSchEvtUserAdd, .guildSchEvtUserRemove: data = try values.decode(GuildSchEvtUserEvt.self, forKey: .data) +*/ + // More events go here + // MARK: Messages + case .messageCreate: data = .messageCreate(try values.decode(Message.self, forKey: .data)) + case .messageUpdate: data = .messageUpdate(try values.decode(PartialMessage.self, forKey: .data)) + case .messageDelete: data = .messageDelete(try values.decode(MessageDelete.self, forKey: .data)) + case .messageDeleteBulk: data = .messageDeleteBulk(try values.decode(MessageDeleteBulk.self, forKey: .data)) + case .messageACK: data = .messageACK(try values.decode(MessageACKEvt.self, forKey: .data)) + + // MARK: Users + case .userUpdate: data = .userUpdate(try values.decode(CurrentUser.self, forKey: .data)) + case .presenceUpdate: data = .presenceUpdate(try values.decode(PresenceUpdate.self, forKey: .data)) + + // MARK: Interactions + case .interactionCreate: data = .interaction(try values.decode(Interaction.self, forKey: .data)) + +/* + case .typingStart: data = try values.decode(TypingStart.self, forKey: .data) + + // MARK: - User account-specific events + case .channelUnreadUpdate: data = try values.decode(ChannelUnreadUpdate.self, forKey: .data) + */ + case .userSettingsProtoUpdate: data = .settingsProtoUpdate( + try values.decode(GatewaySettingsProtoUpdate.self, forKey: .data) + ) + default: data = .unknown + } + } + } +} + +public struct GatewayOutgoing: Encodable { + enum CodingKeys: String, CodingKey { + case opcode = "op" + case data = "d" + case seq = "s" + } + + public let opcode: GatewayOutgoingOpcodes + public let data: T? + public let seq: Int? // Sequence # +} diff --git a/Sources/DiscordKitCommon/Objects/Gateway/UserSettings.swift b/Sources/DiscordKitCore/Objects/Gateway/UserSettings.swift similarity index 97% rename from Sources/DiscordKitCommon/Objects/Gateway/UserSettings.swift rename to Sources/DiscordKitCore/Objects/Gateway/UserSettings.swift index 211acd7bf..6cc2d18f4 100644 --- a/Sources/DiscordKitCommon/Objects/Gateway/UserSettings.swift +++ b/Sources/DiscordKitCore/Objects/Gateway/UserSettings.swift @@ -8,8 +8,8 @@ import Foundation public enum UITheme: String, Codable { - case dark = "dark" - case light = "light" + case dark + case light } public struct UserSettings: Decodable, GatewayData, Equatable { diff --git a/Sources/DiscordKitCommon/Objects/README.md b/Sources/DiscordKitCore/Objects/README.md similarity index 100% rename from Sources/DiscordKitCommon/Objects/README.md rename to Sources/DiscordKitCore/Objects/README.md diff --git a/Sources/DiscordKitCore/Objects/REST/NewMessage.swift b/Sources/DiscordKitCore/Objects/REST/NewMessage.swift new file mode 100644 index 000000000..b31f4aafb --- /dev/null +++ b/Sources/DiscordKitCore/Objects/REST/NewMessage.swift @@ -0,0 +1,78 @@ +// +// NewMessage.swift +// DiscordAPI +// +// Created by Vincent Kwok on 25/2/22. +// + +import Foundation + +public struct NewAttachment: Codable { + public let id: String // Will not be a valid snowflake for new attachments + public let filename: String + + public init(id: String, filename: String) { + self.id = id + self.filename = filename + } +} + +public struct NewMessage: Encodable { + public let content: String? + public let tts: Bool? + public let embeds: [Embed]? + public let allowed_mentions: AllowedMentions? + public let message_reference: MessageReference? + public let components: [Component]? + public let sticker_ids: [Snowflake]? + public let attachments: [NewAttachment]? + // file[n] // Handle file uploading later + // attachments + // let payload_json: Codable? // Handle this later + public let flags: Int? + + public init(content: String?, tts: Bool? = false, embeds: [Embed]? = nil, allowed_mentions: AllowedMentions? = nil, message_reference: MessageReference? = nil, components: [Component]? = nil, sticker_ids: [Snowflake]? = nil, attachments: [NewAttachment]? = nil, flags: Int? = nil) { + self.content = content + self.tts = tts + self.embeds = embeds + self.allowed_mentions = allowed_mentions + self.message_reference = message_reference + self.components = components + self.sticker_ids = sticker_ids + self.attachments = attachments + self.flags = flags + } + + enum CodingKeys: CodingKey { + case content + case tts + case embeds + case allowed_mentions + case message_reference + case components + case sticker_ids + case attachments + case flags + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(content, forKey: .content) + try container.encodeIfPresent(tts, forKey: .tts) + try container.encodeIfPresent(embeds, forKey: .embeds) + try container.encodeIfPresent(allowed_mentions, forKey: .allowed_mentions) + try container.encodeIfPresent(message_reference, forKey: .message_reference) + try container.encodeIfPresent(sticker_ids, forKey: .sticker_ids) + try container.encodeIfPresent(attachments, forKey: .attachments) + try container.encodeIfPresent(flags, forKey: .flags) + + // Same workaround to encode array of protocols + if let components = components { + var componentContainer = container.nestedUnkeyedContainer(forKey: .components) + for component in components { + try componentContainer.encode(component) + } + } + } +} diff --git a/Sources/DiscordKitCore/Objects/REST/ResolvedInvite.swift b/Sources/DiscordKitCore/Objects/REST/ResolvedInvite.swift index 4611e6ce4..4f311139e 100644 --- a/Sources/DiscordKitCore/Objects/REST/ResolvedInvite.swift +++ b/Sources/DiscordKitCore/Objects/REST/ResolvedInvite.swift @@ -1,12 +1,11 @@ // -// File.swift +// ResolvedInvite.swift // // // Created by Vincent Kwok on 10/7/22. // import Foundation -import DiscordKitCommon public enum InviteTargetType: Int, Codable { case stream = 1 @@ -19,7 +18,7 @@ public enum InviteTargetType: Int, Codable { public struct Invite: Decodable { /// The invite code (unique ID) public let code: String - /// The guild this invite is for + // The guild this invite is for // public let guild: Guild? /// The channel this invite is for public let channel: Channel? diff --git a/Sources/DiscordKitCore/REST/APIAchievements.swift b/Sources/DiscordKitCore/REST/APIAchievements.swift new file mode 100644 index 000000000..cd19d82c8 --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIAchievements.swift @@ -0,0 +1,87 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// Get Achievements + /// + /// > GET: `/applications/{application.id}/achievements` + func getAchievements( + _ applicationId: Snowflake + ) async throws -> T { + return try await getReq( + path: "applications/\(applicationId)/achievements/" + ) + } + /// Get Achievement + /// + /// > GET: `/applications/{application.id}/achievements/{achievement.id}` + func getAchievement( + _ applicationId: Snowflake, + _ achievementId: Snowflake + ) async throws -> T { + return try await getReq( + path: "applications/\(applicationId)/achievements/\(achievementId)/" + ) + } + /// Create Achievement + /// + /// > POST: `/applications/{application.id}/achievements` + func createAchievement( + _ applicationId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "applications/\(applicationId)/achievements/", + body: body + ) + } + /// Update Achievement + /// + /// > PATCH: `/applications/{application.id}/achievements/{achievement.id}` + func updateAchievement( + _ applicationId: Snowflake, + _ achievementId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "applications/\(applicationId)/achievements/\(achievementId)/", + body: body + ) + } + /// Delete Achievement + /// + /// > DELETE: `/applications/{application.id}/achievements/{achievement.id}` + func deleteAchievement( + _ applicationId: Snowflake, + _ achievementId: Snowflake + ) async throws { + try await deleteReq( + path: "applications/\(applicationId)/achievements/\(achievementId)/" + ) + } + /// Update User Achievement + /// + /// > PUT: `/users/{user.id}/applications/{application.id}/achievements/{achievement.id}` + func updateUserAchievement( + _ userId: Snowflake, + _ applicationId: Snowflake, + _ achievementId: Snowflake, + _ body: B + ) async throws -> T { + return try await putReq( + path: "users/\(userId)/applications/\(applicationId)/achievements/\(achievementId)/", + body: body + ) + } + /// Get User Achievements + /// + /// > GET: `/users/@me/applications/{application.id}/achievements` + func getUserAchievements( + _ applicationId: Snowflake + ) async throws -> T { + return try await getReq( + path: "users/@me/applications/\(applicationId)/achievements/" + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIApplicationCommands.swift b/Sources/DiscordKitCore/REST/APIApplicationCommands.swift new file mode 100644 index 000000000..7b1cbc40a --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIApplicationCommands.swift @@ -0,0 +1,200 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// Get Global Application Commands + /// + /// > GET: `/applications/{application.id}/commands` + func getGlobalApplicationCommands( + _ applicationId: Snowflake + ) async throws -> T { + return try await getReq( + path: "applications/\(applicationId)/commands/" + ) + } + /// Create Global Application Command + /// + /// > POST: `/applications/{application.id}/commands` + func createGlobalApplicationCommand( + _ applicationId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "applications/\(applicationId)/commands/", + body: body + ) + } + /// Get Global Application Command + /// + /// > GET: `/applications/{application.id}/commands/{command.id}` + func getGlobalApplicationCommand( + _ applicationId: Snowflake, + _ commandId: Snowflake + ) async throws -> T { + return try await getReq( + path: "applications/\(applicationId)/commands/\(commandId)/" + ) + } + /// Edit Global Application Command + /// + /// > PATCH: `/applications/{application.id}/commands/{command.id}` + func editGlobalApplicationCommand( + _ applicationId: Snowflake, + _ commandId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "applications/\(applicationId)/commands/\(commandId)/", + body: body + ) + } + /// Delete Global Application Command + /// + /// > DELETE: `/applications/{application.id}/commands/{command.id}` + func deleteGlobalApplicationCommand( + _ applicationId: Snowflake, + _ commandId: Snowflake + ) async throws { + try await deleteReq( + path: "applications/\(applicationId)/commands/\(commandId)/" + ) + } + /// Bulk Overwrite Global Application Commands + /// + /// > PUT: `/applications/{application.id}/commands` + func bulkOverwriteGlobalApplicationCommands( + _ applicationId: Snowflake, + _ body: B + ) async throws -> T { + return try await putReq( + path: "applications/\(applicationId)/commands/", + body: body + ) + } + /// Get Guild Application Commands + /// + /// > GET: `/applications/{application.id}/guilds/{guild.id}/commands` + func getGuildApplicationCommands( + _ applicationId: Snowflake, + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "applications/\(applicationId)/guilds/\(guildId)/commands/" + ) + } + /// Create Guild Application Command + /// + /// > POST: `/applications/{application.id}/guilds/{guild.id}/commands` + func createGuildApplicationCommand( + _ applicationId: Snowflake, + _ guildId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "applications/\(applicationId)/guilds/\(guildId)/commands/", + body: body + ) + } + /// Get Guild Application Command + /// + /// > GET: `/applications/{application.id}/guilds/{guild.id}/commands/{command.id}` + func getGuildApplicationCommand( + _ applicationId: Snowflake, + _ guildId: Snowflake, + _ commandId: Snowflake + ) async throws -> T { + return try await getReq( + path: "applications/\(applicationId)/guilds/\(guildId)/commands/\(commandId)/" + ) + } + /// Edit Guild Application Command + /// + /// > PATCH: `/applications/{application.id}/guilds/{guild.id}/commands/{command.id}` + func editGuildApplicationCommand( + _ applicationId: Snowflake, + _ guildId: Snowflake, + _ commandId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "applications/\(applicationId)/guilds/\(guildId)/commands/\(commandId)/", + body: body + ) + } + /// Delete Guild Application Command + /// + /// > DELETE: `/applications/{application.id}/guilds/{guild.id}/commands/{command.id}` + func deleteGuildApplicationCommand( + _ applicationId: Snowflake, + _ guildId: Snowflake, + _ commandId: Snowflake + ) async throws { + try await deleteReq( + path: "applications/\(applicationId)/guilds/\(guildId)/commands/\(commandId)/" + ) + } + /// Bulk Overwrite Guild Application Commands + /// + /// > PUT: `/applications/{application.id}/guilds/{guild.id}/commands` + func bulkOverwriteGuildApplicationCommands( + _ applicationId: Snowflake, + _ guildId: Snowflake, + _ body: B + ) async throws -> T { + return try await putReq( + path: "applications/\(applicationId)/guilds/\(guildId)/commands/", + body: body + ) + } + /// Get Guild Application Command Permissions + /// + /// > GET: `/applications/{application.id}/guilds/{guild.id}/commands/permissions` + func getGuildApplicationCommandPermissions( + _ applicationId: Snowflake, + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "applications/\(applicationId)/guilds/\(guildId)/commands/permissions/" + ) + } + /// Get Application Command Permissions + /// + /// > GET: `/applications/{application.id}/guilds/{guild.id}/commands/{command.id}/permissions` + func getApplicationCommandPermissions( + _ applicationId: Snowflake, + _ guildId: Snowflake, + _ commandId: Snowflake + ) async throws -> T { + return try await getReq( + path: "applications/\(applicationId)/guilds/\(guildId)/commands/\(commandId)/permissions/" + ) + } + /// Edit Application Command Permissions + /// + /// > PUT: `/applications/{application.id}/guilds/{guild.id}/commands/{command.id}/permissions` + func editApplicationCommandPermissions( + _ applicationId: Snowflake, + _ guildId: Snowflake, + _ commandId: Snowflake, + _ body: B + ) async throws -> T { + return try await putReq( + path: "applications/\(applicationId)/guilds/\(guildId)/commands/\(commandId)/permissions/", + body: body + ) + } + /// Batch Edit Application Command Permissions + /// + /// > PUT: `/applications/{application.id}/guilds/{guild.id}/commands/permissions` + func batchEditApplicationCommandPermissions( + _ applicationId: Snowflake, + _ guildId: Snowflake, + _ body: B + ) async throws -> T { + return try await putReq( + path: "applications/\(applicationId)/guilds/\(guildId)/commands/permissions/", + body: body + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIApplicationRoleConnectionMetadata.swift b/Sources/DiscordKitCore/REST/APIApplicationRoleConnectionMetadata.swift new file mode 100644 index 000000000..f1d18026c --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIApplicationRoleConnectionMetadata.swift @@ -0,0 +1,28 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// Get Application Role Connection Metadata Records + /// + /// > GET: `/applications/{application.id}/role-connections/metadata` + func getApplicationRoleConnectionMetadataRecords( + _ applicationId: Snowflake + ) async throws -> T { + return try await getReq( + path: "applications/\(applicationId)/role-connections/metadata/" + ) + } + /// Update Application Role Connection Metadata Records + /// + /// > PUT: `/applications/{application.id}/role-connections/metadata` + func updateApplicationRoleConnectionMetadataRecords( + _ applicationId: Snowflake, + _ body: B + ) async throws -> T { + return try await putReq( + path: "applications/\(applicationId)/role-connections/metadata/", + body: body + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIAuditLog.swift b/Sources/DiscordKitCore/REST/APIAuditLog.swift new file mode 100644 index 000000000..015378b41 --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIAuditLog.swift @@ -0,0 +1,16 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// Get Guild Audit Log + /// + /// > GET: `/guilds/{guild.id}/audit-logs` + func getGuildAuditLog( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/audit-logs/" + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIAutoModeration.swift b/Sources/DiscordKitCore/REST/APIAutoModeration.swift new file mode 100644 index 000000000..735af55ce --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIAutoModeration.swift @@ -0,0 +1,63 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// List Auto Moderation Rules for Guild + /// + /// > GET: `/guilds/{guild.id}/auto-moderation/rules` + func listAutoModerationRulesforGuild( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/auto-moderation/rules/" + ) + } + /// Get Auto Moderation Rule + /// + /// > GET: `/guilds/{guild.id}/auto-moderation/rules/{auto_moderation_rule.id}` + func getAutoModerationRule( + _ guildId: Snowflake, + _ auto_moderation_ruleId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/auto-moderation/rules/\(auto_moderation_ruleId)/" + ) + } + /// Create Auto Moderation Rule + /// + /// > POST: `/guilds/{guild.id}/auto-moderation/rules` + func createAutoModerationRule( + _ guildId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "guilds/\(guildId)/auto-moderation/rules/", + body: body + ) + } + /// Edit Auto Moderation Rule + /// + /// > PATCH: `/guilds/{guild.id}/auto-moderation/rules/{auto_moderation_rule.id}` + func editAutoModerationRule( + _ guildId: Snowflake, + _ auto_moderation_ruleId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/auto-moderation/rules/\(auto_moderation_ruleId)/", + body: body + ) + } + /// Delete Auto Moderation Rule + /// + /// > DELETE: `/guilds/{guild.id}/auto-moderation/rules/{auto_moderation_rule.id}` + func deleteAutoModerationRule( + _ guildId: Snowflake, + _ auto_moderation_ruleId: Snowflake + ) async throws { + try await deleteReq( + path: "guilds/\(guildId)/auto-moderation/rules/\(auto_moderation_ruleId)/" + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIChannel.swift b/Sources/DiscordKitCore/REST/APIChannel.swift index 94862ed34..5853b70b1 100644 --- a/Sources/DiscordKitCore/REST/APIChannel.swift +++ b/Sources/DiscordKitCore/REST/APIChannel.swift @@ -1,76 +1,463 @@ -// -// APIChannel.swift -// DiscordAPI -// -// Created by Vincent Kwok on 21/2/22. -// +// NOTE: This file is auto-generated import Foundation -import DiscordKitCommon public extension DiscordREST { - // MARK: Get Channel - // GET /channels/{channel.id} - func getChannel(id: Snowflake) async -> Channel? { - return await getReq(path: "channels/\(id)") - } - - // MARK: Get Channel Messages - // GET /channels/{channel.id}/messages + /// Get Channel Messages + /// + /// > GET: `/channels/{channel.id}/messages` func getChannelMsgs( id: Snowflake, limit: Int = 50, around: Snowflake? = nil, before: Snowflake? = nil, after: Snowflake? = nil - ) async -> [Message]? { + ) async throws -> [Message] { var query = [URLQueryItem(name: "limit", value: String(limit))] if around != nil { query.append(URLQueryItem(name: "around", value: around?.description)) } else if before != nil {query.append(URLQueryItem(name: "before", value: before?.description))} else if after != nil { query.append(URLQueryItem(name: "after", value: after?.description)) } - return await getReq(path: "channels/\(id)/messages", query: query) + return try await getReq(path: "channels/\(id)/messages", query: query) } - // MARK: Get Channel Message (Actual endpoint only available to bots, so we're using a workaround) - // Ailas of getChannelMsgs with predefined params + /// Get Channel Message + /// + /// > Ailas of getChannelMsgs with predefined params func getChannelMsg( id: Snowflake, msgID: Snowflake - ) async -> Message? { - guard let m = await getChannelMsgs(id: id, limit: 1, around: msgID), !m.isEmpty - else { return nil } - return m[0] + ) async throws -> Message { + if DiscordKitConfig.default.isBot { + return try await getReq(path: "channels/\(id)/messages/\(msgID)") + } else { + // Actual endpoint only available to bots, so we're using a workaround + let messages = try await getChannelMsgs(id: id, limit: 1, around: msgID) + guard !messages.isEmpty else { + throw RequestError.genericError(reason: "Messages endpoint did not return any messages") + } + return messages[0] + } } - // MARK: Create Channel Message - // POST /channels/{channel.id}/messages + /// Create Channel Message + /// + /// > POST: `/channels/{channel.id}/messages` func createChannelMsg( message: NewMessage, - attachments: [URL], + attachments: [URL] = [], id: Snowflake - ) async -> Message? { - return await postReq(path: "channels/\(id)/messages", body: message, attachments: attachments) + ) async throws -> Message { + return try await postReq(path: "channels/\(id)/messages", body: message, attachments: attachments) } - // MARK: Delete Message - // DELETE /channels/{channel.id}/messages/{message.id} + /// Delete Message + /// + /// > DELETE: `/channels/{channel.id}/messages/{message.id}` func deleteMsg( id: Snowflake, msgID: Snowflake - ) async -> Bool { - return await deleteReq(path: "channels/\(id)/messages/\(msgID)") + ) async throws { + return try await deleteReq(path: "channels/\(id)/messages/\(msgID)") } - // MARK: Acknowledge Message Read (Undocumented endpoint!) - // POST /channels/{channel.id}/messages/{message.id}/ack + /// Acknowledge Message Read (Undocumented endpoint!) + /// + /// > POST: `/channels/{channel.id}/messages/{message.id}/ack` func ackMessageRead( id: Snowflake, msgID: Snowflake - ) async -> MessageReadAck? { - return await postReq(path: "channels/\(id)/messages/\(msgID)/ack", body: MessageReadAck(token: nil)) + ) async throws -> MessageReadAck { + return try await postReq(path: "channels/\(id)/messages/\(msgID)/ack", body: MessageReadAck(token: nil), attachments: []) } - // MARK: Typing Start (Undocumented endpoint!) - func typingStart(id: Snowflake) async -> Bool { - return await emptyPostReq(path: "channels/\(id)/typing") + /// Typing Start (Undocumented endpoint!) + /// + /// > POST: `/channels/{channel.id}/typing` + func typingStart(id: Snowflake) async throws { + return try await postReq(path: "channels/\(id)/typing") + } + + /// Edit Channel + /// + /// > PATCH: `/channels/{channel.id}` + func editChannel( + _ channelId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "channels/\(channelId)/", + body: body + ) + } + /// Crosspost Message + /// + /// > POST: `/channels/{channel.id}/messages/{message.id}/crosspost` + func crosspostMessage( + _ channelId: Snowflake, + _ messageId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "channels/\(channelId)/messages/\(messageId)/crosspost/", + body: body + ) + } + /// Create Reaction + /// + /// > PUT: `/channels/{channel.id}/messages/{message.id}/reactions/{emoji}/@me` + func createReaction( + _ channelId: Snowflake, + _ messageId: Snowflake, + _ emoji: String + ) async throws { + try await patchReq( + path: "channels/\(channelId)/messages/\(messageId)/reactions/\(emoji)/@me/" + ) + } + /// Delete Own Reaction + /// + /// > DELETE: `/channels/{channel.id}/messages/{message.id}/reactions/{emoji}/@me` + func deleteOwnReaction( + _ channelId: Snowflake, + _ messageId: Snowflake, + _ emoji: String + ) async throws { + try await deleteReq( + path: "channels/\(channelId)/messages/\(messageId)/reactions/\(emoji)/@me/" + ) + } + /// Delete User Reaction + /// + /// > DELETE: `/channels/{channel.id}/messages/{message.id}/reactions/{emoji}/{user.id}` + func deleteUserReaction( + _ channelId: Snowflake, + _ messageId: Snowflake, + _ emoji: String, + _ userId: Snowflake + ) async throws { + try await deleteReq( + path: "channels/\(channelId)/messages/\(messageId)/reactions/\(emoji)/\(userId)/" + ) + } + /// Get Reactions + /// + /// > GET: `/channels/{channel.id}/messages/{message.id}/reactions/{emoji}` + func getReactions( + _ channelId: Snowflake, + _ messageId: Snowflake, + _ emoji: String + ) async throws -> T { + return try await getReq( + path: "channels/\(channelId)/messages/\(messageId)/reactions/\(emoji)/" + ) + } + /// Delete All Reactions + /// + /// > DELETE: `/channels/{channel.id}/messages/{message.id}/reactions` + func deleteAllReactions( + _ channelId: Snowflake, + _ messageId: Snowflake + ) async throws { + try await deleteReq( + path: "channels/\(channelId)/messages/\(messageId)/reactions/" + ) + } + /// Delete All Reactions for Emoji + /// + /// > DELETE: `/channels/{channel.id}/messages/{message.id}/reactions/{emoji}` + func deleteAllReactionsforEmoji( + _ channelId: Snowflake, + _ messageId: Snowflake, + _ emoji: String + ) async throws { + try await deleteReq( + path: "channels/\(channelId)/messages/\(messageId)/reactions/\(emoji)/" + ) + } + /// Edit Message + /// + /// > PATCH: `/channels/{channel.id}/messages/{message.id}` + func editMessage( + _ channelId: Snowflake, + _ messageId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "channels/\(channelId)/messages/\(messageId)/", + body: body + ) + } + /// Bulk Delete Messages + /// + /// > POST: `/channels/{channel.id}/messages/bulk-delete` + func bulkDeleteMessages( + _ channelId: Snowflake, + _ body: B + ) async throws { + try await postReq( + path: "channels/\(channelId)/messages/bulk-delete/", + body: body + ) + } + /// Edit Channel Permissions + /// + /// > PUT: `/channels/{channel.id}/permissions/{overwrite.id}` + func editChannelPermissions( + _ channelId: Snowflake, + _ overwriteId: Snowflake, + _ body: B + ) async throws { + try await putReq( + path: "channels/\(channelId)/permissions/\(overwriteId)/", + body: body + ) + } + /// Get Channel Invites + /// + /// > GET: `/channels/{channel.id}/invites` + func getChannelInvites( + _ channelId: Snowflake + ) async throws -> T { + return try await getReq( + path: "channels/\(channelId)/invites/" + ) + } + /// Create Channel Invite + /// + /// > POST: `/channels/{channel.id}/invites` + func createChannelInvite( + _ channelId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "channels/\(channelId)/invites/", + body: body + ) + } + /// Delete Channel Permission + /// + /// > DELETE: `/channels/{channel.id}/permissions/{overwrite.id}` + func deleteChannelPermission( + _ channelId: Snowflake, + _ overwriteId: Snowflake + ) async throws { + try await deleteReq( + path: "channels/\(channelId)/permissions/\(overwriteId)/" + ) + } + /// Follow Announcement Channel + /// + /// > POST: `/channels/{channel.id}/followers` + func followAnnouncementChannel( + _ channelId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "channels/\(channelId)/followers/", + body: body + ) + } + /// Trigger Typing Indicator + /// + /// > POST: `/channels/{channel.id}/typing` + func triggerTypingIndicator( + _ channelId: Snowflake, + _ body: B + ) async throws { + try await postReq( + path: "channels/\(channelId)/typing/", + body: body + ) + } + /// Get Pinned Messages + /// + /// > GET: `/channels/{channel.id}/pins` + func getPinnedMessages( + _ channelId: Snowflake + ) async throws -> T { + return try await getReq( + path: "channels/\(channelId)/pins/" + ) + } + /// Pin Message + /// + /// > PUT: `/channels/{channel.id}/pins/{message.id}` + func pinMessage( + _ channelId: Snowflake, + _ messageId: Snowflake, + _ body: B + ) async throws { + try await putReq( + path: "channels/\(channelId)/pins/\(messageId)/", + body: body + ) + } + /// Unpin Message + /// + /// > DELETE: `/channels/{channel.id}/pins/{message.id}` + func unpinMessage( + _ channelId: Snowflake, + _ messageId: Snowflake + ) async throws { + try await deleteReq( + path: "channels/\(channelId)/pins/\(messageId)/" + ) + } + /// Group DM Add Recipient + /// + /// > PUT: `/channels/{channel.id}/recipients/{user.id}` + func groupDMAddRecipient( + _ channelId: Snowflake, + _ userId: Snowflake, + _ body: B + ) async throws -> T { + return try await putReq( + path: "channels/\(channelId)/recipients/\(userId)/", + body: body + ) + } + /// Group DM Remove Recipient + /// + /// > DELETE: `/channels/{channel.id}/recipients/{user.id}` + func groupDMRemoveRecipient( + _ channelId: Snowflake, + _ userId: Snowflake + ) async throws { + try await deleteReq( + path: "channels/\(channelId)/recipients/\(userId)/" + ) + } + /// Start Thread from Message + /// + /// > POST: `/channels/{channel.id}/messages/{message.id}/threads` + func startThreadfromMessage( + _ channelId: Snowflake, + _ messageId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "channels/\(channelId)/messages/\(messageId)/threads/", + body: body + ) + } + /// Start Thread without Message + /// + /// > POST: `/channels/{channel.id}/threads` + func startThreadwithoutMessage( + _ channelId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "channels/\(channelId)/threads/", + body: body + ) + } + /// Start Thread in Forum Channel + /// + /// > POST: `/channels/{channel.id}/threads` + func startThreadinForumChannel( + _ channelId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "channels/\(channelId)/threads/", + body: body + ) + } + /// Join Thread + /// + /// > PUT: `/channels/{channel.id}/thread-members/@me` + func joinThread( + _ channelId: Snowflake, + _ body: B + ) async throws { + try await putReq( + path: "channels/\(channelId)/thread-members/@me/", + body: body + ) + } + /// Add Thread Member + /// + /// > PUT: `/channels/{channel.id}/thread-members/{user.id}` + func addThreadMember( + _ channelId: Snowflake, + _ userId: Snowflake, + _ body: B + ) async throws { + try await putReq( + path: "channels/\(channelId)/thread-members/\(userId)/", + body: body + ) + } + /// Leave Thread + /// + /// > DELETE: `/channels/{channel.id}/thread-members/@me` + func leaveThread( + _ channelId: Snowflake + ) async throws { + try await deleteReq( + path: "channels/\(channelId)/thread-members/@me/" + ) + } + /// Remove Thread Member + /// + /// > DELETE: `/channels/{channel.id}/thread-members/{user.id}` + func removeThreadMember( + _ channelId: Snowflake, + _ userId: Snowflake + ) async throws { + try await deleteReq( + path: "channels/\(channelId)/thread-members/\(userId)/" + ) + } + /// Get Thread Member + /// + /// > GET: `/channels/{channel.id}/thread-members/{user.id}` + func getThreadMember( + _ channelId: Snowflake, + _ userId: Snowflake + ) async throws -> T { + return try await getReq( + path: "channels/\(channelId)/thread-members/\(userId)/" + ) + } + /// List Thread Members + /// + /// > GET: `/channels/{channel.id}/thread-members` + func listThreadMembers( + _ channelId: Snowflake + ) async throws -> T { + return try await getReq( + path: "channels/\(channelId)/thread-members/" + ) + } + /// List Public Archived Threads + /// + /// > GET: `/channels/{channel.id}/threads/archived/public` + func listPublicArchivedThreads( + _ channelId: Snowflake + ) async throws -> T { + return try await getReq( + path: "channels/\(channelId)/threads/archived/public/" + ) + } + /// List Private Archived Threads + /// + /// > GET: `/channels/{channel.id}/threads/archived/private` + func listPrivateArchivedThreads( + _ channelId: Snowflake + ) async throws -> T { + return try await getReq( + path: "channels/\(channelId)/threads/archived/private/" + ) + } + /// List Joined Private Archived Threads + /// + /// > GET: `/channels/{channel.id}/users/@me/threads/archived/private` + func listJoinedPrivateArchivedThreads( + _ channelId: Snowflake + ) async throws -> T { + return try await getReq( + path: "channels/\(channelId)/users/@me/threads/archived/private/" + ) } } diff --git a/Sources/DiscordKitCore/REST/APICurrentUser.swift b/Sources/DiscordKitCore/REST/APICurrentUser.swift index 0abe21abd..ccd707210 100644 --- a/Sources/DiscordKitCore/REST/APICurrentUser.swift +++ b/Sources/DiscordKitCore/REST/APICurrentUser.swift @@ -6,17 +6,15 @@ // import Foundation -import DiscordKitCommon /// API endpoints for everything related to the current user only /// Most (all) endpoints here aren't documented and were found /// from reverse engineering, observation and speculation. - public extension DiscordREST { // MARK: Get Current User DMs // GET /users/@me/channels - func getDMs() async -> [DecodableThrowable]? { - return await getReq(path: "users/@me/channels") + func getDMs() async throws -> [DecodableThrowable] { + return try await getReq(path: "users/@me/channels") } // MARK: Change Current User Password diff --git a/Sources/DiscordKitCore/REST/APIEmoji.swift b/Sources/DiscordKitCore/REST/APIEmoji.swift new file mode 100644 index 000000000..b3b37ffd3 --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIEmoji.swift @@ -0,0 +1,63 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// List Guild Emojis + /// + /// > GET: `/guilds/{guild.id}/emojis` + func listGuildEmojis( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/emojis/" + ) + } + /// Get Guild Emoji + /// + /// > GET: `/guilds/{guild.id}/emojis/{emoji.id}` + func getGuildEmoji( + _ guildId: Snowflake, + _ emojiId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/emojis/\(emojiId)/" + ) + } + /// Create Guild Emoji + /// + /// > POST: `/guilds/{guild.id}/emojis` + func createGuildEmoji( + _ guildId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "guilds/\(guildId)/emojis/", + body: body + ) + } + /// Edit Guild Emoji + /// + /// > PATCH: `/guilds/{guild.id}/emojis/{emoji.id}` + func editGuildEmoji( + _ guildId: Snowflake, + _ emojiId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/emojis/\(emojiId)/", + body: body + ) + } + /// Delete Guild Emoji + /// + /// > DELETE: `/guilds/{guild.id}/emojis/{emoji.id}` + func deleteGuildEmoji( + _ guildId: Snowflake, + _ emojiId: Snowflake + ) async throws { + try await deleteReq( + path: "guilds/\(guildId)/emojis/\(emojiId)/" + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIGateway.swift b/Sources/DiscordKitCore/REST/APIGateway.swift new file mode 100644 index 000000000..8a5629b7a --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIGateway.swift @@ -0,0 +1,22 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// Get Gateway + /// + /// > GET: `/gateway` + func getGateway() async throws -> T { + return try await getReq( + path: "gateway/" + ) + } + /// Get Gateway Bot + /// + /// > GET: `/gateway/bot` + func getGatewayBot() async throws -> T { + return try await getReq( + path: "gateway/bot/" + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIGuild.swift b/Sources/DiscordKitCore/REST/APIGuild.swift index bffc27f58..e96a69b1b 100644 --- a/Sources/DiscordKitCore/REST/APIGuild.swift +++ b/Sources/DiscordKitCore/REST/APIGuild.swift @@ -1,29 +1,486 @@ -// -// APIGuild.swift -// DiscordAPI -// -// Created by Vincent Kwok on 22/2/22. -// +// NOTE: This file is auto-generated import Foundation -import DiscordKitCommon public extension DiscordREST { - // MARK: Get Guild - // GET /guilds/{guild.id} - func getGuild(id: Snowflake) async -> Guild? { - return await getReq(path: "guilds/\(id)") + /// Get Guild + /// + /// > GET: `/guilds/{guild.id}` + func getGuild(id: Snowflake) async throws -> Guild { + return try await getReq(path: "guilds/\(id)") } - // MARK: Get Guild Channels - // GET /guilds/{guild.id}/channels - func getGuildChannels(id: Snowflake) async -> [DecodableThrowable]? { - return await getReq(path: "guilds/\(id)/channels") + /// Get Guild Channels + /// + /// > GET: `/guilds/{guild.id}/channels` + func getGuildChannels(id: Snowflake) async throws -> [DecodableThrowable] { + return try await getReq(path: "guilds/\(id)/channels") } - // MARK: Get Guild Roles - // GET /guilds/{guild.id}/roles - func getGuildRoles(id: Snowflake) async -> [Role]? { - return await getReq(path: "guilds/\(id)/roles") + /// Get Guild Roles + /// + /// > GET: `/guilds/{guild.id}/roles` + func getGuildRoles(id: Snowflake) async throws -> [Role] { + return try await getReq(path: "guilds/\(id)/roles") + } + /// Create Guild + /// + /// > POST: `/guilds` + func createGuild(_ body: B) async throws -> T { + return try await postReq( + path: "guilds/", + body: body + ) + } + /// Get Guild Preview + /// + /// > GET: `/guilds/{guild.id}/preview` + func getGuildPreview( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/preview/" + ) + } + /// Edit Guild + /// + /// > PATCH: `/guilds/{guild.id}` + func editGuild( + _ guildId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/", + body: body + ) + } + /// Delete Guild + /// + /// > DELETE: `/guilds/{guild.id}` + func deleteGuild( + _ guildId: Snowflake + ) async throws { + try await deleteReq( + path: "guilds/\(guildId)/" + ) + } + /// Create Guild Channel + /// + /// > POST: `/guilds/{guild.id}/channels` + func createGuildChannel( + _ guildId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "guilds/\(guildId)/channels/", + body: body + ) + } + /// Edit Guild Channel Positions + /// + /// > PATCH: `/guilds/{guild.id}/channels` + func editGuildChannelPositions( + _ guildId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/channels/", + body: body + ) + } + /// List Active Guild Threads + /// + /// > GET: `/guilds/{guild.id}/threads/active` + func listActiveGuildThreads( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/threads/active/" + ) + } + /// Get Guild Member + /// + /// > GET: `/guilds/{guild.id}/members/{user.id}` + func getGuildMember( + _ guildId: Snowflake, + _ userId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/members/\(userId)/" + ) + } + /// List Guild Members + /// + /// > GET: `/guilds/{guild.id}/members` + func listGuildMembers( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/members/" + ) + } + /// Search Guild Members + /// + /// > GET: `/guilds/{guild.id}/members/search` + func searchGuildMembers( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/members/search/" + ) + } + /// Add Guild Member + /// + /// > PUT: `/guilds/{guild.id}/members/{user.id}` + func addGuildMember( + _ guildId: Snowflake, + _ userId: Snowflake, + _ body: B + ) async throws -> T? { + return try await putReq( + path: "guilds/\(guildId)/members/\(userId)/", + body: body + ) + } + /// Edit Guild Member + /// + /// > PATCH: `/guilds/{guild.id}/members/{user.id}` + func editGuildMember( + _ guildId: Snowflake, + _ userId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/members/\(userId)/", + body: body + ) + } + /// Edit Current Member + /// + /// > PATCH: `/guilds/{guild.id}/members/@me` + func editCurrentMember( + _ guildId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/members/@me/", + body: body + ) + } + /// Edit Current User Nick + /// + /// > PATCH: `/guilds/{guild.id}/members/@me/nick` + func editCurrentUserNick( + _ guildId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/members/@me/nick/", + body: body + ) + } + /// Add Guild Member Role + /// + /// > PUT: `/guilds/{guild.id}/members/{user.id}/roles/{role.id}` + func addGuildMemberRole( + _ guildId: Snowflake, + _ userId: Snowflake, + _ roleId: Snowflake, + _ body: B + ) async throws { + try await putReq( + path: "guilds/\(guildId)/members/\(userId)/roles/\(roleId)/", + body: body + ) + } + /// Remove Guild Member Role + /// + /// > DELETE: `/guilds/{guild.id}/members/{user.id}/roles/{role.id}` + func removeGuildMemberRole( + _ guildId: Snowflake, + _ userId: Snowflake, + _ roleId: Snowflake + ) async throws { + try await deleteReq( + path: "guilds/\(guildId)/members/\(userId)/roles/\(roleId)/" + ) + } + /// Remove Guild Member + /// + /// > DELETE: `/guilds/{guild.id}/members/{user.id}` + func removeGuildMember( + _ guildId: Snowflake, + _ userId: Snowflake + ) async throws { + try await deleteReq( + path: "guilds/\(guildId)/members/\(userId)/" + ) + } + /// Get Guild Bans + /// + /// > GET: `/guilds/{guild.id}/bans` + func getGuildBans( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/bans/" + ) + } + /// Get Guild Ban + /// + /// > GET: `/guilds/{guild.id}/bans/{user.id}` + func getGuildBan( + _ guildId: Snowflake, + _ userId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/bans/\(userId)/" + ) + } + /// Create Guild Ban + /// + /// > PUT: `/guilds/{guild.id}/bans/{user.id}` + func createGuildBan( + _ guildId: Snowflake, + _ userId: Snowflake, + _ body: B + ) async throws { + try await putReq( + path: "guilds/\(guildId)/bans/\(userId)/", + body: body + ) + } + /// Remove Guild Ban + /// + /// > DELETE: `/guilds/{guild.id}/bans/{user.id}` + func removeGuildBan( + _ guildId: Snowflake, + _ userId: Snowflake + ) async throws { + try await deleteReq( + path: "guilds/\(guildId)/bans/\(userId)/" + ) + } + /// Create Guild Role + /// + /// > POST: `/guilds/{guild.id}/roles` + func createGuildRole( + _ guildId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "guilds/\(guildId)/roles/", + body: body + ) + } + /// Edit Guild Role Positions + /// + /// > PATCH: `/guilds/{guild.id}/roles` + func editGuildRolePositions( + _ guildId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/roles/", + body: body + ) + } + /// Edit Guild Role + /// + /// > PATCH: `/guilds/{guild.id}/roles/{role.id}` + func editGuildRole( + _ guildId: Snowflake, + _ roleId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/roles/\(roleId)/", + body: body + ) + } + /// Edit Guild MFA Level + /// + /// > POST: `/guilds/{guild.id}/mfa` + func editGuildMFALevel( + _ guildId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "guilds/\(guildId)/mfa/", + body: body + ) + } + /// Delete Guild Role + /// + /// > DELETE: `/guilds/{guild.id}/roles/{role.id}` + func deleteGuildRole( + _ guildId: Snowflake, + _ roleId: Snowflake + ) async throws { + try await deleteReq( + path: "guilds/\(guildId)/roles/\(roleId)/" + ) + } + /// Get Guild Prune Count + /// + /// > GET: `/guilds/{guild.id}/prune` + func getGuildPruneCount( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/prune/" + ) + } + /// Begin Guild Prune + /// + /// > POST: `/guilds/{guild.id}/prune` + func beginGuildPrune( + _ guildId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "guilds/\(guildId)/prune/", + body: body + ) + } + /// Get Guild Voice Regions + /// + /// > GET: `/guilds/{guild.id}/regions` + func getGuildVoiceRegions( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/regions/" + ) + } + /// Get Guild Invites + /// + /// > GET: `/guilds/{guild.id}/invites` + func getGuildInvites( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/invites/" + ) + } + /// Get Guild Integrations + /// + /// > GET: `/guilds/{guild.id}/integrations` + func getGuildIntegrations( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/integrations/" + ) + } + /// Delete Guild Integration + /// + /// > DELETE: `/guilds/{guild.id}/integrations/{integration.id}` + func deleteGuildIntegration( + _ guildId: Snowflake, + _ integrationId: Snowflake + ) async throws { + try await deleteReq( + path: "guilds/\(guildId)/integrations/\(integrationId)/" + ) + } + /// Get Guild Widget Settings + /// + /// > GET: `/guilds/{guild.id}/widget` + func getGuildWidgetSettings( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/widget/" + ) + } + /// Edit Guild Widget + /// + /// > PATCH: `/guilds/{guild.id}/widget` + func editGuildWidget( + _ guildId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/widget/", + body: body + ) + } + /// Get Guild Widget + /// + /// > GET: `/guilds/{guild.id}/widget.json` + func getGuildWidget( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/widget.json/" + ) + } + /// Get Guild Vanity URL + /// + /// > GET: `/guilds/{guild.id}/vanity-url` + func getGuildVanityURL( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/vanity-url/" + ) + } + /// Get Guild Widget Image + /// + /// > GET: `/guilds/{guild.id}/widget.png` + func getGuildWidgetImage( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/widget.png/" + ) + } + /// Get Guild Welcome Screen + /// + /// > GET: `/guilds/{guild.id}/welcome-screen` + func getGuildWelcomeScreen( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/welcome-screen/" + ) + } + /// Edit Guild Welcome Screen + /// + /// > PATCH: `/guilds/{guild.id}/welcome-screen` + func editGuildWelcomeScreen( + _ guildId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/welcome-screen/", + body: body + ) + } + /// Edit Current User Voice State + /// + /// > PATCH: `/guilds/{guild.id}/voice-states/@me` + func editCurrentUserVoiceState( + _ guildId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/voice-states/@me/", + body: body + ) + } + /// Edit User Voice State + /// + /// > PATCH: `/guilds/{guild.id}/voice-states/{user.id}` + func editUserVoiceState( + _ guildId: Snowflake, + _ userId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/voice-states/\(userId)/", + body: body + ) } } diff --git a/Sources/DiscordKitCore/REST/APIGuildScheduledEvent.swift b/Sources/DiscordKitCore/REST/APIGuildScheduledEvent.swift new file mode 100644 index 000000000..c26953d8c --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIGuildScheduledEvent.swift @@ -0,0 +1,74 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// List Scheduled Events for Guild + /// + /// > GET: `/guilds/{guild.id}/scheduled-events` + func listScheduledEventsforGuild( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/scheduled-events/" + ) + } + /// Create Guild Scheduled Event + /// + /// > POST: `/guilds/{guild.id}/scheduled-events` + func createGuildScheduledEvent( + _ guildId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "guilds/\(guildId)/scheduled-events/", + body: body + ) + } + /// Get Guild Scheduled Event + /// + /// > GET: `/guilds/{guild.id}/scheduled-events/{guild_scheduled_event.id}` + func getGuildScheduledEvent( + _ guildId: Snowflake, + _ guild_scheduled_eventId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/scheduled-events/\(guild_scheduled_eventId)/" + ) + } + /// Edit Guild Scheduled Event + /// + /// > PATCH: `/guilds/{guild.id}/scheduled-events/{guild_scheduled_event.id}` + func editGuildScheduledEvent( + _ guildId: Snowflake, + _ guild_scheduled_eventId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/scheduled-events/\(guild_scheduled_eventId)/", + body: body + ) + } + /// Delete Guild Scheduled Event + /// + /// > DELETE: `/guilds/{guild.id}/scheduled-events/{guild_scheduled_event.id}` + func deleteGuildScheduledEvent( + _ guildId: Snowflake, + _ guild_scheduled_eventId: Snowflake + ) async throws { + try await deleteReq( + path: "guilds/\(guildId)/scheduled-events/\(guild_scheduled_eventId)/" + ) + } + /// Get Guild Scheduled Event Users + /// + /// > GET: `/guilds/{guild.id}/scheduled-events/{guild_scheduled_event.id}/users` + func getGuildScheduledEventUsers( + _ guildId: Snowflake, + _ guild_scheduled_eventId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/scheduled-events/\(guild_scheduled_eventId)/users/" + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIGuildTemplate.swift b/Sources/DiscordKitCore/REST/APIGuildTemplate.swift new file mode 100644 index 000000000..59d3ae3ef --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIGuildTemplate.swift @@ -0,0 +1,87 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// Get Guild Template + /// + /// > GET: `/guilds/templates/{template.code}` + func getGuildTemplate( + _ templateCode: String + ) async throws -> T { + return try await getReq( + path: "guilds/templates/\(templateCode)/" + ) + } + /// Create Guild from Guild Template + /// + /// > POST: `/guilds/templates/{template.code}` + func createGuildfromGuildTemplate( + _ templateCode: String, + _ body: B + ) async throws -> T { + return try await postReq( + path: "guilds/templates/\(templateCode)/", + body: body + ) + } + /// Get Guild Templates + /// + /// > GET: `/guilds/{guild.id}/templates` + func getGuildTemplates( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/templates/" + ) + } + /// Create Guild Template + /// + /// > POST: `/guilds/{guild.id}/templates` + func createGuildTemplate( + _ guildId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "guilds/\(guildId)/templates/", + body: body + ) + } + /// Sync Guild Template + /// + /// > PUT: `/guilds/{guild.id}/templates/{template.code}` + func syncGuildTemplate( + _ guildId: Snowflake, + _ templateCode: String, + _ body: B + ) async throws -> T { + return try await putReq( + path: "guilds/\(guildId)/templates/\(templateCode)/", + body: body + ) + } + /// Edit Guild Template + /// + /// > PATCH: `/guilds/{guild.id}/templates/{template.code}` + func editGuildTemplate( + _ guildId: Snowflake, + _ templateCode: String, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/templates/\(templateCode)/", + body: body + ) + } + /// Delete Guild Template + /// + /// > DELETE: `/guilds/{guild.id}/templates/{template.code}` + func deleteGuildTemplate( + _ guildId: Snowflake, + _ templateCode: String + ) async throws { + try await deleteReq( + path: "guilds/\(guildId)/templates/\(templateCode)/" + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIInvite.swift b/Sources/DiscordKitCore/REST/APIInvite.swift index aff7dd1c2..9184ddbb2 100644 --- a/Sources/DiscordKitCore/REST/APIInvite.swift +++ b/Sources/DiscordKitCore/REST/APIInvite.swift @@ -1,9 +1,4 @@ -// -// File.swift -// -// -// Created by Vincent Kwok on 10/7/22. -// +// NOTE: This file is auto-generated import Foundation @@ -12,17 +7,40 @@ public extension DiscordREST { /// /// `GET /invites/{inviteID}` /// - ///https://canary.discord.com/api/v9/invites/dosjopkqwef?inputValue=dosjopkqwef&with_counts=true&with_expiration=true /// Get guild member object for current user in a guild + /// + /// > Example URL: + /// > + /// > `https://canary.discord.com/api/v9/invites/dosjopkqwef?inputValue=dosjopkqwef&with_counts=true&with_expiration=true` func resolveInvite( inviteID: String, inputValue: String, withCounts: Bool = true, withExpiration: Bool = true - ) async -> Invite? { - return await getReq(path: "invites/\(inviteID)", query: [ + ) async throws -> Invite { + return try await getReq(path: "invites/\(inviteID)", query: [ URLQueryItem(name: "with_counts", value: String(withCounts)), URLQueryItem(name: "with_expiration", value: String(withExpiration)) ]) } + /// Get Invite + /// + /// > GET: `/invites/{invite.code}` + func getInvite( + _ inviteCode: String + ) async throws -> T { + return try await getReq( + path: "invites/\(inviteCode)/" + ) + } + /// Delete Invite + /// + /// > DELETE: `/invites/{invite.code}` + func deleteInvite( + _ inviteCode: String + ) async throws { + try await deleteReq( + path: "invites/\(inviteCode)/" + ) + } } diff --git a/Sources/DiscordKitCore/REST/APILobbies.swift b/Sources/DiscordKitCore/REST/APILobbies.swift new file mode 100644 index 000000000..6d867c295 --- /dev/null +++ b/Sources/DiscordKitCore/REST/APILobbies.swift @@ -0,0 +1,71 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// Create Lobby + /// + /// > POST: `/lobbies` + func createLobby(_ body: B) async throws -> T { + return try await postReq( + path: "lobbies/", + body: body + ) + } + /// Update Lobby + /// + /// > PATCH: `/lobbies/{lobby.id}` + func updateLobby( + _ lobbyId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "lobbies/\(lobbyId)/", + body: body + ) + } + /// Delete Lobby + /// + /// > DELETE: `/lobbies/{lobby.id}` + func deleteLobby( + _ lobbyId: Snowflake + ) async throws { + try await deleteReq( + path: "lobbies/\(lobbyId)/" + ) + } + /// Update Lobby Member + /// + /// > PATCH: `/lobbies/{lobby.id}/members/{user.id}` + func updateLobbyMember( + _ lobbyId: Snowflake, + _ userId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "lobbies/\(lobbyId)/members/\(userId)/", + body: body + ) + } + /// Create Lobby Search + /// + /// > POST: `/lobbies/search` + func createLobbySearch(_ body: B) async throws -> T { + return try await postReq( + path: "lobbies/search/", + body: body + ) + } + /// Send Lobby Data + /// + /// > POST: `/lobbies/{lobby.id}/send` + func sendLobbyData( + _ lobbyId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "lobbies/\(lobbyId)/send/", + body: body + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIMultipartFormBody.swift b/Sources/DiscordKitCore/REST/APIMultipartFormBody.swift index 5acd42a2b..bd9265a97 100644 --- a/Sources/DiscordKitCore/REST/APIMultipartFormBody.swift +++ b/Sources/DiscordKitCore/REST/APIMultipartFormBody.swift @@ -6,7 +6,6 @@ // import Foundation -import DiscordKitCommon public extension DiscordREST { static func createMultipartBody( @@ -16,16 +15,18 @@ public extension DiscordREST { ) -> Data { var body = Data() - for (n, attachment) in attachments.enumerated() { - let name = try! attachment.resourceValues(forKeys: [URLResourceKey.nameKey]).name! + for (num, attachment) in attachments.enumerated() { + guard let name = try? attachment.resourceValues(forKeys: [URLResourceKey.nameKey]).name else { + continue + } guard let attachmentData = try? Data(contentsOf: attachment) else { - DiscordREST.log.error("Could not get data of attachment #\(n)") + DiscordREST.log.error("Could not get data of attachment #\(num)") continue } body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append( - "Content-Disposition: form-data; name=\"files[\(n)]\"; filename=\"\(name)\"\r\n".data(using: .utf8)! + "Content-Disposition: form-data; name=\"files[\(num)]\"; filename=\"\(name)\"\r\n".data(using: .utf8)! ) body.append("Content-Type: \(attachment.mimeType)\r\n\r\n".data(using: .utf8)!) body.append(attachmentData) diff --git a/Sources/DiscordKitCore/REST/APIOAuth2.swift b/Sources/DiscordKitCore/REST/APIOAuth2.swift new file mode 100644 index 000000000..aaf5b4246 --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIOAuth2.swift @@ -0,0 +1,22 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// Get Current Bot Application Information + /// + /// > GET: `/oauth2/applications/@me` + func getCurrentBotApplicationInformation() async throws -> T { + return try await getReq( + path: "oauth2/applications/@me/" + ) + } + /// Get Current Authorization Information + /// + /// > GET: `/oauth2/@me` + func getCurrentAuthorizationInformation() async throws -> T { + return try await getReq( + path: "oauth2/@me/" + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIReceivingandResponding.swift b/Sources/DiscordKitCore/REST/APIReceivingandResponding.swift new file mode 100644 index 000000000..39458042b --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIReceivingandResponding.swift @@ -0,0 +1,105 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// Create Interaction Response + /// + /// > POST: `/interactions/{interaction.id}/{interaction.token}/callback` + func createInteractionResponse( + _ interactionId: Snowflake, + _ interactionToken: String, + _ body: B + ) async throws { + return try await postReq( + path: "interactions/\(interactionId)/\(interactionToken)/callback/", + body: body + ) + } + /// Get Original Interaction Response + /// + /// > GET: `/webhooks/{application.id}/{interaction.token}/messages/@original` + func getOriginalInteractionResponse( + _ applicationId: Snowflake, + _ interactionToken: String + ) async throws -> T { + return try await getReq( + path: "webhooks/\(applicationId)/\(interactionToken)/messages/@original/" + ) + } + /// Edit Original Interaction Response + /// + /// > PATCH: `/webhooks/{application.id}/{interaction.token}/messages/@original` + func editOriginalInteractionResponse( + _ applicationId: Snowflake, + _ interactionToken: String, + _ body: B + ) async throws { + try await patchReq( + path: "webhooks/\(applicationId)/\(interactionToken)/messages/@original/", + body: body + ) + } + /// Delete Original Interaction Response + /// + /// > DELETE: `/webhooks/{application.id}/{interaction.token}/messages/@original` + func deleteOriginalInteractionResponse( + _ applicationId: Snowflake, + _ interactionToken: String + ) async throws { + try await deleteReq( + path: "webhooks/\(applicationId)/\(interactionToken)/messages/@original/" + ) + } + /// Create Followup Message + /// + /// > POST: `/webhooks/{application.id}/{interaction.token}` + func createFollowupMessage( + _ applicationId: Snowflake, + _ interactionToken: String, + _ body: B + ) async throws -> T { + return try await postReq( + path: "webhooks/\(applicationId)/\(interactionToken)/", + body: body + ) + } + /// Get Followup Message + /// + /// > GET: `/webhooks/{application.id}/{interaction.token}/messages/{message.id}` + func getFollowupMessage( + _ applicationId: Snowflake, + _ interactionToken: String, + _ messageId: Snowflake + ) async throws -> T { + return try await getReq( + path: "webhooks/\(applicationId)/\(interactionToken)/messages/\(messageId)/" + ) + } + /// Edit Followup Message + /// + /// > PATCH: `/webhooks/{application.id}/{interaction.token}/messages/{message.id}` + func editFollowupMessage( + _ applicationId: Snowflake, + _ interactionToken: String, + _ messageId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "webhooks/\(applicationId)/\(interactionToken)/messages/\(messageId)/", + body: body + ) + } + /// Delete Followup Message + /// + /// > DELETE: `/webhooks/{application.id}/{interaction.token}/messages/{message.id}` + func deleteFollowupMessage( + _ applicationId: Snowflake, + _ interactionToken: String, + _ messageId: Snowflake + ) async throws { + try await deleteReq( + path: "webhooks/\(applicationId)/\(interactionToken)/messages/\(messageId)/" + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIRequest.swift b/Sources/DiscordKitCore/REST/APIRequest.swift index 6d86a46ab..3611c86ec 100644 --- a/Sources/DiscordKitCore/REST/APIRequest.swift +++ b/Sources/DiscordKitCore/REST/APIRequest.swift @@ -6,14 +6,22 @@ // import Foundation -import DiscordKitCommon /// Utility wrappers for easy request-making public extension DiscordREST { + enum RequestError: Error { + case unexpectedResponseCode(_ code: Int) + case invalidResponse + case superEncodeFailure + case jsonDecodingError(error: Error) // This is not strongly typed because it was simpler to just use one catch + case genericError(reason: String) + } + /// The few supported request methods enum RequestMethod: String { case get = "GET" case post = "POST" + case put = "PUT" case delete = "DELETE" case patch = "PATCH" } @@ -41,43 +49,42 @@ public extension DiscordREST { attachments: [URL] = [], body: Data? = nil, method: RequestMethod = .get - ) async throws -> Data? { - guard let token = token else { - DiscordREST.log.error("Not making request without token. Call setToken(token:) to set a token.") - return nil - } + ) async throws -> Data { + assert(token != nil, "Token should not be nil. Please set a token before using the REST API.") + let token = token! // Force unwrapping is appropriete here - DiscordREST.log.debug("\(method.rawValue): \(path)") + Self.log.trace("Making request", metadata: [ + "method": "\(method)", + "path": "\(path)" + ]) - guard var apiURL = URL(string: GatewayConfig.default.restBase) else { return nil } - apiURL.appendPathComponent(path, isDirectory: false) + let apiURL = DiscordKitConfig.default.restBase.appendingPathComponent(path, isDirectory: false) // Add query params (if any) - var urlBuilder = URLComponents(url: apiURL, resolvingAgainstBaseURL: true) - urlBuilder?.queryItems = query - guard let reqURL = urlBuilder?.url else { return nil } + var urlBuilder = URLComponents(url: apiURL, resolvingAgainstBaseURL: true)! + urlBuilder.queryItems = query + let reqURL = urlBuilder.url! // Create URLRequest and set headers var req = URLRequest(url: reqURL) req.httpMethod = method.rawValue - req.setValue(token, forHTTPHeaderField: "authorization") - req.setValue(GatewayConfig.default.baseURL, forHTTPHeaderField: "origin") + req.setValue(DiscordKitConfig.default.isBot ? "Bot \(token)" : token, forHTTPHeaderField: "authorization") + req.setValue(DiscordKitConfig.default.baseURL.absoluteString, forHTTPHeaderField: "origin") // These headers are to match headers present in actual requests from the official client // req.setValue("?0", forHTTPHeaderField: "sec-ch-ua-mobile") // The day this runs on iOS... // req.setValue("macOS", forHTTPHeaderField: "sec-ch-ua-platform") // We only run on macOS // The top 2 headers are only sent when running in browsers - req.setValue(DiscordREST.userAgent, forHTTPHeaderField: "user-agent") + req.setValue(DiscordKitConfig.default.userAgent, forHTTPHeaderField: "user-agent") req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode") req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site") req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest") req.setValue(Locale.englishUS.rawValue, forHTTPHeaderField: "x-discord-locale") req.setValue("bugReporterEnabled", forHTTPHeaderField: "x-debug-options") - guard let superEncoded = try? DiscordREST.encoder.encode(DiscordREST.getSuperProperties()) - else { - Self.log.error("Couldn't encode super properties, something is seriously wrong") - return nil + guard let superEncoded = try? DiscordREST.encoder.encode(DiscordKitConfig.default.properties) else { + assertionFailure("Couldn't encode super properties for request") + throw RequestError.superEncodeFailure } req.setValue(superEncoded.base64EncodedString(), forHTTPHeaderField: "x-super-properties") @@ -92,12 +99,14 @@ public extension DiscordREST { } // Make request - let (data, response) = try await DiscordREST.session.data(for: req) - guard let httpResponse = response as? HTTPURLResponse else { return nil } + guard let (data, response) = try? await DiscordREST.session.data(for: req), + let httpResponse = response as? HTTPURLResponse else { + throw RequestError.invalidResponse + } guard httpResponse.statusCode / 100 == 2 else { // Check if status code is 2** - DiscordREST.log.warning("Status code is not 2xx: \(httpResponse.statusCode, privacy: .public)") - DiscordREST.log.warning("Response: \(String(decoding: data, as: UTF8.self), privacy: .public)") - return nil + Self.log.error("Response status code not 2xx", metadata: ["res.statusCode": "\(httpResponse.statusCode)"]) + Self.log.debug("Raw response: \(String(decoding: data, as: UTF8.self))") + throw RequestError.unexpectedResponseCode(httpResponse.statusCode) } return data @@ -117,28 +126,14 @@ public extension DiscordREST { func getReq( path: String, query: [URLQueryItem] = [] - ) async -> T? { + ) async throws -> T { // This helps debug JSON decoding errors + let respData = try await makeRequest(path: path, query: query) do { - guard let d = try? await makeRequest(path: path, query: query) - else { return nil } - - return try DiscordREST.decoder.decode(T.self, from: d) - } catch let DecodingError.dataCorrupted(context) { - print(context.debugDescription) - } catch let DecodingError.keyNotFound(key, context) { - print("Key '\(key)' not found:", context.debugDescription) - print("codingPath:", context.codingPath) - } catch let DecodingError.valueNotFound(value, context) { - print("Value '\(value)' not found:", context.debugDescription) - print("codingPath:", context.codingPath) - } catch let DecodingError.typeMismatch(type, context) { - print("Type '\(type)' mismatch:", context.debugDescription) - print("codingPath:", context.codingPath) + return try DiscordREST.decoder.decode(T.self, from: respData) } catch { - print("error: ", error) + throw RequestError.jsonDecodingError(error: error) } - return nil } /// Make a `POST` request to the Discord REST API @@ -146,51 +141,82 @@ public extension DiscordREST { path: String, body: B? = nil, attachments: [URL] = [] - ) async -> D? { - let p = body != nil ? try? DiscordREST.encoder.encode(body) : nil - guard let d = try? await makeRequest( + ) async throws -> D { + let payload = body != nil ? try DiscordREST.encoder.encode(body) : nil + let respData = try await makeRequest( path: path, attachments: attachments, - body: p, + body: payload, method: .post ) - else { return nil } - - return try? DiscordREST.decoder.decode(D.self, from: d) + do { + return try DiscordREST.decoder.decode(D.self, from: respData) + } catch { + throw RequestError.jsonDecodingError(error: error) + } } - + /// Make a `POST` request to the Discord REST API /// /// For endpoints that returns a 204 empty response func postReq( path: String, body: B - ) async -> Bool { - let p = try? DiscordREST.encoder.encode(body) - guard (try? await makeRequest( + ) async throws { + let payload = try DiscordREST.encoder.encode(body) + _ = try await makeRequest( path: path, - body: p, + body: payload, method: .post - )) != nil - else { return false } - return true + ) } /// Make a `POST` request to the Discord REST API, for endpoints /// that both require no payload and returns a 204 empty response - func emptyPostReq(path: String) async -> Bool { - guard (try? await makeRequest( + func postReq(path: String) async throws { + _ = try await makeRequest( path: path, body: nil, method: .post - )) != nil - else { return false } - return true + ) + } + + /// Make a `PUT` request to the Discord REST API + func putReq( + path: String, + body: B + ) async throws -> Response { + let payload = try DiscordREST.encoder.encode(body) + let data = try await makeRequest( + path: path, + body: payload, + method: .put + ) + do { + return try DiscordREST.decoder.decode(Response.self, from: data) + } catch { + throw RequestError.jsonDecodingError(error: error) + } + } + + /// Make a `PUT` request to the Discord REST API + /// + /// For endpoints that returns a 204 empty response + func putReq( + path: String, + body: B + ) async throws { + let payload = try DiscordREST.encoder.encode(body) + _ = try await makeRequest( + path: path, + body: payload, + method: .put + ) } /// Make a `DELETE` request to the Discord REST API - func deleteReq(path: String) async -> Bool { - return (try? await makeRequest(path: path, method: .delete)) != nil + func deleteReq(path: String) async throws { + _ = try await makeRequest(path: path, method: .delete) } /// Make a `PATCH` request to the Discord REST API @@ -200,14 +226,17 @@ public extension DiscordREST { func patchReq( path: String, body: B - ) async -> Bool { - let p = try? DiscordREST.encoder.encode(body) - guard (try? await makeRequest( + ) async throws { + let payload: Data? + payload = try? DiscordREST.encoder.encode(body) + _ = try await makeRequest( path: path, - body: p, + body: payload, method: .patch - )) != nil - else { return false } - return true + ) + } + + func patchReq(path: String) async throws { + _ = try await makeRequest(path: path, body: nil, method: .patch) } } diff --git a/Sources/DiscordKitCore/REST/APIStageInstance.swift b/Sources/DiscordKitCore/REST/APIStageInstance.swift new file mode 100644 index 000000000..05fc18f8a --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIStageInstance.swift @@ -0,0 +1,47 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// Create Stage Instance + /// + /// > POST: `/stage-instances` + func createStageInstance(_ body: B) async throws -> T { + return try await postReq( + path: "stage-instances/", + body: body + ) + } + /// Get Stage Instance + /// + /// > GET: `/stage-instances/{channel.id}` + func getStageInstance( + _ channelId: Snowflake + ) async throws -> T { + return try await getReq( + path: "stage-instances/\(channelId)/" + ) + } + /// Edit Stage Instance + /// + /// > PATCH: `/stage-instances/{channel.id}` + func editStageInstance( + _ channelId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "stage-instances/\(channelId)/", + body: body + ) + } + /// Delete Stage Instance + /// + /// > DELETE: `/stage-instances/{channel.id}` + func deleteStageInstance( + _ channelId: Snowflake + ) async throws { + try await deleteReq( + path: "stage-instances/\(channelId)/" + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APISticker.swift b/Sources/DiscordKitCore/REST/APISticker.swift index 98aa6d8cb..d880ce71b 100644 --- a/Sources/DiscordKitCore/REST/APISticker.swift +++ b/Sources/DiscordKitCore/REST/APISticker.swift @@ -1,17 +1,77 @@ -// -// APISticker.swift -// DiscordAPI -// -// Created by Vincent Kwok on 24/2/22. -// +// NOTE: This file is auto-generated import Foundation -import DiscordKitCommon public extension DiscordREST { - // MARK: Get Sticker - // GET /stickers/{sticker.id} - func getSticker(id: Snowflake) async -> Sticker? { - return await getReq(path: "stickers/\(id)") + /// Get Sticker + /// + /// > GET: `/stickers/{sticker.id}` + func getSticker(id: Snowflake) async throws -> Sticker { + return try await getReq(path: "stickers/\(id)") + } + /// List Nitro Sticker Packs + /// + /// > GET: `/sticker-packs` + func listNitroStickerPacks() async throws -> T { + return try await getReq( + path: "sticker-packs/" + ) + } + /// List Guild Stickers + /// + /// > GET: `/guilds/{guild.id}/stickers` + func listGuildStickers( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/stickers/" + ) + } + /// Get Guild Sticker + /// + /// > GET: `/guilds/{guild.id}/stickers/{sticker.id}` + func getGuildSticker( + _ guildId: Snowflake, + _ stickerId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/stickers/\(stickerId)/" + ) + } + /// Create Guild Sticker + /// + /// > POST: `/guilds/{guild.id}/stickers` + func createGuildSticker( + _ guildId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "guilds/\(guildId)/stickers/", + body: body + ) + } + /// Edit Guild Sticker + /// + /// > PATCH: `/guilds/{guild.id}/stickers/{sticker.id}` + func editGuildSticker( + _ guildId: Snowflake, + _ stickerId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "guilds/\(guildId)/stickers/\(stickerId)/", + body: body + ) + } + /// Delete Guild Sticker + /// + /// > DELETE: `/guilds/{guild.id}/stickers/{sticker.id}` + func deleteGuildSticker( + _ guildId: Snowflake, + _ stickerId: Snowflake + ) async throws { + try await deleteReq( + path: "guilds/\(guildId)/stickers/\(stickerId)/" + ) } } diff --git a/Sources/DiscordKitCore/REST/APIStore.swift b/Sources/DiscordKitCore/REST/APIStore.swift new file mode 100644 index 000000000..f4b04a62e --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIStore.swift @@ -0,0 +1,83 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// Get Entitlements + /// + /// > GET: `/applications/{application.id}/entitlements` + func getEntitlements( + _ applicationId: Snowflake + ) async throws -> T { + return try await getReq( + path: "applications/\(applicationId)/entitlements/" + ) + } + /// Get Entitlement + /// + /// > GET: `/applications/{application.id}/entitlements/{entitlement.id}` + func getEntitlement( + _ applicationId: Snowflake, + _ entitlementId: Snowflake + ) async throws -> T { + return try await getReq( + path: "applications/\(applicationId)/entitlements/\(entitlementId)/" + ) + } + /// Get SKUs + /// + /// > GET: `/applications/{application.id}/skus` + func getSKUs( + _ applicationId: Snowflake + ) async throws -> T { + return try await getReq( + path: "applications/\(applicationId)/skus/" + ) + } + /// Consume SKU + /// + /// > POST: `/applications/{application.id}/entitlements/{entitlement.id}/consume` + func consumeSKU( + _ applicationId: Snowflake, + _ entitlementId: Snowflake + ) async throws { + try await postReq( + path: "applications/\(applicationId)/entitlements/\(entitlementId)/consume/" + ) + } + /// Delete Test Entitlement + /// + /// > DELETE: `/applications/{application.id}/entitlements/{entitlement.id}` + func deleteTestEntitlement( + _ applicationId: Snowflake, + _ entitlementId: Snowflake + ) async throws { + try await deleteReq( + path: "applications/\(applicationId)/entitlements/\(entitlementId)/" + ) + } + /// Create Purchase Discount + /// + /// > PUT: `/store/skus/{sku.id}/discounts/{user.id}` + func createPurchaseDiscount( + _ skuId: Snowflake, + _ userId: Snowflake, + _ body: B + ) async throws { + try await putReq( + path: "store/skus/\(skuId)/discounts/\(userId)/", + body: body + ) + } + /// Delete Purchase Discount + /// + /// > DELETE: `/store/skus/{sku.id}/discounts/{user.id}` + func deletePurchaseDiscount( + _ skuId: Snowflake, + _ userId: Snowflake + ) async throws { + try await deleteReq( + path: "store/skus/\(skuId)/discounts/\(userId)/" + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIUser.swift b/Sources/DiscordKitCore/REST/APIUser.swift index 3b76b04ea..4743d58a4 100644 --- a/Sources/DiscordKitCore/REST/APIUser.swift +++ b/Sources/DiscordKitCore/REST/APIUser.swift @@ -1,19 +1,13 @@ -// -// APIUser.swift -// DiscordAPI -// -// Created by Vincent Kwok on 22/2/22. -// +// NOTE: This file is auto-generated import Foundation -import DiscordKitCommon public extension DiscordREST { - /// Get Current User + /// Get Current User /// /// `GET /users/@me` - func getCurrentUser() async -> User? { - return await getReq(path: "users/@me") + func getCurrentUser() async throws -> User { + return try await getReq(path: "users/@me") } /// Get User @@ -21,8 +15,8 @@ public extension DiscordREST { /// `GET /users/{user.id}` /// /// - Parameter user: ID of user to retrieve - func getUser(user: Snowflake) async -> User? { - return await getReq(path: "users/\(user)") + func getUser(user: Snowflake) async throws -> User { + return try await getReq(path: "users/\(user)") } /// Get Profile @@ -39,16 +33,17 @@ public extension DiscordREST { user: Snowflake, mutualGuilds: Bool = false, guildID: Snowflake? = nil - ) async -> UserProfile? { + ) async throws -> UserProfile { var query = [URLQueryItem(name: "with_mutual_guilds", value: String(mutualGuilds))] if let guildID = guildID { query.append(URLQueryItem(name: "guild_id", value: guildID)) } - return await getReq(path: "users/\(user)/profile", query: query) + return try await getReq(path: "users/\(user)/profile", query: query) } - // MARK: Modify Current User - // TODO: Patch not yet implemented + /// Modify Current User + /// + /// TODO: Patch not yet implemented /// Get Current User Guilds /// @@ -57,8 +52,8 @@ public extension DiscordREST { before: Snowflake? = nil, after: Snowflake? = nil, limit: Int = 200 - ) async -> [PartialGuild]? { - return await getReq(path: "users/@me/guilds") + ) async throws -> [PartialGuild] { + return try await getReq(path: "users/@me/guilds") } /// Get Current User Guild Member @@ -66,18 +61,17 @@ public extension DiscordREST { /// `GET /users/@me/guilds/{guild.id}/member` /// /// Get guild member object for current user in a guild - func getGuildMember(guild: Snowflake) async -> Member? { - return await getReq(path: "users/@me/guilds/\(guild)/member") + func getGuildMember(guild: Snowflake) async throws -> Member { + return try await getReq(path: "users/@me/guilds/\(guild)/member") } - // MARK: Leave Guild - // DELETE /users/@me/guilds/{guild.id} - func leaveGuild(guild: Snowflake) async -> Bool { - return await deleteReq(path: guild) + /// Leave Guild + /// + /// > DELETE: `/users/@me/guilds/{guild.id}` + func leaveGuild(guild: Snowflake) async throws { + return try await deleteReq(path: guild) } - // MARK: Create DM - /// Log out /// /// `POST /auth/logout` @@ -86,8 +80,72 @@ public extension DiscordREST { /// - Parameters: /// - provider: Unknown, always observed to be nil /// - voipProvider: Unknown, always observed to be nil - @discardableResult - func logOut(provider: String? = nil, voipProvider: String? = nil) async -> Bool { - return await postReq(path: "auth/logout", body: LogOut(provider: provider, voip_provider: voipProvider)) + func logOut(provider: String? = nil, voipProvider: String? = nil) async throws { + return try await postReq(path: "auth/logout", body: LogOut(provider: provider, voip_provider: voipProvider)) + } + /// Get Current User + /// + /// > GET: `/users/@me` + func getCurrentUser() async throws -> T { + return try await getReq( + path: "users/@me/" + ) + } + /// Edit Current User + /// + /// > PATCH: `/users/@me` + func editCurrentUser(_ body: B) async throws { + try await patchReq( + path: "users/@me/", + body: body + ) + } + /// Create DM + /// + /// > POST: `/users/@me/channels` + func createDM(_ body: B) async throws -> T { + return try await postReq( + path: "users/@me/channels/", + body: body + ) + } + /// Create Group DM + /// + /// > POST: `/users/@me/channels` + func createGroupDM(_ body: B) async throws -> T { + return try await postReq( + path: "users/@me/channels/", + body: body + ) + } + /// Get User Connections + /// + /// > GET: `/users/@me/connections` + func getUserConnections() async throws -> T { + return try await getReq( + path: "users/@me/connections/" + ) + } + /// Get User Application Role Connection + /// + /// > GET: `/users/@me/applications/{application.id}/role-connection` + func getUserApplicationRoleConnection( + _ applicationId: Snowflake + ) async throws -> T { + return try await getReq( + path: "users/@me/applications/\(applicationId)/role-connection/" + ) + } + /// Update User Application Role Connection + /// + /// > PUT: `/users/@me/applications/{application.id}/role-connection` + func updateUserApplicationRoleConnection( + _ applicationId: Snowflake, + _ body: B + ) async throws -> T { + return try await putReq( + path: "users/@me/applications/\(applicationId)/role-connection/", + body: body + ) } } diff --git a/Sources/DiscordKitCore/REST/APIVoice.swift b/Sources/DiscordKitCore/REST/APIVoice.swift new file mode 100644 index 000000000..f1cb5791f --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIVoice.swift @@ -0,0 +1,14 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// List Voice Regions + /// + /// > GET: `/voice/regions` + func listVoiceRegions() async throws -> T { + return try await getReq( + path: "voice/regions/" + ) + } +} diff --git a/Sources/DiscordKitCore/REST/APIWebhook.swift b/Sources/DiscordKitCore/REST/APIWebhook.swift new file mode 100644 index 000000000..9accd0434 --- /dev/null +++ b/Sources/DiscordKitCore/REST/APIWebhook.swift @@ -0,0 +1,182 @@ +// NOTE: This file is auto-generated + +import Foundation + +public extension DiscordREST { + /// Create Webhook + /// + /// > POST: `/channels/{channel.id}/webhooks` + func createWebhook( + _ channelId: Snowflake, + _ body: B + ) async throws -> T { + return try await postReq( + path: "channels/\(channelId)/webhooks/", + body: body + ) + } + /// Get Channel Webhooks + /// + /// > GET: `/channels/{channel.id}/webhooks` + func getChannelWebhooks( + _ channelId: Snowflake + ) async throws -> T { + return try await getReq( + path: "channels/\(channelId)/webhooks/" + ) + } + /// Get Guild Webhooks + /// + /// > GET: `/guilds/{guild.id}/webhooks` + func getGuildWebhooks( + _ guildId: Snowflake + ) async throws -> T { + return try await getReq( + path: "guilds/\(guildId)/webhooks/" + ) + } + /// Get Webhook + /// + /// > GET: `/webhooks/{webhook.id}` + func getWebhook( + _ webhookId: Snowflake + ) async throws -> T { + return try await getReq( + path: "webhooks/\(webhookId)/" + ) + } + /// Get Webhook with Token + /// + /// > GET: `/webhooks/{webhook.id}/{webhook.token}` + func getWebhookwithToken( + _ webhookId: Snowflake, + _ webhookToken: String + ) async throws -> T { + return try await getReq( + path: "webhooks/\(webhookId)/\(webhookToken)/" + ) + } + /// Edit Webhook + /// + /// > PATCH: `/webhooks/{webhook.id}` + func editWebhook( + _ webhookId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "webhooks/\(webhookId)/", + body: body + ) + } + /// Edit Webhook with Token + /// + /// > PATCH: `/webhooks/{webhook.id}/{webhook.token}` + func editWebhookwithToken( + _ webhookId: Snowflake, + _ webhookToken: String, + _ body: B + ) async throws { + try await patchReq( + path: "webhooks/\(webhookId)/\(webhookToken)/", + body: body + ) + } + /// Delete Webhook + /// + /// > DELETE: `/webhooks/{webhook.id}` + func deleteWebhook( + _ webhookId: Snowflake + ) async throws { + try await deleteReq( + path: "webhooks/\(webhookId)/" + ) + } + /// Delete Webhook with Token + /// + /// > DELETE: `/webhooks/{webhook.id}/{webhook.token}` + func deleteWebhookwithToken( + _ webhookId: Snowflake, + _ webhookToken: String + ) async throws { + try await deleteReq( + path: "webhooks/\(webhookId)/\(webhookToken)/" + ) + } + /// Execute Webhook + /// + /// > POST: `/webhooks/{webhook.id}/{webhook.token}` + func executeWebhook( + _ webhookId: Snowflake, + _ webhookToken: String, + _ body: B // TODO: Return type depends on `wait` param + ) async throws -> T? { + return try await postReq( + path: "webhooks/\(webhookId)/\(webhookToken)/", + body: body + ) + } + /// Execute Slack-Compatible Webhook + /// + /// > POST: `/webhooks/{webhook.id}/{webhook.token}/slack` + func executeSlackCompatibleWebhook( + _ webhookId: Snowflake, + _ webhookToken: String, + _ body: B + ) async throws -> T { + return try await postReq( + path: "webhooks/\(webhookId)/\(webhookToken)/slack/", + body: body + ) + } + /// Execute GitHub-Compatible Webhook + /// + /// > POST: `/webhooks/{webhook.id}/{webhook.token}/github` + func executeGitHubCompatibleWebhook( + _ webhookId: Snowflake, + _ webhookToken: String, + _ body: B + ) async throws -> T { + return try await postReq( + path: "webhooks/\(webhookId)/\(webhookToken)/github/", + body: body + ) + } + /// Get Webhook Message + /// + /// > GET: `/webhooks/{webhook.id}/{webhook.token}/messages/{message.id}` + func getWebhookMessage( + _ webhookId: Snowflake, + _ webhookToken: String, + _ messageId: Snowflake + ) async throws -> T { + return try await getReq( + path: "webhooks/\(webhookId)/\(webhookToken)/messages/\(messageId)/" + ) + } + /// Edit Webhook Message + /// + /// > PATCH: `/webhooks/{webhook.id}/{webhook.token}/messages/{message.id}` + func editWebhookMessage( + _ webhookId: Snowflake, + _ webhookToken: String, + _ messageId: Snowflake, + _ body: B + ) async throws { + try await patchReq( + path: "webhooks/\(webhookId)/\(webhookToken)/messages/\(messageId)/", + body: body + ) + } + /// Delete Webhook Message + /// + /// > DELETE: `/webhooks/{webhook.id}/{webhook.token}/messages/{message.id}` + func deleteWebhookMessage( + _ webhookId: Snowflake, + _ webhookToken: String, + _ messageId: Snowflake + ) async throws { + try await deleteReq( + path: "webhooks/\(webhookId)/\(webhookToken)/messages/\(messageId)/" + ) + } +} diff --git a/Sources/DiscordKitCommon/Utils/DecodableThrowable.swift b/Sources/DiscordKitCore/Utils/DecodableThrowable.swift similarity index 100% rename from Sources/DiscordKitCommon/Utils/DecodableThrowable.swift rename to Sources/DiscordKitCore/Utils/DecodableThrowable.swift diff --git a/Sources/DiscordKitCommon/Utils/EventDispatch.swift b/Sources/DiscordKitCore/Utils/EventDispatch.swift similarity index 100% rename from Sources/DiscordKitCommon/Utils/EventDispatch.swift rename to Sources/DiscordKitCore/Utils/EventDispatch.swift diff --git a/Sources/DiscordKitCommon/Utils/HashedAsset.swift b/Sources/DiscordKitCore/Utils/HashedAsset.swift similarity index 100% rename from Sources/DiscordKitCommon/Utils/HashedAsset.swift rename to Sources/DiscordKitCore/Utils/HashedAsset.swift diff --git a/Sources/DiscordKitCommon/Utils/NullEncodable.swift b/Sources/DiscordKitCore/Utils/NullEncodable.swift similarity index 100% rename from Sources/DiscordKitCommon/Utils/NullEncodable.swift rename to Sources/DiscordKitCore/Utils/NullEncodable.swift diff --git a/Tests/DiscordKitCommonTests/PermissionTests.swift b/Tests/DiscordKitCommonTests/PermissionTests.swift index e25d17b23..1c3674bbf 100644 --- a/Tests/DiscordKitCommonTests/PermissionTests.swift +++ b/Tests/DiscordKitCommonTests/PermissionTests.swift @@ -6,17 +6,17 @@ // import XCTest -import DiscordKitCommon +import DiscordKitCore class PermissionTests: XCTestCase { func testPermissionsDecode() { XCTAssertEqual( Permissions([.viewChannel, .addReactions, .banMembers]), - try! JSONDecoder().decode(Permissions.self, from: "\"1092\"".data(using: .utf8)!) + try JSONDecoder().decode(Permissions.self, from: "\"1092\"".data(using: .utf8)!) ) XCTAssertEqual( Permissions([]), - try! JSONDecoder().decode(Permissions.self, from: "\"\"".data(using: .utf8)!) + try JSONDecoder().decode(Permissions.self, from: "\"\"".data(using: .utf8)!) ) XCTAssertThrowsError( try JSONDecoder().decode(Permissions.self, from: "1092".data(using: .utf8)!) @@ -26,7 +26,7 @@ class PermissionTests: XCTestCase { func testPermissionsEncode() { XCTAssertEqual( "\"3072\"", - String(data: try! JSONEncoder().encode(Permissions([.viewChannel, .sendMessages])), encoding: .utf8) + String(data: try JSONEncoder().encode(Permissions([.viewChannel, .sendMessages])), encoding: .utf8) ) } }