diff --git a/.swift-version b/.swift-version deleted file mode 100644 index 4d54daddb..000000000 --- a/.swift-version +++ /dev/null @@ -1 +0,0 @@ -4.0.2 diff --git a/.travis.yml b/.travis.yml index 17ef7c0f7..4f2c733e0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,11 @@ matrix: include: - os: linux - dist: trusty + dist: bionic sudo: required language: generic - os: osx - osx_image: xcode9.3 + osx_image: xcode11 language: objective-c branches: only: diff --git a/.travis/before_install.sh b/.travis/before_install.sh index 6b54db2c1..7c3beb614 100755 --- a/.travis/before_install.sh +++ b/.travis/before_install.sh @@ -7,9 +7,9 @@ if [[ $TRAVIS_OS_NAME == 'osx' ]]; then brew install libsodium brew install opus else - # Install Swift, Vapor and Opus + # Install Vapor and Opus eval "$(curl -sL https://apt.vapor.sh)" - sudo apt-get install swift vapor libopus-dev + sudo apt-get install vapor libopus-dev # Sodium wget https://download.libsodium.org/libsodium/releases/libsodium-1.0.16.tar.gz diff --git a/.travis/run.sh b/.travis/run.sh index 2c34bcd81..8c24eb7a9 100755 --- a/.travis/run.sh +++ b/.travis/run.sh @@ -1,7 +1,16 @@ #!/bin/bash if [[ $TRAVIS_OS_NAME == 'osx' ]]; then - swift test -Xlinker -L/usr/local/lib -Xlinker -lopus -Xcc -I/usr/local/include + swift test else - swift test -Xlinker -L/usr/lib -Xlinker -lopus -Xcc -I/usr/include + git clone https://github.com/kylef/swiftenv.git ~/.swiftenv + echo 'export SWIFTENV_ROOT="$HOME/.swiftenv"' >> ~/.bash_profile + echo 'export PATH="$SWIFTENV_ROOT/bin:$PATH"' >> ~/.bash_profile + echo 'eval "$(swiftenv init -)"' >> ~/.bash_profile + source ~/.bash_profile + + # Swift + swiftenv install 5.1 && swiftenv global 5.1 + swift --version + swift test fi diff --git a/Package.resolved b/Package.resolved index 43eff05c6..456ab6da4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -2,12 +2,12 @@ "object": { "pins": [ { - "package": "Bits", - "repositoryURL": "https://github.com/vapor/bits.git", + "package": "Socket", + "repositoryURL": "https://github.com/IBM-Swift/BlueSocket", "state": { "branch": null, - "revision": "c32f5e6ae2007dccd21a92b7e33eba842dd80d2f", - "version": "1.1.0" + "revision": "c46a3d41f5b2401d18bcb46d0101cdc5cd13e307", + "version": "1.0.52" } }, { @@ -20,84 +20,48 @@ } }, { - "package": "Core", - "repositoryURL": "https://github.com/vapor/core.git", - "state": { - "branch": null, - "revision": "f9f3a585ab0ea5764b46d7a36d9c0d9d508b9c63", - "version": "2.2.0" - } - }, - { - "package": "Crypto", - "repositoryURL": "https://github.com/vapor/crypto.git", - "state": { - "branch": null, - "revision": "946edc6642d6825982a2f52a268a8ba9bd520a3d", - "version": "2.1.2" - } - }, - { - "package": "CTLS", - "repositoryURL": "https://github.com/vapor/ctls.git", - "state": { - "branch": null, - "revision": "fba1297f4986a4dac8b02f7ac4e84a77fd492e4c", - "version": "1.1.3" - } - }, - { - "package": "Debugging", - "repositoryURL": "https://github.com/vapor/debugging.git", - "state": { - "branch": null, - "revision": "fc5a27d6eb236141dc24e5f14eedaa2e035ae7b3", - "version": "1.1.1" - } - }, - { - "package": "Engine", - "repositoryURL": "https://github.com/vapor/engine", + "package": "Sodium", + "repositoryURL": "https://github.com/nuclearace/Sodium", "state": { "branch": null, - "revision": "0ecc50fa8c7bc03ec9af78ca37e3b57f192bd258", - "version": "2.2.4" + "revision": "5812a3d879b77aae0fdfbd62d0e8354e914d15ae", + "version": "2.0.0" } }, { - "package": "Random", - "repositoryURL": "https://github.com/vapor/random.git", + "package": "swift-log", + "repositoryURL": "https://github.com/apple/swift-log.git", "state": { "branch": null, - "revision": "d7c4397d125caba795d14d956efacfe2a27a63d0", + "revision": "74d7b91ceebc85daf387ebb206003f78813f71aa", "version": "1.2.0" } }, { - "package": "Sockets", - "repositoryURL": "https://github.com/vapor/sockets.git", + "package": "swift-nio", + "repositoryURL": "https://github.com/apple/swift-nio.git", "state": { "branch": null, - "revision": "79fc180cdcb451dbbb01b1156be36720adde80e0", - "version": "2.2.3" + "revision": "c5fa0b456524cd73dc3ddbb263d4f46c20b86ca3", + "version": "2.17.0" } }, { - "package": "Sodium", - "repositoryURL": "https://github.com/nuclearace/Sodium", + "package": "swift-nio-ssl", + "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", "state": { "branch": null, - "revision": "5812a3d879b77aae0fdfbd62d0e8354e914d15ae", - "version": "2.0.0" + "revision": "ccf96bbe65ecc7c1558ab0dba7ffabdea5c1d31f", + "version": "2.4.4" } }, { - "package": "TLS", - "repositoryURL": "https://github.com/vapor/tls.git", + "package": "websocket-kit", + "repositoryURL": "https://github.com/vapor/websocket-kit", "state": { "branch": null, - "revision": "02a47309249e69358aa3c28b5853897585d7a750", - "version": "2.1.2" + "revision": "021edd1ca55451ad15b3e84da6b4064e4b877b34", + "version": "2.1.0" } } ] diff --git a/Package.swift b/Package.swift index 3b44ad532..6c71c2393 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:4.0 +// swift-tools-version:5.1 // The MIT License (MIT) // Copyright (c) 2016 Erik Little @@ -20,27 +20,26 @@ import PackageDescription var deps: [Package.Dependency] = [ + .package(url: "https://github.com/vapor/websocket-kit", .upToNextMinor(from: "2.1.0")), + .package(url: "https://github.com/IBM-Swift/BlueSocket", .upToNextMinor(from: "1.0.0")), .package(url: "https://github.com/nuclearace/copus", .upToNextMinor(from: "2.1.1")), .package(url: "https://github.com/nuclearace/Sodium", .upToNextMinor(from: "2.0.0")), - .package(url: "https://github.com/vapor/engine", .upToNextMinor(from: "2.2.0")), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), ] -var targetDeps: [Target.Dependency] = ["WebSockets"] - -#if !os(Linux) -deps += [.package(url: "https://github.com/daltoniam/Starscream", .upToNextMinor(from: "3.0.0")),] -targetDeps += ["Starscream"] -#endif - +var targetDeps: [Target.Dependency] = ["WebSocketKit", "COPUS", "Sodium", "Socket", "Logging"] let package = Package( name: "SwiftDiscord", + platforms: [.macOS(.v10_15)], products: [ .library(name: "SwiftDiscord", targets: ["SwiftDiscord"]) ], dependencies: deps, targets: [ .target(name: "SwiftDiscord", dependencies: targetDeps), +// .systemLibrary(name: "COPUS", pkgConfig: "opus"), +// .systemLibrary(name: "Sodium", pkgConfig: "libsodium"), .testTarget(name: "SwiftDiscordTests", dependencies: ["SwiftDiscord"]), ] ) diff --git a/Sources/COPUS/module.modulemap b/Sources/COPUS/module.modulemap new file mode 100644 index 000000000..c6c5ed1c9 --- /dev/null +++ b/Sources/COPUS/module.modulemap @@ -0,0 +1,5 @@ +module COPUS [system] { + header "shim.h" + link "opus" + export * +} diff --git a/Sources/COPUS/shim.h b/Sources/COPUS/shim.h new file mode 100644 index 000000000..f2a19dca3 --- /dev/null +++ b/Sources/COPUS/shim.h @@ -0,0 +1,22 @@ +#ifndef __COPUS_SHIM_H__ +#define __COPUS_SHIM_H__ + +#include + +int configure_encoder(OpusEncoder *enc, int bitrate, int vbr) +{ + int err; + + err = opus_encoder_ctl(enc, OPUS_SET_BITRATE(bitrate)); + err = opus_encoder_ctl(enc, OPUS_SET_VBR(vbr)); + + return err; +} + +int configure_decoder(OpusDecoder *dec, int gain) +{ + return opus_decoder_ctl(dec, OPUS_SET_GAIN(gain)); +} + + +#endif diff --git a/Sources/Sodium/module.modulemap b/Sources/Sodium/module.modulemap new file mode 100644 index 000000000..3bfeacd38 --- /dev/null +++ b/Sources/Sodium/module.modulemap @@ -0,0 +1,5 @@ +module Sodium [system] { + header "shim.h" + link "sodium" + export * +} diff --git a/Sources/Sodium/shim.h b/Sources/Sodium/shim.h new file mode 100644 index 000000000..29bd32ee4 --- /dev/null +++ b/Sources/Sodium/shim.h @@ -0,0 +1,6 @@ +#ifndef __SODIUM_SHIM_H__ +#define __SODIUM_SHIM_H__ + +#include + +#endif diff --git a/Sources/SwiftDiscord/Audit/DiscordAuditLogEntry.swift b/Sources/SwiftDiscord/Audit/DiscordAuditLogEntry.swift index 34e2f456b..133cfe4bb 100644 --- a/Sources/SwiftDiscord/Audit/DiscordAuditLogEntry.swift +++ b/Sources/SwiftDiscord/Audit/DiscordAuditLogEntry.swift @@ -32,7 +32,7 @@ public struct DiscordAuditLogEntry { // TODO An actual struct for this? /// Optional audit entry information for certain action types. - /// [Structure](https://discordapp.com/developers/docs/resources/audit-log#audit-log-entry-object-optional-audit-entry-info) + /// [Structure](https://discord.com/developers/docs/resources/audit-log#audit-log-entry-object-optional-audit-entry-info) public let options: [String: Any] /// The reason for this entry. diff --git a/Sources/SwiftDiscord/Channel/DiscordChannel.swift b/Sources/SwiftDiscord/Channel/DiscordChannel.swift index d9b34a5f1..6c7f1bd26 100644 --- a/Sources/SwiftDiscord/Channel/DiscordChannel.swift +++ b/Sources/SwiftDiscord/Channel/DiscordChannel.swift @@ -16,8 +16,15 @@ // DEALINGS IN THE SOFTWARE. import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Logging + import class Dispatch.DispatchSemaphore +fileprivate let logger = Logger(label: "DiscordChannel") + /// Protocol that declares a type will be a Discord channel. public protocol DiscordChannel : DiscordClientHolder { // MARK: Properties @@ -56,7 +63,7 @@ public extension DiscordChannel { // MARK: Properties /// - returns: The guild that this channel is associated with. Or nil if this channel has no guild. - public var guild: DiscordGuild? { + var guild: DiscordGuild? { return client?.guildForChannel(id) } @@ -65,10 +72,10 @@ public extension DiscordChannel { /// /// Deletes this channel. /// - public func delete(reason: String? = nil) { + func delete(reason: String? = nil) { guard let client = self.client else { return } - DefaultDiscordLogger.Logger.log("Deleting channel: \(id)", type: "DiscordChannel") + logger.info("Deleting channel: \(id)") client.deleteChannel(id, reason: reason) } @@ -78,7 +85,7 @@ public extension DiscordChannel { /// /// - parameter options: An array of `DiscordEndpointOptions.ModifyChannel` /// - public func modifyChannel(options: [DiscordEndpoint.Options.ModifyChannel], reason: String? = nil) { + func modifyChannel(options: [DiscordEndpoint.Options.ModifyChannel], reason: String? = nil) { guard let client = self.client else { return } client.modifyChannel(id, options: options, reason: reason) @@ -93,7 +100,7 @@ public extension DiscordTextChannel { /// /// - parameter message: The message to pin /// - public func pinMessage(_ message: DiscordMessage) { + func pinMessage(_ message: DiscordMessage) { guard let client = self.client else { return } client.addPinnedMessage(message.id, on: id) @@ -104,7 +111,7 @@ public extension DiscordTextChannel { /// /// - parameter message: The message to delete /// - public func deleteMessage(_ message: DiscordMessage) { + func deleteMessage(_ message: DiscordMessage) { guard let client = self.client else { return } client.deleteMessage(message.id, on: id) @@ -115,7 +122,7 @@ public extension DiscordTextChannel { /// /// - parameter callback: The callback. /// - public func getPinnedMessages(callback: @escaping ([DiscordMessage], HTTPURLResponse?) -> ()) { + func getPinnedMessages(callback: @escaping ([DiscordMessage], HTTPURLResponse?) -> ()) { guard let client = self.client else { return callback([], nil) } client.getPinnedMessages(for: id) {pins, response in @@ -144,7 +151,7 @@ public extension DiscordTextChannel { /// /// - parameter message: The message to send. /// - public func send(_ message: DiscordMessage) { + func send(_ message: DiscordMessage) { guard let client = self.client else { return } client.sendMessage(message, to: id) @@ -153,7 +160,7 @@ public extension DiscordTextChannel { /// /// Sends that this user is typing on this channel. /// - public func triggerTyping() { + func triggerTyping() { guard let client = self.client else { return } client.triggerTyping(on: id) @@ -164,7 +171,7 @@ public extension DiscordTextChannel { /// /// - parameter message: The message to unpin. /// - public func unpinMessage(_ message: DiscordMessage) { + func unpinMessage(_ message: DiscordMessage) { guard let client = self.client else { return } client.deletePinnedMessage(message.id, on: id) diff --git a/Sources/SwiftDiscord/Channel/DiscordMessage.swift b/Sources/SwiftDiscord/Channel/DiscordMessage.swift index b63f88aad..73c078acd 100644 --- a/Sources/SwiftDiscord/Channel/DiscordMessage.swift +++ b/Sources/SwiftDiscord/Channel/DiscordMessage.swift @@ -21,9 +21,21 @@ import Foundation public struct DiscordMessage : DiscordClientHolder, ExpressibleByStringLiteral { // Used for `createDataForSending` private struct FieldsList : Encodable { + enum CodingKeys: String, CodingKey { + case content + case tts + case embed + case allowedMentions = "allowed_mentions" + case messageReference = "message_reference" + case components + } + let content: String let tts: Bool let embed: DiscordEmbed? + let allowedMentions: DiscordAllowedMentions? + let messageReference: DiscordMessageReference? + let components: [DiscordMessageComponent]? } // MARK: Typealiases @@ -87,12 +99,32 @@ public struct DiscordMessage : DiscordClientHolder, ExpressibleByStringLiteral { /// The reactions a message has. public let reactions: [DiscordReaction] + /// The stickers a message has. + public let stickers: [DiscordMessageSticker] + /// The timestamp of this message. public let timestamp: Date /// Whether or not this message should be read by a screen reader. public let tts: Bool + /// Finer-grained control over the allowed mentions in an outgoing message. + public let allowedMentions: DiscordAllowedMentions? + + /// A referenced message in an incoming message. Only present if it's a reply. + /// + /// TODO: This is actually a DiscordMessage object too, but would cause the + /// value type to become recursive, which is not allowed yet (since optionals + /// are value types themselves that do not box the value). + public let referencedMessage: [String: Any]? + + /// A referenced message in an outgoing message. + public let messageReference: DiscordMessageReference? + + /// Interactive components in the message. This top-level array should only + /// contain action rows (which can then e.g. contain buttons). + public let components: [DiscordMessageComponent]? + /// The type of this message. public let type: MessageType @@ -128,9 +160,14 @@ public struct DiscordMessage : DiscordClientHolder, ExpressibleByStringLiteral { nonce = messageObject.getSnowflake(key: "nonce") pinned = messageObject.get("pinned", or: false) reactions = DiscordReaction.reactionsFromArray(messageObject.get("reactions", or: [])) + stickers = DiscordMessageSticker.stickersFromArray(messageObject.get("sticker", or: [])) tts = messageObject.get("tts", or: false) editedTimestamp = DiscordDateFormatter.format(messageObject.get("edited_timestamp", or: "")) ?? Date() timestamp = DiscordDateFormatter.format(messageObject.get("timestamp", or: "")) ?? Date() + allowedMentions = nil + referencedMessage = messageObject.get("referenced_message", as: [String: Any].self) + messageReference = nil + components = nil files = [] type = MessageType(rawValue: messageObject.get("type", or: 0)) ?? .default self.client = client @@ -144,7 +181,15 @@ public struct DiscordMessage : DiscordClientHolder, ExpressibleByStringLiteral { /// - parameter files: The files to send with this message. /// - parameter tts: Whether this message should be text-to-speach. /// - public init(content: String, embed: DiscordEmbed? = nil, files: [DiscordFileUpload] = [], tts: Bool = false) { + public init( + content: String, + embed: DiscordEmbed? = nil, + files: [DiscordFileUpload] = [], + tts: Bool = false, + allowedMentions: DiscordAllowedMentions? = nil, + messageReference: DiscordMessageReference? = nil, + components: [DiscordMessageComponent] = [] + ) { self.content = content if let embed = embed { self.embeds = [embed] @@ -155,6 +200,10 @@ public struct DiscordMessage : DiscordClientHolder, ExpressibleByStringLiteral { self.application = nil self.files = files self.tts = tts + self.allowedMentions = allowedMentions + self.messageReference = messageReference + self.components = components + self.referencedMessage = nil self.attachments = [] self.author = DiscordUser(userObject: [:]) self.channelId = 0 @@ -165,6 +214,7 @@ public struct DiscordMessage : DiscordClientHolder, ExpressibleByStringLiteral { self.nonce = 0 self.pinned = false self.reactions = [] + self.stickers = [] self.editedTimestamp = Date() self.timestamp = Date() self.type = .default @@ -200,7 +250,14 @@ public struct DiscordMessage : DiscordClientHolder, ExpressibleByStringLiteral { // MARK: Methods func createDataForSending() -> Either { - let fields = FieldsList(content: content, tts: tts, embed: embeds.first) + let fields = FieldsList( + content: content, + tts: tts, + embed: embeds.first, + allowedMentions: allowedMentions, + messageReference: messageReference, + components: components + ) let fieldsData = JSON.encodeJSONData(fields) ?? Data() if files.count > 0 { return .right(createMultipartBody(encodedJSON: fieldsData, files: files)) @@ -223,34 +280,58 @@ public struct DiscordMessage : DiscordClientHolder, ExpressibleByStringLiteral { public extension DiscordMessage { /// Type of message - public enum MessageType : Int { + enum MessageType : Int { /// Default. - case `default` + case `default` = 0 /// Recipient Add. - case recipientAdd + case recipientAdd = 1 /// Recipient Remove. - case recipientRemove + case recipientRemove = 2 /// Call. - case call + case call = 3 /// Channel name change. - case channelNameChange + case channelNameChange = 4 /// Channel icon change. - case channelIconChange + case channelIconChange = 5 /// Channel pinned message. - case channelPinnedMessage + case channelPinnedMessage = 6 /// Guild member join. - case guildMemberJoin + case guildMemberJoin = 7 + + /// User premium guild subscription. + case userPremiumGuildSubscription = 8 + + /// User premium guild subscription tier 1. + case userPremiumGuildSubscriptionTier1 = 9 + + /// User premium guild subscription tier 2. + case userPremiumGuildSubscriptionTier2 = 10 + + /// User premium guild subscription tier 3. + case userPremiumGuildSubscriptionTier3 = 11 + + /// Channel follow add. + case channelFollowAdd = 12 + + /// Guild discovery disqualified. + case guildDiscoveryDisqualified = 14 + + /// Guild discovery requalified. + case guildDiscoveryRequalified = 15 + + /// Message reply. + case reply = 19 } /// Represents an action that be taken on a message. - public struct MessageActivity { + struct MessageActivity { /// Represents the type of activity. public enum ActivityType : Int { /// Join. @@ -275,7 +356,7 @@ public extension DiscordMessage { } /// Represents an application in a `DiscordMessage` object. - public struct MessageApplication { + struct MessageApplication { /// The id of this application. public let id: Snowflake @@ -799,3 +880,212 @@ public struct DiscordReaction { return reactionsArray.map(DiscordReaction.init) } } + +public enum DiscordAllowedMentionType : String, Encodable { + case roles + case users + case everyone +} + +/// Allows for more granular control over mentions +/// without having to modify the message content. +public struct DiscordAllowedMentions : Encodable { + public enum CodingKeys : String, CodingKey { + case parse + case roles + case users + case repliedUser = "replied_user" + } + + /// An array of allowed mentions types to parse from the content. + public let parse: DiscordAllowedMentionType + /// Array of role ids to mention. + public let roles: [RoleID] + /// Array of user ids to mention. + public let users: [UserID] + /// For replies, whether to mention the author of the message being replied to (default: false) + public let repliedUser: Bool + + public init(parse: DiscordAllowedMentionType = .everyone, roles: [RoleID] = [], users: [UserID] = [], repliedUser: Bool = false) { + self.parse = parse + self.roles = roles + self.users = users + self.repliedUser = repliedUser + } +} + +/// A reference to a message, e.g. used in outgoing replies. +public struct DiscordMessageReference : Encodable { + public enum CodingKeys : String, CodingKey { + case messageId = "message_id" + case channelId = "channel_id" + case guildId = "guild_id" + } + + public let messageId: MessageID? + public let channelId: ChannelID? + public let guildId: GuildID? + + public init(messageId: MessageID? = nil, channelId: ChannelID? = nil, guildId: GuildID? = nil) { + self.messageId = messageId + self.channelId = channelId + self.guildId = guildId + } +} + +/// An interactive part of a message. +public struct DiscordMessageComponent : Encodable { + public enum CodingKeys : String, CodingKey { + case type + case components + case style + case label + case emoji + case customId = "custom_id" + case url + case disabled + } + + /// The type of the component. + public let type: DiscordMessageComponentType + /// Sub-components. Only valid for action rows. + public let components: [DiscordMessageComponent]? + /// One of a few button styles. Only valid for buttons. + public let style: DiscordMessageComponentButtonStyle? + /// Label that appears on a button. Only valid for buttons. + public let label: String? + /// Emoji that appears on the button. Only valid for buttons. + public let emoji: DiscordMessageComponentEmoji? + /// A developer-defined id for the button, max 100 chars. Only valid for buttons. + public let customId: String? + /// A URL for link-style buttons. Only valid for buttons. + public let url: URL? + /// Whether the button is disabled. False by default. Only valid for buttons. + public let disabled: Bool? + + public init( + type: DiscordMessageComponentType, + components: [DiscordMessageComponent]? = nil, + style: DiscordMessageComponentButtonStyle? = nil, + label: String? = nil, + emoji: DiscordMessageComponentEmoji? = nil, + customId: String? = nil, + url: URL? = nil, + disabled: Bool? = nil + ) { + self.type = type + self.components = components + self.style = style + self.label = label + self.emoji = emoji + self.customId = customId + self.url = url + self.disabled = disabled + } + + /// Creates a new button component. + public static func button( + style: DiscordMessageComponentButtonStyle? = nil, + label: String? = nil, + emoji: DiscordMessageComponentEmoji? = nil, + customId: String? = nil, + url: URL? = nil, + disabled: Bool? = nil + ) -> DiscordMessageComponent { + DiscordMessageComponent( + type: .button, + style: style, + label: label, + emoji: emoji, + customId: customId, + url: url, + disabled: disabled + ) + } + + /// Creates a new action row component. Cannot contain other action rows. + public static func actionRow(components: [DiscordMessageComponent]) -> DiscordMessageComponent { + DiscordMessageComponent( + type: .actionRow, + components: components + ) + } +} + +public struct DiscordMessageComponentType : RawRepresentable, Hashable, Encodable { + public let rawValue: Int + + public static let actionRow = DiscordMessageComponentType(rawValue: 1) + public static let button = DiscordMessageComponentType(rawValue: 2) + + public init(rawValue: Int) { + self.rawValue = rawValue + } +} + +/// A partial emoji for use in message components. +public struct DiscordMessageComponentEmoji : Encodable { + public let id: EmojiID? + public let name: String? + public let animated: Bool + + public init(id: EmojiID? = nil, name: String? = nil, animated: Bool = false) { + self.id = id + self.name = name + self.animated = animated + } +} + +public struct DiscordMessageComponentButtonStyle : RawRepresentable, Hashable, Encodable { + public let rawValue: Int + + public static let primary = DiscordMessageComponentButtonStyle(rawValue: 1) + public static let secondary = DiscordMessageComponentButtonStyle(rawValue: 2) + public static let success = DiscordMessageComponentButtonStyle(rawValue: 3) + public static let danger = DiscordMessageComponentButtonStyle(rawValue: 4) + public static let link = DiscordMessageComponentButtonStyle(rawValue: 5) + + public init(rawValue: Int) { + self.rawValue = rawValue + } +} + +public enum DiscordMessageStickerFormatType: Int { + case png = 1 + case apng = 2 + case lottie = 3 +} + +public struct DiscordMessageSticker { + /// ID of the sticker + public let id: Snowflake + /// ID of the sticker pack + public let packId: Snowflake + /// Name of the sticker + public let name: String + /// Description of the sticker + public let description: String + /// List of tags for the sticker + public let tags: [String] + /// Sticker asset hash + public let asset: String? + /// Sticker preview asset hash + public let previewAsset: String? + /// Type of sticker format + public let formatType: DiscordMessageStickerFormatType? + + init(stickerObject: [String: Any]) { + id = stickerObject.getSnowflake(key: "id") + packId = stickerObject.getSnowflake(key: "pack_id") + name = stickerObject.get("name", or: "") + description = stickerObject.get("description", or: "") + tags = stickerObject.get("tags", or: "").split(separator: ",").map(String.init) + asset = stickerObject.get("asset", as: String.self) + previewAsset = stickerObject.get("preview_asset", as: String.self) + formatType = stickerObject.get("format_type", as: Int.self).flatMap(DiscordMessageStickerFormatType.init(rawValue:)) + } + + static func stickersFromArray(_ stickerArray: [[String: Any]]) -> [DiscordMessageSticker] { + return stickerArray.map(DiscordMessageSticker.init) + } +} diff --git a/Sources/SwiftDiscord/DiscordClient.swift b/Sources/SwiftDiscord/DiscordClient.swift index 156d79e21..6785a40e1 100644 --- a/Sources/SwiftDiscord/DiscordClient.swift +++ b/Sources/SwiftDiscord/DiscordClient.swift @@ -17,6 +17,10 @@ import Foundation import Dispatch +import Logging +import NIO + +fileprivate let logger = Logger(label: "DiscordClient") /// /// The base class for SwiftDiscord. Most interaction with Discord will be done through this class. @@ -43,6 +47,9 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// The rate limiter for this client. public var rateLimiter: DiscordRateLimiterSpec! + /// The run loops. + public let runloops = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + /// The Discord JWT token. public let token: DiscordToken @@ -62,6 +69,9 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// If we should only represent a single shard, this is the shard information. public var shardingInfo = try! DiscordShardInformation(shardRange: 0..<1, totalShards: 1) + /// The gateway intents. + public var intents = DiscordGatewayIntent.unprivilegedIntents + /// Whether large guilds should have their users fetched as soon as they are created. public var fillLargeGuilds = false @@ -91,7 +101,6 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco var channelCache = [ChannelID: DiscordChannel]() - private var logType: String { return "DiscordClient" } private let voiceQueue = DispatchQueue(label: "voiceQueue") // MARK: Initializers @@ -112,16 +121,14 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco switch config { case let .handleQueue(queue): handleQueue = queue - case let .log(level): - DefaultDiscordLogger.Logger.level = level - case let .logger(logger): - DefaultDiscordLogger.Logger = logger case let .rateLimiter(limiter): self.rateLimiter = limiter case let .shardingInfo(shardingInfo): self.shardingInfo = shardingInfo case let .voiceConfiguration(config): self.voiceManager.engineConfiguration = config + case let .intents(intents): + self.intents = intents case .discardPresences: discardPresences = true case .fillLargeGuilds: @@ -136,6 +143,10 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco rateLimiter = rateLimiter ?? DiscordRateLimiter(callbackQueue: handleQueue, failFast: false) } + deinit { + try! runloops.syncShutdownGracefully() + } + // MARK: Methods /// @@ -143,9 +154,9 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// with the client. /// open func connect() { - DefaultDiscordLogger.Logger.log("Connecting", type: logType) + logger.info("Connecting") - shardManager.manuallyShatter(withInfo: shardingInfo) + shardManager.manuallyShatter(withInfo: shardingInfo, intents: intents) shardManager.connect() } @@ -156,7 +167,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// Calling this method turns off automatic resuming, set `resume` to `true` before calling `connect()` again. /// open func disconnect() { - DefaultDiscordLogger.Logger.log("Disconnecting", type: logType) + logger.info("Disconnecting") connected = false @@ -175,7 +186,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// public func findChannel(fromId channelId: ChannelID) -> DiscordChannel? { if let channel = channelCache[channelId] { - DefaultDiscordLogger.Logger.debug("Got cached channel \(channel)", type: logType) + logger.debug("Got cached channel \(channel)") return channel } @@ -187,14 +198,14 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco } else if let dmChannel = directChannels[channelId] { channel = dmChannel } else { - DefaultDiscordLogger.Logger.debug("Couldn't find channel \(channelId)", type: logType) + logger.debug("Couldn't find channel \(channelId)") return nil } channelCache[channel.id] = channel - DefaultDiscordLogger.Logger.debug("Found channel \(channel)", type: logType) + logger.debug("Found channel \(channel)") return channel } @@ -209,8 +220,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// open func handleDispatch(event: DiscordDispatchEvent, data: DiscordGatewayPayloadData) { guard case let .object(eventData) = data else { - DefaultDiscordLogger.Logger.error("Got dispatch event without an object: \(event), \(data)", - type: "DiscordDispatchEventHandler") + logger.error("Got dispatch event without an object: \(event), \(data)") return } @@ -232,6 +242,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco case .channelUpdate: handleChannelUpdate(with: eventData) case .channelCreate: handleChannelCreate(with: eventData) case .channelDelete: handleChannelDelete(with: eventData) + case .interactionCreate: handleInteractionCreate(with: eventData) case .voiceServerUpdate: handleVoiceServerUpdate(with: eventData) case .voiceStateUpdate: handleVoiceStateUpdate(with: eventData) case .ready: handleReady(with: eventData) @@ -260,7 +271,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco return } - DefaultDiscordLogger.Logger.log("Joining voice channel: \(channel)", type: self.logType) + logger.info("Joining voice channel: \(channel)") shardManager.sendPayload(DiscordGatewayPayload(code: .gateway(.voiceStatusUpdate), payload: .object(["guild_id": String(describing: guild.id), @@ -277,7 +288,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter onGuild: The snowflake of the guild that you want to leave. /// open func leaveVoiceChannel(onGuild guildId: GuildID) { - DefaultDiscordLogger.Logger.log("Leaving voice channel on guild: \(guildId)", type: logType) + logger.info("Leaving voice channel on guild: \(guildId)") voiceManager.leaveVoiceChannel(onGuild: guildId) } @@ -447,7 +458,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleChannelCreate(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling channel create", type: logType) + logger.info("Handling channel create") guard let channel = channelFromObject(data, withClient: self) else { return } @@ -462,7 +473,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco break } - DefaultDiscordLogger.Logger.verbose("Created channel: \(channel)", type: logType) + logger.debug("(verbose) Created channel: \(channel)") delegate?.client(self, didCreateChannel: channel) } @@ -477,7 +488,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleChannelDelete(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling channel delete", type: logType) + logger.info("Handling channel delete") guard let type = DiscordChannelType(rawValue: data["type"] as? Int ?? -1) else { return } guard let channelId = Snowflake(data["id"] as? String) else { return } @@ -496,7 +507,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco channelCache.removeValue(forKey: channelId) - DefaultDiscordLogger.Logger.verbose("Removed channel: \(removedChannel)", type: logType) + logger.debug("(verbose) Removed channel: \(removedChannel)") delegate?.client(self, didDeleteChannel: removedChannel) } @@ -511,13 +522,13 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleChannelUpdate(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling channel update", type: logType) + logger.info("Handling channel update") guard let channel = guildChannel(fromObject: data, guildID: nil, client: self) else { return } - DefaultDiscordLogger.Logger.verbose("Updated channel: \(channel)", type: logType) + logger.debug("(verbose) Updated channel: \(channel)") guilds[channel.guildId]?.channels[channel.id] = channel @@ -536,11 +547,11 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleGuildCreate(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling guild create", type: logType) + logger.info("Handling guild create") let guild = DiscordGuild(guildObject: data, client: self) - DefaultDiscordLogger.Logger.verbose("Created guild: \(guild)", type: self.logType) + logger.debug("(verbose) Created guild: \(guild)") guilds[guild.id] = guild @@ -549,7 +560,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco guard fillLargeGuilds && guild.large else { return } // Fill this guild with users immediately - DefaultDiscordLogger.Logger.debug("Fill large guild \(guild.id) with all users", type: logType) + logger.debug("Fill large guild \(guild.id) with all users") requestAllUsers(on: guild.id) } @@ -564,7 +575,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleGuildDelete(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling guild delete", type: logType) + logger.info("Handling guild delete") guard let guildId = Snowflake(data["id"] as? String) else { return } guard let removedGuild = guilds.removeValue(forKey: guildId) else { return } @@ -573,7 +584,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco channelCache[channel] = nil } - DefaultDiscordLogger.Logger.verbose("Removed guild: \(removedGuild)", type: logType) + logger.debug("(verbose) Removed guild: \(removedGuild)") delegate?.client(self, didDeleteGuild: removedGuild) } @@ -588,14 +599,14 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleGuildEmojiUpdate(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling guild emoji update", type: logType) + logger.info("Handling guild emoji update") guard let guildId = Snowflake(data["guild_id"] as? String), let guild = guilds[guildId] else { return } guard let emojis = data["emojis"] as? [[String: Any]] else { return } let discordEmojis = DiscordEmoji.emojisFromArray(emojis) - DefaultDiscordLogger.Logger.verbose("Created guild emojis: \(discordEmojis)", type: logType) + logger.debug("(verbose) Created guild emojis: \(discordEmojis)") guild.emojis = discordEmojis @@ -612,13 +623,13 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleGuildMemberAdd(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling guild member add", type: logType) + logger.info("Handling guild member add") guard let guildId = Snowflake(data["guild_id"] as? String), let guild = guilds[guildId] else { return } let guildMember = DiscordGuildMember(guildMemberObject: data, guildId: guild.id, guild: guild) - DefaultDiscordLogger.Logger.verbose("Created guild member: \(guildMember)", type: logType) + logger.debug("(verbose) Created guild member: \(guildMember)") guild.members[guildMember.user.id] = guildMember guild.memberCount += 1 @@ -636,7 +647,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleGuildMemberRemove(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling guild member remove", type: logType) + logger.info("Handling guild member remove") guard let guildId = Snowflake(data["guild_id"] as? String), let guild = guilds[guildId] else { return } guard let user = data["user"] as? [String: Any], let id = Snowflake(user["id"] as? String) else { return } @@ -645,7 +656,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco guard let removedGuildMember = guild.members.removeValue(forKey: id) else { return } - DefaultDiscordLogger.Logger.verbose("Removed guild member: \(removedGuildMember)", type: logType) + logger.debug("(verbose) Removed guild member: \(removedGuildMember)") delegate?.client(self, didRemoveGuildMember: removedGuildMember) } @@ -660,13 +671,13 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleGuildMemberUpdate(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling guild member update", type: logType) + logger.info("Handling guild member update") guard let guildId = Snowflake(data["guild_id"] as? String), let guild = guilds[guildId] else { return } guard let user = data["user"] as? [String: Any], let id = Snowflake(user["id"] as? String) else { return } guard let guildMember = guild.members[id]?.updateMember(data) else { return } - DefaultDiscordLogger.Logger.verbose("Updated guild member: \(guildMember)", type: logType) + logger.debug("(verbose) Updated guild member: \(guildMember)") delegate?.client(self, didUpdateGuildMember: guildMember) } @@ -681,7 +692,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleGuildMembersChunk(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling guild members chunk", type: logType) + logger.info("Handling guild members chunk") guard let guildId = Snowflake(data["guild_id"] as? String), let guild = guilds[guildId] else { return } guard let members = data["members"] as? [[String: Any]] else { return } @@ -703,13 +714,13 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleGuildRoleCreate(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling guild role create", type: logType) + logger.info("Handling guild role create") guard let guildId = Snowflake(data["guild_id"] as? String), let guild = guilds[guildId] else { return } guard let roleObject = data["role"] as? [String: Any] else { return } let role = DiscordRole(roleObject: roleObject) - DefaultDiscordLogger.Logger.verbose("Created role: \(role)", type: logType) + logger.debug("(verbose) Created role: \(role)") guild.roles[role.id] = role @@ -726,13 +737,13 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleGuildRoleRemove(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling guild role remove", type: logType) + logger.info("Handling guild role remove") guard let guildId = Snowflake(data["guild_id"] as? String), let guild = guilds[guildId] else { return } guard let roleId = Snowflake(data["role_id"] as? String) else { return } guard let removedRole = guild.roles.removeValue(forKey: roleId) else { return } - DefaultDiscordLogger.Logger.verbose("Removed role: \(removedRole)", type: logType) + logger.debug("(verbose) Removed role: \(removedRole)") delegate?.client(self, didDeleteRole: removedRole, fromGuild: guild) } @@ -747,14 +758,14 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleGuildRoleUpdate(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling guild role update", type: logType) + logger.info("Handling guild role update") // Functionally the same as adding guard let guildId = Snowflake(data["guild_id"] as? String), let guild = guilds[guildId] else { return } guard let roleObject = data["role"] as? [String: Any] else { return } let role = DiscordRole(roleObject: roleObject) - DefaultDiscordLogger.Logger.verbose("Updated role: \(role)", type: logType) + logger.debug("(verbose) Updated role: \(role)") guild.roles[role.id] = role @@ -771,12 +782,12 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleGuildUpdate(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling guild update", type: logType) + logger.info("Handling guild update") guard let guildId = Snowflake(data["id"] as? String) else { return } guard let updatedGuild = guilds[guildId]?.updateGuild(fromGuildUpdate: data) else { return } - DefaultDiscordLogger.Logger.verbose("Updated guild: \(updatedGuild)", type: logType) + logger.debug("(verbose) Updated guild: \(updatedGuild)") delegate?.client(self, didUpdateGuild: updatedGuild) } @@ -791,11 +802,11 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleMessageUpdate(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling message update", type: logType) + logger.info("Handling message update") let message = DiscordMessage(messageObject: data, client: self) - DefaultDiscordLogger.Logger.verbose("Message: \(message)", type: logType) + logger.debug("(verbose) Message: \(message)") delegate?.client(self, didUpdateMessage: message) } @@ -810,11 +821,11 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleMessageCreate(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling message create", type: logType) + logger.info("Handling message create") let message = DiscordMessage(messageObject: data, client: self) - DefaultDiscordLogger.Logger.verbose("Message: \(message)", type: logType) + logger.debug("(verbose) Message: \(message)") delegate?.client(self, didCreateMessage: message) } @@ -841,7 +852,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco } if !discardPresences { - DefaultDiscordLogger.Logger.debug("Updated presence: \(presence!)", type: logType) + logger.debug("Updated presence: \(presence!)") guild.presences[userId] = presence! } @@ -851,6 +862,22 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco guild.updateGuild(fromPresence: presence!, fillingUsers: fillUsers, pruningUsers: pruneUsers) } + /// + /// Handles interaction creations from Discord, i.e. slash command + /// invocations. You shouldn't need to call this method directly. + /// + /// Override to provide additional customization around this event. + /// + /// Calls the `didCreateInteraction` delegate method. + /// + /// - parameter with: The data from the event + /// + open func handleInteractionCreate(with data: [String: Any]) { + logger.info("Handling interaction create") + + delegate?.client(self, didCreateInteraction: DiscordInteraction(interactionObject: data)) + } + /// /// Handles the ready event from Discord. You shouldn't need to call this method directly. /// @@ -861,7 +888,7 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleReady(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling ready", type: logType) + logger.info("Handling ready") if let user = data["user"] as? [String: Any] { self.user = DiscordUser(userObject: user) @@ -894,8 +921,8 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleVoiceServerUpdate(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling voice server update", type: logType) - DefaultDiscordLogger.Logger.verbose("Voice server update: \(data)", type: logType) + logger.info("Handling voice server update") + logger.debug("(verbose) Voice server update: \(data)") let info = DiscordVoiceServerInformation(voiceServerInformationObject: data) @@ -914,13 +941,13 @@ open class DiscordClient : DiscordClientSpec, DiscordDispatchEventHandler, Disco /// - parameter with: The data from the event /// open func handleVoiceStateUpdate(with data: [String: Any]) { - DefaultDiscordLogger.Logger.log("Handling voice state update", type: logType) + logger.info("Handling voice state update") guard let guildId = Snowflake(data["guild_id"] as? String) else { return } let state = DiscordVoiceState(voiceStateObject: data, guildId: guildId) - DefaultDiscordLogger.Logger.verbose("Voice state: \(state)", type: logType) + logger.debug("(verbose) Voice state: \(state)") if state.channelId == 0 { guilds[guildId]?.voiceStates[state.userId] = nil diff --git a/Sources/SwiftDiscord/DiscordClientDelegate.swift b/Sources/SwiftDiscord/DiscordClientDelegate.swift index 126879fc5..ac8f1202f 100644 --- a/Sources/SwiftDiscord/DiscordClientDelegate.swift +++ b/Sources/SwiftDiscord/DiscordClientDelegate.swift @@ -19,7 +19,7 @@ /// Declares that a type will be a delegate for a `DiscordClient`. After the client handles any events, /// the corresponding delegate method will be called. /// -public protocol DiscordClientDelegate : class { +public protocol DiscordClientDelegate : AnyObject { // MARK: Methods /// @@ -161,6 +161,14 @@ public protocol DiscordClientDelegate : class { /// func client(_ client: DiscordClient, didReceivePresenceUpdate presence: DiscordPresence) + /// + /// Called when the client receives a new interaction, i.e. + /// a slash command invocation. + /// + /// - parameter interaction: The invocation data + /// + func client(_ client: DiscordClient, didCreateInteraction interaction: DiscordInteraction) + /// /// Called when the client receives a ready event. /// @@ -307,6 +315,9 @@ public extension DiscordClientDelegate { /// Default. func client(_ client: DiscordClient, didReceivePresenceUpdate presence: DiscordPresence) { } + /// Default. + func client(_ client: DiscordClient, didCreateInteraction interaction: DiscordInteraction) { } + /// Default. func client(_ client: DiscordClient, didReceiveReady readyData: [String: Any]) { } diff --git a/Sources/SwiftDiscord/DiscordClientOption.swift b/Sources/SwiftDiscord/DiscordClientOption.swift index 56c86d3de..1ba3c2349 100644 --- a/Sources/SwiftDiscord/DiscordClientOption.swift +++ b/Sources/SwiftDiscord/DiscordClientOption.swift @@ -16,6 +16,7 @@ // DEALINGS IN THE SOFTWARE. import Dispatch +import Logging import Foundation /// A enum representing a configuration option. @@ -34,16 +35,14 @@ public enum DiscordClientOption : CustomStringConvertible, Equatable { /// This is also the queue that properties should be read from. case handleQueue(DispatchQueue) - /// The log level for the logger. - case log(DiscordLogLevel) - - /// Used to set a custom logger. - case logger(DiscordLogger) - /// If this option is given, the client will automatically unload users who go offline. This can save some memory. /// However this means that invsible users will also be pruned. case pruneUsers + /// The gateway intents. By default, only the unprivileged intents are used, i.e. you won't + /// get guild member and presence events, unless you specify these here (e.g. by using .allIntents). + case intents(DiscordGatewayIntent) + /// A DiscordRateLimiter for this client. All REST calls will be put through this limiter. case rateLimiter(DiscordRateLimiterSpec) @@ -62,11 +61,10 @@ public enum DiscordClientOption : CustomStringConvertible, Equatable { case .fillLargeGuilds: return "fillLargeGuilds" case .fillUsers: return "fillUsers" case .handleQueue: return "handleQueue" - case .log: return "log" - case .logger: return "logger" case .rateLimiter: return "rateLimiter" case .shardingInfo: return "shardingInfo" case .pruneUsers: return "pruneUsers" + case .intents: return "intents" case .voiceConfiguration: return "voiceConfiguration" } } diff --git a/Sources/SwiftDiscord/DiscordJSON.swift b/Sources/SwiftDiscord/DiscordJSON.swift index 4f6a7d63f..fa728be69 100644 --- a/Sources/SwiftDiscord/DiscordJSON.swift +++ b/Sources/SwiftDiscord/DiscordJSON.swift @@ -16,6 +16,12 @@ // DEALINGS IN THE SOFTWARE. import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Logging + +fileprivate let logger = Logger(label: "DiscordJSON") enum JSON { case array([Any]) @@ -27,10 +33,10 @@ enum JSON { do { return try encoder.encode(object) } catch let error as EncodingError { - DefaultDiscordLogger.Logger.error("Failed to encode json \(object): \(error.localizedDescription)", type: "JSON") + logger.error("Failed to encode json \(object): \(error.localizedDescription)") return nil } catch { - DefaultDiscordLogger.Logger.error("Failed to encode json \(object): \(error)", type: "JSON") + logger.error("Failed to encode json \(object): \(error)") return nil } } @@ -55,26 +61,26 @@ enum JSON { static func jsonFromResponse(data: Data?, response: HTTPURLResponse?) -> JSON? { guard let response = response else { - DefaultDiscordLogger.Logger.error("No response from jsonFromResponse", type: "JSON") + logger.error("No response from jsonFromResponse") return nil } guard let data = data, let stringData = String(data: data, encoding: .utf8) else { - DefaultDiscordLogger.Logger.error("Not string data? Response code: \(response.statusCode)", type: "JSON") + logger.error("Not string data? Response code: \(response.statusCode)") return nil } guard response.statusCode != 204 else { - DefaultDiscordLogger.Logger.debug("Response code 204: No content", type: "JSON") + logger.debug("Response code 204: No content") return nil } guard response.statusCode == 200 || response.statusCode == 201 else { - DefaultDiscordLogger.Logger.error("Invalid response code \(response.statusCode)", type: "JSON") - DefaultDiscordLogger.Logger.error("Response: \(stringData)", type: "JSON") + logger.error("Invalid response code \(response.statusCode)") + logger.error("Response: \(stringData)") return nil } diff --git a/Sources/SwiftDiscord/DiscordLogger.swift b/Sources/SwiftDiscord/DiscordLogger.swift deleted file mode 100644 index 296766a48..000000000 --- a/Sources/SwiftDiscord/DiscordLogger.swift +++ /dev/null @@ -1,90 +0,0 @@ -// The MIT License (MIT) -// Copyright (c) 2016 Erik Little - -// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated -// documentation files (the "Software"), to deal in the Software without restriction, including without -// limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -// Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the -// Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING -// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -// EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN -// ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -import Foundation - -/// Represents the level of verbosity for the logger. -public enum DiscordLogLevel { - /// Log nothing. - case none - /// Log connecting, disconnecting, events (but not content), etc. - case info - /// Log content of events. - case verbose - /// Log everything. - case debug -} - -/// Declares that a type will act as a logger. -public protocol DiscordLogger { - // MARK: Properties - - /// Whether to log or not. - var level: DiscordLogLevel { get set } - - // MARK: Methods - - /// Normal log messages. - func log( _ message: @autoclosure () -> String, type: String) - - /// More info on log messages. - func verbose(_ message: @autoclosure () -> String, type: String) - - /// Debug messages. - func debug(_ message: @autoclosure () -> String, type: String) - - /// Error Messages. - func error(_ message: @autoclosure () -> String, type: String) -} - -public extension DiscordLogger { - /// Normal log messages. - func log(_ message: @autoclosure () -> String, type: String) { - guard level == .info || level == .verbose || level == .debug else { return } - - abstractLog("LOG", message: message(), type: type) - } - - /// More info on log messages. - func verbose(_ message: @autoclosure () -> String, type: String) { - guard level == .verbose || level == .debug else { return } - - abstractLog("VERBOSE", message: message(), type: type) - } - - /// Debug messages. - func debug(_ message: @autoclosure () -> String, type: String) { - guard level == .debug else { return } - - abstractLog("DEBUG", message: message(), type: type) - } - - /// Error Messages. - func error(_ message: @autoclosure () -> String, type: String) { - abstractLog("ERROR", message: message(), type: type) - } - - private func abstractLog(_ logType: String, message: String, type: String) { - NSLog("\(logType): \(type): \(message)") - } -} - -class DefaultDiscordLogger : DiscordLogger { - static var Logger: DiscordLogger = DefaultDiscordLogger() - - var level = DiscordLogLevel.none -} diff --git a/Sources/SwiftDiscord/DiscordSnowflakeID.swift b/Sources/SwiftDiscord/DiscordSnowflakeID.swift index 7a9558616..3d96ae1fc 100644 --- a/Sources/SwiftDiscord/DiscordSnowflakeID.swift +++ b/Sources/SwiftDiscord/DiscordSnowflakeID.swift @@ -72,6 +72,15 @@ public typealias IntegrationID = Snowflake /// A Snowflake ID representing an Attachment public typealias AttachmentID = Snowflake +/// A Snowflake ID representing an Interaction +public typealias InteractionID = Snowflake + +/// A Snowflake ID representing an Application +public typealias ApplicationID = Snowflake + +/// A Snowflake ID representing a Slash Command +public typealias CommandID = Snowflake + // MARK: Extra snowflake information extension Snowflake { diff --git a/Sources/SwiftDiscord/Gateway/DiscordEngine.swift b/Sources/SwiftDiscord/Gateway/DiscordEngine.swift index 95cf3eceb..c311b6176 100644 --- a/Sources/SwiftDiscord/Gateway/DiscordEngine.swift +++ b/Sources/SwiftDiscord/Gateway/DiscordEngine.swift @@ -16,11 +16,9 @@ // DEALINGS IN THE SOFTWARE. import Foundation -#if !os(Linux) -import Starscream -#else -import WebSockets -#endif +import NIO +import Logging +import WebSocketKit import Dispatch #if os(macOS) @@ -31,6 +29,8 @@ private let os = "iOS" private let os = "Linux" #endif +fileprivate let logger = Logger(label: "DiscordEngine") + /// /// The base class for Discord WebSocket communications. /// @@ -39,7 +39,7 @@ open class DiscordEngine : DiscordEngineSpec { /// The url for the gateway. open var connectURL: String { - return DiscordEndpointGateway.gatewayURL + "/?v=6" + return DiscordEndpointGateway.gatewayURL + "/?v=8&encoding=json" } /// The type of DiscordEngineSpec. Used to correctly fire events. @@ -52,6 +52,7 @@ open class DiscordEngine : DiscordEngineSpec { open var handshakeObject: [String: Any] { var identify: [String: Any] = [ "token": delegate!.token.token, + "intents": intents.rawValue, "properties": [ "$os": os, "$browser": "SwiftDiscord", @@ -86,9 +87,15 @@ open class DiscordEngine : DiscordEngineSpec { /// The total number of shards. public let numShards: Int + /// The run loop for this shard. + public let runloop: EventLoop + /// The shard number of this engine. public let shardNum: Int + /// The intents used when connecting to the gateway. + public let intents: DiscordGatewayIntent + /// The queue that WebSockets use to parse things. public let parseQueue = DispatchQueue(label: "discordEngine.parseQueue") @@ -99,8 +106,6 @@ open class DiscordEngine : DiscordEngineSpec { public var sessionId: String? /// The underlying WebSocket. - /// - /// On Linux this is a WebSockets.WebSocket. While on macOS/iOS this is a Starscream.WebSocket public var websocket: WebSocket? /// Whether this engine is connected to the gateway. @@ -135,10 +140,12 @@ open class DiscordEngine : DiscordEngineSpec { /// /// - parameter delegate: The DiscordClientSpec this engine should be associated with. /// - public required init(delegate: DiscordShardDelegate, shardNum: Int = 0, numShards: Int = 1) { + public required init(delegate: DiscordShardDelegate, shardNum: Int = 0, numShards: Int = 1, intents: DiscordGatewayIntent, onLoop: EventLoop) { self.delegate = delegate self.shardNum = shardNum self.numShards = numShards + self.intents = intents + self.runloop = onLoop } // MARK: Methods @@ -147,7 +154,7 @@ open class DiscordEngine : DiscordEngineSpec { /// Disconnects the engine. An `engine.disconnect` is fired on disconnection. /// public func disconnect() { - DefaultDiscordLogger.Logger.log("Disconnecting, \(description)", type: "DiscordWebSocketable") + logger.info("Disconnecting, \(description)") closed = true @@ -164,7 +171,7 @@ open class DiscordEngine : DiscordEngineSpec { connected = false - DefaultDiscordLogger.Logger.log("Disconnected, shard: \(shardNum)", type: logType) + logger.info("Disconnected, shard: \(shardNum)") if closeReason == .sessionTimeout { sessionId = nil @@ -186,7 +193,7 @@ open class DiscordEngine : DiscordEngineSpec { /// open func handleDispatch(_ payload: DiscordGatewayPayload) { guard let type = payload.name, let event = DiscordDispatchEvent(rawValue: type) else { - DefaultDiscordLogger.Logger.error("Could not create dispatch event \(payload)", type: logType) + logger.error("Could not create dispatch event \(payload)") return } @@ -218,9 +225,9 @@ open class DiscordEngine : DiscordEngineSpec { func _handleGatewayPayload(_ payload: DiscordGatewayPayload) { func handleInvalidSession() { if case let .bool(netsplit) = payload.payload, netsplit { - DefaultDiscordLogger.Logger.log("Netsplit recieved, trying to resume", type: logType) + logger.info("Netsplit recieved, trying to resume") } else { - DefaultDiscordLogger.Logger.log("Invalid session received. Invalidating session", type: logType) + logger.info("Invalid session received. Invalidating session") sessionId = nil } @@ -248,7 +255,7 @@ open class DiscordEngine : DiscordEngineSpec { sendPayload(DiscordGatewayPayload(code: .gateway(.heartbeat), payload: .integer(lastSequenceNumber))) case .heartbeatAck: heartbeatQueue.sync { self.pongsMissed = 0 } - DefaultDiscordLogger.Logger.debug("Got heartbeat ack", type: logType) + logger.debug("Got heartbeat ack") default: error(message: "Unhandled payload: \(payload.code)") } @@ -276,7 +283,7 @@ open class DiscordEngine : DiscordEngineSpec { /// Handles the resumed event. You shouldn't call this directly. /// open func handleResumed(_ payload: DiscordGatewayPayload) { - DefaultDiscordLogger.Logger.log("Resumed gateway session on shard: \(shardNum)", type: logType) + logger.info("Resumed gateway session on shard: \(shardNum)") heartbeatQueue.sync { self.pongsMissed = 0 } resuming = false @@ -307,12 +314,12 @@ open class DiscordEngine : DiscordEngineSpec { /// open func resumeGateway() { guard !resuming && !closed else { - DefaultDiscordLogger.Logger.log("Already trying to resume or closed, ignoring", type: logType) + logger.info("Already trying to resume or closed, ignoring") return } - DefaultDiscordLogger.Logger.log("Trying to resume gateway session on shard: \(shardNum)", type: logType) + logger.info("Trying to resume gateway session on shard: \(shardNum)") resuming = true @@ -323,8 +330,7 @@ open class DiscordEngine : DiscordEngineSpec { handleQueue.asyncAfter(deadline: DispatchTime.now() + Double(wait)) {[weak self] in guard let this = self, this.resuming else { return } - DefaultDiscordLogger.Logger.debug("Calling engine connect for gateway resume with wait: \(wait)", - type: this.logType) + logger.debug("Calling engine connect for gateway resume with wait: \(wait)") this.connect() this._resumeGateway(wait: 10) @@ -338,14 +344,13 @@ open class DiscordEngine : DiscordEngineSpec { /// open func sendHeartbeat() { guard connected else { - DefaultDiscordLogger.Logger.error("Tried heartbeating on disconnected shard, shard: \(shardNum)", - type: logType) + logger.error("Tried heartbeating on disconnected shard, shard: \(shardNum)") return } guard pongsMissed < 2 else { - DefaultDiscordLogger.Logger.log("Too many pongs missed; closing, shard: \(shardNum)", type: logType) + logger.info("Too many pongs missed; closing, shard: \(shardNum)") pongsMissed = 0 closeWebSockets(fast: true) @@ -353,7 +358,7 @@ open class DiscordEngine : DiscordEngineSpec { return } - DefaultDiscordLogger.Logger.debug("Sending heartbeat, shard: \(shardNum)", type: logType) + logger.debug("Sending heartbeat, shard: \(shardNum)") pongsMissed += 1 sendPayload(DiscordGatewayPayload(code: .gateway(.heartbeat), payload: .integer(lastSequenceNumber))) @@ -380,11 +385,11 @@ open class DiscordEngine : DiscordEngineSpec { } if sessionId != nil { - DefaultDiscordLogger.Logger.log("Sending resume, shard: \(shardNum)", type: logType) + logger.info("Sending resume, shard: \(shardNum)") sendPayload(DiscordGatewayPayload(code: .gateway(.resume), payload: .object(resumeObject))) } else { - DefaultDiscordLogger.Logger.log("Sending handshake, shard: \(shardNum)", type: logType) + logger.info("Sending handshake, shard: \(shardNum)") sendPayload(DiscordGatewayPayload(code: .gateway(.identify), payload: .object(handshakeObject))) } @@ -396,6 +401,8 @@ open class DiscordEngine : DiscordEngineSpec { /// - parameter milliseconds: The heartbeat interval /// public func startHeartbeat(milliseconds: Int) { + logger.debug("Starting heartbeat, shard: \(shardNum), \(milliseconds)ms") + heartbeatInterval = milliseconds sendHeartbeat() diff --git a/Sources/SwiftDiscord/Gateway/DiscordEngineSpec.swift b/Sources/SwiftDiscord/Gateway/DiscordEngineSpec.swift index a9847054a..16c8137ed 100644 --- a/Sources/SwiftDiscord/Gateway/DiscordEngineSpec.swift +++ b/Sources/SwiftDiscord/Gateway/DiscordEngineSpec.swift @@ -17,13 +17,11 @@ import Dispatch import Foundation -#if !os(Linux) -import Starscream -#else -import WebSockets -import Sockets -import TLS -#endif +import Logging +import NIO +import WebSocketKit + +fileprivate let logger = Logger(label: "DiscordEngineSpec") /// Declares that a type will be an Engine for the Discord Gateway. public protocol DiscordEngineSpec : DiscordShard { @@ -37,7 +35,7 @@ public protocol DiscordEngineSpec : DiscordShard { } /// Declares that a type will be capable of communicating with Discord's WebSockets -public protocol DiscordWebSocketable : class { +public protocol DiscordWebSocketable : AnyObject { /// MARK: Properties /// The url to connect to. @@ -52,8 +50,7 @@ public protocol DiscordWebSocketable : class { /// The queue WebSockets do their parsing on. var parseQueue: DispatchQueue { get } - /// A reference to the underlying WebSocket. This is a WebSockets.Websocket on Linux and Starscream.WebSocket on - /// macOS/iOS. + /// A reference to the underlying WebSocket. var websocket: WebSocket? { get set } // MARK: Methods @@ -63,8 +60,6 @@ public protocol DiscordWebSocketable : class { /// /// Override if you need to provide custom handlers. /// - /// Note: You should handle both WebSockets.WebSocket and Starscream.WebSocket handlers. - /// func attachWebSocketHandlers() /// @@ -85,92 +80,81 @@ public protocol DiscordWebSocketable : class { func handleClose(reason: Error?) } -public extension DiscordWebSocketable where Self: DiscordGatewayable { +public extension DiscordWebSocketable where Self: DiscordGatewayable & DiscordRunLoopable { /// Default implementation. - public func attachWebSocketHandlers() { - #if !os(Linux) - websocket?.onConnect = {[weak self] in + func attachWebSocketHandlers() { + websocket?.onText { [weak self] ws, text in guard let this = self else { return } - DefaultDiscordLogger.Logger.log("WebSocket Connected, \(this.description)", type: "DiscordWebSocketable") - - this.connectUUID = UUID() - - this.startHandshake() - } - - websocket?.onDisconnect = {[weak self] err in - guard let this = self else { return } + logger.debug("\(this.description), Got text: \(text)") - DefaultDiscordLogger.Logger.log("WebSocket disconnected \(String(describing: err)), \(this.description)", type: "DiscordWebSocketable") - - this.handleClose(reason: err) - } - - websocket?.onText = {[weak self] string in - guard let this = self else { return } - - DefaultDiscordLogger.Logger.debug("\(this.description) Got text: \(string)", type: "DiscordWebSocketable") - - this.parseGatewayMessage(string) + this.parseGatewayMessage(text) } - #else - websocket?.onText = {[weak self] ws, text in + + websocket?.onClose.whenSuccess { [weak self] in guard let this = self else { return } - - DefaultDiscordLogger.Logger.debug("\(this.description), Got text: \(text)", type: "DiscordWebSocketable") - - this.parseGatewayMessage(text) + + logger.info("Websocket closed, \(this.description)") + + this.handleClose(reason: nil) } - websocket?.onClose = {[weak self] _, _, reason, clean in + websocket?.onClose.whenFailure { [weak self] err in guard let this = self else { return } - DefaultDiscordLogger.Logger.log("WebSocket closed, \(this.description); clean: \(clean); reason: \(reason ?? "")", - type: "DiscordWebSocketable") + logger.info("WebSocket errored: \(err), \(this.description);") this.handleClose(reason: nil) } - #endif } /// /// Starts the connection to the Discord gateway. /// - public func connect() { - DefaultDiscordLogger.Logger.log("Connecting to \(connectURL), \(description)", type: "DiscordWebSocketable") - DefaultDiscordLogger.Logger.log("Attaching WebSocket, shard: \(description)", type: "DiscordWebSocketable") + func connect() { + runloop.execute(self._connect) + } - #if !os(Linux) - websocket = WebSocket(url: URL(string: connectURL)!) - websocket?.callbackQueue = parseQueue + private func _connect() { + logger.info("Connecting to \(connectURL), \(description)") + logger.info("Attaching WebSocket, shard: \(description)") - attachWebSocketHandlers() - websocket?.connect() - #else let url = URL(string: connectURL)! - do { - let socket = try TCPInternetSocket(scheme: "https", hostname: url.host ?? "gateway.discord.gg", - port: Port(url.port ?? 443)) - let stream = try TLS.InternetSocket(socket, TLS.Context(.client)) - try WebSocket.background(to: connectURL, using: stream) {[weak self] ws in - guard let this = self else { return } - DefaultDiscordLogger.Logger.log("Websocket connected, shard: \(this.description)", type: "DiscordWebSocketable") - - this.websocket = ws - this.connectUUID = UUID() - - this.attachWebSocketHandlers() - this.startHandshake() - } - } catch { - DefaultDiscordLogger.Logger.error("\(error)", type: "DiscordWebSocketable") + let path = url.path.isEmpty ? "/" : url.path + + let future = WebSocket.connect( + scheme: url.scheme ?? "wss", + host: url.host!, + port: url.port ?? 443, + path: path, + configuration: .init( + tlsConfiguration: .clientDefault, + maxFrameSize: 1 << 31 + ), + on: runloop + ) { [weak self] ws in + guard let this = self else { return } + + logger.info("Websocket connected, shard: \(this.description)") + + this.websocket = ws + this.connectUUID = UUID() + + this.attachWebSocketHandlers() + this.startHandshake() + } + + future.whenFailure { [weak self] err in + guard let this = self else { return } + + logger.info("Websocket errored, closing: \(err), \(this.description)") + + this.handleClose(reason: err) } - #endif } internal func closeWebSockets(fast: Bool = false) { - DefaultDiscordLogger.Logger.log("Closing WebSocket, shard: \(description)", type: "DiscordWebSocketable") + logger.info("Closing WebSocket, shard: \(description)") guard !fast else { handleClose(reason: nil) @@ -178,17 +162,7 @@ public extension DiscordWebSocketable where Self: DiscordGatewayable { return } - #if !os(Linux) - websocket?.disconnect() - #else - do { - try websocket?.close() - } catch { - DefaultDiscordLogger.Logger.log("Error in closing: \(error)", type: "DiscordWebSocketable") - - handleClose(reason: nil) - } - #endif + let _ = websocket?.close() } /// @@ -196,7 +170,7 @@ public extension DiscordWebSocketable where Self: DiscordGatewayable { /// /// - parameter message: The error message /// - public func error(message: String) { - DefaultDiscordLogger.Logger.error(message, type: description) + func error(message: String) { + logger.error("\(message)") } } diff --git a/Sources/SwiftDiscord/Gateway/DiscordEvent.swift b/Sources/SwiftDiscord/Gateway/DiscordEvent.swift index 8301b1fb7..4776892c4 100644 --- a/Sources/SwiftDiscord/Gateway/DiscordEvent.swift +++ b/Sources/SwiftDiscord/Gateway/DiscordEvent.swift @@ -127,4 +127,13 @@ public enum DiscordDispatchEvent : String { /// Webhooks Update (Not handled) case webhooksUpdate = "WEBHOOKS_UPDATE" + + // Applications + + case applicationCommandCreate = "APPLICATION_COMMAND_CREATE" + + // Interactions + + /// Interaction Create (Handled) + case interactionCreate = "INTERACTION_CREATE" } diff --git a/Sources/SwiftDiscord/Gateway/DiscordGateway.swift b/Sources/SwiftDiscord/Gateway/DiscordGateway.swift index c55cda3e4..5fd34fb63 100644 --- a/Sources/SwiftDiscord/Gateway/DiscordGateway.swift +++ b/Sources/SwiftDiscord/Gateway/DiscordGateway.swift @@ -16,6 +16,9 @@ // DEALINGS IN THE SOFTWARE. import Foundation +import Logging + +fileprivate let logger = Logger(label: "DiscordGateway") /// Declares that a type will communicate with a Discord gateway. public protocol DiscordGatewayable : DiscordEngineHeartbeatable { @@ -88,7 +91,7 @@ public protocol DiscordGatewayable : DiscordEngineHeartbeatable { func startHandshake() } -public extension DiscordGatewayable where Self: DiscordWebSocketable { +public extension DiscordGatewayable where Self: DiscordWebSocketable & DiscordRunLoopable { /// Default Implementation. func sendPayload(_ payload: DiscordGatewayPayload) { guard let payloadString = payload.createPayloadString() else { @@ -97,13 +100,11 @@ public extension DiscordGatewayable where Self: DiscordWebSocketable { return } - DefaultDiscordLogger.Logger.debug("Sending ws: \(payloadString)", type: description) + logger.debug("Sending ws: \(payloadString)") -#if !os(Linux) - websocket?.write(string: payloadString) -#else - try? websocket?.send(payloadString) -#endif + runloop.execute { + self.websocket?.send(payloadString) + } } } diff --git a/Sources/SwiftDiscord/Gateway/DiscordGatewayIntent.swift b/Sources/SwiftDiscord/Gateway/DiscordGatewayIntent.swift new file mode 100644 index 000000000..3392b67eb --- /dev/null +++ b/Sources/SwiftDiscord/Gateway/DiscordGatewayIntent.swift @@ -0,0 +1,71 @@ +/// An intent defines the events the gateway should +/// subscribe to. +/// +/// See https://discord.com/developers/docs/topics/gateway#gateway-intents +public struct DiscordGatewayIntent : OptionSet { + public let rawValue: Int + + /// Creation, updates and deletions of guilds, roles and channels. + public static let guilds = DiscordGatewayIntent(rawValue: 1 << 0) + /// Guild member update events. This is a privileged intent. + public static let guildMembers = DiscordGatewayIntent(rawValue: 1 << 1) + /// Guild ban and unban events. + public static let guildBans = DiscordGatewayIntent(rawValue: 1 << 2) + /// Guild emoji update events. + public static let guildEmojis = DiscordGatewayIntent(rawValue: 1 << 3) + /// Guild integration update events. + public static let guildIntegrations = DiscordGatewayIntent(rawValue: 1 << 4) + /// Guild webhook update events. + public static let guildWebhooks = DiscordGatewayIntent(rawValue: 1 << 5) + /// Guild invite events. + public static let guildInvites = DiscordGatewayIntent(rawValue: 1 << 6) + /// Guild voice state update events. + public static let guildVoiceStates = DiscordGatewayIntent(rawValue: 1 << 7) + /// Guild presence update events. This is a privileged intent. + public static let guildPresences = DiscordGatewayIntent(rawValue: 1 << 8) + /// Guild message creations, updates and deletions. + public static let guildMessages = DiscordGatewayIntent(rawValue: 1 << 9) + /// Guild message reaction creations, updates and deletions. + public static let guildMessageReactions = DiscordGatewayIntent(rawValue: 1 << 10) + /// Guild typing indicators. + public static let guildMessageTyping = DiscordGatewayIntent(rawValue: 1 << 11) + /// Direct message creations, updates and deletions. + public static let directMessages = DiscordGatewayIntent(rawValue: 1 << 12) + /// Direct message reaction creations, updates and deletions. + public static let directMessageReactions = DiscordGatewayIntent(rawValue: 1 << 13) + /// Direct message typing indicators. + public static let directMessageTyping = DiscordGatewayIntent(rawValue: 1 << 14) + + /// The privileged intents (which may require enabling in the Discord developer console). + public static let privilegedIntents: DiscordGatewayIntent = [ + .guildMembers, + .guildPresences + ] + + /// The unprivileged intents. Use these if you don't need the privileged intents. + public static let unprivilegedIntents: DiscordGatewayIntent = [ + .guilds, + .guildBans, + .guildEmojis, + .guildIntegrations, + .guildWebhooks, + .guildInvites, + .guildVoiceStates, + .guildMessages, + .guildMessageReactions, + .guildMessageTyping, + .directMessages, + .directMessageReactions, + .directMessageTyping + ] + + /// All intents. + public static let allIntents: DiscordGatewayIntent = [ + .privilegedIntents, + .unprivilegedIntents + ] + + public init(rawValue: Int) { + self.rawValue = rawValue + } +} diff --git a/Sources/SwiftDiscord/Gateway/DiscordSharding.swift b/Sources/SwiftDiscord/Gateway/DiscordSharding.swift index d8cb9470c..5a0f51905 100644 --- a/Sources/SwiftDiscord/Gateway/DiscordSharding.swift +++ b/Sources/SwiftDiscord/Gateway/DiscordSharding.swift @@ -17,6 +17,10 @@ import Dispatch import Foundation +import Logging +import NIO + +fileprivate let logger = Logger(label: "DiscordSharding") /// Struct that represents shard information. /// Used when a client is doing manual sharding. @@ -49,9 +53,23 @@ public struct DiscordShardInformation { } } +/// Marks that a type will contain a runloop. +public protocol DiscordRunLoopable { + /// The run loop for this entity. + var runloop: EventLoop { get } +} + +/// Declares that a type will be manager of a run loop. +public protocol DiscordEventLoopGroupManager { + // MARK: Properties + + /// The run loops. + var runloops: MultiThreadedEventLoopGroup { get } +} + /// Protocol that represents a sharded gateway connection. This is the top-level protocol for `DiscordEngineSpec` and /// `DiscordEngine` -public protocol DiscordShard : DiscordWebSocketable, DiscordGatewayable { +public protocol DiscordShard : DiscordWebSocketable, DiscordGatewayable, DiscordRunLoopable { // MARK: Properties /// Whether this shard is connected to the gateway @@ -73,11 +91,11 @@ public protocol DiscordShard : DiscordWebSocketable, DiscordGatewayable { /// /// - parameter client: The client this engine should be associated with. /// - init(delegate: DiscordShardDelegate, shardNum: Int, numShards: Int) + init(delegate: DiscordShardDelegate, shardNum: Int, numShards: Int, intents: DiscordGatewayIntent, onLoop: EventLoop) } /// Declares that a type will be a shard's delegate. -public protocol DiscordShardDelegate : class, DiscordTokenBearer { +public protocol DiscordShardDelegate : AnyObject, DiscordTokenBearer { /// /// Used by shards to signal that they have connected. /// @@ -114,7 +132,7 @@ public protocol DiscordShardDelegate : class, DiscordTokenBearer { } /// The delegate for a `DiscordShardManager`. -public protocol DiscordShardManagerDelegate : class, DiscordTokenBearer { +public protocol DiscordShardManagerDelegate : AnyObject, DiscordEventLoopGroupManager, DiscordTokenBearer { // MARK: Methods /// @@ -196,7 +214,7 @@ open class DiscordShardManager : DiscordShardDelegate, Lockable { let shards = get(self.shards) for (i, shard) in shards.enumerated() { - let deadline = DispatchTime(secondsFromNow: Double(5 * i)) + let deadline = DispatchTime.now() + Double(5 * i) DispatchQueue.global().asyncAfter(deadline: deadline) { [weak self, weak shard] in guard let this = self, this.get(!this.closed) else { return } shard?.connect() @@ -213,8 +231,8 @@ open class DiscordShardManager : DiscordShardDelegate, Lockable { /// - returns: A new `DiscordShard` /// open func createShardWithDelegate(_ delegate: DiscordShardManagerDelegate, withShardNum shardNum: Int, - totalShards: Int) -> DiscordShard { - return DiscordEngine(delegate: self, shardNum: shardNum, numShards: totalShards) + totalShards: Int, intents: DiscordGatewayIntent, onloop: EventLoop) -> DiscordShard { + return DiscordEngine(delegate: self, shardNum: shardNum, numShards: totalShards, intents: intents, onLoop: onloop) } /// @@ -240,16 +258,20 @@ open class DiscordShardManager : DiscordShardDelegate, Lockable { /// /// - parameter withInfo: The information about this single shard. /// - open func manuallyShatter(withInfo info: DiscordShardInformation) { + open func manuallyShatter(withInfo info: DiscordShardInformation, intents: DiscordGatewayIntent) { guard let delegate = self.delegate else { return } - DefaultDiscordLogger.Logger.verbose("Handling shard range \(info.shardRange)", type: "DiscordShardManager") + logger.debug("(verbose) Handling shard range \(info.shardRange)") cleanUp() protected { for shardNum in info.shardRange { - shards.append(createShardWithDelegate(delegate, withShardNum: shardNum, totalShards: info.totalShards)) + shards.append(createShardWithDelegate(delegate, + withShardNum: shardNum, + totalShards: info.totalShards, + intents: intents, + onloop: delegate.runloops.next())) } } } @@ -292,7 +314,7 @@ open class DiscordShardManager : DiscordShardDelegate, Lockable { /// - parameter shardNum: The number of the shard that disconnected. /// open func shardDidConnect(_ shard: DiscordShard) { - DefaultDiscordLogger.Logger.verbose("Shard #\(shard.shardNum), connected", type: "DiscordShardManager") + logger.debug("(verbose) Shard #\(shard.shardNum), connected") protected { connectedShards += 1 } @@ -307,7 +329,7 @@ open class DiscordShardManager : DiscordShardDelegate, Lockable { /// - parameter shardNum: The number of the shard that disconnected. /// open func shardDidDisconnect(_ shard: DiscordShard) { - DefaultDiscordLogger.Logger.verbose("Shard #\(shard.shardNum), disconnected", type: "DiscordShardManager") + logger.debug("(verbose) Shard #\(shard.shardNum), disconnected") protected { closedShards += 1 } diff --git a/Sources/SwiftDiscord/Guild/DiscordEmoji.swift b/Sources/SwiftDiscord/Guild/DiscordEmoji.swift index 2294e11ad..3fdca1393 100644 --- a/Sources/SwiftDiscord/Guild/DiscordEmoji.swift +++ b/Sources/SwiftDiscord/Guild/DiscordEmoji.swift @@ -15,6 +15,10 @@ // ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +import Logging + +fileprivate let logger = Logger(label: "DiscordEmoji") + /// Represents an Emoji. public struct DiscordEmoji { // MARK: Properties @@ -24,6 +28,9 @@ public struct DiscordEmoji { /// Whether this is a managed emoji. public let managed: Bool + + /// Whether this is an animated emoji. + public let animated: Bool /// The name of the emoji or unicode representation if it's a unicode emoji. public let name: String @@ -37,6 +44,7 @@ public struct DiscordEmoji { init(emojiObject: [String: Any]) { id = Snowflake(emojiObject["id"] as? String) managed = emojiObject.get("managed", or: false) + animated = emojiObject.get("animated", or: false) name = emojiObject.get("name", or: "") requireColons = emojiObject.get("require_colons", or: false) roles = (emojiObject["roles"] as? [String])?.compactMap(Snowflake.init) ?? [] @@ -50,7 +58,7 @@ public struct DiscordEmoji { if let emojiID = emoji.id { emojis[emojiID] = emoji } else { - DefaultDiscordLogger.Logger.debug("EmojisFromArray used on array with non-custom emoji", type: "DiscordEmoji") + logger.debug("EmojisFromArray used on array with non-custom emoji") } } diff --git a/Sources/SwiftDiscord/Guild/DiscordGuild.swift b/Sources/SwiftDiscord/Guild/DiscordGuild.swift index 27425da67..d952751a0 100644 --- a/Sources/SwiftDiscord/Guild/DiscordGuild.swift +++ b/Sources/SwiftDiscord/Guild/DiscordGuild.swift @@ -17,11 +17,15 @@ import class Dispatch.DispatchSemaphore import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Logging + +fileprivate let logger = Logger(label: "DiscordGuild") /// Represents a Guild. public final class DiscordGuild : DiscordClientHolder, CustomStringConvertible { - private static let logType = "DiscordGuild" - // MARK: Properties // TODO figure out what features are @@ -79,14 +83,17 @@ public final class DiscordGuild : DiscordClientHolder, CustomStringConvertible { public private(set) var defaultMessageNotifications: Int /// The snowflake id of the embed channel for this guild. - public private(set) var embedChannelId: ChannelID + public private(set) var widgetChannelId: ChannelID /// Whether this guild has embed enabled. - public private(set) var embedEnabled: Bool + public private(set) var widgetEnabled: Bool /// The base64 encoded icon image for this guild. public private(set) var icon: String + /// The base64 encoded banner image for this guild. + public private(set) var banner: String + /// The multi-factor authentication level for this guild. public private(set) var mfaLevel: Int @@ -106,11 +113,12 @@ public final class DiscordGuild : DiscordClientHolder, CustomStringConvertible { id = guildObject.getSnowflake() channels = guildChannels(fromArray: guildObject.get("channels", or: JSONArray()), guildID: id, client: client) defaultMessageNotifications = guildObject.get("default_message_notifications", or: -1) - embedEnabled = guildObject.get("embed_enabled", or: false) - embedChannelId = guildObject.getSnowflake(key: "embed_channel_id") + widgetEnabled = guildObject.get("widget_enabled", or: false) + widgetChannelId = guildObject.getSnowflake(key: "widget_channel_id") emojis = DiscordEmoji.emojisFromArray(guildObject.get("emojis", or: JSONArray())) features = guildObject.get("features", or: Array()) icon = guildObject.get("icon", or: "") + banner = guildObject.get("banner", or: "") large = guildObject.get("large", or: false) memberCount = guildObject.get("member_count", or: 0) mfaLevel = guildObject.get("mfa_level", or: -1) @@ -159,7 +167,7 @@ public final class DiscordGuild : DiscordClientHolder, CustomStringConvertible { public func createChannel(with options: [DiscordEndpoint.Options.GuildCreateChannel], reason: String? = nil) { guard let client = self.client else { return } - DefaultDiscordLogger.Logger.log("Creating guild channel on \(id)", type: "DiscordGuild") + logger.info("Creating guild channel on \(id)") client.createGuildChannel(on: id, options: options, reason: reason) } @@ -201,7 +209,7 @@ public final class DiscordGuild : DiscordClientHolder, CustomStringConvertible { guard let client = self.client else { return callback(nil, nil) } client.getGuildMember(by: userId, on: id) {member, response in - DefaultDiscordLogger.Logger.debug("Got member: \(userId)", type: "DiscordGuild") + logger.debug("Got member: \(userId)") var member = member member?.guild = self @@ -273,12 +281,12 @@ public final class DiscordGuild : DiscordClientHolder, CustomStringConvertible { let userId = presence.user.id if pruneUsers && presence.status == .offline { - DefaultDiscordLogger.Logger.debug("Pruning guild member \(userId) on \(id)", type: DiscordGuild.logType) + logger.debug("Pruning guild member \(userId) on \(id)") members[userId] = nil presences[userId] = nil } else if fillUsers && !members.contains(userId) { - DefaultDiscordLogger.Logger.debug("Should get member \(userId); pull from the API", type: DiscordGuild.logType) + logger.debug("Should get member \(userId); pull from the API") members[lazy: userId] = .lazy({[weak self] in guard let this = self else { @@ -305,18 +313,22 @@ public final class DiscordGuild : DiscordClientHolder, CustomStringConvertible { self.defaultMessageNotifications = defaultMessageNotifications } - if let embedChannelId = Snowflake(newGuild["embed_channel_id"] as? String) { - self.embedChannelId = embedChannelId + if let widgetChannelId = Snowflake(newGuild["widget_channel_id"] as? String) { + self.widgetChannelId = widgetChannelId } - if let embedEnabled = newGuild["embed_enabled"] as? Bool { - self.embedEnabled = embedEnabled + if let widgetEnabled = newGuild["widget_enabled"] as? Bool { + self.widgetEnabled = widgetEnabled } if let icon = newGuild["icon"] as? String { self.icon = icon } + if let banner = newGuild["banner"] as? String { + self.banner = banner + } + if let memberCount = newGuild["member_count"] as? Int { self.memberCount = memberCount } @@ -352,7 +364,7 @@ public final class DiscordGuild : DiscordClientHolder, CustomStringConvertible { public func unban(_ user: DiscordUser) { guard let client = self.client else { return } - DefaultDiscordLogger.Logger.log("Unbanning user \(user) on \(id)", type: "DiscordGuild") + logger.info("Unbanning user \(user) on \(id)") client.removeGuildBan(for: user.id, on: id) } diff --git a/Sources/SwiftDiscord/Guild/DiscordGuildChannel.swift b/Sources/SwiftDiscord/Guild/DiscordGuildChannel.swift index f0aec02ac..054357d3f 100644 --- a/Sources/SwiftDiscord/Guild/DiscordGuildChannel.swift +++ b/Sources/SwiftDiscord/Guild/DiscordGuildChannel.swift @@ -15,6 +15,10 @@ // ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +import Logging + +fileprivate let logger = Logger(label: "DiscordGuildChannel") + /// Protocol that declares a type will be a Discord guild channel. public protocol DiscordGuildChannel : DiscordChannel { /// The snowflake id of the guild this channel is on. @@ -153,8 +157,7 @@ func guildChannel(fromObject channelObject: [String: Any], case .category: return DiscordGuildChannelCategory(categoryObject: channelObject, guildID: guildID, client: client) default: - DefaultDiscordLogger.Logger.error("Unhandled guild channel in guildChannelFromObject", - type: "DiscordGuildChannel") + logger.error("Unhandled guild channel in guildChannelFromObject") return nil } } @@ -206,6 +209,9 @@ public struct DiscordGuildTextChannel : DiscordTextChannel, DiscordGuildChannel /// The topic of this channel, if this is a text channel. public var topic: String + /// If this channel is NSFW + public var nsfw: Bool + init(guildChannelObject: [String: Any], guildID: GuildID?, client: DiscordClient? = nil) { id = Snowflake(guildChannelObject["id"] as? String) ?? 0 guildId = guildID ?? Snowflake(guildChannelObject["guild_id"] as? String) ?? 0 @@ -216,6 +222,7 @@ public struct DiscordGuildTextChannel : DiscordTextChannel, DiscordGuildChannel position = guildChannelObject.get("position", or: 0) topic = guildChannelObject.get("topic", or: "") parentId = Snowflake(guildChannelObject.get("parent_id", or: "")) + nsfw = guildChannelObject.get("nsfw", or: false) self.client = client } } diff --git a/Sources/SwiftDiscord/Guild/DiscordGuildMember.swift b/Sources/SwiftDiscord/Guild/DiscordGuildMember.swift index f6ce94588..d5b2005ab 100644 --- a/Sources/SwiftDiscord/Guild/DiscordGuildMember.swift +++ b/Sources/SwiftDiscord/Guild/DiscordGuildMember.swift @@ -16,6 +16,9 @@ // DEALINGS IN THE SOFTWARE. import Foundation +import Logging + +fileprivate let logger = Logger(label: "DiscordGuildMember") /// Represents a guild member. public struct DiscordGuildMember { @@ -115,7 +118,7 @@ public struct DiscordGuildMember { for guildMember in guildMembersArray { guard let user = guildMember["user"] as? [String: Any], let id = Snowflake(user["id"] as? String) else { - DefaultDiscordLogger.Logger.error("Couldn't extract userId from user JSON", type: "GuildMembersFromArray") + logger.error("Couldn't extract userId from user JSON") continue } diff --git a/Sources/SwiftDiscord/Interaction/DiscordApplicationCommand.swift b/Sources/SwiftDiscord/Interaction/DiscordApplicationCommand.swift new file mode 100644 index 000000000..2a988fc6d --- /dev/null +++ b/Sources/SwiftDiscord/Interaction/DiscordApplicationCommand.swift @@ -0,0 +1,199 @@ +import Foundation + +/// Represents a slash-command. The base command model of the +/// application. +public struct DiscordApplicationCommand: Encodable { + public enum CodingKeys: String, CodingKey { + case id + case applicationId = "application_id" + case name + case description + case parameters + } + + // MARK: Properties + + /// The ID of the command. + public let id: CommandID + + /// The ID of the parent application. + public let applicationId: ApplicationID + + /// 3-32 character name + public let name: String + + /// 1-100 character description + public let description: String + + /// The parameters for the command + public let parameters: [DiscordApplicationCommandOption] + + init(commandObject: [String: Any]) { + id = Snowflake((commandObject["id"] as? String) ?? "") ?? 0 + applicationId = Snowflake((commandObject["application_id"] as? String) ?? "") ?? 0 + name = (commandObject["name"] as? String) ?? "" + description = (commandObject["description"] as? String) ?? "" + parameters = ((commandObject["parameters"] as? [[String: Any]]) ?? []).map(DiscordApplicationCommandOption.init(optionObject:)) + } + + static func commandsFromArray(_ array: [[String: Any]]) -> [DiscordApplicationCommand] { + return array.map({ DiscordApplicationCommand(commandObject: $0) }) + } +} + +public struct DiscordApplicationCommandOption: Encodable { + public enum CodingKeys: String, CodingKey { + case type + case name + case description + case isDefault = "default" + case isRequired = "required" + case choices + case options + } + + /// The expected type + public let type: DiscordApplicationCommandOptionType? + + /// 1-32 character name + public let name: String + + /// 1-100 character description + public let description: String + + /// The first required option for the user to complete + /// Only one option can be default + public let isDefault: Bool? + + /// If the parameter is required or optional, default is false + public let isRequired: Bool? + + /// Choices for string and int types for the user to pick from + public let choices: [DiscordApplicationCommandOptionChoice]? + + /// If the option is a subcommand or subcommand group, these + /// nested options will be the parameters + public let options: [DiscordApplicationCommandOption]? + + public init( + type: DiscordApplicationCommandOptionType, + name: String, + description: String, + isDefault: Bool? = nil, + isRequired: Bool? = nil, + choices: [DiscordApplicationCommandOptionChoice]? = nil, + options: [DiscordApplicationCommandOption]? = nil + ) { + self.type = type + self.name = name + self.description = description + self.isDefault = isDefault + self.isRequired = isRequired + self.choices = choices + self.options = options + } + + init(optionObject: [String: Any]) { + type = (optionObject["type"] as? Int).flatMap(DiscordApplicationCommandOptionType.init(rawValue:)) + name = (optionObject["name"] as? String) ?? "" + description = (optionObject["description"] as? String) ?? "" + isDefault = (optionObject["default"] as? Bool) ?? false + isRequired = (optionObject["required"] as? Bool) ?? false + choices = (optionObject["choices"] as? [[String: Any]]).map { $0.compactMap(DiscordApplicationCommandOptionChoice.init(choiceObject:)) } + options = (optionObject["options"] as? [[String: Any]]).map { $0.map(DiscordApplicationCommandOption.init(optionObject:)) } + } +} + +public struct DiscordApplicationCommandOptionChoice: Encodable { + /// 1-100 character choice name + public let name: String + + /// Value of the choice + public let value: DiscordApplicationCommandOptionChoiceValue? + + public init(name: String, value: DiscordApplicationCommandOptionChoiceValue? = nil) { + self.name = name + self.value = value + } + + init?(choiceObject: [String: Any]) { + name = (choiceObject["name"] as? String) ?? "" + let rawValue = choiceObject["value"] + if let value = rawValue as? String { + self.value = .string(value) + } else if let value = rawValue as? Int { + self.value = .int(value) + } else { + return nil + } + } +} + +public enum DiscordApplicationCommandOptionChoiceValue: Encodable { + case string(String) + case int(Int) + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let s): + try container.encode(s) + case .int(let i): + try container.encode(i) + } + } +} + +public enum DiscordApplicationCommandOptionType: Int, Encodable { + case subCommand = 1 + case subCommandGroup = 2 + case string = 3 + case integer = 4 + case boolean = 5 + case user = 6 + case channel = 7 + case role = 8 + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +public struct DiscordApplicationCommandInteractionData { + /// The ID of the invoked command + public let id: CommandID + + /// The name of the invoked command + public let name: String + + /// A custom (developer-defined) id attached to e.g. a button interaction. + public let customId: String? + + /// The params + values by the user + public let options: [DiscordApplicationCommandInteractionDataOption] + + init(dataObject: [String: Any]) { + id = Snowflake(dataObject["id"] as? String) ?? 0 + name = dataObject.get("name", as: String.self) ?? "" + customId = dataObject["custom_id"] as? String + options = (dataObject["options"] as? [[String: Any]])?.map(DiscordApplicationCommandInteractionDataOption.init(optionObject:)).compactMap { $0 } ?? [] + } +} + +public struct DiscordApplicationCommandInteractionDataOption { + /// The name of the parameter. + public let name: String + + /// The value of the pair. Type is the OptionType of the command. + public let value: Any? + + /// Present if this option is a group or subcommand. + public let options: [DiscordApplicationCommandInteractionDataOption]? + + init(optionObject: [String: Any]) { + name = optionObject.get("name", as: String.self) ?? "" + value = optionObject["value"] + options = (optionObject["options"] as? [[String: Any]])?.map(DiscordApplicationCommandInteractionDataOption.init(optionObject:)).compactMap { $0 } ?? [] + } +} diff --git a/Sources/SwiftDiscord/Interaction/DiscordInteraction.swift b/Sources/SwiftDiscord/Interaction/DiscordInteraction.swift new file mode 100644 index 000000000..7c7015891 --- /dev/null +++ b/Sources/SwiftDiscord/Interaction/DiscordInteraction.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Represents a slash-command invocation by the user. +public struct DiscordInteraction { + // MARK: Properties + + /// ID of the interaction + public let id: InteractionID + + /// Type of the interaction + public let type: DiscordInteractionType? + + /// Command data payload + /// Always specified for DiscordApplicationCommand interaction + /// types, but optional for future-proofing. + public let data: DiscordApplicationCommandInteractionData? + + /// The message a user interacted with, e.g. when pressing a button. + public let message: DiscordMessage? + + /// Guild it was sent from + public let guildId: GuildID + + /// Channel it was sent from + public let channelId: ChannelID + + /// Guild member data for the invoking user + public let member: DiscordGuildMember? + + /// Continuation token for responding to the interaction + public let token: String + + /// Read-only property, always 1 + public let version: Int + + init(interactionObject: [String: Any]) { + id = Snowflake(interactionObject["id"] as? String) ?? 0 + type = (interactionObject["type"] as? Int).flatMap(DiscordInteractionType.init(rawValue:)) + data = (interactionObject["data"] as? [String: Any]).map(DiscordApplicationCommandInteractionData.init(dataObject:)) + message = (interactionObject["message"] as? [String: Any]).map { DiscordMessage(messageObject: $0, client: nil) } + let guildId = Snowflake(interactionObject["guild_id"] as? String) ?? 0 + self.guildId = guildId + channelId = Snowflake(interactionObject["channel_id"] as? String) ?? 0 + member = (interactionObject["member"] as? [String: Any]).map { DiscordGuildMember(guildMemberObject: $0, guildId: guildId) } + token = (interactionObject["token"] as? String) ?? "" + version = (interactionObject["version"] as? Int) ?? 1 + } +} + +public struct DiscordInteractionType: RawRepresentable, Hashable { + public var rawValue: Int + + public static let ping = DiscordInteractionType(rawValue: 1) + public static let applicationCommand = DiscordInteractionType(rawValue: 2) + public static let messageComponent = DiscordInteractionType(rawValue: 3) + + public init(rawValue: Int) { + self.rawValue = rawValue + } +} diff --git a/Sources/SwiftDiscord/Interaction/DiscordInteractionResponse.swift b/Sources/SwiftDiscord/Interaction/DiscordInteractionResponse.swift new file mode 100644 index 000000000..fc4409dbd --- /dev/null +++ b/Sources/SwiftDiscord/Interaction/DiscordInteractionResponse.swift @@ -0,0 +1,67 @@ +import Foundation + +public struct DiscordInteractionResponse: Encodable { + // MARK: Properties + + /// The type of response + public let type: DiscordInteractionResponseType + + /// An optional response message + public let data: DiscordInteractionApplicationCommandCallbackData? + + public init( + type: DiscordInteractionResponseType, + data: DiscordInteractionApplicationCommandCallbackData? = nil + ) { + self.type = type + self.data = data + } +} + +public enum DiscordInteractionResponseType: Int, Encodable { + /// Ack a ping + case pong = 1 + + /// Ack a command without sending a message, eating the user's input + case acknowledge = 2 + + /// Respond with a message, eating the user's input + case channelMessage = 3 + + /// Respond with a message, showing the user's input + case channelMessageWithSource = 4 + + /// Ack a command without sending a message, showing the user's input + case ackWithSource = 5 + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +public struct DiscordInteractionApplicationCommandCallbackData: Encodable { + public enum CodingKeys: String, CodingKey { + case tts + case content + case embeds + case allowedMentions = "allowed_mentions" + } + + public let tts: Bool? + public let content: String? + public let embeds: [DiscordEmbed]? + public let allowedMentions: DiscordAllowedMentions? + + public init( + tts: Bool? = nil, + content: String? = nil, + embeds: [DiscordEmbed]? = nil, + allowedMentions: DiscordAllowedMentions? = nil + ) { + self.tts = tts + self.content = content + self.embeds = embeds + self.allowedMentions = allowedMentions + } +} diff --git a/Sources/SwiftDiscord/Rest/DiscordEndpoint.swift b/Sources/SwiftDiscord/Rest/DiscordEndpoint.swift index ee5040fdb..06496c1ab 100644 --- a/Sources/SwiftDiscord/Rest/DiscordEndpoint.swift +++ b/Sources/SwiftDiscord/Rest/DiscordEndpoint.swift @@ -16,6 +16,12 @@ // DEALINGS IN THE SOFTWARE. import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Logging + +fileprivate let logger = Logger(label: "DiscordEndpoint") // TODO Group DM // TODO Add guild member @@ -146,6 +152,33 @@ public enum DiscordEndpoint : CustomStringConvertible { case webhookGithub(id: WebhookID, token: String) /* End Webhooks */ + /* Emoji */ + // The guild's emojis endpoint. + case guildEmojis(guild: GuildID) + + // The guild's emoji endpoint + case guildEmoji(guild: GuildID, emoji: EmojiID) + /* End Emoji */ + + + /* Applications */ + /// The global slash-commands endpoint. + case globalApplicationCommands(applicationId: ApplicationID) + + /// The endpoint for a specific global slash-command. + case globalApplicationCommand(applicationId: ApplicationID, commandId: CommandID) + + /// The guild-specific slash-commands endpoint. + case guildApplicationCommands(applicationId: ApplicationID, guildId: GuildID) + + /// The endpoint for a specific guild-specific slash-command. + case guildApplicationCommand(applicationId: ApplicationID, guildId: GuildID, commandId: CommandID) + /* End Application */ + + /* Interactions */ + case interactionsCallback(interactionId: InteractionID, interactionToken: String) + /* End Interactions */ + var combined: String { return DiscordEndpoint.baseURL.description + description } @@ -157,14 +190,14 @@ public extension DiscordEndpoint { /// /// * An HTTP Request for an Endpoint. This includes any associated data. /// - public enum EndpointRequest { + enum EndpointRequest { /// A GET request. case get(params: [String: String]?, extraHeaders: [DiscordHeader: String]?) /// A POST request. case post(content: HTTPContent?, extraHeaders: [DiscordHeader: String]?) - /// A POST request. + /// A PUT request. case put(content: HTTPContent?, extraHeaders: [DiscordHeader: String]?) /// A PATCH request. @@ -215,20 +248,21 @@ public extension DiscordEndpoint { private func addContent(to request: inout URLRequest) { let content: HTTPContent? let extraHeaders: [DiscordHeader: String]? + let requiresBody: Bool switch self { case let .get(_, headers?): - (content, extraHeaders) = (nil, headers) + (content, extraHeaders, requiresBody) = (nil, headers, false) case let .post(optionalContent, headers): - (content, extraHeaders) = (optionalContent, headers) + (content, extraHeaders, requiresBody) = (optionalContent, headers, true) case let .put(optionalContent, headers): - (content, extraHeaders) = (optionalContent, headers) + (content, extraHeaders, requiresBody) = (optionalContent, headers, true) case let .patch(optionalContent, headers): - (content, extraHeaders) = (optionalContent, headers) + (content, extraHeaders, requiresBody) = (optionalContent, headers, true) case let .delete(optionalContent, headers): - (content, extraHeaders) = (optionalContent, headers) + (content, extraHeaders, requiresBody) = (optionalContent, headers, false) default: - (content, extraHeaders) = (nil, nil) + (content, extraHeaders, requiresBody) = (nil, nil, false) } for (header, value) in extraHeaders ?? [:] { @@ -237,7 +271,11 @@ public extension DiscordEndpoint { } switch content { - case nil: break + case nil: + if requiresBody { + request.httpBody = Data() + request.setValue("0", forHTTPHeaderField: "Content-Length") + } case let .json(data)?: request.httpBody = data request.setValue(HTTPContent.jsonType, forHTTPHeaderField: "Content-Type") @@ -252,10 +290,10 @@ public extension DiscordEndpoint { // MARK: Endpoint string calculation - public var description: String { + var description: String { switch self { case .baseURL: - return "https://discordapp.com/api/v6" + return "https://discord.com/api/v8" /* Channels */ case let .channel(id): @@ -343,6 +381,29 @@ public extension DiscordEndpoint { case let .webhookGithub(id, token): return "/webhooks/\(id)/\(token)/github" /* End Webhooks */ + + /* Emoji */ + case let .guildEmojis(guild): + return "/guilds/\(guild)/emojis" + case let .guildEmoji(guild, emoji): + return "/guilds/\(guild)/emojis/\(emoji)" + /* End Emoji */ + + /* Application */ + case let .globalApplicationCommands(applicationId): + return "/applications/\(applicationId)/commands" + case let .globalApplicationCommand(applicationId, commandId): + return "/applications/\(applicationId)/commands/\(commandId)" + case let .guildApplicationCommands(applicationId, guildId): + return "/applications/\(applicationId)/guilds/\(guildId)/commands" + case let .guildApplicationCommand(applicationId, guildId, commandId): + return "/applications/\(applicationId)/guilds/\(guildId)/commands/\(commandId)" + /* End Application */ + + /* Interactions */ + case let .interactionsCallback(interactionId, interactionToken): + return "/interactions/\(interactionId)/\(interactionToken)/callback" + /* End Interactions */ } } @@ -446,6 +507,28 @@ public extension DiscordEndpoint { case .webhookGithub: return DiscordRateLimitKey(urlParts: [.webhooks, .webhookID, .webhookToken, .github]) /* End Webhooks */ + + /* Emoji */ + case let .guildEmojis(guild): + return DiscordRateLimitKey(id: guild, urlParts: [.guilds, .guildID, .emojis]) + case let .guildEmoji(guild, _): + return DiscordRateLimitKey(id: guild, urlParts: [.guilds, .guildID, .emojis, .emojiID]) + + /* Applications */ + case let .globalApplicationCommands(applicationId): + return DiscordRateLimitKey(id: applicationId, urlParts: [.applications, .applicationID, .commands]) + case let .globalApplicationCommand(applicationId, _): + return DiscordRateLimitKey(id: applicationId, urlParts: [.applications, .applicationID, .commands, .commandID]) + case let .guildApplicationCommands(applicationId, _): + return DiscordRateLimitKey(id: applicationId, urlParts: [.applications, .applicationID, .guilds, .guildID, .commands]) + case let .guildApplicationCommand(applicationId, _, _): + return DiscordRateLimitKey(id: applicationId, urlParts: [.applications, .applicationID, .guilds, .guildID, .commands, .commandID]) + /* End Applications */ + + /* Interactions */ + case let .interactionsCallback(interactionId, _): + return DiscordRateLimitKey(id: interactionId, urlParts: [.interactions, .interactionID, .interactionToken, .callback]) + /* End Interactions */ } } @@ -454,15 +537,13 @@ public extension DiscordEndpoint { private func createURL(getParams: [String: String]?) -> URL? { // This can fail, specifically if you try to include a non-url-encoded emoji in it guard let url = URL(string: self.combined) else { - DefaultDiscordLogger.Logger.error("Couldn't convert \"\(self.combined)\" to a URL. This shouldn't happen.", - type: "DiscordEndpoint") + logger.error("Couldn't convert \"\(self.combined)\" to a URL. This shouldn't happen.") return nil } guard let getParams = getParams else { return url } guard var com = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - DefaultDiscordLogger.Logger.error("Couldn't convert \"\(url)\" to URLComponents. This shouldn't happen.", - type: "DiscordEndpoint") + logger.error("Couldn't convert \"\(url)\" to URLComponents. This shouldn't happen.") return nil } @@ -503,7 +584,7 @@ public enum DiscordHeader : String { public extension DiscordEndpoint { /// A namespace struct for endpoint options. - public struct Options { + struct Options { private init() {} /// Options when getting an audit log. @@ -551,7 +632,7 @@ public extension DiscordEndpoint { case name(String) /// The permissions this role has. - case permissions(Int) + case permissions(DiscordPermission) } /// Options for getting messages diff --git a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Applications.swift b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Applications.swift new file mode 100644 index 000000000..9fe6a35f9 --- /dev/null +++ b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Applications.swift @@ -0,0 +1,162 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +private struct CommandParams: Encodable { + let name: String + let description: String + let options: [DiscordApplicationCommandOption]? +} + +public extension DiscordEndpointConsumer where Self: DiscordUserActor { + /// Default implementation + func getApplicationCommands(callback: @escaping ([DiscordApplicationCommand], HTTPURLResponse?) -> ()) { + guard let applicationId = user?.id else { callback([], nil); return } + let requestCallback: DiscordRequestCallback = { data, response, error in + guard case let .array(commands)? = JSON.jsonFromResponse(data: data, response: response) else { + callback([], response) + return + } + + callback(DiscordApplicationCommand.commandsFromArray(commands as! [[String: Any]]), response) + } + rateLimiter.executeRequest(endpoint: .globalApplicationCommands(applicationId: applicationId), + token: token, + requestInfo: .get(params: nil, extraHeaders: nil), + callback: requestCallback) + } + + /// Default implementation + func createApplicationCommand(name: String, + description: String, + options: [DiscordApplicationCommandOption]? = nil, + callback: ((DiscordApplicationCommand?, HTTPURLResponse?) -> ())? = nil) { + guard let applicationId = user?.id else { callback?(nil, nil); return } + let requestCallback: DiscordRequestCallback = { data, response, error in + guard case let .object(command)? = JSON.jsonFromResponse(data: data, response: response) else { + callback?(nil, response) + return + } + + callback?(DiscordApplicationCommand(commandObject: command), response) + } + let params = CommandParams(name: name, description: description, options: options) + rateLimiter.executeRequest(endpoint: .globalApplicationCommands(applicationId: applicationId), + token: token, + requestInfo: .post(content: .json(JSON.encodeJSONData(params) ?? Data()), extraHeaders: nil), + callback: requestCallback) + } + + /// Default implementation + func editApplicationCommand(_ commandId: CommandID, + name: String, + description: String, + options: [DiscordApplicationCommandOption]? = nil, + callback: ((DiscordApplicationCommand?, HTTPURLResponse?) -> ())? = nil) { + guard let applicationId = user?.id else { callback?(nil, nil); return } + let requestCallback: DiscordRequestCallback = { data, response, error in + guard case let .object(command)? = JSON.jsonFromResponse(data: data, response: response) else { + callback?(nil, response) + return + } + + callback?(DiscordApplicationCommand(commandObject: command), response) + } + let params = CommandParams(name: name, description: description, options: options) + rateLimiter.executeRequest(endpoint: .globalApplicationCommand(applicationId: applicationId, commandId: commandId), + token: token, + requestInfo: .patch(content: .json(JSON.encodeJSONData(params) ?? Data()), extraHeaders: nil), + callback: requestCallback) + } + + /// Default implementation + func deleteApplicationCommand(_ commandId: CommandID, + callback: ((HTTPURLResponse?) -> ())? = nil) { + guard let applicationId = user?.id else { callback?(nil); return } + let requestCallback: DiscordRequestCallback = { data, response, error in + callback?(response) + } + rateLimiter.executeRequest(endpoint: .globalApplicationCommand(applicationId: applicationId, commandId: commandId), + token: token, + requestInfo: .delete(content: nil, extraHeaders: nil), + callback: requestCallback) + } + + /// Default implementation + func getApplicationCommands(on guildId: GuildID, + callback: @escaping ([DiscordApplicationCommand], HTTPURLResponse?) -> ()) { + guard let applicationId = user?.id else { callback([], nil); return } + let requestCallback: DiscordRequestCallback = { data, response, error in + guard case let .array(commands)? = JSON.jsonFromResponse(data: data, response: response) else { + callback([], response) + return + } + + callback(DiscordApplicationCommand.commandsFromArray(commands as! [[String: Any]]), response) + } + rateLimiter.executeRequest(endpoint: .guildApplicationCommands(applicationId: applicationId, guildId: guildId), + token: token, + requestInfo: .get(params: nil, extraHeaders: nil), + callback: requestCallback) + } + + /// Default implementation + func createApplicationCommand(on guildId: GuildID, + name: String, + description: String, + options: [DiscordApplicationCommandOption]? = nil, + callback: ((DiscordApplicationCommand?, HTTPURLResponse?) -> ())? = nil) { + guard let applicationId = user?.id else { callback?(nil, nil); return } + let requestCallback: DiscordRequestCallback = { data, response, error in + guard case let .object(command)? = JSON.jsonFromResponse(data: data, response: response) else { + callback?(nil, response) + return + } + + callback?(DiscordApplicationCommand(commandObject: command), response) + } + let params = CommandParams(name: name, description: description, options: options) + rateLimiter.executeRequest(endpoint: .guildApplicationCommands(applicationId: applicationId, guildId: guildId), + token: token, + requestInfo: .post(content: .json(JSON.encodeJSONData(params) ?? Data()), extraHeaders: nil), + callback: requestCallback) + } + + /// Default implementation + func editApplicationCommand(_ commandId: CommandID, + on guildId: GuildID, + name: String, + description: String, + options: [DiscordApplicationCommandOption]? = nil, + callback: ((DiscordApplicationCommand?, HTTPURLResponse?) -> ())? = nil) { + guard let applicationId = user?.id else { callback?(nil, nil); return } + let requestCallback: DiscordRequestCallback = { data, response, error in + guard case let .object(command)? = JSON.jsonFromResponse(data: data, response: response) else { + callback?(nil, response) + return + } + + callback?(DiscordApplicationCommand(commandObject: command), response) + } + let params = CommandParams(name: name, description: description, options: options) + rateLimiter.executeRequest(endpoint: .guildApplicationCommand(applicationId: applicationId, guildId: guildId, commandId: commandId), + token: token, + requestInfo: .patch(content: .json(JSON.encodeJSONData(params) ?? Data()), extraHeaders: nil), + callback: requestCallback) + } + + /// Default implementation + func deleteApplicationCommand(_ commandId: CommandID, + on guildId: GuildID, + callback: ((HTTPURLResponse?) -> ())? = nil) { + guard let applicationId = user?.id else { callback?(nil); return } + let requestCallback: DiscordRequestCallback = { data, response, error in + callback?(response) + } + rateLimiter.executeRequest(endpoint: .guildApplicationCommand(applicationId: applicationId, guildId: guildId, commandId: commandId), + token: token, + requestInfo: .delete(content: nil, extraHeaders: nil), + callback: requestCallback) + } +} diff --git a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Channels.swift b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Channels.swift index 5abbfe5bd..f4d709dac 100644 --- a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Channels.swift +++ b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Channels.swift @@ -16,10 +16,16 @@ // DEALINGS IN THE SOFTWARE. import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Logging + +fileprivate let logger = Logger(label: "DiscordEndpointChannels") public extension DiscordEndpointConsumer where Self: DiscordUserActor { /// Default implementation - public func addPinnedMessage(_ messageId: MessageID, + func addPinnedMessage(_ messageId: MessageID, on channelId: ChannelID, callback: ((Bool, HTTPURLResponse?) -> ())? = nil) { rateLimiter.executeRequest(endpoint: .pinnedMessage(channel: channelId, message: messageId), @@ -29,7 +35,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func bulkDeleteMessages(_ messages: [MessageID], + func bulkDeleteMessages(_ messages: [MessageID], on channelId: ChannelID, callback: ((Bool, HTTPURLResponse?) -> ())? = nil) { guard let contentData = JSON.encodeJSONData(["messages": messages.map({ $0.description })]) else { return } @@ -41,7 +47,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func createInvite(for channelId: ChannelID, + func createInvite(for channelId: ChannelID, options: [DiscordEndpoint.Options.CreateInvite], reason: String? = nil, callback: @escaping (DiscordInvite?, HTTPURLResponse?) -> ()) { @@ -84,7 +90,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func createReaction(for messageId: MessageID, + func createReaction(for messageId: MessageID, on channelId: ChannelID, emoji: String, callback: ((DiscordMessage?, HTTPURLResponse?) -> ())? = nil) { @@ -104,7 +110,38 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func deleteChannel(_ channelId: ChannelID, + func deleteOwnReaction(for messageId: MessageID, + on channelId: ChannelID, + emoji: String, + callback: ((Bool, HTTPURLResponse?) -> ())?) { + let requestCallback: DiscordRequestCallback = { data, response, error in + callback?(response?.statusCode == 204, response) + } + + rateLimiter.executeRequest(endpoint: .reactions(channel: channelId, message: messageId, emoji: emoji.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? emoji), + token: token, + requestInfo: .delete(content: nil, extraHeaders: nil), + callback: requestCallback) + } + + /// Default implementation + func deleteUserReaction(for messageId: MessageID, + on channelId: ChannelID, + emoji: String, + by userId: UserID, + callback: ((Bool, HTTPURLResponse?) -> ())?) { + let requestCallback: DiscordRequestCallback = { data, response, error in + callback?(response?.statusCode == 204, response) + } + + rateLimiter.executeRequest(endpoint: .userReactions(channel: channelId, message: messageId, emoji: emoji.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? emoji, user: userId), + token: token, + requestInfo: .delete(content: nil, extraHeaders: nil), + callback: requestCallback) + } + + /// Default implementation + func deleteChannel(_ channelId: ChannelID, reason: String? = nil, callback: ((Bool, HTTPURLResponse?) -> ())? = nil) { var extraHeaders = [DiscordHeader: String]() @@ -120,7 +157,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func deleteChannelPermission(_ overwriteId: OverwriteID, + func deleteChannelPermission(_ overwriteId: OverwriteID, on channelId: ChannelID, reason: String? = nil, callback: ((Bool, HTTPURLResponse?) -> ())? = nil) { @@ -137,7 +174,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func deleteMessage(_ messageId: MessageID, + func deleteMessage(_ messageId: MessageID, on channelId: ChannelID, callback: ((Bool, HTTPURLResponse?) -> ())? = nil) { rateLimiter.executeRequest(endpoint: .channelMessageDelete(channel: channelId, message: messageId), @@ -147,7 +184,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func deletePinnedMessage(_ messageId: MessageID, + func deletePinnedMessage(_ messageId: MessageID, on channelId: ChannelID, callback: ((Bool, HTTPURLResponse?) -> ())? = nil) { rateLimiter.executeRequest(endpoint: .pinnedMessage(channel: channelId, message: messageId), @@ -157,7 +194,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func editChannelPermission(_ permissionOverwrite: DiscordPermissionOverwrite, + func editChannelPermission(_ permissionOverwrite: DiscordPermissionOverwrite, on channelId: ChannelID, reason: String? = nil, callback: ((Bool, HTTPURLResponse?) -> ())? = nil) { @@ -176,7 +213,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getInvites(for channelId: ChannelID, + func getInvites(for channelId: ChannelID, callback: @escaping ([DiscordInvite], HTTPURLResponse?) -> ()) { let requestCallback: DiscordRequestCallback = { data, response, error in guard case let .array(invites)? = JSON.jsonFromResponse(data: data, response: response) else { @@ -194,7 +231,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func editMessage(_ messageId: MessageID, + func editMessage(_ messageId: MessageID, on channelId: ChannelID, content: String, callback: ((DiscordMessage?, HTTPURLResponse?) -> ())? = nil) { @@ -216,7 +253,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getChannel(_ channelId: ChannelID, + func getChannel(_ channelId: ChannelID, callback: @escaping (DiscordChannel?, HTTPURLResponse?) -> ()) { let requestCallback: DiscordRequestCallback = {data, response, error in guard case let .object(channel)? = JSON.jsonFromResponse(data: data, response: response) else { @@ -234,7 +271,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getMessages(for channelId: ChannelID, + func getMessages(for channelId: ChannelID, selection: DiscordEndpoint.Options.MessageSelection? = nil, limit: Int? = nil, callback: @escaping ([DiscordMessage], HTTPURLResponse?) -> ()) { @@ -263,7 +300,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getPinnedMessages(for channelId: ChannelID, + func getPinnedMessages(for channelId: ChannelID, callback: @escaping ([DiscordMessage], HTTPURLResponse?) -> ()) { let requestCallback: DiscordRequestCallback = {data, response, error in guard case let .array(messages)? = JSON.jsonFromResponse(data: data, response: response) else { @@ -281,7 +318,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func modifyChannel(_ channelId: ChannelID, + func modifyChannel(_ channelId: ChannelID, options: [DiscordEndpoint.Options.ModifyChannel], reason: String? = nil, callback: ((DiscordGuildChannel?, HTTPURLResponse?) -> ())? = nil) { @@ -326,7 +363,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation. - public func sendMessage(_ message: DiscordMessage, + func sendMessage(_ message: DiscordMessage, to channelId: ChannelID, callback: ((DiscordMessage?, HTTPURLResponse?) -> ())? = nil) { let requestInfo: DiscordEndpoint.EndpointRequest @@ -339,8 +376,8 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { extraHeaders: nil) } - DefaultDiscordLogger.Logger.log("Sending message to: \(channelId)", type: "DiscordEndpointChannels") - DefaultDiscordLogger.Logger.verbose("Message: \(message)", type: "DiscordEndpointChannels") + logger.info("Sending message to: \(channelId)") + logger.debug("(verbose) Message: \(message)") let requestCallback: DiscordRequestCallback = { data, response, error in guard case let .object(message)? = JSON.jsonFromResponse(data: data, response: response) else { @@ -369,7 +406,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { If all messages were sent successfully, the length of the array will be the same as the length of the input. Otherwise, the callback's array will be shorter. */ - public func sendMessages(_ messages: [DiscordMessage], + func sendMessages(_ messages: [DiscordMessage], to channelID: ChannelID, callback: (([DiscordMessage], HTTPURLResponse?) -> ())? = nil ) { guard let firstMessage = messages.first else { @@ -404,8 +441,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func triggerTyping(on channelId: ChannelID, - callback: ((Bool, HTTPURLResponse?) -> ())? = nil) { + func triggerTyping(on channelId: ChannelID, callback: ((Bool, HTTPURLResponse?) -> ())? = nil) { rateLimiter.executeRequest(endpoint: .typing(channel: channelId), token: token, requestInfo: .post(content: nil, extraHeaders: nil), diff --git a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Emoji.swift b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Emoji.swift new file mode 100644 index 000000000..1c6bb9dd3 --- /dev/null +++ b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Emoji.swift @@ -0,0 +1,86 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Logging + +fileprivate let logger = Logger(label: "DiscordEndpointEmoji") + +public extension DiscordEndpointConsumer where Self: DiscordUserActor { + // Default implementation + func createGuildEmoji(on guildId: GuildID, + name: String, + image: String, + roles: [RoleID], + callback: ((DiscordEmoji?, HTTPURLResponse?) -> ())? = nil) { + var createJSON = [String: Encodable]() + + createJSON["name"] = name + createJSON["image"] = image + createJSON["roles"] = roles.map { String($0.rawValue) } + + guard let contentData = JSON.encodeJSONData(GenericEncodableDictionary(createJSON)) else { return } + + let requestCallback: DiscordRequestCallback = { data, response, error in + guard case let .object(emoji)? = JSON.jsonFromResponse(data: data, response: response) else { + callback?(nil, response) + return + } + + callback?(DiscordEmoji(emojiObject: emoji), response) + } + + rateLimiter.executeRequest(endpoint: .guildEmojis(guild: guildId), + token: token, + requestInfo: .post(content: .json(contentData), extraHeaders: nil), + callback: requestCallback) + } + + // Default implementation + func getGuildEmojis(on guildId: GuildID, + callback: @escaping ([DiscordEmoji], HTTPURLResponse?) -> ()) { + let requestCallback: DiscordRequestCallback = { data, response, error in + guard case let .array(rawEmojis)? = JSON.jsonFromResponse(data: data, response: response), + let emojis = rawEmojis as? [[String: Any]] else { + callback([], response) + return + } + + callback(emojis.map(DiscordEmoji.init(emojiObject:)), response) + } + + rateLimiter.executeRequest(endpoint: .guildEmojis(guild: guildId), + token: token, + requestInfo: .get(params: nil, extraHeaders: nil), + callback: requestCallback) + } + + // Default implementation + func getGuildEmoji(on guildId: GuildID, + for emojiId: EmojiID, + callback: @escaping (DiscordEmoji?, HTTPURLResponse?) -> ()) { + let requestCallback: DiscordRequestCallback = { data, response, error in + guard case let .object(emoji)? = JSON.jsonFromResponse(data: data, response: response) else { + callback(nil, response) + return + } + + callback(DiscordEmoji(emojiObject: emoji), response) + } + + rateLimiter.executeRequest(endpoint: .guildEmoji(guild: guildId, emoji: emojiId), + token: token, + requestInfo: .get(params: nil, extraHeaders: nil), + callback: requestCallback) + } + + // Default implementation + func deleteGuildEmoji(on guildId: GuildID, + for emojiId: EmojiID, + callback: ((Bool, HTTPURLResponse?) -> ())? = nil) { + rateLimiter.executeRequest(endpoint: .guildEmoji(guild: guildId, emoji: emojiId), + token: token, + requestInfo: .delete(content: nil, extraHeaders: nil), + callback: { _, response, _ in callback?(response?.statusCode == 204, response) }) + } +} diff --git a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Guilds.swift b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Guilds.swift index afb2439aa..c896e8e10 100644 --- a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Guilds.swift +++ b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Guilds.swift @@ -16,10 +16,16 @@ // DEALINGS IN THE SOFTWARE. import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Logging + +fileprivate let logger = Logger(label: "DiscordEndpointGuild") public extension DiscordEndpointConsumer where Self: DiscordUserActor { /// Default implementation - public func addGuildMemberRole(_ roleId: RoleID, + func addGuildMemberRole(_ roleId: RoleID, to userId: UserID, on guildId: GuildID, reason: String? = nil, @@ -37,7 +43,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func createGuildChannel(on guildId: GuildID, + func createGuildChannel(on guildId: GuildID, options: [DiscordEndpoint.Options.GuildCreateChannel], reason: String? = nil, callback: ((DiscordGuildChannel?, HTTPURLResponse?) -> ())? = nil) { @@ -82,7 +88,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func createGuildRole(on guildId: GuildID, + func createGuildRole(on guildId: GuildID, withOptions options: [DiscordEndpoint.Options.CreateRole] = [], reason: String? = nil, callback: @escaping (DiscordRole?, HTTPURLResponse?) -> ()) { @@ -104,12 +110,12 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { case let .name(name): roleData["name"] = name case let .permissions(permissions): - roleData["permissions"] = permissions + roleData["permissions"] = permissions.rawValue.description } } - DefaultDiscordLogger.Logger.log("Creating a new role on \(guildId)", type: "DiscordEndpointGuild") - DefaultDiscordLogger.Logger.verbose("Role options \(roleData)", type: "DiscordEndpointGuild") + logger.info("Creating a new role on \(guildId)") + logger.debug("(verbose) Role options \(roleData)") guard let contentData = JSON.encodeJSONData(GenericEncodableDictionary(roleData)) else { return callback(nil, nil) } @@ -130,7 +136,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func deleteGuild(_ guildId: GuildID, + func deleteGuild(_ guildId: GuildID, callback: ((DiscordGuild?, HTTPURLResponse?) -> ())? = nil) { let requestCallback: DiscordRequestCallback = { data, response, error in guard case let .object(guild)? = JSON.jsonFromResponse(data: data, response: response) else { @@ -152,7 +158,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { func getGuildAuditLog(for guildId: GuildID, withOptions options: [DiscordEndpoint.Options.AuditLog], callback: @escaping (DiscordAuditLog?, HTTPURLResponse?) -> ()) { - DefaultDiscordLogger.Logger.debug("Getting audit log for \(guildId)", type: "DiscordEndpointGuild") + logger.debug("Getting audit log for \(guildId)") var getParams = [String: String]() @@ -178,7 +184,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { return } - DefaultDiscordLogger.Logger.debug("Got audit log for \(guildId)", type: "DiscordEndpointGuild") + logger.debug("Got audit log for \(guildId)") callback(DiscordAuditLog(auditLogObject: log), response) } @@ -190,7 +196,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getGuildBans(for guildId: GuildID, + func getGuildBans(for guildId: GuildID, callback: @escaping ([DiscordBan], HTTPURLResponse?) -> ()) { let requestCallback: DiscordRequestCallback = { data, response, error in guard case let .array(bans)? = JSON.jsonFromResponse(data: data, response: response) else { @@ -199,7 +205,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { return } - DefaultDiscordLogger.Logger.debug("Got guild bans \(bans)", type: "DiscordEndpointGuild") + logger.debug("Got guild bans \(bans)") callback(DiscordBan.bansFromArray(bans as! [[String: Any]]), response) } @@ -210,7 +216,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getGuildChannels(_ guildId: GuildID, + func getGuildChannels(_ guildId: GuildID, callback: @escaping ([DiscordGuildChannel], HTTPURLResponse?) -> ()) { let requestCallback: DiscordRequestCallback = { data, response, error in guard case let .array(channels)? = JSON.jsonFromResponse(data: data, response: response) else { @@ -229,7 +235,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getGuildMember(by id: UserID, + func getGuildMember(by id: UserID, on guildId: GuildID, callback: @escaping (DiscordGuildMember?, HTTPURLResponse?) -> ()) { let requestCallback: DiscordRequestCallback = { data, response, error in @@ -249,7 +255,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getGuildMembers(on guildId: GuildID, + func getGuildMembers(on guildId: GuildID, options: [DiscordEndpoint.Options.GuildGetMembers], callback: @escaping ([DiscordGuildMember], HTTPURLResponse?) -> ()) { var getParams: [String: String] = [:] @@ -282,7 +288,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getGuildRoles(for guildId: GuildID, + func getGuildRoles(for guildId: GuildID, callback: @escaping ([DiscordRole], HTTPURLResponse?) -> ()) { let requestCallback: DiscordRequestCallback = { data, response, error in guard case let .array(roles)? = JSON.jsonFromResponse(data: data, response: response) else { @@ -301,7 +307,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func guildBan(userId: UserID, + func guildBan(userId: UserID, on guildId: GuildID, deleteMessageDays: Int = 7, reason: String? = nil, @@ -320,7 +326,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func modifyGuild(_ guildId: GuildID, + func modifyGuild(_ guildId: GuildID, options: [DiscordEndpoint.Options.ModifyGuild], reason: String? = nil, callback: ((DiscordGuild?, HTTPURLResponse?) -> ())? = nil) { @@ -373,7 +379,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func modifyGuildChannelPositions(on guildId: GuildID, + func modifyGuildChannelPositions(on guildId: GuildID, channelPositions: [[String: Any]], callback: (([DiscordGuildChannel], HTTPURLResponse?) -> ())? = nil) { guard let contentData = JSON.encodeJSONData(GenericEncodableArray(channelPositions)) else { return } @@ -418,8 +424,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { guard let contentData = JSON.encodeJSONData(GenericEncodableDictionary(patchParams)) else { return } - DefaultDiscordLogger.Logger.debug("Modifying guild member \(id) with options: \(patchParams) on \(guildId)", - type: "DiscordEndpointGuild") + logger.debug("Modifying guild member \(id) with options: \(patchParams) on \(guildId)") rateLimiter.executeRequest(endpoint: .guildMember(guild: guildId, user: id), token: token, @@ -428,7 +433,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func modifyGuildRole(_ role: DiscordRole, + func modifyGuildRole(_ role: DiscordRole, on guildId: GuildID, reason: String? = nil, callback: ((DiscordRole?, HTTPURLResponse?) -> ())? = nil) { @@ -456,11 +461,11 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func removeGuildBan(for userId: UserID, + func removeGuildBan(for userId: UserID, on guildId: GuildID, reason: String? = nil, callback: ((Bool, HTTPURLResponse?) -> ())? = nil) { - DefaultDiscordLogger.Logger.log("Unbanning \(userId) on \(guildId)", type: "DiscordEndpointGuild") + logger.info("Unbanning \(userId) on \(guildId)") var extraHeaders = [DiscordHeader: String]() @@ -475,7 +480,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation. - public func removeGuildMemberRole(_ roleId: RoleID, + func removeGuildMemberRole(_ roleId: RoleID, from userId: UserID, on guildId: GuildID, reason: String? = nil, @@ -493,7 +498,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func removeGuildRole(_ roleId: RoleID, + func removeGuildRole(_ roleId: RoleID, on guildId: GuildID, reason: String? = nil, callback: ((DiscordRole?, HTTPURLResponse?) -> ())? = nil) { diff --git a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Interactions.swift b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Interactions.swift new file mode 100644 index 000000000..ffb72160b --- /dev/null +++ b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Interactions.swift @@ -0,0 +1,20 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public extension DiscordEndpointConsumer where Self: DiscordUserActor { + /// Default implementation + func createInteractionResponse(for interactionId: InteractionID, + token interactionToken: String, + response: DiscordInteractionResponse, + callback: ((HTTPURLResponse?) -> ())? = nil){ + let requestCallback: DiscordRequestCallback = { data, response, error in + callback?(response) + } + rateLimiter.executeRequest(endpoint: .interactionsCallback(interactionId: interactionId, interactionToken: interactionToken), + token: token, + requestInfo: .post(content: .json(JSON.encodeJSONData(response) ?? Data()), extraHeaders: nil), + callback: requestCallback) + } +} diff --git a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Invites.swift b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Invites.swift index 6be623f4a..aeeb88b01 100644 --- a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Invites.swift +++ b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Invites.swift @@ -16,10 +16,13 @@ // DEALINGS IN THE SOFTWARE. import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif public extension DiscordEndpointConsumer where Self: DiscordUserActor { /// Default implementation - public func acceptInvite(_ invite: String, + func acceptInvite(_ invite: String, callback: ((DiscordInvite?, HTTPURLResponse?) -> ())? = nil) { let requestCallback: DiscordRequestCallback = { data, response, error in guard case let .object(invite)? = JSON.jsonFromResponse(data: data, response: response) else { @@ -38,7 +41,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func deleteInvite(_ invite: String, + func deleteInvite(_ invite: String, reason: String? = nil, callback: ((DiscordInvite?, HTTPURLResponse?) -> ())? = nil) { var extraHeaders = [DiscordHeader: String]() @@ -64,7 +67,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getInvite(_ invite: String, + func getInvite(_ invite: String, callback: @escaping (DiscordInvite?, HTTPURLResponse?) -> ()) { let requestCallback: DiscordRequestCallback = { data, response, error in guard case let .object(invite)? = JSON.jsonFromResponse(data: data, response: response) else { diff --git a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+User.swift b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+User.swift index 98ca2dd64..b0c6ee360 100644 --- a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+User.swift +++ b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+User.swift @@ -16,10 +16,16 @@ // DEALINGS IN THE SOFTWARE. import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Logging + +fileprivate let logger = Logger(label: "DiscordEndpointUser") public extension DiscordEndpointConsumer where Self: DiscordUserActor { /// Default implementation - public func createDM(with: UserID, + func createDM(with: UserID, callback: @escaping (DiscordDMChannel?, HTTPURLResponse?) -> ()) { guard let contentData = JSON.encodeJSONData(["recipient_id": with]) else { return } @@ -40,7 +46,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getDMs(callback: @escaping ([ChannelID: DiscordDMChannel], HTTPURLResponse?) -> ()) { + func getDMs(callback: @escaping ([ChannelID: DiscordDMChannel], HTTPURLResponse?) -> ()) { let requestCallback: DiscordRequestCallback = { data, response, error in guard case let .array(channels)? = JSON.jsonFromResponse(data: data, response: response) else { callback([:], response) @@ -48,7 +54,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { return } - DefaultDiscordLogger.Logger.debug("Got DMChannels: \(channels)", type: "DiscordEndpointUser") + logger.debug("Got DMChannels: \(channels)") callback(DiscordDMChannel.DMsfromArray(channels as! [[String: Any]]), response) } @@ -59,7 +65,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getGuilds(callback: @escaping ([GuildID: DiscordUserGuild], HTTPURLResponse?) -> ()) { + func getGuilds(callback: @escaping ([GuildID: DiscordUserGuild], HTTPURLResponse?) -> ()) { let requestCallback: DiscordRequestCallback = {data, response, error in guard case let .array(guilds)? = JSON.jsonFromResponse(data: data, response: response) else { callback([:], response) diff --git a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Webhooks.swift b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Webhooks.swift index 6ee58b3ab..ebf968d8a 100644 --- a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Webhooks.swift +++ b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer+Webhooks.swift @@ -16,10 +16,16 @@ // DEALINGS IN THE SOFTWARE. import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Logging + +fileprivate let logger = Logger(label: "DiscordEndpointWebhooks") public extension DiscordEndpointConsumer where Self: DiscordUserActor { /// Default implementation - public func createWebhook(forChannel channelId: ChannelID, + func createWebhook(forChannel channelId: ChannelID, options: [DiscordEndpoint.Options.WebhookOption], reason: String? = nil, callback: @escaping (DiscordWebhook?, HTTPURLResponse?) -> () = {_, _ in }) { @@ -39,7 +45,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } } - DefaultDiscordLogger.Logger.debug("Creating webhook on: \(channelId)", type: "DiscordEndpointChannels") + logger.debug("Creating webhook on: \(channelId)") guard let contentData = JSON.encodeJSONData(GenericEncodableDictionary(createJSON)) else { return } @@ -60,7 +66,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func deleteWebhook(_ webhookId: WebhookID, + func deleteWebhook(_ webhookId: WebhookID, reason: String? = nil, callback: ((Bool, HTTPURLResponse?) -> ())? = nil) { var extraHeaders = [DiscordHeader: String]() @@ -76,7 +82,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getWebhook(_ webhookId: WebhookID, + func getWebhook(_ webhookId: WebhookID, callback: @escaping (DiscordWebhook?, HTTPURLResponse?) -> ()) { let requestCallback: DiscordRequestCallback = { data, response, error in guard case let .object(webhook)? = JSON.jsonFromResponse(data: data, response: response) else { @@ -95,7 +101,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getWebhooks(forChannel channelId: ChannelID, + func getWebhooks(forChannel channelId: ChannelID, callback: @escaping ([DiscordWebhook], HTTPURLResponse?) -> ()) { let requestCallback: DiscordRequestCallback = { data, response, error in guard case let .array(webhooks)? = JSON.jsonFromResponse(data: data, response: response) else { @@ -114,9 +120,9 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func getWebhooks(forGuild guildId: GuildID, + func getWebhooks(forGuild guildId: GuildID, callback: @escaping ([DiscordWebhook], HTTPURLResponse?) -> ()) { - DefaultDiscordLogger.Logger.debug("Getting webhooks for guild: \(guildId)", type: "DiscordEndpointWebhooks") + logger.debug("Getting webhooks for guild: \(guildId)") let requestCallback: DiscordRequestCallback = { data, response, error in guard case let .array(webhooks)? = JSON.jsonFromResponse(data: data, response: response) else { @@ -135,7 +141,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { } /// Default implementation - public func modifyWebhook(_ webhookId: WebhookID, + func modifyWebhook(_ webhookId: WebhookID, options: [DiscordEndpoint.Options.WebhookOption], reason: String? = nil, callback: @escaping (DiscordWebhook?, HTTPURLResponse?) -> () = {_, _ in }) { @@ -157,7 +163,7 @@ public extension DiscordEndpointConsumer where Self: DiscordUserActor { guard let contentData = JSON.encodeJSONData(GenericEncodableDictionary(createJSON)) else { return } - DefaultDiscordLogger.Logger.debug("Modifying webhook: \(webhookId)", type: "DiscordEndpointChannels") + logger.debug("Modifying webhook: \(webhookId)") let requestCallback: DiscordRequestCallback = { data, response, error in guard case let .object(webhook)? = JSON.jsonFromResponse(data: data, response: response) else { diff --git a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer.swift b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer.swift index 0ce041d54..7ddbb2be3 100644 --- a/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer.swift +++ b/Sources/SwiftDiscord/Rest/DiscordEndpointConsumer.swift @@ -16,6 +16,9 @@ // DEALINGS IN THE SOFTWARE. import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// /// Protocol that declares a type will be a consumer of the Discord REST API. @@ -82,6 +85,34 @@ public protocol DiscordEndpointConsumer { emoji: String, callback: ((DiscordMessage?, HTTPURLResponse?) -> ())?) + /// + /// Deletes a reaction the current user has made for the specified message. + /// + /// - parameter for: The message that is to be edited's snowflake id + /// - parameter on: The channel that we are editing on + /// - parameter emoji: The emoji name + /// - parameter callback: An optional callback containing the edited message, if successful. + /// + func deleteOwnReaction(for messageId: MessageID, + on channelId: ChannelID, + emoji: String, + callback: ((Bool, HTTPURLResponse?) -> ())?) + + /// + /// Deletes a reaction another user has made for the specified message. + /// + /// - parameter for: The message that is to be edited's snowflake id + /// - parameter on: The channel that we are editing on + /// - parameter emoji: The emoji name + /// - parameter by: The snowflake id of the user + /// - parameter callback: An optional callback containing the edited message, if successful. + /// + func deleteUserReaction(for messageId: MessageID, + on channelId: ChannelID, + emoji: String, + by userId: UserID, + callback: ((Bool, HTTPURLResponse?) -> ())?) + /// /// Deletes the specified channel. /// @@ -594,6 +625,94 @@ public protocol DiscordEndpointConsumer { /// func getGuilds(callback: @escaping ([ChannelID: DiscordUserGuild], HTTPURLResponse?) -> ()) + // MARK: Applications + + /// + /// Gets the global slash-commands of a user. + /// + /// - parameter callback: The callback function, taking a dictionary of commands. + /// + func getApplicationCommands(callback: @escaping ([DiscordApplicationCommand], HTTPURLResponse?) -> ()) + + /// + /// Creates a global slash-command for a user. + /// + /// - parameter callback: The callback function, taking a command. + /// + func createApplicationCommand(name: String, + description: String, + options: [DiscordApplicationCommandOption]?, + callback: ((DiscordApplicationCommand?, HTTPURLResponse?) -> ())?) + + /// + /// Edits a global slash-command for a user. + /// + /// - parameter callback: The callback function, taking a command. + /// + func editApplicationCommand(_ commandId: CommandID, + name: String, + description: String, + options: [DiscordApplicationCommandOption]?, + callback: ((DiscordApplicationCommand?, HTTPURLResponse?) -> ())?) + + /// + /// Deletes a global slash-command for a user. + /// + /// - parameter callback: The callback function, taking a command. + /// + func deleteApplicationCommand(_ commandId: CommandID, + callback: ((HTTPURLResponse?) -> ())?) + + /// + /// Gets the guild-specific slash-commands of a user. + /// + /// - parameter callback: The callback function, taking a dictionary of commands. + /// + func getApplicationCommands(on guildId: GuildID, + callback: @escaping ([DiscordApplicationCommand], HTTPURLResponse?) -> ()) + + /// + /// Creates a guild-specific slash-command for a user. + /// + /// - parameter callback: The callback function, taking a command. + /// + func createApplicationCommand(on guildId: GuildID, + name: String, + description: String, + options: [DiscordApplicationCommandOption]?, + callback: ((DiscordApplicationCommand?, HTTPURLResponse?) -> ())?) + + /// + /// Edits a guild-specific slash-command for a user. + /// + /// - parameter callback: The callback function, taking a command. + /// + func editApplicationCommand(_ commandId: CommandID, + on guildId: GuildID, + name: String, + description: String, + options: [DiscordApplicationCommandOption]?, + callback: ((DiscordApplicationCommand?, HTTPURLResponse?) -> ())?) + + /// + /// Deletes a guild-specific slash-command for a user. + /// + /// - parameter callback: The callback function, taking a command. + /// + func deleteApplicationCommand(_ commandId: CommandID, + on guildId: GuildID, + callback: ((HTTPURLResponse?) -> ())?) + + /// + /// Creates a response to an interaction from the gateway. + /// + /// - parameter response: The response + /// + func createInteractionResponse(for interactionId: InteractionID, + token: String, + response: DiscordInteractionResponse, + callback: ((HTTPURLResponse?) -> ())?) + // MARK: Misc /// @@ -606,7 +725,7 @@ public protocol DiscordEndpointConsumer { public extension DiscordEndpointConsumer where Self: DiscordUserActor { /// Default implementation - public func getBotURL(with permissions: DiscordPermission) -> URL? { + func getBotURL(with permissions: DiscordPermission) -> URL? { guard let user = self.user else { return nil } return DiscordOAuthEndpoint.createBotAddURL(for: user, with: permissions) diff --git a/Sources/SwiftDiscord/Rest/DiscordEndpointGateway.swift b/Sources/SwiftDiscord/Rest/DiscordEndpointGateway.swift index d55a04aa1..c0fb6f818 100644 --- a/Sources/SwiftDiscord/Rest/DiscordEndpointGateway.swift +++ b/Sources/SwiftDiscord/Rest/DiscordEndpointGateway.swift @@ -16,6 +16,10 @@ // DEALINGS IN THE SOFTWARE. import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + import Dispatch struct DiscordEndpointGateway { @@ -28,7 +32,7 @@ struct DiscordEndpointGateway { #if os(Linux) return "wss://gateway.discord.gg" #else - var request = URLRequest(url: URL(string: "https://discordapp.com/api/gateway")!) + var request = URLRequest(url: URL(string: "https://discord.com/api/gateway")!) request.httpMethod = "GET" diff --git a/Sources/SwiftDiscord/Rest/DiscordOAuth.swift b/Sources/SwiftDiscord/Rest/DiscordOAuth.swift index 8ec91a51b..65e919427 100644 --- a/Sources/SwiftDiscord/Rest/DiscordOAuth.swift +++ b/Sources/SwiftDiscord/Rest/DiscordOAuth.swift @@ -20,7 +20,7 @@ import Foundation /// Represents the Discord OAuth endpoint and the different scopes Disocrd has. public enum DiscordOAuthEndpoint : String { /// The base OAuth endpoint. - case baseURL = "https://discordapp.com/api/oauth2/authorize" + case baseURL = "https://discord.com/api/oauth2/authorize" /// The bot scope. case bot diff --git a/Sources/SwiftDiscord/Rest/DiscordRateLimiter.swift b/Sources/SwiftDiscord/Rest/DiscordRateLimiter.swift index 4398b88c3..7f5546b52 100644 --- a/Sources/SwiftDiscord/Rest/DiscordRateLimiter.swift +++ b/Sources/SwiftDiscord/Rest/DiscordRateLimiter.swift @@ -16,11 +16,18 @@ // DEALINGS IN THE SOFTWARE. import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + import Dispatch +import Logging internal typealias DiscordRequestCallback = (Data?, HTTPURLResponse?, Error?) -> () private typealias RateLimitedRequest = (request: URLRequest, callback: DiscordRequestCallback) +fileprivate let logger = Logger(label: "DiscordRateLimiter") + /// The DiscordRateLimiter is in charge of making sure we don't flood Discord with requests. /// It keeps a dictionary of DiscordRateLimitKeys and DiscordRateLimits. /// All requests to the REST api should be routed through the DiscordRateLimiter. @@ -63,13 +70,13 @@ public final class DiscordRateLimiter : DiscordRateLimiterSpec { if endpointLimits[endpointKey] == nil { // First time handling this endpoint, err on the side caution and limit to one endpointLimits[endpointKey] = DiscordRateLimit(endpointKey: endpointKey, limit: 1, remaining: 1, - reset: Int(Date().timeIntervalSince1970) + 3) + reset: Date().timeIntervalSince1970 + 3) } let rateLimit = endpointLimits[endpointKey]! if rateLimit.atLimit { - DefaultDiscordLogger.Logger.debug("Hit rate limit: \(rateLimit)", type: "DiscordRateLimiter") + logger.debug("Hit rate limit: \(rateLimit)") guard !failFast else { callbackQueue.async { callback(nil, nil, nil) } @@ -85,7 +92,7 @@ public final class DiscordRateLimiter : DiscordRateLimiterSpec { rateLimit.remaining -= 1 - DefaultDiscordLogger.Logger.debug("Doing request: \(request), remaining: \(rateLimit.remaining)", type: "DiscordRateLimiter") + logger.debug("Doing request: \(request), remaining: \(rateLimit.remaining)") session.dataTask(with: request, completionHandler: createResponseHandler(for: request, endpointKey: endpointKey, @@ -148,14 +155,14 @@ public final class DiscordRateLimiter : DiscordRateLimiterSpec { let remaining = response.allHeaderFields["x-ratelimit-remaining"], let reset = response.allHeaderFields["x-ratelimit-reset"] { // Update the limit and attempt to schedule a limit reset - rateLimit.updateLimits(limit: Int(limit as! String)!, - remaining: Int(remaining as! String)!, - reset: Int(reset as! String)!) + rateLimit.updateLimits(limit: Double(limit as! String)!, + remaining: Double(remaining as! String)!, + reset: Double(reset as! String)!) } - DefaultDiscordLogger.Logger.debug("New limit: \(rateLimit.limit)", type: "DiscordRateLimiter") - DefaultDiscordLogger.Logger.debug("New remaining: \(rateLimit.remaining)", type: "DiscordRateLimiter") - DefaultDiscordLogger.Logger.debug("New reset: \(rateLimit.reset)", type: "DiscordRateLimiter") + logger.debug("New limit: \(rateLimit.limit)") + logger.debug("New remaining: \(rateLimit.remaining)") + logger.debug("New reset: \(rateLimit.reset)") callbackQueue.async { callback(data, response, error) } } @@ -217,39 +224,49 @@ public struct DiscordRateLimitKey : Hashable { /// stored separately if needed. Technically, the .guildID and .channelID fields aren't needed since /// the full ID will also be stored, but they're included to make the system more straightforward. public struct DiscordRateLimitURLParts : OptionSet { - public let rawValue: Int - - static let guilds = DiscordRateLimitURLParts(rawValue: 1 << 0) - static let guildID = DiscordRateLimitURLParts(rawValue: 1 << 1) - static let channels = DiscordRateLimitURLParts(rawValue: 1 << 2) - static let channelID = DiscordRateLimitURLParts(rawValue: 1 << 3) - static let messages = DiscordRateLimitURLParts(rawValue: 1 << 4) - static let messagesDelete = DiscordRateLimitURLParts(rawValue: 1 << 5) - static let messageID = DiscordRateLimitURLParts(rawValue: 1 << 6) - static let bulkDelete = DiscordRateLimitURLParts(rawValue: 1 << 7) - static let typing = DiscordRateLimitURLParts(rawValue: 1 << 8) - static let permissions = DiscordRateLimitURLParts(rawValue: 1 << 9) - static let overwriteID = DiscordRateLimitURLParts(rawValue: 1 << 10) - static let invites = DiscordRateLimitURLParts(rawValue: 1 << 11) - static let inviteCode = DiscordRateLimitURLParts(rawValue: 1 << 12) - static let pins = DiscordRateLimitURLParts(rawValue: 1 << 13) - static let webhooks = DiscordRateLimitURLParts(rawValue: 1 << 14) - static let members = DiscordRateLimitURLParts(rawValue: 1 << 15) - static let userID = DiscordRateLimitURLParts(rawValue: 1 << 16) - static let roles = DiscordRateLimitURLParts(rawValue: 1 << 17) - static let roleID = DiscordRateLimitURLParts(rawValue: 1 << 18) - static let bans = DiscordRateLimitURLParts(rawValue: 1 << 19) - static let users = DiscordRateLimitURLParts(rawValue: 1 << 20) - static let webhookID = DiscordRateLimitURLParts(rawValue: 1 << 21) - static let webhookToken = DiscordRateLimitURLParts(rawValue: 1 << 22) - static let slack = DiscordRateLimitURLParts(rawValue: 1 << 23) - static let github = DiscordRateLimitURLParts(rawValue: 1 << 24) - static let auditLog = DiscordRateLimitURLParts(rawValue: 1 << 25) - static let reactions = DiscordRateLimitURLParts(rawValue: 1 << 26) - static let emoji = DiscordRateLimitURLParts(rawValue: 1 << 27) - static let me = DiscordRateLimitURLParts(rawValue: 1 << 28) - - public init(rawValue: Int) { + public let rawValue: Int64 + + static let guilds = DiscordRateLimitURLParts(rawValue: 1 << 0) + static let guildID = DiscordRateLimitURLParts(rawValue: 1 << 1) + static let channels = DiscordRateLimitURLParts(rawValue: 1 << 2) + static let channelID = DiscordRateLimitURLParts(rawValue: 1 << 3) + static let messages = DiscordRateLimitURLParts(rawValue: 1 << 4) + static let messagesDelete = DiscordRateLimitURLParts(rawValue: 1 << 5) + static let messageID = DiscordRateLimitURLParts(rawValue: 1 << 6) + static let bulkDelete = DiscordRateLimitURLParts(rawValue: 1 << 7) + static let typing = DiscordRateLimitURLParts(rawValue: 1 << 8) + static let permissions = DiscordRateLimitURLParts(rawValue: 1 << 9) + static let overwriteID = DiscordRateLimitURLParts(rawValue: 1 << 10) + static let invites = DiscordRateLimitURLParts(rawValue: 1 << 11) + static let inviteCode = DiscordRateLimitURLParts(rawValue: 1 << 12) + static let pins = DiscordRateLimitURLParts(rawValue: 1 << 13) + static let webhooks = DiscordRateLimitURLParts(rawValue: 1 << 14) + static let members = DiscordRateLimitURLParts(rawValue: 1 << 15) + static let userID = DiscordRateLimitURLParts(rawValue: 1 << 16) + static let roles = DiscordRateLimitURLParts(rawValue: 1 << 17) + static let roleID = DiscordRateLimitURLParts(rawValue: 1 << 18) + static let bans = DiscordRateLimitURLParts(rawValue: 1 << 19) + static let users = DiscordRateLimitURLParts(rawValue: 1 << 20) + static let webhookID = DiscordRateLimitURLParts(rawValue: 1 << 21) + static let webhookToken = DiscordRateLimitURLParts(rawValue: 1 << 22) + static let slack = DiscordRateLimitURLParts(rawValue: 1 << 23) + static let github = DiscordRateLimitURLParts(rawValue: 1 << 24) + static let auditLog = DiscordRateLimitURLParts(rawValue: 1 << 25) + static let reactions = DiscordRateLimitURLParts(rawValue: 1 << 26) + static let emoji = DiscordRateLimitURLParts(rawValue: 1 << 27) + static let emojis = DiscordRateLimitURLParts(rawValue: 1 << 28) + static let emojiID = DiscordRateLimitURLParts(rawValue: 1 << 29) + static let me = DiscordRateLimitURLParts(rawValue: 1 << 30) + static let applications = DiscordRateLimitURLParts(rawValue: 1 << 31) + static let applicationID = DiscordRateLimitURLParts(rawValue: 1 << 32) + static let commands = DiscordRateLimitURLParts(rawValue: 1 << 33) + static let commandID = DiscordRateLimitURLParts(rawValue: 1 << 34) + static let interactions = DiscordRateLimitURLParts(rawValue: 1 << 35) + static let interactionID = DiscordRateLimitURLParts(rawValue: 1 << 36) + static let interactionToken = DiscordRateLimitURLParts(rawValue: 1 << 37) + static let callback = DiscordRateLimitURLParts(rawValue: 1 << 38) + + public init(rawValue: Int64) { self.rawValue = rawValue } } @@ -264,11 +281,6 @@ public struct DiscordRateLimitKey : Hashable { /// The list of parts that the URL contains public let urlParts: DiscordRateLimitURLParts - /// The hash of the key. - public var hashValue: Int { - return urlParts.rawValue &+ id.hashValue - } - // MARK: Initializers /// Creates a new endpoint key. @@ -281,6 +293,12 @@ public struct DiscordRateLimitKey : Hashable { public static func ==(lhs: DiscordRateLimitKey, rhs: DiscordRateLimitKey) -> Bool { return lhs.id == rhs.id && lhs.urlParts == rhs.urlParts } + + /// The hash of the key. + public func hash(into hasher: inout Hasher) { + hasher.combine(urlParts.rawValue) + hasher.combine(id) + } } /// A DiscordRateLimit's job is to keep track of a endpoint's rate limit. @@ -288,9 +306,9 @@ public struct DiscordRateLimitKey : Hashable { /// Enqueued requests are handled through limit resets. Which are told to us by Discord in the x-ratelimit-reset header. /// It's up to the DiscordRateLimiter to actually call the scheduleReset method. private final class DiscordRateLimit { - var limit: Int - var remaining: Int - var reset: Int + var limit: Double + var remaining: Double + var reset: Double var queue = [RateLimitedRequest]() private let endpointKey: DiscordRateLimitKey @@ -302,14 +320,14 @@ private final class DiscordRateLimit { } private var deadlineForReset: DispatchTime { - let seconds = reset - Int(Date().timeIntervalSince1970) + let seconds = reset - Date().timeIntervalSince1970 guard seconds > 0 else { return DispatchTime(uptimeNanoseconds: 0) } return DispatchTime.now() + Double(seconds) } - init(endpointKey: DiscordRateLimitKey, limit: Int, remaining: Int, reset: Int) { + init(endpointKey: DiscordRateLimitKey, limit: Double, remaining: Double, reset: Double) { self.endpointKey = endpointKey self.limit = limit self.remaining = remaining @@ -322,7 +340,7 @@ private final class DiscordRateLimit { scheduledReset = true queue.asyncAfter(deadline: deadlineForReset) { - DefaultDiscordLogger.Logger.debug("Reset triggered: \(self.endpointKey)", type: "RateLimit") + logger.debug("Reset triggered: \(self.endpointKey)") self.remaining = self.limit self.scheduledReset = false @@ -336,13 +354,13 @@ private final class DiscordRateLimit { limiter.executeRequest(limitedRequest.request, for: self.endpointKey, callback: limitedRequest.callback) removed += 1 - } while removed < self.remaining && self.queue.count != 0 + } while removed < Int(self.remaining) && self.queue.count != 0 - DefaultDiscordLogger.Logger.debug("Sent \(removed) requests for limit: \(self.endpointKey)", type: "RateLimit") + logger.debug("Sent \(removed) requests for limit: \(self.endpointKey)") } } - func updateLimits(limit: Int, remaining: Int, reset: Int) { + func updateLimits(limit: Double, remaining: Double, reset: Double) { self.limit = limit self.remaining = remaining self.reset = reset diff --git a/Sources/SwiftDiscord/User/DiscordPermission.swift b/Sources/SwiftDiscord/User/DiscordPermission.swift index b5cd16635..b583fc82a 100644 --- a/Sources/SwiftDiscord/User/DiscordPermission.swift +++ b/Sources/SwiftDiscord/User/DiscordPermission.swift @@ -17,6 +17,9 @@ /// Represents a Discord Permission. Calculating Permissions involves bitwise operations. public struct DiscordPermission : OptionSet, Encodable { + // TODO: Migrate to BigInt or similar since permission are string-serialized + // and may have arbitrary size as of v8 + public let rawValue: Int /// This user can create invites. @@ -90,6 +93,11 @@ public struct DiscordPermission : OptionSet, Encodable { public init(rawValue: Int) { self.rawValue = rawValue } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue.description) + } } /// Represents a permission overwrite type for a channel. @@ -138,8 +146,8 @@ public struct DiscordPermissionOverwrite : Encodable { init(permissionOverwriteObject: [String: Any]) { id = permissionOverwriteObject.getSnowflake() type = DiscordPermissionOverwriteType(rawValue: permissionOverwriteObject.get("type", or: "")) ?? .role - allow = DiscordPermission(rawValue: permissionOverwriteObject.get("allow", or: 0)) - deny = DiscordPermission(rawValue: permissionOverwriteObject.get("deny", or: 0)) + allow = DiscordPermission(rawValue: Int(permissionOverwriteObject.get("allow", or: "0")) ?? 0) + deny = DiscordPermission(rawValue: Int(permissionOverwriteObject.get("deny", or: "0")) ?? 0) } static func overwritesFromArray(_ permissionOverwritesArray: [[String: Any]]) -> [OverwriteID: DiscordPermissionOverwrite] { diff --git a/Sources/SwiftDiscord/User/DiscordPresence.swift b/Sources/SwiftDiscord/User/DiscordPresence.swift index b72005b70..12aa7fdbc 100644 --- a/Sources/SwiftDiscord/User/DiscordPresence.swift +++ b/Sources/SwiftDiscord/User/DiscordPresence.swift @@ -27,8 +27,8 @@ public struct DiscordPresence { /// The user associated with this presence. public let user: DiscordUser - /// The game this user is playing, if they are playing a game. - public var game: DiscordActivity? + /// All of the user's current activies. + public var activities: [DiscordActivity] /// This user's nick on this guild. public var nick: String? @@ -42,17 +42,15 @@ public struct DiscordPresence { init(presenceObject: [String: Any], guildId: GuildID) { self.guildId = guildId user = DiscordUser(userObject: presenceObject.get("user", or: [String: Any]())) - game = DiscordActivity(gameObject: presenceObject["game"] as? [String: Any]) + activities = (presenceObject["activities"] as? [[String: Any]])?.map(DiscordActivity.init(gameObject:)).compactMap { $0 } ?? [] nick = presenceObject["nick"] as? String status = DiscordPresenceStatus(rawValue: presenceObject.get("status", or: "")) ?? .offline roles = [] } mutating func updatePresence(presenceObject: [String: Any]) { - if let game = presenceObject["game"] as? [String: Any] { - self.game = DiscordActivity(gameObject: game) - } else { - self.game = nil + if let activities = presenceObject["activities"] as? [[String: Any]] { + self.activities = activities.map(DiscordActivity.init(gameObject:)).compactMap { $0 } } if let nick = presenceObject["nick"] as? String { diff --git a/Sources/SwiftDiscord/User/DiscordRole.swift b/Sources/SwiftDiscord/User/DiscordRole.swift index fc594c3b1..ce23ac2a8 100644 --- a/Sources/SwiftDiscord/User/DiscordRole.swift +++ b/Sources/SwiftDiscord/User/DiscordRole.swift @@ -50,7 +50,7 @@ public struct DiscordRole : Encodable, Equatable { managed = roleObject.get("managed", or: false) mentionable = roleObject.get("mentionable", or: false) name = roleObject.get("name", or: "") - permissions = DiscordPermission(rawValue: roleObject.get("permissions", or: 0)) + permissions = DiscordPermission(rawValue: Int(roleObject.get("permissions", or: "0")) ?? 0) position = roleObject.get("position", or: 0) } diff --git a/Sources/SwiftDiscord/User/DiscordUserGuild.swift b/Sources/SwiftDiscord/User/DiscordUserGuild.swift index 9088ef2b8..170e12add 100644 --- a/Sources/SwiftDiscord/User/DiscordUserGuild.swift +++ b/Sources/SwiftDiscord/User/DiscordUserGuild.swift @@ -39,7 +39,7 @@ public struct DiscordUserGuild { name = userGuildObject.get("name", or: "") icon = userGuildObject.get("icon", or: "") owner = userGuildObject.get("owner", or: false) - permissions = DiscordPermission(rawValue: userGuildObject.get("permissions", or: 0)) + permissions = DiscordPermission(rawValue: Int(userGuildObject.get("permissions", or: "0")) ?? 0) } static func userGuildsFromArray(_ guilds: [[String: Any]]) -> [GuildID: DiscordUserGuild] { diff --git a/Sources/SwiftDiscord/Voice/DiscordOpusCoding.swift b/Sources/SwiftDiscord/Voice/DiscordOpusCoding.swift index f4276ca98..1d1316d70 100644 --- a/Sources/SwiftDiscord/Voice/DiscordOpusCoding.swift +++ b/Sources/SwiftDiscord/Voice/DiscordOpusCoding.swift @@ -48,7 +48,7 @@ public extension DiscordOpusCodeable { /// - parameter assumingSize: The size of the frame, in number of samples per channel. /// - returns: The number of bytes in this frame. /// - public func maxFrameSize(assumingSize size: Int) -> Int { + func maxFrameSize(assumingSize size: Int) -> Int { return size * channels * MemoryLayout.size } } diff --git a/Sources/SwiftDiscord/Voice/DiscordVoiceDataSource.swift b/Sources/SwiftDiscord/Voice/DiscordVoiceDataSource.swift index 5b617be8e..40891da3c 100644 --- a/Sources/SwiftDiscord/Voice/DiscordVoiceDataSource.swift +++ b/Sources/SwiftDiscord/Voice/DiscordVoiceDataSource.swift @@ -18,9 +18,12 @@ import COPUS import Dispatch import Foundation +import Logging + +fileprivate let logger = Logger(label: "DiscordVoiceDataSource") /// Specifies that a type will be a data source for a VoiceEngine. -public protocol DiscordVoiceDataSource : class { +public protocol DiscordVoiceDataSource : AnyObject { // MARK: Properties /// The size of a frame in samples per channel. Needed to calculate the maximum size of a frame. @@ -79,8 +82,6 @@ public enum DiscordVoiceDataSourceStatus : Error { open class DiscordBufferedVoiceDataSource : DiscordVoiceDataSource { // MARK: Properties - private static let logType = "DiscordBufferedVoiceDataSource" - /// The max number of voice packets to buffer. /// Roughly equal to `(nPackets * 20ms) / 1000 = seconds to buffer`. public let bufferSize: Int @@ -139,7 +140,7 @@ open class DiscordBufferedVoiceDataSource : DiscordVoiceDataSource { } deinit { - DefaultDiscordLogger.Logger.debug("deinit", type: DiscordBufferedVoiceDataSource.logType) + logger.debug("deinit") guard !closed else { return } @@ -167,7 +168,7 @@ open class DiscordBufferedVoiceDataSource : DiscordVoiceDataSource { open func createDispatchIO() { self.source = DispatchIO(type: .stream, fileDescriptor: pipe.fileHandleForReading.fileDescriptor, queue: encoderQueue, cleanupHandler: {code in - DefaultDiscordLogger.Logger.debug("Source spent: \(code)", type: DiscordBufferedVoiceDataSource.logType) + logger.debug("Source spent: \(code)") }) } @@ -185,12 +186,11 @@ open class DiscordBufferedVoiceDataSource : DiscordVoiceDataSource { encoderQueue.sync { done = self.done - DefaultDiscordLogger.Logger.debug("Buffer state: count: \(self.readBuffer.count) drain: \(self.drain)", - type: DiscordBufferedVoiceDataSource.logType) + logger.trace("Buffer state: count: \(self.readBuffer.count) drain: \(self.drain)") if self.drain && self.readBuffer.count <= self.drainThreshold { // The swamp has been drained, start reading again - DefaultDiscordLogger.Logger.debug("Buffer drained, scheduling read", type: DiscordBufferedVoiceDataSource.logType) + logger.debug("Buffer drained, scheduling read") self.drain = false self.startReading() @@ -217,7 +217,7 @@ open class DiscordBufferedVoiceDataSource : DiscordVoiceDataSource { open func finishUpAndClose() { guard !closed else { return } - DefaultDiscordLogger.Logger.debug("Closing pipe for writing", type: DiscordBufferedVoiceDataSource.logType) + logger.debug("Closing pipe for writing") writeToHandler.closeFile() @@ -243,23 +243,21 @@ open class DiscordBufferedVoiceDataSource : DiscordVoiceDataSource { guard let this = self else { return } guard let data = data, data.count > 0 else { - DefaultDiscordLogger.Logger.debug("No data, reader probably closed", - type: DiscordBufferedVoiceDataSource.logType) + logger.debug("No data, reader probably closed") this.done = true if done && code == 0 { // EOF reached - DefaultDiscordLogger.Logger.debug("Reader done", type: DiscordBufferedVoiceDataSource.logType) + logger.debug("Reader done") } else { - DefaultDiscordLogger.Logger.debug("Something is weird \(done) \(code)", - type: DiscordBufferedVoiceDataSource.logType) + logger.debug("Something is weird \(done) \(code)") } return } - DefaultDiscordLogger.Logger.debug("Read \(data.count) bytes", type: DiscordBufferedVoiceDataSource.logType) + logger.debug("Read \(data.count) bytes") do { try data.withUnsafeBytes {(bytes: UnsafePointer) in @@ -269,8 +267,7 @@ open class DiscordBufferedVoiceDataSource : DiscordVoiceDataSource { guard this.readBuffer.count < this.bufferSize else { // Buffer is full; wait till it's drained // Whatever is in charge of taking from the buffer should queue up more reading - DefaultDiscordLogger.Logger.debug("Buffer full, not reading again", - type: DiscordBufferedVoiceDataSource.logType) + logger.debug("Buffer full, not reading again") this.drain = true return @@ -278,7 +275,7 @@ open class DiscordBufferedVoiceDataSource : DiscordVoiceDataSource { this._read() } catch { - DefaultDiscordLogger.Logger.error("Error encoding bytes", type: DiscordBufferedVoiceDataSource.logType) + logger.error("Error encoding bytes") } } } @@ -299,8 +296,6 @@ open class DiscordBufferedVoiceDataSource : DiscordVoiceDataSource { open class DiscordVoiceFileDataSource : DiscordBufferedVoiceDataSource { // MARK: Properties - private static let logType = "DiscordVoiceFileDataSource" - /// A FileHandle for reading the wrapped file. public let wrappedFile: FileHandle @@ -325,7 +320,7 @@ open class DiscordVoiceFileDataSource : DiscordBufferedVoiceDataSource { } deinit { - DefaultDiscordLogger.Logger.debug("deinit", type: DiscordVoiceFileDataSource.logType) + logger.debug("deinit") } // MARK: Methods @@ -338,7 +333,7 @@ open class DiscordVoiceFileDataSource : DiscordBufferedVoiceDataSource { open override func createDispatchIO() { self.source = DispatchIO(type: .stream, fileDescriptor: wrappedFile.fileDescriptor, queue: encoderQueue, cleanupHandler: {code in - DefaultDiscordLogger.Logger.debug("Source spent: \(code)", type: DiscordVoiceFileDataSource.logType) + logger.debug("Source spent: \(code)") }) } } @@ -418,8 +413,20 @@ public class DiscordEncoderMiddleware { pipe = Pipe() middleware.standardOutput = pipe + + let ffmpegPath = "/usr/local/bin/ffmpeg" + let ffmpegURL = URL(fileURLWithPath: ffmpegPath) + + pathSetter: do { + #if os(macOS) + guard #available(macOS 10.13, *) else { + ffmpeg.launchPath = ffmpegPath + break pathSetter + } + #endif + ffmpeg.executableURL = ffmpegURL + } - ffmpeg.launchPath = "/usr/local/bin/ffmpeg" ffmpeg.standardInput = pipe ffmpeg.standardOutput = source.writeToHandler ffmpeg.arguments = ["-hide_banner", "-loglevel", "quiet", "-i", "pipe:0", "-f", "s16le", "-map", "0:a", @@ -433,13 +440,21 @@ public class DiscordEncoderMiddleware { source?.finishUpAndClose() } } - + /// /// Starts the middleware. /// - public func start() { - ffmpeg.launch() - middleware.launch() + public func start() throws { + #if os(macOS) + guard #available(macOS 10.13, *) else { + ffmpeg.launch() + middleware.launch() + return + } + #endif + + try ffmpeg.run() + try middleware.run() } } #endif diff --git a/Sources/SwiftDiscord/Voice/DiscordVoiceDecoder.swift b/Sources/SwiftDiscord/Voice/DiscordVoiceDecoder.swift index fbaee8e82..7372b7c87 100644 --- a/Sources/SwiftDiscord/Voice/DiscordVoiceDecoder.swift +++ b/Sources/SwiftDiscord/Voice/DiscordVoiceDecoder.swift @@ -17,6 +17,9 @@ import COPUS import Foundation +import Logging + +fileprivate let logger = Logger(label: "DiscordVoiceDecoder") /// Class that decodes Opus voice data into raw PCM data for a VoiceEngine. It can decode multiple streams. Decoding is /// not thread safe, and it is up to the caller to decode safely. @@ -37,10 +40,10 @@ open class DiscordVoiceSessionDecoder { let decoder: DiscordOpusDecoder if let previous = decoders[packet.ssrc] { - DefaultDiscordLogger.Logger.debug("Reusing decoder for ssrc: \(packet.ssrc), seqNum: \(packet.seqNum), timestamp: \(packet.timestamp)", type: "DiscordVoiceSessionDecoder") + logger.debug("Reusing decoder for ssrc: \(packet.ssrc), seqNum: \(packet.seqNum), timestamp: \(packet.timestamp)") decoder = previous } else { - DefaultDiscordLogger.Logger.debug("New decoder for ssrc: \(packet.ssrc), seqNum: \(packet.seqNum), timestamp: \(packet.timestamp)", type: "DiscordVoiceSessionDecoder") + logger.debug("New decoder for ssrc: \(packet.ssrc), seqNum: \(packet.seqNum), timestamp: \(packet.timestamp)") decoder = try DiscordOpusDecoder(sampleRate: 48_000, channels: 2) decoders[packet.ssrc] = decoder } @@ -61,8 +64,8 @@ open class DiscordVoiceSessionDecoder { sequences[packet.ssrc] = packet.seqNum timestamps[packet.ssrc] = packet.timestamp - DefaultDiscordLogger.Logger.debug("Out of order packet", type: "DiscordVoiceSessionDecoder") - DefaultDiscordLogger.Logger.debug("Looks to have a sequence difference of \(packet.seqNum - previousSeqNum)", type: "DiscordVoiceSessionDecoder") + logger.debug("Out of order packet") + logger.debug("Looks to have a sequence difference of \(packet.seqNum - previousSeqNum)") for _ in 0...allocate(capacity: packetSize) let rtpHeader = createRTPHeader() - var nonce = rtpHeader + DiscordVoiceEngine.padding - var buf = data + let nonce = rtpHeader + DiscordVoiceEngine.padding + let buf = data defer { encrypted.deallocate() } @@ -239,7 +241,7 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { return rtpHeader + Array(UnsafeBufferPointer(start: encrypted, count: packetSize)) } - private func decryptVoiceData(_ data: [UInt8]) throws -> [UInt8] { + private func decryptVoiceData(_ data: Data) throws -> [UInt8] { // TODO this isn't totally correct, there might be an extension after the rtp header let rtpHeader = Array(data.prefix(12)) let voiceData = Array(data.dropFirst(12)) @@ -248,7 +250,7 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { guard audioSize > 0 else { throw EngineError.decryptionError } let unencrypted = UnsafeMutablePointer.allocate(capacity: audioSize) - var nonce = rtpHeader + DiscordVoiceEngine.padding + let nonce = rtpHeader + DiscordVoiceEngine.padding defer { unencrypted.deallocate() } @@ -263,7 +265,7 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { /// Disconnects the voice engine. /// public func disconnect() { - DefaultDiscordLogger.Logger.log("Disconnecting VoiceEngine", type: DiscordVoiceEngine.logType) + logger.info("Disconnecting VoiceEngine") closeWebSockets() closeOutEngine() @@ -271,10 +273,10 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { voiceDelegate?.voiceEngineDidDisconnect(self) } - private func extractIPAndPort(from bytes: [UInt8]) throws -> (String, Int) { - DefaultDiscordLogger.Logger.debug("Extracting ip and port from \(bytes)", type: DiscordVoiceEngine.logType) + private func extractIPAndPort(from bytes: Data) throws -> (String, Int) { + logger.debug("Extracting ip and port from \(bytes)") - let ipData = Data(bytes: bytes.dropLast(2)) + let ipData = bytes.dropLast(2) let portBytes = Array(bytes.suffix(from: bytes.endIndex.advanced(by: -2))) let port = (Int(portBytes[0]) | Int(portBytes[1])) << 8 @@ -285,18 +287,18 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { return (ipString, port) } - // https://discordapp.com/developers/docs/topics/voice-connections#ip-discovery + // https://discord.com/developers/docs/topics/voice-connections#ip-discovery private func findIP() { udpQueueWrite.async { guard let udpSocket = self.udpSocket else { return } - // print("Finding IP") let discoveryData = [UInt8](repeating: 0x00, count: 70) do { - try udpSocket.sendto(data: discoveryData) + var data = Data() + try udpSocket.write(from: Data(discoveryData)) - let (data, _) = try udpSocket.recvfrom(maxBytes: 70) + _ = try udpSocket.readDatagram(into: &data) let (ip, port) = try self.extractIPAndPort(from: data) self.selectProtocol(with: ip, on: port) @@ -329,18 +331,17 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { do { sendVoiceData(try source.engineNeedsData(self)) } catch DiscordVoiceDataSourceStatus.noData { - DefaultDiscordLogger.Logger.debug("No data", type: DiscordVoiceEngine.logType) + logger.trace("No data") if speaking { sendSpeaking(false) } } catch DiscordVoiceDataSourceStatus.done { - DefaultDiscordLogger.Logger.debug("Voice source done, sending silence", type: DiscordVoiceEngine.logType) + logger.debug("Voice source done, sending silence") sendSilence(previousSource: nil) } catch let DiscordVoiceDataSourceStatus.silenceDone(source) { - DefaultDiscordLogger.Logger.debug("Voice silence done, requesting new source", - type: DiscordVoiceEngine.logType) + logger.debug("Voice silence done, requesting new source") if speaking { sendSpeaking(false) @@ -354,7 +355,7 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { self.source = source } } catch { - DefaultDiscordLogger.Logger.error("Error getting voice data: \(error)", type: DiscordVoiceEngine.logType) + logger.error("Error getting voice data: \(error)") } } @@ -364,7 +365,7 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { /// - parameter reason: The reason the socket closed. /// public func handleClose(reason: Error? = nil) { - DefaultDiscordLogger.Logger.log("Voice engine closed", type: DiscordVoiceEngine.logType) + logger.info("Voice engine closed") closeOutEngine() } @@ -378,12 +379,11 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { /// - parameter payload: The dispatch payload /// public func handleHello(_ payload: DiscordGatewayPayload) { - DefaultDiscordLogger.Logger.debug("Handling hello \(payload)", type: DiscordVoiceEngine.logType) + logger.debug("Handling hello \(payload)") guard case let .object(helloPayload) = payload.payload, let heartbeat = helloPayload["heartbeat_interval"] as? Int else { - DefaultDiscordLogger.Logger.error("Error extracting heartbeat info \(payload)", - type: DiscordVoiceEngine.logType) + logger.error("Error extracting heartbeat info \(payload)") return } @@ -412,18 +412,18 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { self.sendSilence(previousSource: self.source) } case .speaking: - DefaultDiscordLogger.Logger.debug("Got speaking \(payload)", type: DiscordVoiceEngine.logType) + logger.debug("Got speaking \(payload)") case .hello: handleHello(payload) case .heartbeatAck: - DefaultDiscordLogger.Logger.debug("Got heartbeat ack", type: DiscordVoiceEngine.logType) + logger.debug("Got heartbeat ack") case .resumed: handleResumed(payload) case .clientDisconnect: // Should we tell someone about this? - DefaultDiscordLogger.Logger.debug("Someone left voice channel \(payload)", type: DiscordVoiceEngine.logType) + logger.debug("Someone left voice channel \(payload)") default: - DefaultDiscordLogger.Logger.debug("Unhandled voice payload \(payload)", type: DiscordVoiceEngine.logType) + logger.debug("Unhandled voice payload \(payload)") } } @@ -431,7 +431,8 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { guard case let .object(voiceInformation) = payload, let ssrc = voiceInformation["ssrc"] as? Int, let udpPort = voiceInformation["port"] as? Int, - let modes = voiceInformation["modes"] as? [String] else { + let modes = voiceInformation["modes"] as? [String], + let ip = voiceInformation["ip"] as? String else { disconnect() return @@ -440,6 +441,7 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { self.udpPort = udpPort self.modes = modes self.ssrc = UInt32(ssrc) + self.udpIp = ip startUDP() } @@ -451,7 +453,7 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { /// public func handleResumed(_ payload: DiscordGatewayPayload) { // TODO implement voice resume - DefaultDiscordLogger.Logger.debug("Should handle resumed \(payload)", type: DiscordVoiceEngine.logType) + logger.debug("Should handle resumed \(payload)") } private func handleVoiceSessionDescription(with payload: DiscordGatewayPayloadData) { @@ -471,7 +473,7 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { /// public func parseGatewayMessage(_ string: String) { guard let decoded = DiscordGatewayPayload.payloadFromString(string, fromGateway: false) else { - DefaultDiscordLogger.Logger.log("Got unknown payload \(string)", type: DiscordVoiceEngine.logType) + logger.info("Got unknown payload \(string)") return } @@ -489,9 +491,11 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { guard let socket = self?.udpSocket, self?.connected ?? false else { return } do { - let (data, _) = try socket.recvfrom(maxBytes: 4096) + var data = Data() - DefaultDiscordLogger.Logger.debug("Received data \(data)", type: "DiscordVoiceEngine") + _ = try socket.readDatagram(into: &data) + + logger.debug("Received data \(data)") guard let this = self else { return } @@ -504,9 +508,9 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { this.voiceDelegate?.voiceEngine(this, didReceiveOpusVoiceData: packet) } } catch DiscordVoiceError.initialPacket { - DefaultDiscordLogger.Logger.debug("Got initial packet", type: DiscordVoiceEngine.logType) + logger.debug("Got initial packet") } catch DiscordVoiceError.decodeFail { - DefaultDiscordLogger.Logger.debug("Failed to decode a packet", type: DiscordVoiceEngine.logType) + logger.debug("Failed to decode a packet") } catch EngineError.decryptionError { self?.error(message: "Error decrypting voice packet") } catch let err { @@ -532,8 +536,7 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { // currently only xsalsa20_poly1305 is supported // After this point we are good to go in sending encrypted voice packets private func selectProtocol(with ip: String, on port: Int) { - DefaultDiscordLogger.Logger.debug("Selecting UDP protocol with ip: \(ip) on port: \(port)", - type: DiscordVoiceEngine.logType) + logger.debug("Selecting UDP protocol with ip: \(ip) on port: \(port)") let payloadData: [String: Any] = [ "protocol": "udp", @@ -551,7 +554,7 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { // which will cause an ask for a new source readSource() - DefaultDiscordLogger.Logger.debug("VoiceEngine is ready!", type: DiscordVoiceEngine.logType) + logger.debug("VoiceEngine is ready!") guard config.captureVoice else { return } @@ -585,8 +588,9 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { self.speaking = speaking let speakingObject: [String: Any] = [ - "speaking": speaking, - "delay": 0 + "speaking": 1 << 1, + "delay": 0, + "ssrc": ssrc ] sendPayload(DiscordGatewayPayload(code: .voice(.speaking), payload: .object(speakingObject))) @@ -606,11 +610,10 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { sendSpeaking(true) } - DefaultDiscordLogger.Logger.debug("Should send voice data: \(data.count) bytes", - type: DiscordVoiceEngine.logType) + logger.trace("Should send voice data: \(data.count) bytes") do { - try udpSocket.sendto(data: createVoicePacket(data)) + try udpSocket.write(from: Data(createVoicePacket(data))) } catch EngineError.encryptionError { error(message: "Error encrypting packet") } catch let err { @@ -647,7 +650,7 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { /// - parameter terminationHandler: Called when the middleware is done. Does not mean that all encoding is done. /// public func setupMiddleware(_ middleware: Process, terminationHandler: (() -> ())?) { - DefaultDiscordLogger.Logger.debug("Setting up middleware", type: DiscordVoiceEngine.logType) + logger.debug("Setting up middleware") // TODO this is bad, fix the types here guard let source = self.source as? DiscordBufferedVoiceDataSource else { return } @@ -655,7 +658,11 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { source.middleware = DiscordEncoderMiddleware(source: source, middleware: middleware, terminationHandler: terminationHandler) - source.middleware?.start() + do { + try source.middleware?.start() + } catch { + logger.error("Could not start middleware: \(error)") + } } #endif @@ -665,7 +672,7 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { public func startHandshake() { guard voiceDelegate != nil else { return } - DefaultDiscordLogger.Logger.log("Starting voice handshake", type: DiscordVoiceEngine.logType) + logger.info("Starting voice handshake") sendPayload(DiscordGatewayPayload(code: .voice(.identify), payload: .object(handshakeObject))) } @@ -682,22 +689,28 @@ public final class DiscordVoiceEngine : DiscordVoiceEngineSpec { } private func startUDP() { - guard udpPort != -1 else { return } + guard udpPort != -1, !udpIp.isEmpty else { return } - let base = voiceServerInformation.endpoint.components(separatedBy: ":")[0] - let udpEndpoint = InternetAddress(hostname: base, port: UInt16(udpPort)) + logger.debug("Starting voice UDP connection") - DefaultDiscordLogger.Logger.debug("Starting voice UDP connection", type: DiscordVoiceEngine.logType) + do { + guard let sig = try Socket.Signature( + protocolFamily: .inet, + socketType: .datagram, + proto: .udp, + hostname: "\(udpIp)", + port: Int32(udpPort) + ) else { + throw EngineError.unknown + } - guard let client = try? UDPInternetSocket(address: udpEndpoint) else { - disconnect() + udpSocket = try Socket.create(connectedUsing: sig) - return + // Begin async UDP setup + findIP() + } catch let err { + // TODO Handle voice error disconnect from voice + logger.error("UDP setup error \(err)") } - - udpSocket = client - - // Begin async UDP setup - findIP() } } diff --git a/Sources/SwiftDiscord/Voice/DiscordVoiceEngineSpec.swift b/Sources/SwiftDiscord/Voice/DiscordVoiceEngineSpec.swift index 2b285f186..b4d9114be 100644 --- a/Sources/SwiftDiscord/Voice/DiscordVoiceEngineSpec.swift +++ b/Sources/SwiftDiscord/Voice/DiscordVoiceEngineSpec.swift @@ -19,7 +19,7 @@ import COPUS import Foundation /// Declares that a type will be a voice engine. -public protocol DiscordVoiceEngineSpec : DiscordWebSocketable, DiscordGatewayable { +public protocol DiscordVoiceEngineSpec : DiscordWebSocketable, DiscordGatewayable, DiscordRunLoopable { // MARK: Properties /// The encoder for this engine. The encoder is responsible for turning raw audio data into OPUS encoded data. @@ -60,7 +60,7 @@ public protocol DiscordVoiceEngineSpec : DiscordWebSocketable, DiscordGatewayabl } /// Declares that a type will be a client for a voice engine. -public protocol DiscordVoiceEngineDelegate : class { +public protocol DiscordVoiceEngineDelegate : AnyObject { // MARK: Methods /// diff --git a/Sources/SwiftDiscord/Voice/DiscordVoiceManager.swift b/Sources/SwiftDiscord/Voice/DiscordVoiceManager.swift index d72afa4bb..e7b6d9175 100644 --- a/Sources/SwiftDiscord/Voice/DiscordVoiceManager.swift +++ b/Sources/SwiftDiscord/Voice/DiscordVoiceManager.swift @@ -17,9 +17,12 @@ import Dispatch import Foundation +import Logging + +fileprivate let logger = Logger(label: "DiscordVoiceManager") /// A delegate for a VoiceManager. -public protocol DiscordVoiceManagerDelegate : class, DiscordTokenBearer { +public protocol DiscordVoiceManagerDelegate : AnyObject, DiscordTokenBearer, DiscordEventLoopGroupManager { // MARK: Methods /// @@ -116,7 +119,7 @@ open class DiscordVoiceManager : DiscordVoiceEngineDelegate, Lockable { /// open func leaveVoiceChannel(onGuild guildId: GuildID) { guard let engine = get(voiceEngines[guildId]) else { - DefaultDiscordLogger.Logger.error("Could not find a voice engine for guild \(guildId)", type: logType) + logger.error("Could not find a voice engine for guild \(guildId)") return } @@ -126,13 +129,13 @@ open class DiscordVoiceManager : DiscordVoiceEngineDelegate, Lockable { voiceServerInformations[guildId] = nil } - DefaultDiscordLogger.Logger.verbose("Disconnecting voice engine for guild \(guildId)", type: logType) + logger.debug("(verbose) Disconnecting voice engine for guild \(guildId)") engine.disconnect() // Make sure everything is cleaned out - DefaultDiscordLogger.Logger.verbose("Rejoining voice channels after leave", type: logType) + logger.debug("(verbose) Rejoining voice channels after leave") for (guildId, _) in voiceEngines { startVoiceConnection(guildId) @@ -154,6 +157,8 @@ open class DiscordVoiceManager : DiscordVoiceEngineDelegate, Lockable { /// Tries to create a voice engine for a guild, and connect. /// **Not thread safe.** private func _startVoiceConnection(_ guildId: GuildID) { + guard let delegate = delegate else { return } + // We need both to start the connection guard let voiceState = voiceStates[guildId], let serverInfo = voiceServerInformations[guildId] else { return @@ -161,14 +166,17 @@ open class DiscordVoiceManager : DiscordVoiceEngineDelegate, Lockable { // Reuse a previous engine's encoder if possible let previousEngine = voiceEngines[guildId] - voiceEngines[guildId] = DiscordVoiceEngine(delegate: self, - config: engineConfiguration, - voiceServerInformation: serverInfo, - voiceState: voiceState, - source: previousEngine?.source, - secret: previousEngine?.secret) - - DefaultDiscordLogger.Logger.log("Connecting voice engine", type: logType) + voiceEngines[guildId] = DiscordVoiceEngine( + delegate: self, + onLoop: delegate.runloops.next(), + config: engineConfiguration, + voiceServerInformation: serverInfo, + voiceState: voiceState, + source: previousEngine?.source, + secret: previousEngine?.secret + ) + + logger.info("Connecting voice engine") DispatchQueue.global().async {[weak engine = voiceEngines[guildId]!] in engine?.connect() diff --git a/Tests/SwiftDiscordTests/Fixtures.swift b/Tests/SwiftDiscordTests/Fixtures.swift index f89f83607..f2a77ce3d 100644 --- a/Tests/SwiftDiscordTests/Fixtures.swift +++ b/Tests/SwiftDiscordTests/Fixtures.swift @@ -16,7 +16,7 @@ let testRole: [String: Any] = [ "managed": false, "mentionable": true, "name": "My Test Role", - "permissions": 0, + "permissions": "0", "position": 0 ] @@ -95,8 +95,8 @@ let testGuildChannelCategory: [String: Any] = [ let testGuild: [String: Any] = [ "channels": [[String: Any]](), "default_message_notifications": 0, - "embed_enabled": false, - "embed_channel_id": "", + "widget_enabled": false, + "widget_channel_id": "", "emojis": [[String: Any]](), "features": [Any](), "icon": "", diff --git a/Tests/SwiftDiscordTests/TestDiscordDataStructures.swift b/Tests/SwiftDiscordTests/TestDiscordDataStructures.swift index 6f5fd6691..59038f485 100644 --- a/Tests/SwiftDiscordTests/TestDiscordDataStructures.swift +++ b/Tests/SwiftDiscordTests/TestDiscordDataStructures.swift @@ -34,7 +34,7 @@ public class TestDiscordDataStructures : XCTestCase { XCTAssertEqual(role1.hoist, role2.hoist, "Hoist should survive JSONification") XCTAssertEqual(role1.managed, role2.managed, "Managed should survive JSONification") XCTAssertEqual(role1.mentionable, role2.mentionable, "Mentionable should survive JSONification") - XCTAssertEqual(role1.name, role2.name, "Name should survive JSONificaiton") + XCTAssertEqual(role1.name, role2.name, "Name should survive JSONification") XCTAssertEqual(role1.permissions, role2.permissions, "Permissions should survive JSONification") XCTAssertEqual(role1.position, role2.position, "Position should survive JSONification") } @@ -68,7 +68,7 @@ public class TestDiscordDataStructures : XCTestCase { func testEmbedJSONification() { let dummyIconA = URL(string: "https://cdn.discordapp.com/embed/avatars/0.png")! let dummyIconB = URL(string: "https://cdn.discordapp.com/embed/avatars/1.png")! - let dummyURL = URL(string: "https://discordapp.com")! + let dummyURL = URL(string: "https://discord.com")! let embed1 = DiscordEmbed( title: "Title", diff --git a/Tests/SwiftDiscordTests/TestDiscordEngine.swift b/Tests/SwiftDiscordTests/TestDiscordEngine.swift index 0f90d39a9..6bd13b5c7 100644 --- a/Tests/SwiftDiscordTests/TestDiscordEngine.swift +++ b/Tests/SwiftDiscordTests/TestDiscordEngine.swift @@ -4,6 +4,7 @@ import Foundation import XCTest +import NIO @testable import SwiftDiscord public class TestDiscordEngine : XCTestCase, DiscordShardDelegate { @@ -39,6 +40,7 @@ public class TestDiscordEngine : XCTestCase, DiscordShardDelegate { var engine: DiscordEngine! var expectation: XCTestExpectation! + var loop: MultiThreadedEventLoopGroup! public static var allTests: [(String, (TestDiscordEngine) -> () -> ())] { return [ @@ -49,7 +51,8 @@ public class TestDiscordEngine : XCTestCase, DiscordShardDelegate { } public override func setUp() { - engine = DiscordEngine(delegate: self) + loop = MultiThreadedEventLoopGroup(numberOfThreads: 1) + engine = DiscordEngine(delegate: self, intents: .unprivilegedIntents, onLoop: loop.next()) } } diff --git a/Tests/SwiftDiscordTests/TestDiscordGuild.swift b/Tests/SwiftDiscordTests/TestDiscordGuild.swift index 970eb3793..2ce0a434c 100644 --- a/Tests/SwiftDiscordTests/TestDiscordGuild.swift +++ b/Tests/SwiftDiscordTests/TestDiscordGuild.swift @@ -25,20 +25,20 @@ public class TestDiscordGuild : XCTestCase { XCTAssertEqual(guild.defaultMessageNotifications, 0, "init should set default message notifications") } - func testCreatingGuildSetsEmbedEnabled() { - tGuild["embed_enabled"] = true + func testCreatingGuildSetsWidgetEnabled() { + tGuild["widget_enabled"] = true let guild = DiscordGuild(guildObject: tGuild, client: nil) - XCTAssertTrue(guild.embedEnabled, "init should set embed enabled") + XCTAssertTrue(guild.widgetEnabled, "init should set widget enabled") } - func testCreatingGuildSetsEmbedChannel() { - tGuild["embed_channel_id"] = "200" + func testCreatingGuildSetsWidgetChannel() { + tGuild["widget_channel_id"] = "200" let guild = DiscordGuild(guildObject: tGuild, client: nil) - XCTAssertEqual(guild.embedChannelId, 200, "init should set the embed channel id") + XCTAssertEqual(guild.widgetChannelId, 200, "init should set the widget channel id") } func testCreatingGuildSetsIcon() { @@ -229,8 +229,8 @@ public class TestDiscordGuild : XCTestCase { ("testCreatingGuildSetsId", testCreatingGuildSetsId), ("testCreatingGuildSetsName", testCreatingGuildSetsName), ("testCreatingGuildSetsDefaultMessageNotifications", testCreatingGuildSetsDefaultMessageNotifications), - ("testCreatingGuildSetsEmbedEnabled", testCreatingGuildSetsEmbedEnabled), - ("testCreatingGuildSetsEmbedChannel", testCreatingGuildSetsEmbedChannel), + ("testCreatingGuildSetsWidgetEnabled", testCreatingGuildSetsWidgetEnabled), + ("testCreatingGuildSetsWidgetChannel", testCreatingGuildSetsWidgetChannel), ("testCreatingGuildSetsIcon", testCreatingGuildSetsIcon), ("testCreatingGuildSetsLarge", testCreatingGuildSetsLarge), ("testCreatingGuildSetsMemberCount", testCreatingGuildSetsMemberCount),