From 76e79523259d9e4263785f37e7be2ea6b424ac91 Mon Sep 17 00:00:00 2001 From: Candygoblen123 Date: Fri, 2 Jun 2023 17:11:59 -0400 Subject: [PATCH 01/41] add guild member add event --- Sources/DiscordKitBot/Client.swift | 4 ++++ Sources/DiscordKitBot/NotificationNames.swift | 2 ++ Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift | 3 +++ 3 files changed, 9 insertions(+) diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index d95a411dc..16458628b 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -25,6 +25,7 @@ public final class Client { private let notificationCenter = NotificationCenter() public let ready: NCWrapper<()> public let messageCreate: NCWrapper + public let guildMemberAdd: NCWrapper // MARK: Configuration Members public let intents: Intents @@ -51,6 +52,7 @@ public final class Client { // Init event wrappers ready = .init(.ready, notificationCenter: notificationCenter) messageCreate = .init(.messageCreate, notificationCenter: notificationCenter) + guildMemberAdd = .init(.guildMemberAdd, notificationCenter: notificationCenter) } deinit { @@ -148,6 +150,8 @@ extension Client { print("Component interaction: \(componentData.custom_id)") default: break } + case .guildMemberAdd(let member): + guildMemberAdd.emit(value: member) default: break } diff --git a/Sources/DiscordKitBot/NotificationNames.swift b/Sources/DiscordKitBot/NotificationNames.swift index e87757f9d..d4599b5ef 100644 --- a/Sources/DiscordKitBot/NotificationNames.swift +++ b/Sources/DiscordKitBot/NotificationNames.swift @@ -11,4 +11,6 @@ public extension NSNotification.Name { static let ready = Self("dk-ready") static let messageCreate = Self("dk-msg-create") + + static let guildMemberAdd = Self("dk-guild-member-add") } diff --git a/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift b/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift index d69107630..50ee6733d 100644 --- a/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift +++ b/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift @@ -83,6 +83,8 @@ public struct GatewayIncoming: Decodable { /// > This event may also be dispatched when a guild becomes unavailable due to a /// > server outage. case guildDelete(GuildUnavailable) + /// Guild member join event + case guildMemberAdd(Member) // MARK: - Channels @@ -238,6 +240,7 @@ public struct GatewayIncoming: Decodable { case .guildCreate: data = .guildCreate(try values.decode(Guild.self, forKey: .data)) case .guildUpdate: data = .guildUpdate(try values.decode(Guild.self, forKey: .data)) case .guildDelete: data = .guildDelete(try values.decode(GuildUnavailable.self, forKey: .data)) + case .guildMemberAdd: data = .guildMemberAdd(try values.decode(Member.self, forKey: .data)) /* case .guildBanAdd, .guildBanRemove: data = try values.decode(GuildBan.self, forKey: .data) case .guildEmojisUpdate: data = try values.decode(GuildEmojisUpdate.self, forKey: .data) From 34694855b7d4cd7015821fbea6d2a51983f60245 Mon Sep 17 00:00:00 2001 From: Candygoblen123 Date: Fri, 2 Jun 2023 17:22:57 -0400 Subject: [PATCH 02/41] fix bug where socket was not created on macOS --- Sources/DiscordKitCore/Gateway/RobustWebSocket.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift b/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift index 1d800c64a..401d4b1ae 100644 --- a/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift +++ b/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift @@ -276,6 +276,7 @@ public class RobustWebSocket: NSObject { var gatewayReq = URLRequest(url: URL(string: DiscordKitConfig.default.gateway)!) // The difference in capitalisation is intentional gatewayReq.setValue(DiscordKitConfig.default.userAgent, forHTTPHeaderField: "User-Agent") + socket = session.webSocketTask(with: gatewayReq) socket!.maximumMessageSize = maxMsgSize #endif From e24b39ea179d53b64f2b36bec08ef583052b9399 Mon Sep 17 00:00:00 2001 From: Candygoblen123 Date: Fri, 2 Jun 2023 18:12:54 -0400 Subject: [PATCH 03/41] edit nick test --- Sources/DiscordKitBot/BotMember.swift | 51 +++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 Sources/DiscordKitBot/BotMember.swift diff --git a/Sources/DiscordKitBot/BotMember.swift b/Sources/DiscordKitBot/BotMember.swift new file mode 100644 index 000000000..7e7a9e6ee --- /dev/null +++ b/Sources/DiscordKitBot/BotMember.swift @@ -0,0 +1,51 @@ +// +// File.swift +// +// +// Created by Andrew Glaze on 6/2/23. +// + +import Foundation +import DiscordKitCore + +public struct BotMember { + public let user: User? + public let nick: String? + public let avatar: String? + public let roles: [Snowflake] + public let joined_at: Date + public let premium_since: Date? // When the user started boosting the guild + public let deaf: Bool + public let mute: Bool + public let pending: Bool? + public let permissions: String? // Total permissions of the member in the channel, including overwrites, returned when in the interaction object + public let communication_disabled_until: Date? // When the user's timeout will expire and the user will be able to communicate in the guild again, null or a time in the past if the user is not timed out + public let guild_id: Snowflake? + public let user_id: Snowflake? + + fileprivate weak var rest: DiscordREST? + + internal init(from member: Member, rest: DiscordREST) { + user = member.user + nick = member.nick + avatar = member.avatar + roles = member.roles + joined_at = member.joined_at + premium_since = member.premium_since + deaf = member.deaf + mute = member.mute + pending = member.pending + permissions = member.permissions + communication_disabled_until = member.communication_disabled_until + guild_id = member.guild_id + user_id = member.user_id + + self.rest = rest + } +} + +public extension BotMember { + func changeNickname() async throws { + try await rest?.editGuildMember(guild_id!, user_id!, ["nick":"test"]) + } +} From 43006d4beb1b9f0d8f0408ff6e556e2c9d7a2980 Mon Sep 17 00:00:00 2001 From: Candygoblen123 Date: Fri, 2 Jun 2023 18:16:21 -0400 Subject: [PATCH 04/41] edit nixk test 2 --- Sources/DiscordKitBot/Client.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 16458628b..db2b98267 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -25,7 +25,7 @@ public final class Client { private let notificationCenter = NotificationCenter() public let ready: NCWrapper<()> public let messageCreate: NCWrapper - public let guildMemberAdd: NCWrapper + public let guildMemberAdd: NCWrapper // MARK: Configuration Members public let intents: Intents @@ -151,7 +151,8 @@ extension Client { default: break } case .guildMemberAdd(let member): - guildMemberAdd.emit(value: member) + let botMember = BotMember(from: member, rest: rest) + guildMemberAdd.emit(value: botMember) default: break } From c945cb9723c1122a100e11ff910721b8c97cb0d3 Mon Sep 17 00:00:00 2001 From: Candygoblen123 Date: Fri, 2 Jun 2023 18:20:25 -0400 Subject: [PATCH 05/41] edit nick test --- Sources/DiscordKitBot/BotMember.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/DiscordKitBot/BotMember.swift b/Sources/DiscordKitBot/BotMember.swift index 7e7a9e6ee..147a04936 100644 --- a/Sources/DiscordKitBot/BotMember.swift +++ b/Sources/DiscordKitBot/BotMember.swift @@ -46,6 +46,6 @@ public struct BotMember { public extension BotMember { func changeNickname() async throws { - try await rest?.editGuildMember(guild_id!, user_id!, ["nick":"test"]) + try await rest?.editGuildMember(guild_id!, user!.id, ["nick":"test"]) } } From 3edba13bac1a650052deb8e2ec2392e0e27e329e Mon Sep 17 00:00:00 2001 From: Candygoblen123 Date: Fri, 2 Jun 2023 18:50:14 -0400 Subject: [PATCH 06/41] add role --- Sources/DiscordKitBot/BotMember.swift | 10 ++++++++-- Sources/DiscordKitBot/Client.swift | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Sources/DiscordKitBot/BotMember.swift b/Sources/DiscordKitBot/BotMember.swift index 147a04936..31d517418 100644 --- a/Sources/DiscordKitBot/BotMember.swift +++ b/Sources/DiscordKitBot/BotMember.swift @@ -45,7 +45,13 @@ public struct BotMember { } public extension BotMember { - func changeNickname() async throws { - try await rest?.editGuildMember(guild_id!, user!.id, ["nick":"test"]) + func changeNickname(_ nickname: String) async throws { + try await rest?.editGuildMember(guild_id!, user!.id, ["nick":nickname]) + } + + func addRole(_ role: Snowflake) async throws { + var roles = roles + roles.append(role) + try await rest?.editGuildMember(guild_id!, user!.id, ["roles":roles]) } } diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index db2b98267..01465765b 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -40,6 +40,7 @@ public final class Client { /// /// This is used for registering application commands, among other actions. public fileprivate(set) var applicationID: String? + public fileprivate(set) var guilds: [Snowflake]? public init(intents: Intents = .unprivileged) { self.intents = intents @@ -130,6 +131,7 @@ extension Client { // Set several members with info about the bot applicationID = readyEvt.application.id user = readyEvt.user + guilds = readyEvt.guilds.map({ $0.id }) if firstTime { Self.logger.info("Bot client ready", metadata: [ "user.id": "\(readyEvt.user.id)", @@ -137,6 +139,7 @@ extension Client { ]) ready.emit() } + case .messageCreate(let message): let botMessage = BotMessage(from: message, rest: rest) messageCreate.emit(value: botMessage) @@ -186,4 +189,8 @@ public extension Client { appCommandHandlers[registeredCommand.id] = command.handler } } + + func getGuild(id: Snowflake) async throws -> Guild { + return try await rest.getGuild(id: id) + } } From 75f8f010e78ba25b5b020be5da2cb4a526faa621 Mon Sep 17 00:00:00 2001 From: Candygoblen123 Date: Fri, 2 Jun 2023 19:11:45 -0400 Subject: [PATCH 07/41] add GetGuildRoles --- Sources/DiscordKitBot/Client.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 01465765b..0f7ce5dc7 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -193,4 +193,8 @@ public extension Client { func getGuild(id: Snowflake) async throws -> Guild { return try await rest.getGuild(id: id) } + + func getGuildRoles(id: Snowflake) async throws -> [Role] { + return try await rest.getGuildRoles(id: id) + } } From edf168adb5d9961d85174d52ffa48a5487938daa Mon Sep 17 00:00:00 2001 From: Candygoblen123 Date: Sun, 4 Jun 2023 16:52:58 -0400 Subject: [PATCH 08/41] ensure disconnect on SIGINT --- Sources/DiscordKitBot/Client.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 0f7ce5dc7..2aaf26525 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -41,6 +41,9 @@ public final class Client { /// This is used for registering application commands, among other actions. public fileprivate(set) var applicationID: String? public fileprivate(set) var guilds: [Snowflake]? + + // Static refrence to the client. + private static var client: Client? public init(intents: Intents = .unprivileged) { self.intents = intents @@ -73,6 +76,15 @@ public final class Client { evtHandlerID = gateway?.onEvent.addHandler { [weak self] data in self?.handleEvent(data) } + + let signalCallback: sig_t = { signal in + Client.logger.info("Got signal: \(signal)") + Client.client?.disconnect() + sleep(0) + exit(signal) + } + + signal(SIGINT, signalCallback) } /// Login to the Discord API with a token from the environment /// @@ -87,6 +99,7 @@ public final class Client { precondition(!token!.isEmpty, "The \"DISCORD_TOKEN\" environment variable is empty.") // We force unwrap here since that's the best way to inform the developer that they're missing a token login(token: token!) + Client.client = self } /// Disconnect from the gateway, undoes ``login(token:)`` @@ -103,6 +116,7 @@ public final class Client { rest.setToken(token: nil) applicationID = nil user = nil + Client.client = nil } } From cfb434c3c34ac0592529e21ab87494e3d22bb4d8 Mon Sep 17 00:00:00 2001 From: Candygoblen123 Date: Sun, 4 Jun 2023 16:55:10 -0400 Subject: [PATCH 09/41] bug fix --- Sources/DiscordKitBot/Client.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 2aaf26525..4dbf65c67 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -78,9 +78,9 @@ public final class Client { } let signalCallback: sig_t = { signal in - Client.logger.info("Got signal: \(signal)") + print("Gracefully stopping...") Client.client?.disconnect() - sleep(0) + sleep(0) // give other threads a tiny amount of time to finish up exit(signal) } From f608a93b9a26bb2cca63a42f5e8eaf6c8b28040c Mon Sep 17 00:00:00 2001 From: Candygoblen123 Date: Sun, 4 Jun 2023 17:14:21 -0400 Subject: [PATCH 10/41] bug fix --- Sources/DiscordKitBot/Client.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 4dbf65c67..76049b2eb 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -76,11 +76,11 @@ public final class Client { evtHandlerID = gateway?.onEvent.addHandler { [weak self] data in self?.handleEvent(data) } - + Client.client = self let signalCallback: sig_t = { signal in print("Gracefully stopping...") Client.client?.disconnect() - sleep(0) // give other threads a tiny amount of time to finish up + sleep(1) // give other threads a tiny amount of time to finish up exit(signal) } @@ -99,7 +99,6 @@ public final class Client { precondition(!token!.isEmpty, "The \"DISCORD_TOKEN\" environment variable is empty.") // We force unwrap here since that's the best way to inform the developer that they're missing a token login(token: token!) - Client.client = self } /// Disconnect from the gateway, undoes ``login(token:)`` From d862c59408da3989379113634c6637059e6ab7b3 Mon Sep 17 00:00:00 2001 From: Candygoblen123 Date: Sun, 4 Jun 2023 17:54:31 -0400 Subject: [PATCH 11/41] add BotGuild --- Sources/DiscordKitBot/Client.swift | 4 +- .../DiscordKitBot/UserObjects/BotGuild.swift | 99 +++++++++++++++++++ .../{ => UserObjects}/BotMember.swift | 0 .../{ => UserObjects}/BotMessage.swift | 0 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 Sources/DiscordKitBot/UserObjects/BotGuild.swift rename Sources/DiscordKitBot/{ => UserObjects}/BotMember.swift (100%) rename Sources/DiscordKitBot/{ => UserObjects}/BotMessage.swift (100%) diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 76049b2eb..73725cd3a 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -203,8 +203,8 @@ public extension Client { } } - func getGuild(id: Snowflake) async throws -> Guild { - return try await rest.getGuild(id: id) + func getGuild(id: Snowflake) async throws -> BotGuild { + return try await BotGuild(rest.getGuild(id: id)) } func getGuildRoles(id: Snowflake) async throws -> [Role] { diff --git a/Sources/DiscordKitBot/UserObjects/BotGuild.swift b/Sources/DiscordKitBot/UserObjects/BotGuild.swift new file mode 100644 index 000000000..e82db8985 --- /dev/null +++ b/Sources/DiscordKitBot/UserObjects/BotGuild.swift @@ -0,0 +1,99 @@ +// +// File.swift +// +// +// Created by Andrew Glaze on 6/4/23. +// + +import Foundation +import DiscordKitCore + +public struct BotGuild { + public let id: Snowflake + public let name: String + public let icon: HashedAsset? // Icon hash + public let splash: String? // Splash hash + public let discovery_splash: String? + public let owner_id: Snowflake + public let permissions: String? + public let region: String? // Voice region id for the guild (deprecated) + public let afk_channel_id: Snowflake? + public let afk_timeout: Int + public let widget_enabled: Bool? + public let widget_channel_id: Snowflake? + public let verification_level: VerificationLevel + public let explicit_content_filter: ExplicitContentFilterLevel + public let features: [DecodableThrowable] + public let mfa_level: MFALevel + public let application_id: Snowflake? // For bot-created guilds + public let system_channel_id: Snowflake? // ID of channel for system-created messages + public let rules_channel_id: Snowflake? + public var joined_at: Date? + public var large: Bool? + public var unavailable: Bool? // If guild is unavailable due to an outage + public var member_count: Int? + public var voice_states: [VoiceState]? + public var presences: [PresenceUpdate]? + public let max_members: Int? + public let vanity_url_code: String? + public let description: String? + public let banner: HashedAsset? // Banner hash + public let premium_tier: PremiumLevel + public let premium_subscription_count: Int? // Number of server boosts + public let preferred_locale: DiscordKitCore.Locale // Defaults to en-US + public let public_updates_channel_id: Snowflake? + public let max_video_channel_users: Int? + public let approximate_member_count: Int? // Approximate number of members in this guild, returned from the GET /guilds/ endpoint when with_counts is true + public let approximate_presence_count: Int? // Approximate number of non-offline members in this guild, returned from the GET /guilds/ endpoint when with_counts is true + public let nsfw_level: NSFWLevel + public var stage_instances: [StageInstance]? + public var guild_scheduled_events: [GuildScheduledEvent]? + public let premium_progress_bar_enabled: Bool + + public init(_ guild: Guild) { + id = guild.id + name = guild.name + icon = guild.icon + splash = guild.splash + discovery_splash = guild.discovery_splash + owner_id = guild.owner_id + permissions = guild.permissions + region = guild.region + afk_channel_id = guild.afk_channel_id + afk_timeout = guild.afk_timeout + widget_enabled = guild.widget_enabled + widget_channel_id = guild.widget_channel_id + verification_level = guild.verification_level + explicit_content_filter = guild.explicit_content_filter + features = guild.features + mfa_level = guild.mfa_level + application_id = guild.application_id + system_channel_id = guild.system_channel_id + rules_channel_id = guild.rules_channel_id + joined_at = guild.joined_at + large = guild.large + unavailable = guild.unavailable + member_count = guild.member_count + voice_states = guild.voice_states + presences = guild.presences + max_members = guild.max_members + vanity_url_code = guild.vanity_url_code + description = guild.description + banner = guild.banner + premium_tier = guild.premium_tier + premium_subscription_count = guild.premium_subscription_count + preferred_locale = guild.preferred_locale + public_updates_channel_id = guild.public_updates_channel_id + max_video_channel_users = guild.max_video_channel_users + approximate_member_count = guild.approximate_member_count + approximate_presence_count = guild.approximate_presence_count + nsfw_level = guild.nsfw_level + stage_instances = guild.stage_instances + guild_scheduled_events = guild.guild_scheduled_events + premium_progress_bar_enabled = guild.premium_progress_bar_enabled + } +} + +public extension BotGuild { + +} diff --git a/Sources/DiscordKitBot/BotMember.swift b/Sources/DiscordKitBot/UserObjects/BotMember.swift similarity index 100% rename from Sources/DiscordKitBot/BotMember.swift rename to Sources/DiscordKitBot/UserObjects/BotMember.swift diff --git a/Sources/DiscordKitBot/BotMessage.swift b/Sources/DiscordKitBot/UserObjects/BotMessage.swift similarity index 100% rename from Sources/DiscordKitBot/BotMessage.swift rename to Sources/DiscordKitBot/UserObjects/BotMessage.swift From 63f7a3bff5f2cd3b388ce0f7668fa089078b0e66 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Tue, 6 Jun 2023 15:24:22 +0000 Subject: [PATCH 12/41] add convenience method for loading token from file --- Sources/DiscordKitBot/Client.swift | 107 +++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 15 deletions(-) diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 73725cd3a..5780debd6 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -9,7 +9,7 @@ import Foundation import Logging import DiscordKitCore -/// The main client class for bots to interact with Discord's API +/// The main client class for bots to interact with Discord's API. Only one client is allowed to be logged in at a time. public final class Client { // REST handler private let rest = DiscordREST() @@ -42,8 +42,8 @@ public final class Client { public fileprivate(set) var applicationID: String? public fileprivate(set) var guilds: [Snowflake]? - // Static refrence to the client. - private static var client: Client? + /// Static reference to the currently logged in client. + public fileprivate(set) static var current: Client? public init(intents: Intents = .unprivileged) { self.intents = intents @@ -63,42 +63,96 @@ public final class Client { disconnect() } - /// Login to the Discord API with a token + /// Login to the Discord API with a manually provided token /// - /// Calling this function will cause a connection to the Gateway to be attempted. + /// Calling this function will cause a connection to the Gateway to be attempted, using the provided token. /// - /// > Warning: Ensure this function is called _before_ any calls to the API are made, + /// > Important: Ensure this function is called _before_ any calls to the API are made, /// > and _after_ all event sinks have been registered. API calls made before this call /// > will fail, and no events will be received while the gateway is disconnected. + /// + /// > Warning: Calling this method while a bot is already logged in will disconnect that bot and + /// > replace it with the new one. You cannot have 2 bots logged in at the same time. + /// + /// ## See Also + /// - ``login(filePath:)`` + /// - ``login()`` public func login(token: String) { + if Client.current != nil { + Client.current?.disconnect() + } rest.setToken(token: token) gateway = .init(token: token) evtHandlerID = gateway?.onEvent.addHandler { [weak self] data in self?.handleEvent(data) } - Client.client = self + Client.current = self let signalCallback: sig_t = { signal in print("Gracefully stopping...") - Client.client?.disconnect() + Client.current?.disconnect() sleep(1) // give other threads a tiny amount of time to finish up exit(signal) } signal(SIGINT, signalCallback) } + + /// Login to the Discord API with a token stored in a file + /// + /// This method attempts to retrieve the token from the file provided, + /// and calls ``login(token:)`` if it was found. + /// + /// > Important: Ensure this function is called _before_ any calls to the API are made, + /// > and _after_ all event sinks have been registered. API calls made before this call + /// > will fail, and no events will be received while the gateway is disconnected. + /// + /// > Warning: Calling this method while a bot is already logged in will disconnect that bot and + /// > replace it with the new one. You cannot have 2 bots logged in at the same time. + /// + /// - Parameter filePath: A path to the file that contains your Discord bot's token + /// + /// - Throws: `AuthError.emptyToken` if the file is empty. + /// + /// ## See Also + /// - ``login()`` + /// - ``login(token:)`` + public func login(filePath: String) throws { + let token = try String(contentsOfFile: filePath).trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + throw AuthError.emptyToken + } + login(token: token) + } + /// Login to the Discord API with a token from the environment /// /// This method attempts to retrieve the token from the `DISCORD_TOKEN` environment /// variable, and calls ``login(token:)`` if it was found. + /// + /// > Important: Ensure this function is called _before_ any calls to the API are made, + /// > and _after_ all event sinks have been registered. API calls made before this call + /// > will fail, and no events will be received while the gateway is disconnected. + /// + /// > Warning: Calling this method while a bot is already logged in will disconnect that bot and + /// > replace it with the new one. You cannot have 2 bots logged in at the same time. + /// + /// - Throws: `AuthError.emptyToken` if the `DISCORD_TOKEN` environment variable is empty. + /// `AuthError.missingEnvVar` if the `DISCORD_TOKEN` environment variable does not exist. /// /// ## See Also - /// - ``login(token:)`` If you'd like to manually provide a token instead - public func login() { + /// - ``login(filePath:)`` + /// - ``login(token:)`` + /// + public func login() throws { let token = ProcessInfo.processInfo.environment["DISCORD_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines) - precondition(token != nil, "The \"DISCORD_TOKEN\" environment variable was not found.") - precondition(!token!.isEmpty, "The \"DISCORD_TOKEN\" environment variable is empty.") - // We force unwrap here since that's the best way to inform the developer that they're missing a token - login(token: token!) + if let token = token { + if token.isEmpty { + throw AuthError.emptyToken + } + login(token: token) + } else { + throw AuthError.missingEnvVar + } } /// Disconnect from the gateway, undoes ``login(token:)`` @@ -115,10 +169,33 @@ public final class Client { rest.setToken(token: nil) applicationID = nil user = nil - Client.client = nil + Client.current = nil + } +} + +enum AuthError: Error { + case invalidToken + case missingFile + case missingEnvVar + case emptyToken +} + +extension AuthError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidToken: + return NSLocalizedString("A user-friendly description of the error.", comment: "My error") + case .missingFile: + return NSLocalizedString("The file does not exist, or your bot does not have read access to it.", comment: "File access error.") + case .missingEnvVar: + return NSLocalizedString("The \"DISCORD_TOKEN\" environment variable was not found.", comment: "ENV VAR access error.") + case .emptyToken: + return NSLocalizedString("The token provided is empty.", comment: "Invalid token.") + } } } + // Gateway API extension Client { public var isReady: Bool { gateway?.sessionOpen == true } From 36989e5c224c332ddea358717c060177bd29be19 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Tue, 6 Jun 2023 15:25:29 +0000 Subject: [PATCH 13/41] Add properties to BotMessage --- .gitignore | 1 + .../BotGuild.swift | 2 +- .../BotMember.swift | 0 .../DiscordKitBot/BotObjects/BotMessage.swift | 218 ++++++++++++++++++ Sources/DiscordKitBot/Client.swift | 6 +- .../Objects/CreateThreadRequest.swift | 5 + .../UserObjects/BotMessage.swift | 40 ---- 7 files changed, 229 insertions(+), 43 deletions(-) rename Sources/DiscordKitBot/{UserObjects => BotObjects}/BotGuild.swift (99%) rename Sources/DiscordKitBot/{UserObjects => BotObjects}/BotMember.swift (100%) create mode 100644 Sources/DiscordKitBot/BotObjects/BotMessage.swift create mode 100644 Sources/DiscordKitBot/Objects/CreateThreadRequest.swift delete mode 100644 Sources/DiscordKitBot/UserObjects/BotMessage.swift diff --git a/.gitignore b/.gitignore index 4589fb6f4..fb0a444c2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ xcuserdata/ .build/ .swiftpm/ .idea/ +_docs/ \ No newline at end of file diff --git a/Sources/DiscordKitBot/UserObjects/BotGuild.swift b/Sources/DiscordKitBot/BotObjects/BotGuild.swift similarity index 99% rename from Sources/DiscordKitBot/UserObjects/BotGuild.swift rename to Sources/DiscordKitBot/BotObjects/BotGuild.swift index e82db8985..64089e3a7 100644 --- a/Sources/DiscordKitBot/UserObjects/BotGuild.swift +++ b/Sources/DiscordKitBot/BotObjects/BotGuild.swift @@ -49,7 +49,7 @@ public struct BotGuild { public var stage_instances: [StageInstance]? public var guild_scheduled_events: [GuildScheduledEvent]? public let premium_progress_bar_enabled: Bool - + public init(_ guild: Guild) { id = guild.id name = guild.name diff --git a/Sources/DiscordKitBot/UserObjects/BotMember.swift b/Sources/DiscordKitBot/BotObjects/BotMember.swift similarity index 100% rename from Sources/DiscordKitBot/UserObjects/BotMember.swift rename to Sources/DiscordKitBot/BotObjects/BotMember.swift diff --git a/Sources/DiscordKitBot/BotObjects/BotMessage.swift b/Sources/DiscordKitBot/BotObjects/BotMessage.swift new file mode 100644 index 000000000..40578c857 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/BotMessage.swift @@ -0,0 +1,218 @@ +// +// BotMessage.swift +// +// +// Created by Vincent Kwok on 22/11/22. +// + +import Foundation +import DiscordKitCore + +/// A Discord message, with convenience methods. +public struct BotMessage { + /// ID of the message + public let id: Snowflake + + /// Channel the message was sent in + //public let channel: Channel + + /// ID of the channel the message was sent in + public let channelID: Snowflake + + /// The guild the message was sent in + public let guild: BotGuild? + /// ID of the guild the message was sent in + public let guildID: Snowflake? + + /// The author of this message (not guaranteed to be a valid user, see discussion) + /// + /// Will not be a valid user if the message was sent by a webhook. + /// > The author object follows the structure of the user object, + /// > but is only a valid user in the case where the message is generated + /// > by a user or bot user. If the message is generated by a webhook, the + /// > author object corresponds to the webhook's id, username, and avatar. + /// > You can tell if a message is generated by a webhook by checking for + /// > the webhook_id on the message object. + public var author: User + + /// Member properties for this message's author + public var member: BotMember? + + /// Contents of the message + /// + /// Up to 2000 characters for non-premium users. + public var content: String + + /// When this message was sent + public let timestamp: Date + + /// When this message was edited (or null if never) + public var editedTimestamp: Date? + + /// If this was a TTS message + public var tts: Bool + + /// Whether this message mentions everyone + public var mentionEveryone: Bool + + /// Users specifically mentioned in the message + public var mentions: [User] + + /// Roles specifically mentioned in this message + public var mentionRoles: [Snowflake] + + /// Channels specifically mentioned in this message + public var mentionChannels: [ChannelMention]? + + /// Any attached files + /// + /// See ``Attachment`` for more details. + public var attachments: [Attachment] + + /// Any embedded content + /// + /// See ``Embed`` for more details + public var embeds: [Embed] + + /// Reactions to the message + public var reactions: [Reaction]? + // Nonce can either be string or int and isn't important so I'm not including it for now + + /// If this message is pinned + public var pinned: Bool + + /// If the message is generated by a webhook, this is the webhook's ID + /// + /// Use this to check if the message is sent by a webhook. ``Message/author`` + /// will not be valid if this is not nil (was sent by a webhook). + public var webhookID: Snowflake? + + /// Type of message + /// + /// Refer to ``MessageType`` for possible values. + public let type: MessageType + + /// Sent with Rich Presence-related chat embeds + public var activity: MessageActivity? + + /// Sent with Rich Presence-related chat embeds + public var application: Application? + + /// If the message is an Interaction or application-owned webhook, this is the ID of the application + public var application_id: Snowflake? + + /// Data showing the source of a crosspost, channel follow add, pin, or reply message + public var messageReference: MessageReference? + + /// Message flags + public var flags: Int? + + /// The message associated with the message\_reference + /// + /// This field is only returned for messages with a type of ``MessageType/reply`` + /// or ``MessageType/threadStarterMsg``. If the message is a reply but the + /// referenced\_message field is not present, the backend did not attempt to + /// fetch the message that was being replied to, so its state is unknown. If + /// the field exists but is null, the referenced message was deleted. + /// + /// > Currently, it is not possible to distinguish between the field being `nil` + /// > or the field not being present. This is due to limitations with the built-in + /// > `Decodable` type. + public let referencedMessage: Message? + + /// Present if the message is a response to an Interaction + public var interaction: MessageInteraction? + + /// The thread that was started from this message, includes thread member object + public var thread: Channel? + + /// Present if the message contains components like buttons, action rows, or other interactive components + public var components: [MessageComponent]? + + /// Present if the message contains stickers + public var stickers: [StickerItem]? + + /// Present if the message is a call in DM + public var call: CallMessageComponent? + + /// The url to jump to this message + public var jumpURL: URL? + + // The REST handler associated with this message, used for message actions + fileprivate weak var rest: DiscordREST? + + internal init(from message: Message, rest: DiscordREST) async { + content = message.content + channelID = message.channel_id + id = message.id + guildID = message.guild_id + author = message.author + if let messageMember = message.member { + member = BotMember(from: messageMember, rest: rest) + } + timestamp = message.timestamp + editedTimestamp = message.edited_timestamp + tts = message.tts + mentionEveryone = message.mention_everyone + mentions = message.mentions + mentionRoles = message.mention_roles + mentionChannels = message.mention_channels + attachments = message.attachments + embeds = message.embeds + reactions = message.reactions + pinned = message.pinned + webhookID = message.webhook_id + type = message.type + activity = message.activity + application = message.application + application_id = message.application_id + messageReference = message.message_reference + flags = message.flags + referencedMessage = message.referenced_message + interaction = message.interaction + thread = message.thread + components = message.components + stickers = message.sticker_items + call = message.call + + self.rest = rest + + guild = try? await Client.current?.getGuild(id: guildID ?? "") + + //jumpURL = nil + } +} + +public extension BotMessage { + func reply(_ content: String) async throws -> Message { + return try await rest!.createChannelMsg( + message: .init(content: content, message_reference: .init(message_id: id), components: []), + id: channelID + ) + } + + func delete() async throws { + try await rest!.deleteMsg(id: channelID, msgID: id) + } + + func addReaction(emoji: Snowflake) async throws { + try await rest!.createReaction(channelID, id, emoji) + } + + func removeReaction(emoji: Snowflake) async throws { + try await rest!.deleteOwnReaction(channelID, id, emoji) + } + + func clearAllReactions() async throws { + try await rest!.deleteAllReactions(channelID, id) + } + + func clearAllReactions(for emoji: Snowflake) async throws { + try await rest!.deleteAllReactionsforEmoji(channelID, id, emoji) + } + + func createThread(name: String, autoArchiveDuration: Int?, rateLimitPerUser: Int?) async throws -> Channel { + let body = CreateThreadRequest(name: name, auto_archive_duration: autoArchiveDuration, rate_limit_per_user: rateLimitPerUser) + return try await rest!.startThreadfromMessage(channelID, id, body) + } +} diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 5780debd6..54242f9d2 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -231,8 +231,10 @@ extension Client { } case .messageCreate(let message): - let botMessage = BotMessage(from: message, rest: rest) - messageCreate.emit(value: botMessage) + Task { + let botMessage = await BotMessage(from: message, rest: rest) + messageCreate.emit(value: botMessage) + } case .interaction(let interaction): Self.logger.trace("Received interaction", metadata: ["interaction.id": "\(interaction.id)"]) // Handle interactions based on type diff --git a/Sources/DiscordKitBot/Objects/CreateThreadRequest.swift b/Sources/DiscordKitBot/Objects/CreateThreadRequest.swift new file mode 100644 index 000000000..743715759 --- /dev/null +++ b/Sources/DiscordKitBot/Objects/CreateThreadRequest.swift @@ -0,0 +1,5 @@ +struct CreateThreadRequest: Codable { + let name: String + let auto_archive_duration: Int? + let rate_limit_per_user: Int? +} \ No newline at end of file diff --git a/Sources/DiscordKitBot/UserObjects/BotMessage.swift b/Sources/DiscordKitBot/UserObjects/BotMessage.swift deleted file mode 100644 index 847aa1158..000000000 --- a/Sources/DiscordKitBot/UserObjects/BotMessage.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// BotMessage.swift -// -// -// Created by Vincent Kwok on 22/11/22. -// - -import Foundation -import DiscordKitCore - -/// A Discord message, with convenience methods -/// -/// This struct represents a message on Discord, -/// > Internally, `Message`s are converted to and from this type -/// > for easier use -public struct BotMessage { - public let content: String - public let channelID: Snowflake // This will be changed very soon - public let id: Snowflake // This too - - // The REST handler associated with this message, used for message actions - fileprivate weak var rest: DiscordREST? - - internal init(from message: Message, rest: DiscordREST) { - content = message.content - channelID = message.channel_id - id = message.id - - self.rest = rest - } -} - -public extension BotMessage { - func reply(_ content: String) async throws -> Message { - return try await rest!.createChannelMsg( - message: .init(content: content, message_reference: .init(message_id: id), components: []), - id: channelID - ) - } -} From a946b9419e5e67ca995637490da5bdcaf873d437 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Tue, 6 Jun 2023 15:28:17 +0000 Subject: [PATCH 14/41] Block main thread to prevent exit --- Sources/DiscordKitBot/Client.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 54242f9d2..a3a5cd53e 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -87,14 +87,19 @@ public final class Client { self?.handleEvent(data) } Client.current = self + + // Handle exit with SIGINT let signalCallback: sig_t = { signal in print("Gracefully stopping...") Client.current?.disconnect() sleep(1) // give other threads a tiny amount of time to finish up exit(signal) } - signal(SIGINT, signalCallback) + + // Block thread to prevent exit + RunLoop.main.run() + } /// Login to the Discord API with a token stored in a file From 242e03d3ccba484672a14e79526b13ffe388e952 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Wed, 7 Jun 2023 15:25:49 +0000 Subject: [PATCH 15/41] rename Bot classes --- .../DiscordKitBot/BotObjects/BotMember.swift | 57 ----- .../{BotGuild.swift => Guild.swift} | 8 +- Sources/DiscordKitBot/BotObjects/Member.swift | 76 +++++++ .../{BotMessage.swift => Message.swift} | 203 ++++++++++++++++-- 4 files changed, 264 insertions(+), 80 deletions(-) delete mode 100644 Sources/DiscordKitBot/BotObjects/BotMember.swift rename Sources/DiscordKitBot/BotObjects/{BotGuild.swift => Guild.swift} (97%) create mode 100644 Sources/DiscordKitBot/BotObjects/Member.swift rename Sources/DiscordKitBot/BotObjects/{BotMessage.swift => Message.swift} (51%) diff --git a/Sources/DiscordKitBot/BotObjects/BotMember.swift b/Sources/DiscordKitBot/BotObjects/BotMember.swift deleted file mode 100644 index 31d517418..000000000 --- a/Sources/DiscordKitBot/BotObjects/BotMember.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// File.swift -// -// -// Created by Andrew Glaze on 6/2/23. -// - -import Foundation -import DiscordKitCore - -public struct BotMember { - public let user: User? - public let nick: String? - public let avatar: String? - public let roles: [Snowflake] - public let joined_at: Date - public let premium_since: Date? // When the user started boosting the guild - public let deaf: Bool - public let mute: Bool - public let pending: Bool? - public let permissions: String? // Total permissions of the member in the channel, including overwrites, returned when in the interaction object - public let communication_disabled_until: Date? // When the user's timeout will expire and the user will be able to communicate in the guild again, null or a time in the past if the user is not timed out - public let guild_id: Snowflake? - public let user_id: Snowflake? - - fileprivate weak var rest: DiscordREST? - - internal init(from member: Member, rest: DiscordREST) { - user = member.user - nick = member.nick - avatar = member.avatar - roles = member.roles - joined_at = member.joined_at - premium_since = member.premium_since - deaf = member.deaf - mute = member.mute - pending = member.pending - permissions = member.permissions - communication_disabled_until = member.communication_disabled_until - guild_id = member.guild_id - user_id = member.user_id - - self.rest = rest - } -} - -public extension BotMember { - func changeNickname(_ nickname: String) async throws { - try await rest?.editGuildMember(guild_id!, user!.id, ["nick":nickname]) - } - - func addRole(_ role: Snowflake) async throws { - var roles = roles - roles.append(role) - try await rest?.editGuildMember(guild_id!, user!.id, ["roles":roles]) - } -} diff --git a/Sources/DiscordKitBot/BotObjects/BotGuild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift similarity index 97% rename from Sources/DiscordKitBot/BotObjects/BotGuild.swift rename to Sources/DiscordKitBot/BotObjects/Guild.swift index 64089e3a7..0915100c9 100644 --- a/Sources/DiscordKitBot/BotObjects/BotGuild.swift +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -8,7 +8,7 @@ import Foundation import DiscordKitCore -public struct BotGuild { +public struct Guild { public let id: Snowflake public let name: String public let icon: HashedAsset? // Icon hash @@ -50,7 +50,7 @@ public struct BotGuild { public var guild_scheduled_events: [GuildScheduledEvent]? public let premium_progress_bar_enabled: Bool - public init(_ guild: Guild) { + public init(_ guild: DiscordKitCore.Guild) { id = guild.id name = guild.name icon = guild.icon @@ -94,6 +94,6 @@ public struct BotGuild { } } -public extension BotGuild { - +public extension Guild { + } diff --git a/Sources/DiscordKitBot/BotObjects/Member.swift b/Sources/DiscordKitBot/BotObjects/Member.swift new file mode 100644 index 000000000..02dbd8398 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Member.swift @@ -0,0 +1,76 @@ +// +// File.swift +// +// +// Created by Andrew Glaze on 6/2/23. +// + +import Foundation +import DiscordKitCore + +public struct Member { + public let user: User? + public let nick: String? + public let avatar: String? + public let roles: [Snowflake] + public let joinedAt: Date + public let premiumSince: Date? // When the user started boosting the guild + public let deaf: Bool + public let mute: Bool + public let pending: Bool? + public let permissions: String? // Total permissions of the member in the channel, including overwrites, returned when in the interaction object + public let timedOutUntil: Date? // When the user's timeout will expire and the user will be able to communicate in the guild again, null or a time in the past if the user is not timed out + public let guildID: Snowflake? + public let userID: Snowflake? + + fileprivate weak var rest: DiscordREST? + + internal init(from member: DiscordKitCore.Member, rest: DiscordREST) { + user = member.user + nick = member.nick + avatar = member.avatar + roles = member.roles + joinedAt = member.joined_at + premiumSince = member.premium_since + deaf = member.deaf + mute = member.mute + pending = member.pending + permissions = member.permissions + timedOutUntil = member.communication_disabled_until + guildID = member.guild_id + userID = member.user_id + + self.rest = rest + } +} + +public extension Member { + func changeNickname(_ nickname: String) async throws { + try await rest?.editGuildMember(guildID!, user!.id, ["nick": nickname]) + } + + func addRole(_ role: Snowflake) async throws { + var roles = roles + roles.append(role) + try await rest?.editGuildMember(guildID!, user!.id, ["roles": roles]) + } + + func removeRole(_ role: Snowflake) async throws { + try await rest!.removeGuildMemberRole(guildID!, user!.id, role) + } + + func timeout(time: Date) async throws { + try await rest!.editGuildMember(guildID!, user!.id, ["communication_disabled_until" : time]) + } + + /// Creates a DM with this user. + /// + /// Important: You should not use this endpoint to DM everyone in a server about something. + /// DMs should generally be initiated by a user action. If you open a significant + /// amount of DMs too quickly, your bot may be rate limited or blocked from opening new ones. + /// + /// - Returns: The DM ``Channel`` + func createDM() async throws -> Channel { + return try await rest!.createDM(["recipient_id":user!.id]) + } +} diff --git a/Sources/DiscordKitBot/BotObjects/BotMessage.swift b/Sources/DiscordKitBot/BotObjects/Message.swift similarity index 51% rename from Sources/DiscordKitBot/BotObjects/BotMessage.swift rename to Sources/DiscordKitBot/BotObjects/Message.swift index 40578c857..5262db387 100644 --- a/Sources/DiscordKitBot/BotObjects/BotMessage.swift +++ b/Sources/DiscordKitBot/BotObjects/Message.swift @@ -9,18 +9,18 @@ import Foundation import DiscordKitCore /// A Discord message, with convenience methods. -public struct BotMessage { +public struct Message { /// ID of the message public let id: Snowflake /// Channel the message was sent in - //public let channel: Channel + // public let channel: Channel /// ID of the channel the message was sent in public let channelID: Snowflake /// The guild the message was sent in - public let guild: BotGuild? + public let guild: Guild? /// ID of the guild the message was sent in public let guildID: Snowflake? @@ -36,7 +36,7 @@ public struct BotMessage { public var author: User /// Member properties for this message's author - public var member: BotMember? + public var member: Member? /// Contents of the message /// @@ -65,13 +65,9 @@ public struct BotMessage { public var mentionChannels: [ChannelMention]? /// Any attached files - /// - /// See ``Attachment`` for more details. public var attachments: [Attachment] /// Any embedded content - /// - /// See ``Embed`` for more details public var embeds: [Embed] /// Reactions to the message @@ -83,7 +79,7 @@ public struct BotMessage { /// If the message is generated by a webhook, this is the webhook's ID /// - /// Use this to check if the message is sent by a webhook. ``Message/author`` + /// Use this to check if the message is sent by a webhook. ``author`` /// will not be valid if this is not nil (was sent by a webhook). public var webhookID: Snowflake? @@ -118,7 +114,7 @@ public struct BotMessage { /// > Currently, it is not possible to distinguish between the field being `nil` /// > or the field not being present. This is due to limitations with the built-in /// > `Decodable` type. - public let referencedMessage: Message? + public let referencedMessage: DiscordKitCore.Message? /// Present if the message is a response to an Interaction public var interaction: MessageInteraction? @@ -141,14 +137,14 @@ public struct BotMessage { // The REST handler associated with this message, used for message actions fileprivate weak var rest: DiscordREST? - internal init(from message: Message, rest: DiscordREST) async { + internal init(from message: DiscordKitCore.Message, rest: DiscordREST) async { content = message.content channelID = message.channel_id id = message.id guildID = message.guild_id author = message.author if let messageMember = message.member { - member = BotMember(from: messageMember, rest: rest) + member = Member(from: messageMember, rest: rest) } timestamp = message.timestamp editedTimestamp = message.edited_timestamp @@ -162,7 +158,7 @@ public struct BotMessage { reactions = message.reactions pinned = message.pinned webhookID = message.webhook_id - type = message.type + type = MessageType(message.type)! activity = message.activity application = message.application application_id = message.application_id @@ -179,40 +175,209 @@ public struct BotMessage { guild = try? await Client.current?.getGuild(id: guildID ?? "") - //jumpURL = nil + // jumpURL = nil } } -public extension BotMessage { - func reply(_ content: String) async throws -> Message { - return try await rest!.createChannelMsg( +public extension Message { + /// Sends a reply to the message + /// + /// - Parameter content: The content of the reply message + func reply(_ content: String) async throws -> DiscordKitBot.Message { + let coreMessage = try await rest!.createChannelMsg( message: .init(content: content, message_reference: .init(message_id: id), components: []), id: channelID ) + + return await Message(from: coreMessage, rest: rest!) } + /// Deletes the message. + /// + /// You can always delete your own messages, but deleting other people's messages requires the `manage_messages` guild permission. func delete() async throws { try await rest!.deleteMsg(id: channelID, msgID: id) } - func addReaction(emoji: Snowflake) async throws { + /// Edits the message + /// + /// You can only edit your own messages. + /// + /// - Parameter content: The content of the edited message + func edit(content: String?) async throws { + try await rest!.editMessage(channelID, id, DiscordKitCore.NewMessage(content: content)) + } + + /// Add a reaction emoji to the message. + /// + /// - Parameter emoji: The emote in the form `:emote_name:emote_id` + func addReaction(emoji: String) async throws { try await rest!.createReaction(channelID, id, emoji) } + /// Removes your own reaction from a message + /// + /// - Parameter emoji: The emote in the form `:emote_name:emote_id` func removeReaction(emoji: Snowflake) async throws { try await rest!.deleteOwnReaction(channelID, id, emoji) } + /// Clear all reactions from a message + /// + /// Requires the the `manage_messages` guild permission. func clearAllReactions() async throws { try await rest!.deleteAllReactions(channelID, id) } - + /// Clear all reactions from a message of a specific emoji + /// + /// Requires the the `manage_messages` guild permission. + /// + /// - Parameter emoji: The emote in the form `:emote_name:emote_id` func clearAllReactions(for emoji: Snowflake) async throws { try await rest!.deleteAllReactionsforEmoji(channelID, id, emoji) } + /// Starts a thread from the message + /// + /// Requires the `create_public_threads`` guild permission. func createThread(name: String, autoArchiveDuration: Int?, rateLimitPerUser: Int?) async throws -> Channel { let body = CreateThreadRequest(name: name, auto_archive_duration: autoArchiveDuration, rate_limit_per_user: rateLimitPerUser) return try await rest!.startThreadfromMessage(channelID, id, body) } + + /// Pins the message. + /// + /// Requires the `manage_messages` guild permission to do this in a non-private channel context. + func pin() async throws { + try await rest!.pinMessage(channelID, id) + } + + /// Unpins the message. + /// + /// Requires the `manage_messages` guild permission to do this in a non-private channel context. + func unpin() async throws { + try await rest!.unpinMessage(channelID, id) + } + + /// Publishes a message in an announcement channel to it's followers. + /// + /// Requires the `SEND_MESSAGES` permission, if the bot sent the message, or the `MANAGE_MESSAGES` permission for all other messages + func publish() async throws -> Message { + let coreMessage: DiscordKitCore.Message = try await rest!.crosspostMessage(channelID, id) + return await Message(from: coreMessage, rest: rest!) + } + + static func ==(lhs: Message, rhs: Message) -> Bool { + return lhs.id == rhs.id + } + + static func !=(lhs: Message, rhs: Message) -> Bool { + return lhs.id != rhs.id + } +} + +/// An `enum` representing message types. +/// +/// Some of these descriptions were taken from [The Discord.py documentation](https://discordpy.readthedocs.io/en/stable/api.html?#discord.MessageType), +/// which is licensed under the MIT license. +public enum MessageType: Int, Codable { + /// Default text message + case defaultMsg = 0 + + /// Sent when a member joins a group DM + case recipientAdd = 1 + + /// Sent when a member is removed from a group DM + case recipientRemove = 2 + + /// Incoming call + case call = 3 + + /// Channel name changes + case chNameChange = 4 + + /// Channel icon changes + case chIconChange = 5 + + /// Pinned message add/remove + case chPinnedMsg = 6 + + /// Sent when a user joins a server + case guildMemberJoin = 7 + + /// Sent when a user boosts a server + case userPremiumGuildSub = 8 + + /// Sent when a user boosts a server and that server reaches boost level 1 + case userPremiumGuildSubTier1 = 9 + + /// Sent when a user boosts a server and that server reaches boost level 2 + case userPremiumGuildSubTier2 = 10 + + /// Sent when a user boosts a server and that server reaches boost level 3 + case userPremiumGuildSubTier3 = 11 + + /// Sent when an announcement channel has been followed + case chFollowAdd = 12 + + /// Sent when a server is no longer eligible for server discovery + case guildDiscoveryDisqualified = 14 + + /// Sent when a server is eligible for server discovery + case guildDiscoveryRequalified = 15 + + /// Sent when a server has not met the Server Discovery requirements for 1 week + case guildDiscoveryGraceInitial = 16 + + /// Sent when a server has not met the Server Discovery requirements for 3 weeks in a row + case guildDiscoveryGraceFinal = 17 + + /// Sent when a thread has been created on an old message + /// + /// What qualifies as an "old message" is not defined, and is decided by discord. + /// It should not be something you rely upon. + case threadCreated = 18 + + /// A message replying to another message + case reply = 19 + + /// The system message denoting that a slash command was executed. + case chatInputCmd = 20 + + /// The system message denoting the message in the thread that is the one that started the thread’s conversation topic. + case threadStarterMsg = 21 + + /// The system message reminding you to invite people to the guild. + case guildInviteReminder = 22 + + /// The system message denoting that a context menu command was executed. + case contextMenuCmd = 23 + + /// A message detailing an action taken by automod + case autoModAct = 24 + + /// The system message sent when a user purchases or renews a role subscription. + case roleSubscriptionPurchase = 25 + + /// The system message sent when a user is given an advertisement to purchase a premium tier for an application during an interaction. + case interactionPremiumUpsell = 26 + + /// The system message sent when the stage starts. + case stageStart = 27 + + /// The system message sent when the stage ends. + case stageEnd = 28 + + /// The system message sent when the stage speaker changes. + case stageSpeaker = 29 + + /// The system message sent when the stage topic changes. + case stageTopic = 31 + + /// The system message sent when an application’s premium subscription is purchased for the guild. + case guildApplicationPremiumSubscription = 32 + + init?(_ coreMessageType: DiscordKitCore.MessageType) { + self.init(rawValue: coreMessageType.rawValue) + } } From 214b03661c643ff8565e6d63b933e30cbaeb2826 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Wed, 7 Jun 2023 15:26:12 +0000 Subject: [PATCH 16/41] Docs and Fixes --- .../ApplicationCommand/CommandData.swift | 3 +- Sources/DiscordKitBot/Client.swift | 73 ++++++++++++------- .../Objects/CreateThreadRequest.swift | 2 +- .../Objects/WebhookResponse.swift | 4 +- Sources/DiscordKitBot/REST/APICommand.swift | 2 +- .../DiscordKitCore/Objects/Data/Message.swift | 21 ++++++ .../Objects/Gateway/GatewayIO.swift | 2 +- Sources/DiscordKitCore/REST/APIChannel.swift | 10 +-- Sources/DiscordKitCore/REST/APIRequest.swift | 17 +++++ 9 files changed, 96 insertions(+), 38 deletions(-) diff --git a/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift b/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift index 360766262..f4a77501b 100644 --- a/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift +++ b/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift @@ -133,7 +133,8 @@ public extension CommandData { /// reply in clients. However, if a call to ``deferReply()`` was made, this /// edits the loading message with the content provided. func followUp(content: String?, embeds: [BotEmbed]?, components: [Component]?) async throws -> Message { - try await rest!.sendInteractionFollowUp(.init(content: content, embeds: embeds, components: components), applicationID: applicationID, token: token) + let coreMessage = try await rest!.sendInteractionFollowUp(.init(content: content, embeds: embeds, components: components), applicationID: applicationID, token: token) + return await Message(from: coreMessage, rest: rest!) } /// Defer the reply to this interaction - the user sees a loading state diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index a3a5cd53e..93227696f 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -23,12 +23,27 @@ public final class Client { // MARK: Event publishers private let notificationCenter = NotificationCenter() + /// An event that is fired when the bot is connected to discord and ready to do bot-like things public let ready: NCWrapper<()> - public let messageCreate: NCWrapper - public let guildMemberAdd: NCWrapper + /// An event that is fired whenever a message is created in any channel the bot has access to. + public let messageCreate: NCWrapper + /// An event that is fired when a new user joins a guild that the bot is in. + public let guildMemberAdd: NCWrapper // MARK: Configuration Members + /// The intents configured for this client. + /// + /// ## See also + /// - [Intents on the Discord Developer documentation](https://discord.com/developers/docs/topics/gateway#gateway-intents) public let intents: Intents + /// Set this to false to disable blocking the main thread when calling ``login(token:)``. + /// + /// This is an advanced use case, and considered unsafe to disable, however we provide the option to do so if you wish. + /// It is strongly recommended to instead use a listener on ``ready`` to perform operations after your bot is logged in. + /// + /// ## See also + /// - ``ready`` + public var blockOnLogin: Bool = true // Logger private static let logger = Logger(label: "Client", level: nil) @@ -40,8 +55,11 @@ public final class Client { /// /// This is used for registering application commands, among other actions. public fileprivate(set) var applicationID: String? + /// A list of Guild IDs of the guilds that the bot is in. + /// + /// To get the full ``Guild`` object, call ``getGuild(id:)``. public fileprivate(set) var guilds: [Snowflake]? - + /// Static reference to the currently logged in client. public fileprivate(set) static var current: Client? @@ -67,9 +85,9 @@ public final class Client { /// /// Calling this function will cause a connection to the Gateway to be attempted, using the provided token. /// - /// > Important: Ensure this function is called _before_ any calls to the API are made, - /// > and _after_ all event sinks have been registered. API calls made before this call - /// > will fail, and no events will be received while the gateway is disconnected. + /// > Important: This method will block the main thread for the rest of the execution time. + /// > This means that any code after this call in the main thread will never run, and should be considered unreachable. + /// > If you want to perform operations immediately after your bot is logged in, add a listener to ``ready``. /// /// > Warning: Calling this method while a bot is already logged in will disconnect that bot and /// > replace it with the new one. You cannot have 2 bots logged in at the same time. @@ -97,19 +115,21 @@ public final class Client { } signal(SIGINT, signalCallback) - // Block thread to prevent exit - RunLoop.main.run() + if blockOnLogin { + // Block thread to prevent exit + RunLoop.main.run() + } } /// Login to the Discord API with a token stored in a file /// /// This method attempts to retrieve the token from the file provided, - /// and calls ``login(token:)`` if it was found. + /// and calls ``login(token:)`` if it was found. Any API calls made before this method is ran will fail. /// - /// > Important: Ensure this function is called _before_ any calls to the API are made, - /// > and _after_ all event sinks have been registered. API calls made before this call - /// > will fail, and no events will be received while the gateway is disconnected. + /// > Important: This method will block the main thread for the rest of the execution time. + /// > This means that any code after this call in the main thread will never run, and should be considered unreachable. + /// > If you want to perform operations immediately after your bot is logged in, add a listener to ``ready``. /// /// > Warning: Calling this method while a bot is already logged in will disconnect that bot and /// > replace it with the new one. You cannot have 2 bots logged in at the same time. @@ -132,13 +152,13 @@ public final class Client { /// Login to the Discord API with a token from the environment /// /// This method attempts to retrieve the token from the `DISCORD_TOKEN` environment - /// variable, and calls ``login(token:)`` if it was found. + /// variable, and calls ``login(token:)`` if it was found. Any API calls made before this method is ran will fail. /// - /// > Important: Ensure this function is called _before_ any calls to the API are made, - /// > and _after_ all event sinks have been registered. API calls made before this call - /// > will fail, and no events will be received while the gateway is disconnected. + /// > Important: This method will block the main thread for the rest of the execution time. + /// > This means that any code after this call in the main thread will never run, and should be considered unreachable. + /// > If you want to perform operations immediately after your bot is logged in, add a listener to ``ready``. /// - /// > Warning: Calling this method while a bot is already logged in will disconnect that bot and + /// > Warning: Calling this method while a bot is already logged in will disconnect that bot's session and /// > replace it with the new one. You cannot have 2 bots logged in at the same time. /// /// - Throws: `AuthError.emptyToken` if the `DISCORD_TOKEN` environment variable is empty. @@ -147,7 +167,6 @@ public final class Client { /// ## See Also /// - ``login(filePath:)`` /// - ``login(token:)`` - /// public func login() throws { let token = ProcessInfo.processInfo.environment["DISCORD_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines) if let token = token { @@ -200,9 +219,9 @@ extension AuthError: LocalizedError { } } - // Gateway API extension Client { + /// `true` if the bot is connected to discord and ready to do bot-like things. public var isReady: Bool { gateway?.sessionOpen == true } /// Invoke the handler associated with the respective commands @@ -234,10 +253,9 @@ extension Client { ]) ready.emit() } - case .messageCreate(let message): Task { - let botMessage = await BotMessage(from: message, rest: rest) + let botMessage = await Message(from: message, rest: rest) messageCreate.emit(value: botMessage) } case .interaction(let interaction): @@ -251,7 +269,7 @@ extension Client { default: break } case .guildMemberAdd(let member): - let botMember = BotMember(from: member, rest: rest) + let botMember = Member(from: member, rest: rest) guildMemberAdd.emit(value: botMember) default: break @@ -286,11 +304,14 @@ public extension Client { appCommandHandlers[registeredCommand.id] = command.handler } } - - func getGuild(id: Snowflake) async throws -> BotGuild { - return try await BotGuild(rest.getGuild(id: id)) + /// Gets a ``Guild`` object from a guild ID. + /// + /// - Parameter id: The Snowflake ID of a Guild that your bot is in. + /// - Returns: A ``Guild`` object containing information about the guild. + func getGuild(id: Snowflake) async throws -> Guild { + return try await Guild(rest.getGuild(id: id)) } - + func getGuildRoles(id: Snowflake) async throws -> [Role] { return try await rest.getGuildRoles(id: id) } diff --git a/Sources/DiscordKitBot/Objects/CreateThreadRequest.swift b/Sources/DiscordKitBot/Objects/CreateThreadRequest.swift index 743715759..d6dd3c330 100644 --- a/Sources/DiscordKitBot/Objects/CreateThreadRequest.swift +++ b/Sources/DiscordKitBot/Objects/CreateThreadRequest.swift @@ -2,4 +2,4 @@ struct CreateThreadRequest: Codable { let name: String let auto_archive_duration: Int? let rate_limit_per_user: Int? -} \ No newline at end of file +} diff --git a/Sources/DiscordKitBot/Objects/WebhookResponse.swift b/Sources/DiscordKitBot/Objects/WebhookResponse.swift index ad43dd2fd..d2085c651 100644 --- a/Sources/DiscordKitBot/Objects/WebhookResponse.swift +++ b/Sources/DiscordKitBot/Objects/WebhookResponse.swift @@ -15,7 +15,7 @@ public struct WebhookResponse: Encodable { components: [Component]? = nil, username: String? = nil, avatarURL: URL? = nil, allowedMentions: AllowedMentions? = nil, - flags: Message.Flags? = nil, + flags: DiscordKitCore.Message.Flags? = nil, threadName: String? = nil ) { assert(content != nil || embeds != nil, "Must have at least one of content or embeds (files unsupported)") @@ -40,7 +40,7 @@ public struct WebhookResponse: Encodable { public let allowed_mentions: AllowedMentions? public let components: [Component]? public let attachments: [NewAttachment]? - public let flags: Message.Flags? + public let flags: DiscordKitCore.Message.Flags? public let thread_name: String? enum CodingKeys: CodingKey { diff --git a/Sources/DiscordKitBot/REST/APICommand.swift b/Sources/DiscordKitBot/REST/APICommand.swift index 655563576..8d311db20 100644 --- a/Sources/DiscordKitBot/REST/APICommand.swift +++ b/Sources/DiscordKitBot/REST/APICommand.swift @@ -99,7 +99,7 @@ public extension DiscordREST { /// Send a follow up response to an interaction /// /// > POST: `/webhooks/{application.id}/{interaction.token}` - func sendInteractionFollowUp(_ response: WebhookResponse, applicationID: Snowflake, token: String) async throws -> Message { + func sendInteractionFollowUp(_ response: WebhookResponse, applicationID: Snowflake, token: String) async throws -> DiscordKitCore.Message { try await postReq(path: "webhooks/\(applicationID)/\(token)", body: response) } } diff --git a/Sources/DiscordKitCore/Objects/Data/Message.swift b/Sources/DiscordKitCore/Objects/Data/Message.swift index 28cfa2e16..fde396db7 100644 --- a/Sources/DiscordKitCore/Objects/Data/Message.swift +++ b/Sources/DiscordKitCore/Objects/Data/Message.swift @@ -53,6 +53,27 @@ public enum MessageType: Int, Codable { /// A message detailing an action taken by automod case autoModAct = 24 + + /// The system message sent when a user purchases or renews a role subscription. + case roleSubscriptionPurchase = 25 + + /// The system message sent when a user is given an advertisement to purchase a premium tier for an application during an interaction. + case interactionPremiumUpsell = 26 + + /// The system message sent when the stage starts. + case stageStart = 27 + + /// The system message sent when the stage ends. + case stageEnd = 28 + + /// The system message sent when the stage speaker changes. + case stageSpeaker = 29 + + /// The system message sent when the stage topic changes. + case stageTopic = 31 + + /// The system message sent when an application’s premium subscription is purchased for the guild. + case guildApplicationPremiumSubscription = 32 } /// Represents a message sent in a channel within Discord diff --git a/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift b/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift index 50ee6733d..2c8412420 100644 --- a/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift +++ b/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift @@ -84,7 +84,7 @@ public struct GatewayIncoming: Decodable { /// > server outage. case guildDelete(GuildUnavailable) /// Guild member join event - case guildMemberAdd(Member) + case guildMemberAdd(DiscordKitCore.Member) // MARK: - Channels diff --git a/Sources/DiscordKitCore/REST/APIChannel.swift b/Sources/DiscordKitCore/REST/APIChannel.swift index f4601d6b8..d0adce3e7 100644 --- a/Sources/DiscordKitCore/REST/APIChannel.swift +++ b/Sources/DiscordKitCore/REST/APIChannel.swift @@ -103,16 +103,14 @@ public extension DiscordREST { /// Crosspost Message /// /// > POST: `/channels/{channel.id}/messages/{message.id}/crosspost` - func crosspostMessage( + func crosspostMessage( _ channelId: Snowflake, - _ messageId: Snowflake, - _ body: B + _ messageId: Snowflake ) async throws -> T { return try await postReq( - path: "channels/\(channelId)/messages/\(messageId)/crosspost", - body: body - ) + path: "channels/\(channelId)/messages/\(messageId)/crosspost") } + /// Create Reaction /// /// > PUT: `/channels/{channel.id}/messages/{message.id}/reactions/{emoji}/@me` diff --git a/Sources/DiscordKitCore/REST/APIRequest.swift b/Sources/DiscordKitCore/REST/APIRequest.swift index 880725215..09501c1dd 100644 --- a/Sources/DiscordKitCore/REST/APIRequest.swift +++ b/Sources/DiscordKitCore/REST/APIRequest.swift @@ -174,6 +174,23 @@ public extension DiscordREST { ) } + /// Make a `POST` request to the Discord REST APIfor endpoints + /// that require no payload + func postReq( + path: String + ) async throws -> T { + let respData = try await makeRequest( + path: path, + body: nil, + method: .post + ) + do { + return try DiscordREST.decoder.decode(T.self, from: respData) + } catch { + throw RequestError.jsonDecodingError(error: error) + } + } + /// Make a `POST` request to the Discord REST API, for endpoints /// that both require no payload and returns a 204 empty response func postReq(path: String) async throws { From 680200b26bef2a6b95b417b972a278bbb47bd9ea Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Wed, 7 Jun 2023 15:37:25 +0000 Subject: [PATCH 17/41] Member methods --- Sources/DiscordKitBot/BotObjects/Channel.swift | 0 Sources/DiscordKitBot/BotObjects/Member.swift | 12 ++++++++++++ 2 files changed, 12 insertions(+) create mode 100644 Sources/DiscordKitBot/BotObjects/Channel.swift diff --git a/Sources/DiscordKitBot/BotObjects/Channel.swift b/Sources/DiscordKitBot/BotObjects/Channel.swift new file mode 100644 index 000000000..e69de29bb diff --git a/Sources/DiscordKitBot/BotObjects/Member.swift b/Sources/DiscordKitBot/BotObjects/Member.swift index 02dbd8398..a4c9657a4 100644 --- a/Sources/DiscordKitBot/BotObjects/Member.swift +++ b/Sources/DiscordKitBot/BotObjects/Member.swift @@ -63,6 +63,18 @@ public extension Member { try await rest!.editGuildMember(guildID!, user!.id, ["communication_disabled_until" : time]) } + func kick() async throws { + try await rest!.removeGuildMember(guildID!, user!.id) + } + + func ban(messageDeleteSeconds: Int = 0) async throws { + try await rest!.createGuildBan(guildID!, user!.id, ["delete_message_seconds":messageDeleteSeconds]) + } + + func unban() async throws { + try await rest!.removeGuildBan(guildID!, user!.id) + } + /// Creates a DM with this user. /// /// Important: You should not use this endpoint to DM everyone in a server about something. From 447c5fbfa7c51b0c86b58c1ba9fb140852988c50 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Tue, 13 Jun 2023 12:57:52 +0000 Subject: [PATCH 18/41] Add basic classes and methods --- .../BotObjects/Channels/Category.swift | 22 ++++++ .../BotObjects/Channels/ForumChannel.swift | 6 ++ .../BotObjects/Channels/GuildChannel.swift | 48 ++++++++++++ .../BotObjects/Channels/StageChannel.swift | 6 ++ .../BotObjects/Channels/TextChannel.swift | 78 +++++++++++++++++++ .../BotObjects/Channels/VoiceChannel.swift | 6 ++ Sources/DiscordKitBot/Client.swift | 2 +- .../DiscordKitBot/Extensions/Sequence+.swift | 13 ++++ .../Objects/CreateChannelInviteReq.swift | 6 ++ .../Objects/Data/Snowflake.swift | 14 ++++ Sources/DiscordKitCore/REST/APIChannel.swift | 22 +++++- 11 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 Sources/DiscordKitBot/BotObjects/Channels/Category.swift create mode 100644 Sources/DiscordKitBot/BotObjects/Channels/ForumChannel.swift create mode 100644 Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift create mode 100644 Sources/DiscordKitBot/BotObjects/Channels/StageChannel.swift create mode 100644 Sources/DiscordKitBot/BotObjects/Channels/TextChannel.swift create mode 100644 Sources/DiscordKitBot/BotObjects/Channels/VoiceChannel.swift create mode 100644 Sources/DiscordKitBot/Extensions/Sequence+.swift create mode 100644 Sources/DiscordKitBot/Objects/CreateChannelInviteReq.swift diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift new file mode 100644 index 000000000..e2fbd96a6 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift @@ -0,0 +1,22 @@ +import Foundation +import DiscordKitCore + +public class CategoryChannel: GuildChannel { + private let coreChannels: [DiscordKitCore.Channel]? + public let channels: [GuildChannel]? + public let textChannels: [TextChannel]? + public let voiceChannels: [VoiceChannel]? = nil + public let stageChannels: [StageChannel]? = nil + public let nsfw: Bool + + override init(from channel: DiscordKitCore.Channel, rest: DiscordREST) async { + coreChannels = try? await rest.getGuildChannels(id: channel.guild_id!).compactMap({ try? $0.result.get() }) + channels = await coreChannels?.asyncMap({ await GuildChannel(from: $0, rest: rest) }) + textChannels = await coreChannels?.asyncMap({ await TextChannel(from: $0, rest: rest) }) + + nsfw = channel.nsfw ?? false + await super.init(from: channel, rest: rest) + + } + +} \ No newline at end of file diff --git a/Sources/DiscordKitBot/BotObjects/Channels/ForumChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/ForumChannel.swift new file mode 100644 index 000000000..5cbafb850 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/ForumChannel.swift @@ -0,0 +1,6 @@ +import Foundation +import DiscordKitCore + +public class ForumChannel: GuildChannel { + +} \ No newline at end of file diff --git a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift new file mode 100644 index 000000000..790bdad35 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift @@ -0,0 +1,48 @@ +import Foundation +import DiscordKitCore + +public class GuildChannel { + let name: String? + let category: CategoryChannel? = nil + let createdAt: Date? + fileprivate(set) var guild: Guild? = nil + // let jumpURL: URL + let mention: String + let position: Int? + let type: ChannelType + let id: Snowflake + private let rest: DiscordREST + + internal init(from channel: DiscordKitCore.Channel, rest: DiscordREST) async { + self.name = channel.name + //self.category = channel.parent_id + self.createdAt = channel.id.creationTime() + if let guildID = channel.guild_id { + self.guild = try? await Client.current?.getGuild(id: guildID) + } + position = channel.position + type = channel.type + id = channel.id + self.rest = rest + self.mention = "<#\(id)>" + } + + public convenience init(from id: Snowflake) async { + let coreChannel = try! await Client.current!.rest.getChannel(id: id) + await self.init(from: coreChannel, rest: Client.current!.rest) + } +} + +public extension GuildChannel { + func createInvite(maxAge: Int = 0, maxUsers: Int = 0, temporary: Bool = false, unique: Bool = false) async throws -> Invite { + let body = CreateChannelInviteReq(max_age: maxAge, max_users: maxUsers, temporary: temporary, unique: unique) + return try await rest.createChannelInvite(id, body) + } + + func delete() async throws { + try await rest.deleteChannel(id: id) + } + + +} + diff --git a/Sources/DiscordKitBot/BotObjects/Channels/StageChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/StageChannel.swift new file mode 100644 index 000000000..65b4f12d6 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/StageChannel.swift @@ -0,0 +1,6 @@ +import Foundation +import DiscordKitCore + +public class StageChannel: GuildChannel { + +} \ No newline at end of file diff --git a/Sources/DiscordKitBot/BotObjects/Channels/TextChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/TextChannel.swift new file mode 100644 index 000000000..a8545fbb8 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/TextChannel.swift @@ -0,0 +1,78 @@ +import Foundation +import DiscordKitCore + +public class TextChannel: GuildChannel { + let lastMessage: Message? + let lastMessageID: Snowflake? + // fileprivate(set) var members: [Member]? = nil + let nsfw: Bool + fileprivate(set) var threads: [TextChannel]? = nil + let topic: String? + private let rest: DiscordREST + + internal override init(from channel: Channel, rest: DiscordREST) async { + nsfw = channel.nsfw ?? false + + lastMessageID = channel.last_message_id + if let lastMessageID = lastMessageID { + let coreMessage = try? await rest.getChannelMsg(id: channel.id, msgID: lastMessageID) + if let coreMessage = coreMessage { + lastMessage = await Message(from: coreMessage, rest: rest) + } else { + lastMessage = nil + } + } else { + lastMessage = nil + } + + topic = channel.topic + + let coreThreads = try? await rest.getGuildChannels(id: channel.guild_id!) + .compactMap({ try? $0.result.get() }).filter({ $0.type == .publicThread || $0.type == .privateThread }) + + threads = await coreThreads!.asyncMap({ await TextChannel(from: $0, rest: rest) }) + + self.rest = rest + + await super.init(from: channel, rest: rest) + + } + + public convenience init(from id: Snowflake) async { + let coreChannel = try! await Client.current!.rest.getChannel(id: id) + await self.init(from: coreChannel, rest: Client.current!.rest) + } +} + +public extension TextChannel { + func getMessages(limit: Int = 50) async throws -> [Message] { + let coreMessages = try await rest.getChannelMsgs(id: id, limit: limit) + return await coreMessages.asyncMap({ await Message(from: $0, rest: rest) }) + } + + func getMessages(around: Message, limit: Int = 50) async throws -> [Message] { + let coreMessages = try await rest.getChannelMsgs(id: id, limit: limit, around: around.id) + return await coreMessages.asyncMap({ await Message(from: $0, rest: rest) }) + } + + func getMessages(before: Message, limit: Int = 50) async throws -> [Message] { + let coreMessages = try await rest.getChannelMsgs(id: id, limit: limit, before: before.id) + return await coreMessages.asyncMap({ await Message(from: $0, rest: rest) }) + } + + func getMessages(after: Message, limit: Int = 50) async throws -> [Message] { + let coreMessages = try await rest.getChannelMsgs(id: id, limit: limit, after: after.id) + return await coreMessages.asyncMap({ await Message(from: $0, rest: rest) }) + } + + func send(message: NewMessage) async throws -> Message { + let coreMessage = try await rest.createChannelMsg(message: message, id: id) + return await Message(from: coreMessage, rest: rest) + } + + func deleteMessages(messages: [Message]) async throws { + let snowflakes = messages.map({ $0.id }) + try await rest.bulkDeleteMessages(id, ["messages":snowflakes]) + } + +} \ No newline at end of file diff --git a/Sources/DiscordKitBot/BotObjects/Channels/VoiceChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/VoiceChannel.swift new file mode 100644 index 000000000..5049a994a --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/VoiceChannel.swift @@ -0,0 +1,6 @@ +import Foundation +import DiscordKitCore + +public class VoiceChannel: GuildChannel { + +} \ No newline at end of file diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 93227696f..42817226d 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -12,7 +12,7 @@ import DiscordKitCore /// The main client class for bots to interact with Discord's API. Only one client is allowed to be logged in at a time. public final class Client { // REST handler - private let rest = DiscordREST() + public let rest = DiscordREST() // MARK: Gateway vars fileprivate var gateway: RobustWebSocket? diff --git a/Sources/DiscordKitBot/Extensions/Sequence+.swift b/Sources/DiscordKitBot/Extensions/Sequence+.swift new file mode 100644 index 000000000..9c4f3b896 --- /dev/null +++ b/Sources/DiscordKitBot/Extensions/Sequence+.swift @@ -0,0 +1,13 @@ +extension Sequence { + func asyncMap( + _ transform: (Element) async throws -> T + ) async rethrows -> [T] { + var values = [T]() + + for element in self { + try await values.append(transform(element)) + } + + return values + } +} \ No newline at end of file diff --git a/Sources/DiscordKitBot/Objects/CreateChannelInviteReq.swift b/Sources/DiscordKitBot/Objects/CreateChannelInviteReq.swift new file mode 100644 index 000000000..3581b3fd7 --- /dev/null +++ b/Sources/DiscordKitBot/Objects/CreateChannelInviteReq.swift @@ -0,0 +1,6 @@ +struct CreateChannelInviteReq: Codable { + let max_age: Int + let max_users: Int + let temporary: Bool + let unique: Bool +} \ No newline at end of file diff --git a/Sources/DiscordKitCore/Objects/Data/Snowflake.swift b/Sources/DiscordKitCore/Objects/Data/Snowflake.swift index a8da6fb56..973fcdc4a 100644 --- a/Sources/DiscordKitCore/Objects/Data/Snowflake.swift +++ b/Sources/DiscordKitCore/Objects/Data/Snowflake.swift @@ -8,3 +8,17 @@ import Foundation public typealias Snowflake = String + +public extension Snowflake { + func creationTime() -> Date { + // Convert to a unsigned integer + let snowflake = UInt64(self)! + // shifts the bits so that only the first 42 are used + let snowflakeTimestamp = snowflake >> 22 + // Discord snowflake timestamps start from the first second of 2015 + let discordEpoch = Date(timeIntervalSince1970: 1420070400) + + // Convert from ms to sec, because Date wants sec, but discord provides ms + return Date(timeInterval: Double(snowflakeTimestamp) / 1000, since: discordEpoch) + } +} \ No newline at end of file diff --git a/Sources/DiscordKitCore/REST/APIChannel.swift b/Sources/DiscordKitCore/REST/APIChannel.swift index d0adce3e7..bc593a1a4 100644 --- a/Sources/DiscordKitCore/REST/APIChannel.swift +++ b/Sources/DiscordKitCore/REST/APIChannel.swift @@ -3,7 +3,21 @@ import Foundation public extension DiscordREST { - /// Get Channel Messages + /// Get channel + /// + /// > DELETE: `/channels/{channel.id}` + func getChannel(id: Snowflake) async throws -> Channel { + return try await getReq(path: "channels/\(id)") + } + + /// Delete channel + /// + /// > DELETE: `/channels/{channel.id}` + func deleteChannel(id: Snowflake) async throws { + try await deleteReq(path: "channels/\(id)") + } + + /// Get Channel Messages /// /// > GET: `/channels/{channel.id}/messages` func getChannelMsgs( @@ -199,11 +213,11 @@ public extension DiscordREST { /// Bulk Delete Messages /// /// > POST: `/channels/{channel.id}/messages/bulk-delete` - func bulkDeleteMessages( + func bulkDeleteMessages( _ channelId: Snowflake, _ body: B - ) async throws -> T { - return try await postReq( + ) async throws { + try await postReq( path: "channels/\(channelId)/messages/bulk-delete", body: body ) From bb220a2474573ae4e1edce202a54395c1b2a1e83 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Tue, 13 Jun 2023 15:57:51 +0000 Subject: [PATCH 19/41] Documentation stuff --- .../BotObjects/Channels/Category.swift | 16 +-- .../BotObjects/Channels/GuildChannel.swift | 96 +++++++++++++++--- .../BotObjects/Channels/TextChannel.swift | 60 +++++++++-- Sources/DiscordKitBot/BotObjects/Member.swift | 6 +- .../Documentation.docc/DiscordKitBot.md | 64 ++++++++++++ .../documentation-art/discordkit-icon@2x.png | Bin 0 -> 59121 bytes .../Objects/CreateGuildChannelReq.swift | 20 ++++ .../Objects/EditChannelPermissionsReq.swift | 7 ++ Sources/DiscordKitCore/REST/APIChannel.swift | 6 +- 9 files changed, 239 insertions(+), 36 deletions(-) create mode 100644 Sources/DiscordKitBot/Documentation.docc/DiscordKitBot.md create mode 100644 Sources/DiscordKitBot/Documentation.docc/Resources/documentation-art/discordkit-icon@2x.png create mode 100644 Sources/DiscordKitBot/Objects/CreateGuildChannelReq.swift create mode 100644 Sources/DiscordKitBot/Objects/EditChannelPermissionsReq.swift diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift index e2fbd96a6..93afb2d75 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift @@ -9,14 +9,18 @@ public class CategoryChannel: GuildChannel { public let stageChannels: [StageChannel]? = nil public let nsfw: Bool - override init(from channel: DiscordKitCore.Channel, rest: DiscordREST) async { - coreChannels = try? await rest.getGuildChannels(id: channel.guild_id!).compactMap({ try? $0.result.get() }) - channels = await coreChannels?.asyncMap({ await GuildChannel(from: $0, rest: rest) }) - textChannels = await coreChannels?.asyncMap({ await TextChannel(from: $0, rest: rest) }) - + override init(from channel: DiscordKitCore.Channel, rest: DiscordREST) async throws { + coreChannels = try await rest.getGuildChannels(id: channel.guild_id!).compactMap({ try $0.result.get() }) + channels = try await coreChannels?.asyncMap({ try await GuildChannel(from: $0, rest: rest) }) + textChannels = try await coreChannels?.asyncMap({ try await TextChannel(from: $0, rest: rest) }) nsfw = channel.nsfw ?? false - await super.init(from: channel, rest: rest) + try await super.init(from: channel, rest: rest) + } + + convenience init(from id: Snowflake) async throws { + let coreChannel = try await Client.current!.rest.getChannel(id: id) + try await self.init(from: coreChannel, rest: Client.current!.rest) } } \ No newline at end of file diff --git a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift index 790bdad35..ae87c255b 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift @@ -1,48 +1,114 @@ import Foundation import DiscordKitCore +/// Represents a channel in a guild, a superclass to all other types of channel. public class GuildChannel { - let name: String? - let category: CategoryChannel? = nil - let createdAt: Date? - fileprivate(set) var guild: Guild? = nil + /// The name of the channel. + public let name: String? + /// The category the channel is located in, if any. + public fileprivate(set) var category: CategoryChannel? = nil + /// When the channel was created. + public let createdAt: Date? + /// The guild that the channel belongs to. + public fileprivate(set) var guild: Guild? = nil // let jumpURL: URL - let mention: String - let position: Int? - let type: ChannelType - let id: Snowflake + /// A string you can put in message contents to mention the channel. + public let mention: String + /// The position of the channel in the Guild's channel list + public let position: Int? + /// Permission overwrites for this channel. + public let overwrites: [PermOverwrite]? + /// Whether or not the permissions for this channel are synced with the category it belongs to. + public fileprivate(set) var permissionsSynced: Bool = false + /// The Type of the channel. + public let type: ChannelType + /// The `Snowflake` ID of the channel. + public let id: Snowflake + + private let rest: DiscordREST + internal let coreChannel: DiscordKitCore.Channel - internal init(from channel: DiscordKitCore.Channel, rest: DiscordREST) async { + internal init(from channel: DiscordKitCore.Channel, rest: DiscordREST) async throws { + self.coreChannel = channel self.name = channel.name - //self.category = channel.parent_id self.createdAt = channel.id.creationTime() if let guildID = channel.guild_id { - self.guild = try? await Client.current?.getGuild(id: guildID) + self.guild = try await Client.current?.getGuild(id: guildID) } position = channel.position type = channel.type id = channel.id self.rest = rest self.mention = "<#\(id)>" + self.overwrites = channel.permission_overwrites + if let categoryID = channel.parent_id { + let coreCategory = try await rest.getChannel(id: categoryID) + self.category = try await CategoryChannel(from: coreCategory, rest: rest) + self.permissionsSynced = channel.permissions == coreCategory.permissions + } } - public convenience init(from id: Snowflake) async { - let coreChannel = try! await Client.current!.rest.getChannel(id: id) - await self.init(from: coreChannel, rest: Client.current!.rest) + /// Initialize an Channel using an ID. + /// - Parameter id: The `Snowflake` ID of the channel you want to get. + public convenience init(from id: Snowflake) async throws { + let coreChannel = try await Client.current!.rest.getChannel(id: id) + try await self.init(from: coreChannel, rest: Client.current!.rest) } } public extension GuildChannel { + /// Creates an invite to the current channel. + /// - Parameters: + /// - maxAge: How long the invite should last in seconds. If it’s 0 then the invite doesn’t expire. Defaults to `0`. + /// - maxUsers: How many uses the invite could be used for. If it’s 0 then there are unlimited uses. Defaults to `0`. + /// - temporary: Denotes that the invite grants temporary membership (i.e. they get kicked after they disconnect). Defaults to `false`. + /// - unique: Indicates if a unique invite URL should be created. Defaults to `true`. If this is set to `False` then it will return a previously created invite. + /// - Returns: The newly created `Invite`. func createInvite(maxAge: Int = 0, maxUsers: Int = 0, temporary: Bool = false, unique: Bool = false) async throws -> Invite { let body = CreateChannelInviteReq(max_age: maxAge, max_users: maxUsers, temporary: temporary, unique: unique) return try await rest.createChannelInvite(id, body) } + /// Deletes the channel. See discussion for warnings. + /// + /// > Warning: Deleting a guild channel cannot be undone. Use this with caution, as it is impossible to undo this action when performed on a guild channel. + /// > + /// > In contrast, when used with a private message, it is possible to undo the action by opening a private message with the recipient again. func delete() async throws { try await rest.deleteChannel(id: id) } - + /// Gets all the invites for the current channel. + /// - Returns: An Array of `Invite`s for the current channel. + func invites() async throws -> [Invite] { + return try await rest.getChannelInvites(id) + } + + /// Clones a channel, with the only difference being the name. + /// - Parameter name: The name of the cloned channel. + /// - Returns: The newly cloned channel. + func clone(name: String) async throws -> GuildChannel { + let body = CreateGuildChannelRed(name: name, type: coreChannel.type, topic: coreChannel.topic, bitrate: coreChannel.bitrate, user_limit: coreChannel.user_limit, rate_limit_per_user: coreChannel.rate_limit_per_user, position: coreChannel.position, permission_overwrites: coreChannel.permission_overwrites, parent_id: coreChannel.parent_id, nsfw: coreChannel.nsfw, rtc_region: coreChannel.rtc_region, video_quality_mode: coreChannel.video_quality_mode, default_auto_archive_duration: coreChannel.default_auto_archive_duration) + let newCh: DiscordKitCore.Channel = try await rest.createGuildChannel(guild!.id, body) + return try await GuildChannel(from: newCh, rest: rest) + } + + /// Gets the permission overrides for a specific member. + /// - Parameter member: The member to get overrides for. + /// - Returns: The permission overrides for that member. + func overridesFor(_ member: Member) -> [PermOverwrite]? { + return overwrites?.filter({ $0.id == member.id && $0.type == .member}) + } + + /// Sets the permissions for a member. + /// - Parameters: + /// - member: The member to set permissions for + /// - allow: The permissions you want to allow, as an array of Permissions objects + /// - deny: The permissions you want to deny, as an array of Permissions objects + func setPermissions(for member: Member, allow: [Permissions], deny: [Permissions]) async throws { + let body = EditChannelPermissionsReq(allow: Permissions(allow), deny: Permissions(deny), type: .member) + try await rest.editChannelPermissions(id, member.id!, body) + } } diff --git a/Sources/DiscordKitBot/BotObjects/Channels/TextChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/TextChannel.swift index a8545fbb8..a00493b07 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/TextChannel.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/TextChannel.swift @@ -1,17 +1,28 @@ import Foundation import DiscordKitCore +/// Represents a Discord Text Channel, and contains convenience methods for working with them. public class TextChannel: GuildChannel { + /// The last message sent in this channel. It may not represent a valid message. let lastMessage: Message? + /// The id of the last message sent in this channel. It may not point to a valid message. let lastMessageID: Snowflake? // fileprivate(set) var members: [Member]? = nil + /// If the channel is marked as “not safe for work” or “age restricted”. let nsfw: Bool + /// All the threads that your bot can see. fileprivate(set) var threads: [TextChannel]? = nil + /// The topic of the channel let topic: String? + /// The number of seconds a member must wait between sending messages in this channel. + /// A value of 0 denotes that it is disabled. + /// Bots and users with manage_channels or manage_messages bypass slowmode. + let slowmodeDelay: Int private let rest: DiscordREST - internal override init(from channel: Channel, rest: DiscordREST) async { + internal override init(from channel: Channel, rest: DiscordREST) async throws { nsfw = channel.nsfw ?? false + slowmodeDelay = channel.rate_limit_per_user ?? 0 lastMessageID = channel.last_message_id if let lastMessageID = lastMessageID { @@ -30,46 +41,77 @@ public class TextChannel: GuildChannel { let coreThreads = try? await rest.getGuildChannels(id: channel.guild_id!) .compactMap({ try? $0.result.get() }).filter({ $0.type == .publicThread || $0.type == .privateThread }) - threads = await coreThreads!.asyncMap({ await TextChannel(from: $0, rest: rest) }) + threads = try await coreThreads!.asyncMap({ try await TextChannel(from: $0, rest: rest) }) self.rest = rest - await super.init(from: channel, rest: rest) + try await super.init(from: channel, rest: rest) } - public convenience init(from id: Snowflake) async { + public convenience init(from id: Snowflake) async throws { let coreChannel = try! await Client.current!.rest.getChannel(id: id) - await self.init(from: coreChannel, rest: Client.current!.rest) + try await self.init(from: coreChannel, rest: Client.current!.rest) } } public extension TextChannel { - func getMessages(limit: Int = 50) async throws -> [Message] { + /// Gets a single message in this channel from it's `Snowflake` ID. + /// - Parameter id: The `Snowflake` ID of the message + /// - Returns: The ``Message`` asked for. + func getMessage(_ id: Snowflake) async throws -> Message { + let coreMessage = try await rest.getChannelMsg(id: self.id, msgID: id) + return await Message(from: coreMessage, rest: rest) + } + + /// Retrieve message history starting from the most recent message in the channel. + /// - Parameter limit: The number of messages to retrieve. If not provided, it defaults to 50. + /// - Returns: The last `limit` messages sent in the channel. + func getMessageHistory(limit: Int = 50) async throws -> [Message] { let coreMessages = try await rest.getChannelMsgs(id: id, limit: limit) return await coreMessages.asyncMap({ await Message(from: $0, rest: rest) }) } - func getMessages(around: Message, limit: Int = 50) async throws -> [Message] { + /// Retrieve message history surrounding a certain message. + /// - Parameters: + /// - around: Retrieve messages around this message. + /// - limit: The number of messages to retrieve. If not provided, it defaults to 50. + /// - Returns: An array of ``Message``s around the message provided. + func getMessageHistory(around: Message, limit: Int = 50) async throws -> [Message] { let coreMessages = try await rest.getChannelMsgs(id: id, limit: limit, around: around.id) return await coreMessages.asyncMap({ await Message(from: $0, rest: rest) }) } - func getMessages(before: Message, limit: Int = 50) async throws -> [Message] { + /// Retrieve message before after a certain message. + /// - Parameters: + /// - before: Retrieve messages before this message. + /// - limit: The number of messages to retrieve. If not provided, it defaults to 50. + /// - Returns: An array of ``Message``s before the message provided. + func getMessageHistory(before: Message, limit: Int = 50) async throws -> [Message] { let coreMessages = try await rest.getChannelMsgs(id: id, limit: limit, before: before.id) return await coreMessages.asyncMap({ await Message(from: $0, rest: rest) }) } - func getMessages(after: Message, limit: Int = 50) async throws -> [Message] { + /// Retrieve message after after a certain message. + /// - Parameters: + /// - after: Retrieve messages after this message. + /// - limit: The number of messages to retrieve. If not provided, it defaults to 50. + /// - Returns: An array of ``Message``s after the message provided + func getMessageHistory(after: Message, limit: Int = 50) async throws -> [Message] { let coreMessages = try await rest.getChannelMsgs(id: id, limit: limit, after: after.id) return await coreMessages.asyncMap({ await Message(from: $0, rest: rest) }) } + /// Sends a message in the channel. + /// - Parameter message: The message to send + /// - Returns: The sent message. func send(message: NewMessage) async throws -> Message { let coreMessage = try await rest.createChannelMsg(message: message, id: id) return await Message(from: coreMessage, rest: rest) } + /// Bulk delete messages in this channel. + /// - Parameter messages: An array of ``Message``s to delete. func deleteMessages(messages: [Message]) async throws { let snowflakes = messages.map({ $0.id }) try await rest.bulkDeleteMessages(id, ["messages":snowflakes]) diff --git a/Sources/DiscordKitBot/BotObjects/Member.swift b/Sources/DiscordKitBot/BotObjects/Member.swift index a4c9657a4..f0048e48b 100644 --- a/Sources/DiscordKitBot/BotObjects/Member.swift +++ b/Sources/DiscordKitBot/BotObjects/Member.swift @@ -9,6 +9,7 @@ import Foundation import DiscordKitCore public struct Member { + public let id: Snowflake? public let user: User? public let nick: String? public let avatar: String? @@ -21,7 +22,6 @@ public struct Member { public let permissions: String? // Total permissions of the member in the channel, including overwrites, returned when in the interaction object public let timedOutUntil: Date? // When the user's timeout will expire and the user will be able to communicate in the guild again, null or a time in the past if the user is not timed out public let guildID: Snowflake? - public let userID: Snowflake? fileprivate weak var rest: DiscordREST? @@ -38,7 +38,7 @@ public struct Member { permissions = member.permissions timedOutUntil = member.communication_disabled_until guildID = member.guild_id - userID = member.user_id + id = member.user_id self.rest = rest } @@ -81,7 +81,7 @@ public extension Member { /// DMs should generally be initiated by a user action. If you open a significant /// amount of DMs too quickly, your bot may be rate limited or blocked from opening new ones. /// - /// - Returns: The DM ``Channel`` + /// - Returns: The newly created DM Channel func createDM() async throws -> Channel { return try await rest!.createDM(["recipient_id":user!.id]) } diff --git a/Sources/DiscordKitBot/Documentation.docc/DiscordKitBot.md b/Sources/DiscordKitBot/Documentation.docc/DiscordKitBot.md new file mode 100644 index 000000000..94b7c2866 --- /dev/null +++ b/Sources/DiscordKitBot/Documentation.docc/DiscordKitBot.md @@ -0,0 +1,64 @@ +# ``DiscordKitBot`` + +So you want to make a Discord bot in Swift, I hear? + +@Metadata { + @PageImage( + purpose: icon, + source: "discordkit-icon", + alt: "A technology icon representing the SlothCreator framework.") +} + +``DiscordKitBot`` is a swift library for creating Discord bots in Swift. It aims to make a first-class discord bot building experience in Swift, while also being computationally and memory efficient. + +You are currently at the symbol documentation for ``DiscordKitBot``, which is useful if you just need a quick reference for the library. If you are looking for a getting started guide, we have one that walks you through creating your first bot [over here](https://swiftcord.gitbook.io/discordkit-guide/). + +> Note: Keep in mind that DiscordKitBot support is still in beta, so the API might change at any time without notice. +> We do not recommend using DiscordKitBot for bots in production right now. + +## Topics + +### Clients + +- ``Client`` + +### Working with Guilds + +- ``Guild`` +- ``Member`` + +### Working with Channels + +- ``GuildChannel`` +- ``TextChannel`` +- ``VoiceChannel`` +- ``CategoryChannel`` +- ``StageChannel`` +- ``ForumChannel`` + +### Working with Messages +- ``Message`` +- ``MessageType`` + +### Working with Slash Commands + +- ``AppCommandBuilder`` +- ``AppCommandOptionChoice`` +- ``NewAppCommand`` +- ``SubCommand`` +- ``CommandOption`` +- ``OptionBuilder`` +- ``BooleanOption`` +- ``StringOption`` +- ``NumberOption`` +- ``IntegerOption`` +- ``CommandData`` +- ``InteractionResponse`` + +### Working with Embeds +- ``BotEmbed`` +- ``EmbedBuilder`` +- ``ComponentBuilder`` +- ``EmbedFieldBuilder`` +- ``ActionRow`` +- ``Button`` \ No newline at end of file diff --git a/Sources/DiscordKitBot/Documentation.docc/Resources/documentation-art/discordkit-icon@2x.png b/Sources/DiscordKitBot/Documentation.docc/Resources/documentation-art/discordkit-icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..3b41742b2ad008407541d8fe6b35362f390fe944 GIT binary patch literal 59121 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
fAD1tLt~VKqSe4zTy2 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 PUT: `/channels/{channel.id}/permissions/{overwrite.id}` - func editChannelPermissions( + func editChannelPermissions( _ channelId: Snowflake, _ overwriteId: Snowflake, _ body: B - ) async throws -> T { - return try await putReq( + ) async throws { + try await putReq( path: "channels/\(channelId)/permissions/\(overwriteId)", body: body ) From c11eb29db5c7cc97e3813f8df4c43db678b0bd92 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Wed, 14 Jun 2023 12:56:04 +0000 Subject: [PATCH 20/41] more docs stuff --- .github/workflows/generate-docc.yml | 6 +- .../BotObjects/Channels/GuildChannel.swift | 12 ++-- Sources/DiscordKitBot/BotObjects/Member.swift | 67 ++++++++++++++----- Sources/DiscordKitBot/Client.swift | 2 + .../Documentation.docc/DiscordKitBot.md | 2 +- 5 files changed, 64 insertions(+), 25 deletions(-) diff --git a/.github/workflows/generate-docc.yml b/.github/workflows/generate-docc.yml index dd69411b4..97d8311ad 100644 --- a/.github/workflows/generate-docc.yml +++ b/.github/workflows/generate-docc.yml @@ -19,7 +19,7 @@ env: jobs: generate: - runs-on: macos-12 + runs-on: macos-13 env: BUILD_DIR: _docs/ @@ -41,8 +41,8 @@ jobs: ########################## ## Select Xcode ########################## - - name: Select Xcode 14.2 - run: sudo xcode-select -s /Applications/Xcode_14.2.app + - name: Select Xcode 14.3.1 + run: sudo xcode-select -s /Applications/Xcode_14.3.1.app ########################## ## Cache diff --git a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift index ae87c255b..4df1d844e 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift @@ -98,17 +98,17 @@ public extension GuildChannel { /// - Parameter member: The member to get overrides for. /// - Returns: The permission overrides for that member. func overridesFor(_ member: Member) -> [PermOverwrite]? { - return overwrites?.filter({ $0.id == member.id && $0.type == .member}) + return overwrites?.filter({ $0.id == member.user!.id && $0.type == .member}) } /// Sets the permissions for a member. /// - Parameters: /// - member: The member to set permissions for - /// - allow: The permissions you want to allow, as an array of Permissions objects - /// - deny: The permissions you want to deny, as an array of Permissions objects - func setPermissions(for member: Member, allow: [Permissions], deny: [Permissions]) async throws { - let body = EditChannelPermissionsReq(allow: Permissions(allow), deny: Permissions(deny), type: .member) - try await rest.editChannelPermissions(id, member.id!, body) + /// - allow: The permissions you want to allow, use array notation to pass multiple + /// - deny: The permissions you want to deny, use array notation to pass multiple + func setPermissions(for member: Member, allow: Permissions, deny: Permissions) async throws { + let body = EditChannelPermissionsReq(allow: allow, deny: deny, type: .member) + try await rest.editChannelPermissions(id, member.user!.id, body) } } diff --git a/Sources/DiscordKitBot/BotObjects/Member.swift b/Sources/DiscordKitBot/BotObjects/Member.swift index f0048e48b..4bf17a985 100644 --- a/Sources/DiscordKitBot/BotObjects/Member.swift +++ b/Sources/DiscordKitBot/BotObjects/Member.swift @@ -8,22 +8,33 @@ import Foundation import DiscordKitCore -public struct Member { - public let id: Snowflake? +public class Member { + /// The User object of this member. public let user: User? + /// The member's guild nickname. public let nick: String? + /// The member's profile picture. public let avatar: String? + /// The Snowflake IDs of the roles that this member has. public let roles: [Snowflake] + /// The time that the member joined the guild. public let joinedAt: Date - public let premiumSince: Date? // When the user started boosting the guild + /// The time that the member started boosting the guild. + public let premiumSince: Date? + /// If the member is deafened in the guild's VC channels. public let deaf: Bool + /// if the member is muted in the guild's VC channels. public let mute: Bool + /// If the member is a pending member verification. public let pending: Bool? - public let permissions: String? // Total permissions of the member in the channel, including overwrites, returned when in the interaction object - public let timedOutUntil: Date? // When the user's timeout will expire and the user will be able to communicate in the guild again, null or a time in the past if the user is not timed out + /// The total permissions of this member in the channel, including overwrites. This is only present when handling interactions. + public let permissions: String? + /// The time when a member's timeout will expire, and they will be able to talk in the guild again. `nil` or a time in the past if the user is not timed out. + public let timedOutUntil: Date? + /// The Snowflake ID of the guild this member is a part of. public let guildID: Snowflake? - fileprivate weak var rest: DiscordREST? + fileprivate var rest: DiscordREST internal init(from member: DiscordKitCore.Member, rest: DiscordREST) { user = member.user @@ -38,41 +49,67 @@ public struct Member { permissions = member.permissions timedOutUntil = member.communication_disabled_until guildID = member.guild_id - id = member.user_id self.rest = rest } + + /// Initialize a member from a guild Snowflake ID and a user snowflake ID. + /// - Parameters: + /// - guildID: The Snowflake ID of the guild the member is present in. + /// - userID: The Snowflake ID of the user. + public convenience init(guildID: Snowflake, userID: Snowflake) async throws { + let coreMember: DiscordKitCore.Member = try await Client.current!.rest.getGuildMember(guildID, userID) + self.init(from: coreMember, rest: Client.current!.rest) + } } public extension Member { + /// Changes the nickname of this member in the guild. + /// - Parameter nickname: The new nickname for this member. func changeNickname(_ nickname: String) async throws { - try await rest?.editGuildMember(guildID!, user!.id, ["nick": nickname]) + try await rest.editGuildMember(guildID!, user!.id, ["nick": nickname]) } + /// Adds a guild role to this member. + /// - Parameter role: The snowflake ID of the role to add. func addRole(_ role: Snowflake) async throws { var roles = roles roles.append(role) - try await rest?.editGuildMember(guildID!, user!.id, ["roles": roles]) + try await rest.editGuildMember(guildID!, user!.id, ["roles": roles]) } + /// Removes a guild role from a member. + /// - Parameter role: The Snowflake ID of the role to remove. func removeRole(_ role: Snowflake) async throws { - try await rest!.removeGuildMemberRole(guildID!, user!.id, role) + try await rest.removeGuildMemberRole(guildID!, user!.id, role) + } + + /// Removes all roles from a member. + func removeAllRoles() async throws { + let empty: [Snowflake] = [] + try await rest.editGuildMember(guildID!, user!.id, ["roles": empty]) } + /// Applies a time out to a member until the specified time. + /// - Parameter time: The time that the timeout ends. func timeout(time: Date) async throws { - try await rest!.editGuildMember(guildID!, user!.id, ["communication_disabled_until" : time]) + try await rest.editGuildMember(guildID!, user!.id, ["communication_disabled_until" : time]) } + /// Kicks the member from the guild. func kick() async throws { - try await rest!.removeGuildMember(guildID!, user!.id) + try await rest.removeGuildMember(guildID!, user!.id) } + /// Bans the member from the guild. + /// - Parameter messageDeleteSeconds: The number of seconds worth of messages to delete from the user in the guild. Defaults to `0` if not value is passed. The minimum value is `0` and the maximum value is `604800` (7 days). func ban(messageDeleteSeconds: Int = 0) async throws { - try await rest!.createGuildBan(guildID!, user!.id, ["delete_message_seconds":messageDeleteSeconds]) + try await rest.createGuildBan(guildID!, user!.id, ["delete_message_seconds":messageDeleteSeconds]) } + /// Deletes the ban for this member. func unban() async throws { - try await rest!.removeGuildBan(guildID!, user!.id) + try await rest.removeGuildBan(guildID!, user!.id) } /// Creates a DM with this user. @@ -83,6 +120,6 @@ public extension Member { /// /// - Returns: The newly created DM Channel func createDM() async throws -> Channel { - return try await rest!.createDM(["recipient_id":user!.id]) + return try await rest.createDM(["recipient_id":user!.id]) } } diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 42817226d..2fd5467cd 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -63,6 +63,8 @@ public final class Client { /// Static reference to the currently logged in client. public fileprivate(set) static var current: Client? + /// Create a new Client, with the intents provided. + /// - Parameter intents: The intents the bot should have. If no value is passed, `Intents.unprivileged`. Use array notation to pass multiple. public init(intents: Intents = .unprivileged) { self.intents = intents // Override default config for bots diff --git a/Sources/DiscordKitBot/Documentation.docc/DiscordKitBot.md b/Sources/DiscordKitBot/Documentation.docc/DiscordKitBot.md index 94b7c2866..68f064de1 100644 --- a/Sources/DiscordKitBot/Documentation.docc/DiscordKitBot.md +++ b/Sources/DiscordKitBot/Documentation.docc/DiscordKitBot.md @@ -9,7 +9,7 @@ So you want to make a Discord bot in Swift, I hear? alt: "A technology icon representing the SlothCreator framework.") } -``DiscordKitBot`` is a swift library for creating Discord bots in Swift. It aims to make a first-class discord bot building experience in Swift, while also being computationally and memory efficient. +``DiscordKitBot`` is a library for creating Discord bots in Swift. It aims to make a first-class discord bot building experience in Swift, while also being computationally and memory efficient. You are currently at the symbol documentation for ``DiscordKitBot``, which is useful if you just need a quick reference for the library. If you are looking for a getting started guide, we have one that walks you through creating your first bot [over here](https://swiftcord.gitbook.io/discordkit-guide/). From f07e34df30b790f427e6d0a73d6eac64aa19bb5f Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Wed, 14 Jun 2023 15:04:02 +0000 Subject: [PATCH 21/41] more docs --- .../BotObjects/Channels/Category.swift | 16 +- .../BotObjects/Channels/Forum.swift | 7 + .../BotObjects/Channels/ForumChannel.swift | 6 - .../BotObjects/Channels/GuildChannel.swift | 6 +- .../BotObjects/Channels/Stage.swift | 7 + .../BotObjects/Channels/StageChannel.swift | 6 - .../{TextChannel.swift => Text.swift} | 8 +- .../BotObjects/Channels/Voice.swift | 7 + .../BotObjects/Channels/VoiceChannel.swift | 6 - Sources/DiscordKitBot/BotObjects/Guild.swift | 196 +++++++++++------- Sources/DiscordKitBot/BotObjects/Member.swift | 8 +- .../DiscordKitBot/BotObjects/Message.swift | 10 +- Sources/DiscordKitBot/Client.swift | 14 +- .../Documentation.docc/DiscordKitBot.md | 3 - 14 files changed, 184 insertions(+), 116 deletions(-) create mode 100644 Sources/DiscordKitBot/BotObjects/Channels/Forum.swift delete mode 100644 Sources/DiscordKitBot/BotObjects/Channels/ForumChannel.swift create mode 100644 Sources/DiscordKitBot/BotObjects/Channels/Stage.swift delete mode 100644 Sources/DiscordKitBot/BotObjects/Channels/StageChannel.swift rename Sources/DiscordKitBot/BotObjects/Channels/{TextChannel.swift => Text.swift} (93%) create mode 100644 Sources/DiscordKitBot/BotObjects/Channels/Voice.swift delete mode 100644 Sources/DiscordKitBot/BotObjects/Channels/VoiceChannel.swift diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift index 93afb2d75..67c059ed2 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift @@ -1,15 +1,22 @@ import Foundation import DiscordKitCore +/// Represents a channel category in a Guild. public class CategoryChannel: GuildChannel { private let coreChannels: [DiscordKitCore.Channel]? + /// All the channels in the category. public let channels: [GuildChannel]? + /// The text channels in the category. public let textChannels: [TextChannel]? - public let voiceChannels: [VoiceChannel]? = nil - public let stageChannels: [StageChannel]? = nil + // public let voiceChannels: [VoiceChannel]? = nil + // public let stageChannels: [StageChannel]? = nil + /// If the category is marked as nsfw. public let nsfw: Bool override init(from channel: DiscordKitCore.Channel, rest: DiscordREST) async throws { + if channel.type != .category { + throw GuildChannelError.BadChannelType + } coreChannels = try await rest.getGuildChannels(id: channel.guild_id!).compactMap({ try $0.result.get() }) channels = try await coreChannels?.asyncMap({ try await GuildChannel(from: $0, rest: rest) }) textChannels = try await coreChannels?.asyncMap({ try await TextChannel(from: $0, rest: rest) }) @@ -18,7 +25,10 @@ public class CategoryChannel: GuildChannel { try await super.init(from: channel, rest: rest) } - convenience init(from id: Snowflake) async throws { + /// Get a category from it's Snowflake ID. + /// - Parameter id: The Snowflake ID of the category. + /// - Throws: `GuildChannelError.BadChannelType` if the ID does not correlate with a text channel. + public convenience init(from id: Snowflake) async throws { let coreChannel = try await Client.current!.rest.getChannel(id: id) try await self.init(from: coreChannel, rest: Client.current!.rest) } diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Forum.swift b/Sources/DiscordKitBot/BotObjects/Channels/Forum.swift new file mode 100644 index 000000000..e4a2bd673 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/Forum.swift @@ -0,0 +1,7 @@ +import Foundation +import DiscordKitCore + +// TODO: Implement this. +// public class ForumChannel: GuildChannel { + +// } \ No newline at end of file diff --git a/Sources/DiscordKitBot/BotObjects/Channels/ForumChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/ForumChannel.swift deleted file mode 100644 index 5cbafb850..000000000 --- a/Sources/DiscordKitBot/BotObjects/Channels/ForumChannel.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation -import DiscordKitCore - -public class ForumChannel: GuildChannel { - -} \ No newline at end of file diff --git a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift index 4df1d844e..21dfe9b52 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift @@ -34,7 +34,7 @@ public class GuildChannel { self.name = channel.name self.createdAt = channel.id.creationTime() if let guildID = channel.guild_id { - self.guild = try await Client.current?.getGuild(id: guildID) + self.guild = try await Guild(id: guildID) } position = channel.position type = channel.type @@ -112,3 +112,7 @@ public extension GuildChannel { } } +enum GuildChannelError: Error { + case BadChannelType +} + diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Stage.swift b/Sources/DiscordKitBot/BotObjects/Channels/Stage.swift new file mode 100644 index 000000000..ade5bb57d --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/Stage.swift @@ -0,0 +1,7 @@ +import Foundation +import DiscordKitCore + +// TODO: Implement this +// public class StageChannel: GuildChannel { + +// } \ No newline at end of file diff --git a/Sources/DiscordKitBot/BotObjects/Channels/StageChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/StageChannel.swift deleted file mode 100644 index 65b4f12d6..000000000 --- a/Sources/DiscordKitBot/BotObjects/Channels/StageChannel.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation -import DiscordKitCore - -public class StageChannel: GuildChannel { - -} \ No newline at end of file diff --git a/Sources/DiscordKitBot/BotObjects/Channels/TextChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift similarity index 93% rename from Sources/DiscordKitBot/BotObjects/Channels/TextChannel.swift rename to Sources/DiscordKitBot/BotObjects/Channels/Text.swift index a00493b07..8d6cb4c4d 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/TextChannel.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift @@ -21,6 +21,9 @@ public class TextChannel: GuildChannel { private let rest: DiscordREST internal override init(from channel: Channel, rest: DiscordREST) async throws { + if channel.type != .text { + throw GuildChannelError.BadChannelType + } nsfw = channel.nsfw ?? false slowmodeDelay = channel.rate_limit_per_user ?? 0 @@ -49,8 +52,11 @@ public class TextChannel: GuildChannel { } + /// Initialize a TextChannel from a Snowflake ID. + /// - Parameter id: The Snowflake ID of the channel. + /// - Throws: `GuildChannelError.BadChannelType` if the ID does not correlate with a text channel. public convenience init(from id: Snowflake) async throws { - let coreChannel = try! await Client.current!.rest.getChannel(id: id) + let coreChannel = try await Client.current!.rest.getChannel(id: id) try await self.init(from: coreChannel, rest: Client.current!.rest) } } diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Voice.swift b/Sources/DiscordKitBot/BotObjects/Channels/Voice.swift new file mode 100644 index 000000000..d868a3462 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/Voice.swift @@ -0,0 +1,7 @@ +import Foundation +import DiscordKitCore + +// TODO: Implement this. +// public class VoiceChannel: GuildChannel { + +// } \ No newline at end of file diff --git a/Sources/DiscordKitBot/BotObjects/Channels/VoiceChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/VoiceChannel.swift deleted file mode 100644 index 5049a994a..000000000 --- a/Sources/DiscordKitBot/BotObjects/Channels/VoiceChannel.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation -import DiscordKitCore - -public class VoiceChannel: GuildChannel { - -} \ No newline at end of file diff --git a/Sources/DiscordKitBot/BotObjects/Guild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift index 0915100c9..0dfe8ddb2 100644 --- a/Sources/DiscordKitBot/BotObjects/Guild.swift +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -8,89 +8,145 @@ import Foundation import DiscordKitCore -public struct Guild { +public class Guild { + /// The guild's Snowflake ID. public let id: Snowflake + /// The guild's name. public let name: String - public let icon: HashedAsset? // Icon hash - public let splash: String? // Splash hash - public let discovery_splash: String? - public let owner_id: Snowflake - public let permissions: String? - public let region: String? // Voice region id for the guild (deprecated) - public let afk_channel_id: Snowflake? - public let afk_timeout: Int - public let widget_enabled: Bool? - public let widget_channel_id: Snowflake? - public let verification_level: VerificationLevel - public let explicit_content_filter: ExplicitContentFilterLevel - public let features: [DecodableThrowable] - public let mfa_level: MFALevel - public let application_id: Snowflake? // For bot-created guilds - public let system_channel_id: Snowflake? // ID of channel for system-created messages - public let rules_channel_id: Snowflake? - public var joined_at: Date? + /// The guild's icon asset. + public let icon: HashedAsset? + /// The guild's splash asset. + public let splash: HashedAsset? + /// The guild's discovery splash asset. + public let discoverySplash: HashedAsset? + /// The ID of the guild's owner. + public let ownerID: Snowflake + /// The member that owns the guild. + public fileprivate(set) var owner: Member + /// The AFK Voice Channel. + public fileprivate(set) var afkChannel: GuildChannel? = nil + /// The number of seconds until someone is moved to the afk channel. + public let afkTimeout: Int + /// If the guild has widgets enabled. + public let widgetEnabled: Bool? + /// The widget channel in the guild. + public fileprivate(set) var widgetChannel: GuildChannel? = nil + /// The verification level required in the guild. + public let verificationLevel: VerificationLevel + /// The guild's explicit content filter. + public let explicitContentFilter: ExplicitContentFilterLevel + /// The list of features that the guild has. + public let features: [GuildFeature] + /// The guild's MFA requirement level. + public let mfaLevel: MFALevel + /// The guild's system message channel. + public fileprivate(set) var systemChannel: GuildChannel? = nil + /// The guild's rules channel. + public fileprivate(set) var rulesChannel: GuildChannel? = nil + /// If the guild is a "large" guild. public var large: Bool? - public var unavailable: Bool? // If guild is unavailable due to an outage - public var member_count: Int? - public var voice_states: [VoiceState]? - public var presences: [PresenceUpdate]? - public let max_members: Int? - public let vanity_url_code: String? + /// if the guild is unavailable due to an outage. + public var unavailable: Bool? + /// The number of members in this guild, if available. May be out of date. + /// + /// Note: Due to Discord API restrictions, you must have the `Intents.members` intent for this number to be up-to-date and accurate. + public var memberCount: Int? + /// The maximum number of members that can join this guild. + public let maxMembers: Int? + /// The guild's vanity URL code. + public let vanityURLCode: String? + /// The guild's vanity invite URL. + public fileprivate(set) var vanityURL: URL? = nil + /// The guild's description. public let description: String? - public let banner: HashedAsset? // Banner hash - public let premium_tier: PremiumLevel - public let premium_subscription_count: Int? // Number of server boosts - public let preferred_locale: DiscordKitCore.Locale // Defaults to en-US - public let public_updates_channel_id: Snowflake? - public let max_video_channel_users: Int? - public let approximate_member_count: Int? // Approximate number of members in this guild, returned from the GET /guilds/ endpoint when with_counts is true - public let approximate_presence_count: Int? // Approximate number of non-offline members in this guild, returned from the GET /guilds/ endpoint when with_counts is true - public let nsfw_level: NSFWLevel - public var stage_instances: [StageInstance]? - public var guild_scheduled_events: [GuildScheduledEvent]? - public let premium_progress_bar_enabled: Bool + /// The guild's banner asset. + public let banner: HashedAsset? + /// The guild's boost level. + public let premiumTier: PremiumLevel + /// The number of boosts that the guild has. + public let premiumSubscriptionCount: Int? + /// The preferred locale of the guild. Defaults to `en-US`. + public let preferredLocale: DiscordKitCore.Locale + /// The channel in the guild where mods and admins receive notices from discord. + public fileprivate(set) var publicUpdatesChannel: GuildChannel? = nil + /// The maximum number of users that can be in a video channel. + public let maxVideoChannelUsers: Int? + /// The approximate number of members in the guild. Only available in some contexts. + public let approximateMemberCount: Int? // Approximate number of members in this guild, returned from the GET /guilds/ endpoint when with_counts is true + /// The approximate number of online and active members in the guild. Only available in some contexts. + public let approximatePresenceCount: Int? // Approximate number of non-offline members in this guild, returned from the GET /guilds/ endpoint when with_counts is true + /// The guild's NSFW level. + public let nsfwLevel: NSFWLevel + /// The stage instances in the guild that are currently running. + public var stageInstances: [StageInstance]? + /// The scheduled events in the guild. + public var scheduledEvents: [GuildScheduledEvent]? + /// If the guild has the server boost progress bar enabled. + public let premiumProgressBarEnabled: Bool - public init(_ guild: DiscordKitCore.Guild) { + internal init(guild: DiscordKitCore.Guild, rest: DiscordREST) async throws { id = guild.id name = guild.name icon = guild.icon splash = guild.splash - discovery_splash = guild.discovery_splash - owner_id = guild.owner_id - permissions = guild.permissions - region = guild.region - afk_channel_id = guild.afk_channel_id - afk_timeout = guild.afk_timeout - widget_enabled = guild.widget_enabled - widget_channel_id = guild.widget_channel_id - verification_level = guild.verification_level - explicit_content_filter = guild.explicit_content_filter - features = guild.features - mfa_level = guild.mfa_level - application_id = guild.application_id - system_channel_id = guild.system_channel_id - rules_channel_id = guild.rules_channel_id - joined_at = guild.joined_at + discoverySplash = guild.discovery_splash + ownerID = guild.owner_id + afkTimeout = guild.afk_timeout + widgetEnabled = guild.widget_enabled + verificationLevel = guild.verification_level + explicitContentFilter = guild.explicit_content_filter + features = guild.features.compactMap({ try? $0.result.get() }) + mfaLevel = guild.mfa_level large = guild.large unavailable = guild.unavailable - member_count = guild.member_count - voice_states = guild.voice_states - presences = guild.presences - max_members = guild.max_members - vanity_url_code = guild.vanity_url_code + memberCount = guild.member_count + maxMembers = guild.max_members + vanityURLCode = guild.vanity_url_code description = guild.description banner = guild.banner - premium_tier = guild.premium_tier - premium_subscription_count = guild.premium_subscription_count - preferred_locale = guild.preferred_locale - public_updates_channel_id = guild.public_updates_channel_id - max_video_channel_users = guild.max_video_channel_users - approximate_member_count = guild.approximate_member_count - approximate_presence_count = guild.approximate_presence_count - nsfw_level = guild.nsfw_level - stage_instances = guild.stage_instances - guild_scheduled_events = guild.guild_scheduled_events - premium_progress_bar_enabled = guild.premium_progress_bar_enabled + premiumTier = guild.premium_tier + premiumSubscriptionCount = guild.premium_subscription_count + preferredLocale = guild.preferred_locale + maxVideoChannelUsers = guild.max_video_channel_users + approximateMemberCount = guild.approximate_member_count + approximatePresenceCount = guild.approximate_presence_count + nsfwLevel = guild.nsfw_level + stageInstances = guild.stage_instances + scheduledEvents = guild.guild_scheduled_events + premiumProgressBarEnabled = guild.premium_progress_bar_enabled + + if let afk_channel_id = guild.afk_channel_id { + afkChannel = try? await GuildChannel(from: afk_channel_id) + } + + if let widget_channel_id = guild.widget_channel_id { + widgetChannel = try? await GuildChannel(from: widget_channel_id) + } + + if let rules_channel_id = guild.rules_channel_id { + rulesChannel = try? await GuildChannel(from: rules_channel_id) + } + + if let system_channel_id = guild.system_channel_id { + systemChannel = try? await GuildChannel(from: system_channel_id) + } + + if let public_updates_channel_id = guild.public_updates_channel_id { + publicUpdatesChannel = try? await GuildChannel(from: public_updates_channel_id) + } + + if let vanity_url_code = vanityURLCode { + vanityURL = URL(string: "https://discord.gg/\(vanity_url_code)") + } + + owner = try await Member(guildID: id, userID: ownerID) + } + + /// Get a guild object from a guild ID. + /// - Parameter id: The ID of the guild you want. + public convenience init(id: Snowflake) async throws { + let coreGuild = try await Client.current!.rest.getGuild(id: id) + try await self.init(guild: coreGuild, rest: Client.current!.rest) } } diff --git a/Sources/DiscordKitBot/BotObjects/Member.swift b/Sources/DiscordKitBot/BotObjects/Member.swift index 4bf17a985..d4559af2b 100644 --- a/Sources/DiscordKitBot/BotObjects/Member.swift +++ b/Sources/DiscordKitBot/BotObjects/Member.swift @@ -1,13 +1,7 @@ -// -// File.swift -// -// -// Created by Andrew Glaze on 6/2/23. -// - import Foundation import DiscordKitCore +/// Represents a Member in a ``Guild``, and contains methods for working with them. public class Member { /// The User object of this member. public let user: User? diff --git a/Sources/DiscordKitBot/BotObjects/Message.swift b/Sources/DiscordKitBot/BotObjects/Message.swift index 5262db387..4d44818e6 100644 --- a/Sources/DiscordKitBot/BotObjects/Message.swift +++ b/Sources/DiscordKitBot/BotObjects/Message.swift @@ -20,9 +20,8 @@ public struct Message { public let channelID: Snowflake /// The guild the message was sent in - public let guild: Guild? - /// ID of the guild the message was sent in - public let guildID: Snowflake? + public fileprivate(set) var guild: Guild? = nil + //public let guildID: Snowflake? /// The author of this message (not guaranteed to be a valid user, see discussion) /// @@ -141,7 +140,6 @@ public struct Message { content = message.content channelID = message.channel_id id = message.id - guildID = message.guild_id author = message.author if let messageMember = message.member { member = Member(from: messageMember, rest: rest) @@ -173,7 +171,9 @@ public struct Message { self.rest = rest - guild = try? await Client.current?.getGuild(id: guildID ?? "") + if let guildID = message.guild_id { + guild = try? await Guild(id: guildID) + } // jumpURL = nil } diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 2fd5467cd..c4b90a327 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -11,7 +11,12 @@ import DiscordKitCore /// The main client class for bots to interact with Discord's API. Only one client is allowed to be logged in at a time. public final class Client { - // REST handler + /// Low level access to the Discord REST API. Used internally to power the higher level classes. Intended only for advanced users. + /// + /// This is provided to you so that you can access the raw discord API if you so choose. + /// This should be considered advanced usage for advanced users only, as you pretty much have to do everything yourself. + /// The methods in this object are basically undocumented, however they closely resemble [the official api docs](https://discord.com/developers/docs/intro). + /// The methods in this object return "DiscordKitCore" objects, which do not contain the support methods found in "DiscordKitBot" objects. public let rest = DiscordREST() // MARK: Gateway vars @@ -306,13 +311,6 @@ public extension Client { appCommandHandlers[registeredCommand.id] = command.handler } } - /// Gets a ``Guild`` object from a guild ID. - /// - /// - Parameter id: The Snowflake ID of a Guild that your bot is in. - /// - Returns: A ``Guild`` object containing information about the guild. - func getGuild(id: Snowflake) async throws -> Guild { - return try await Guild(rest.getGuild(id: id)) - } func getGuildRoles(id: Snowflake) async throws -> [Role] { return try await rest.getGuildRoles(id: id) diff --git a/Sources/DiscordKitBot/Documentation.docc/DiscordKitBot.md b/Sources/DiscordKitBot/Documentation.docc/DiscordKitBot.md index 68f064de1..c44d2680f 100644 --- a/Sources/DiscordKitBot/Documentation.docc/DiscordKitBot.md +++ b/Sources/DiscordKitBot/Documentation.docc/DiscordKitBot.md @@ -31,10 +31,7 @@ You are currently at the symbol documentation for ``DiscordKitBot``, which is us - ``GuildChannel`` - ``TextChannel`` -- ``VoiceChannel`` - ``CategoryChannel`` -- ``StageChannel`` -- ``ForumChannel`` ### Working with Messages - ``Message`` From 2f995498d88932a871718e8544adbb089d330554 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Wed, 14 Jun 2023 15:27:33 +0000 Subject: [PATCH 22/41] guild properties --- Sources/DiscordKitBot/BotObjects/Guild.swift | 38 ++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/Sources/DiscordKitBot/BotObjects/Guild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift index 0dfe8ddb2..5d89fc7bb 100644 --- a/Sources/DiscordKitBot/BotObjects/Guild.swift +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -19,10 +19,14 @@ public class Guild { public let splash: HashedAsset? /// The guild's discovery splash asset. public let discoverySplash: HashedAsset? + /// The bot's member object. + public let me: Member /// The ID of the guild's owner. public let ownerID: Snowflake /// The member that owns the guild. public fileprivate(set) var owner: Member + /// The time that the guild was created. + public let createdAt: Date /// The AFK Voice Channel. public fileprivate(set) var afkChannel: GuildChannel? = nil /// The number of seconds until someone is moved to the afk channel. @@ -44,15 +48,24 @@ public class Guild { /// The guild's rules channel. public fileprivate(set) var rulesChannel: GuildChannel? = nil /// If the guild is a "large" guild. - public var large: Bool? - /// if the guild is unavailable due to an outage. - public var unavailable: Bool? + public let large: Bool? + /// If the guild is unavailable due to an outage. + public let unavailable: Bool? + /// A list of the guild's members. + public let members: [Member]? /// The number of members in this guild, if available. May be out of date. /// /// Note: Due to Discord API restrictions, you must have the `Intents.members` intent for this number to be up-to-date and accurate. - public var memberCount: Int? + public let memberCount: Int? + /// If the guild is "chunked" + /// + /// A chunked guild means that ``memberCount`` is equal to the number of members stored in the internal ``members`` cache. + /// If this value is false, you should request for offline members. + public fileprivate(set) var chunked: Bool = false /// The maximum number of members that can join this guild. public let maxMembers: Int? + /// The maximum number of presences for the guild. + public let maxPresences: Int? /// The guild's vanity URL code. public let vanityURLCode: String? /// The guild's vanity invite URL. @@ -78,9 +91,9 @@ public class Guild { /// The guild's NSFW level. public let nsfwLevel: NSFWLevel /// The stage instances in the guild that are currently running. - public var stageInstances: [StageInstance]? + public let stageInstances: [StageInstance]? /// The scheduled events in the guild. - public var scheduledEvents: [GuildScheduledEvent]? + public let scheduledEvents: [GuildScheduledEvent]? /// If the guild has the server boost progress bar enabled. public let premiumProgressBarEnabled: Bool @@ -114,6 +127,7 @@ public class Guild { stageInstances = guild.stage_instances scheduledEvents = guild.guild_scheduled_events premiumProgressBarEnabled = guild.premium_progress_bar_enabled + maxPresences = guild.max_presences if let afk_channel_id = guild.afk_channel_id { afkChannel = try? await GuildChannel(from: afk_channel_id) @@ -140,6 +154,18 @@ public class Guild { } owner = try await Member(guildID: id, userID: ownerID) + + let coreMembers: [DiscordKitCore.Member] = try await rest.listGuildMembers(id) + members = coreMembers.map({ Member(from: $0, rest: rest)}) + + if let members = members { + chunked = memberCount == members.count + } + + createdAt = id.creationTime() + + let coreMe = try await rest.getGuildMember(guild: id) + me = Member(from: coreMe, rest: rest) } /// Get a guild object from a guild ID. From 167ebf8266bef32afe35c85a73716aab17a2c151 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Wed, 14 Jun 2023 15:32:10 +0000 Subject: [PATCH 23/41] fix instance properties in TextChannel --- Sources/DiscordKitBot/BotObjects/Channels/Text.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Text.swift b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift index 8d6cb4c4d..a19cee767 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Text.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift @@ -4,20 +4,20 @@ import DiscordKitCore /// Represents a Discord Text Channel, and contains convenience methods for working with them. public class TextChannel: GuildChannel { /// The last message sent in this channel. It may not represent a valid message. - let lastMessage: Message? + public let lastMessage: Message? /// The id of the last message sent in this channel. It may not point to a valid message. - let lastMessageID: Snowflake? + public let lastMessageID: Snowflake? // fileprivate(set) var members: [Member]? = nil /// If the channel is marked as “not safe for work” or “age restricted”. - let nsfw: Bool + public let nsfw: Bool /// All the threads that your bot can see. - fileprivate(set) var threads: [TextChannel]? = nil + public fileprivate(set) var threads: [TextChannel]? = nil /// The topic of the channel - let topic: String? + public let topic: String? /// The number of seconds a member must wait between sending messages in this channel. /// A value of 0 denotes that it is disabled. /// Bots and users with manage_channels or manage_messages bypass slowmode. - let slowmodeDelay: Int + public let slowmodeDelay: Int private let rest: DiscordREST internal override init(from channel: Channel, rest: DiscordREST) async throws { From 98fd3f1c5f539071cd991c3436c988a9839cd46a Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Thu, 15 Jun 2023 15:09:15 +0000 Subject: [PATCH 24/41] computed properties --- .../DiscordKitBot/BotObjects/Channel.swift | 0 .../BotObjects/Channels/Category.swift | 45 ++-- .../BotObjects/Channels/GuildChannel.swift | 53 +++-- .../BotObjects/Channels/Text.swift | 53 +++-- Sources/DiscordKitBot/BotObjects/Guild.swift | 208 ++++++++++++------ .../DiscordKitBot/BotObjects/Message.swift | 67 +++--- Sources/DiscordKitBot/Client.swift | 15 -- .../DiscordKitBot/Extensions/Sequence+.swift | 15 ++ .../Gateway/RobustWebSocket.swift | 2 +- .../DiscordKitCore/Objects/Data/Guild.swift | 4 +- 10 files changed, 293 insertions(+), 169 deletions(-) delete mode 100644 Sources/DiscordKitBot/BotObjects/Channel.swift diff --git a/Sources/DiscordKitBot/BotObjects/Channel.swift b/Sources/DiscordKitBot/BotObjects/Channel.swift deleted file mode 100644 index e69de29bb..000000000 diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift index 67c059ed2..b09016b31 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift @@ -3,26 +3,43 @@ import DiscordKitCore /// Represents a channel category in a Guild. public class CategoryChannel: GuildChannel { - private let coreChannels: [DiscordKitCore.Channel]? + private var coreChannels: [DiscordKitCore.Channel] { + get async throws { + try await rest.getGuildChannels(id: coreChannel.guild_id!).compactMap({ try $0.result.get() }).filter({ $0.parent_id == id }) + } + } /// All the channels in the category. - public let channels: [GuildChannel]? + public var channels: [GuildChannel] { + get async throws { + return try await coreChannels.asyncMap({ try GuildChannel(from: $0, rest: rest) }) + } + } /// The text channels in the category. - public let textChannels: [TextChannel]? - // public let voiceChannels: [VoiceChannel]? = nil - // public let stageChannels: [StageChannel]? = nil + public var textChannels: [TextChannel] { + get async throws { + return try await coreChannels.filter({ $0.type == .text }).asyncMap({ try TextChannel(from: $0, rest: rest) }) + } + } + /// The voice channels in the category. + public var voiceChannels: [GuildChannel] { + get async throws { + return try await coreChannels.filter({ $0.type == .voice }).asyncMap({ try TextChannel(from: $0, rest: rest) }) + } + } + /// The stage channels in the category. + public var stageChannels: [GuildChannel] { + get async throws { + return try await coreChannels.filter({ $0.type == .stageVoice }).asyncMap({ try TextChannel(from: $0, rest: rest) }) + } + } /// If the category is marked as nsfw. public let nsfw: Bool - override init(from channel: DiscordKitCore.Channel, rest: DiscordREST) async throws { - if channel.type != .category { - throw GuildChannelError.BadChannelType - } - coreChannels = try await rest.getGuildChannels(id: channel.guild_id!).compactMap({ try $0.result.get() }) - channels = try await coreChannels?.asyncMap({ try await GuildChannel(from: $0, rest: rest) }) - textChannels = try await coreChannels?.asyncMap({ try await TextChannel(from: $0, rest: rest) }) + override init(from channel: DiscordKitCore.Channel, rest: DiscordREST) throws { + if channel.type != .category { throw GuildChannelError.BadChannelType } nsfw = channel.nsfw ?? false - try await super.init(from: channel, rest: rest) + try super.init(from: channel, rest: rest) } /// Get a category from it's Snowflake ID. @@ -30,7 +47,7 @@ public class CategoryChannel: GuildChannel { /// - Throws: `GuildChannelError.BadChannelType` if the ID does not correlate with a text channel. public convenience init(from id: Snowflake) async throws { let coreChannel = try await Client.current!.rest.getChannel(id: id) - try await self.init(from: coreChannel, rest: Client.current!.rest) + try self.init(from: coreChannel, rest: Client.current!.rest) } } \ No newline at end of file diff --git a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift index 21dfe9b52..725fbb86d 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift @@ -1,16 +1,30 @@ import Foundation import DiscordKitCore -/// Represents a channel in a guild, a superclass to all other types of channel. -public class GuildChannel { +/// Represents a channel in a guild, a superclass to all guild channel types. +public class GuildChannel: Identifiable { /// The name of the channel. public let name: String? /// The category the channel is located in, if any. - public fileprivate(set) var category: CategoryChannel? = nil + public var category: CategoryChannel? { + get async throws { + if let categoryID = coreChannel.parent_id { + return try await CategoryChannel(from: categoryID) + } + return nil + } + } /// When the channel was created. public let createdAt: Date? /// The guild that the channel belongs to. - public fileprivate(set) var guild: Guild? = nil + public var guild: Guild { + get async throws { + if let guildID = coreChannel.guild_id { + return try await Guild(id: guildID) + } + throw GuildChannelError.NotAGuildChannel // This should be inaccessible + } + } // let jumpURL: URL /// A string you can put in message contents to mention the channel. public let mention: String @@ -19,41 +33,41 @@ public class GuildChannel { /// Permission overwrites for this channel. public let overwrites: [PermOverwrite]? /// Whether or not the permissions for this channel are synced with the category it belongs to. - public fileprivate(set) var permissionsSynced: Bool = false + public var permissionsSynced: Bool { + get async throws { + if let category = try await category { + return coreChannel.permissions == category.coreChannel.permissions + } + return false + } + } /// The Type of the channel. public let type: ChannelType /// The `Snowflake` ID of the channel. public let id: Snowflake - - private let rest: DiscordREST + internal let rest: DiscordREST internal let coreChannel: DiscordKitCore.Channel - internal init(from channel: DiscordKitCore.Channel, rest: DiscordREST) async throws { + internal init(from channel: DiscordKitCore.Channel, rest: DiscordREST) throws { + guard channel.guild_id == nil else { throw GuildChannelError.NotAGuildChannel } self.coreChannel = channel self.name = channel.name self.createdAt = channel.id.creationTime() - if let guildID = channel.guild_id { - self.guild = try await Guild(id: guildID) - } position = channel.position type = channel.type id = channel.id self.rest = rest self.mention = "<#\(id)>" self.overwrites = channel.permission_overwrites - if let categoryID = channel.parent_id { - let coreCategory = try await rest.getChannel(id: categoryID) - self.category = try await CategoryChannel(from: coreCategory, rest: rest) - self.permissionsSynced = channel.permissions == coreCategory.permissions - } } /// Initialize an Channel using an ID. /// - Parameter id: The `Snowflake` ID of the channel you want to get. + /// - Throws: `GuildChannelError.NotAGuildChannel` when the channel ID points to a channel that is not in a guild. public convenience init(from id: Snowflake) async throws { let coreChannel = try await Client.current!.rest.getChannel(id: id) - try await self.init(from: coreChannel, rest: Client.current!.rest) + try self.init(from: coreChannel, rest: Client.current!.rest) } } @@ -90,8 +104,8 @@ public extension GuildChannel { /// - Returns: The newly cloned channel. func clone(name: String) async throws -> GuildChannel { let body = CreateGuildChannelRed(name: name, type: coreChannel.type, topic: coreChannel.topic, bitrate: coreChannel.bitrate, user_limit: coreChannel.user_limit, rate_limit_per_user: coreChannel.rate_limit_per_user, position: coreChannel.position, permission_overwrites: coreChannel.permission_overwrites, parent_id: coreChannel.parent_id, nsfw: coreChannel.nsfw, rtc_region: coreChannel.rtc_region, video_quality_mode: coreChannel.video_quality_mode, default_auto_archive_duration: coreChannel.default_auto_archive_duration) - let newCh: DiscordKitCore.Channel = try await rest.createGuildChannel(guild!.id, body) - return try await GuildChannel(from: newCh, rest: rest) + let newCh: DiscordKitCore.Channel = try await rest.createGuildChannel(guild.id, body) + return try GuildChannel(from: newCh, rest: rest) } /// Gets the permission overrides for a specific member. @@ -114,5 +128,6 @@ public extension GuildChannel { enum GuildChannelError: Error { case BadChannelType + case NotAGuildChannel } diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Text.swift b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift index a19cee767..955a1008d 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Text.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift @@ -4,51 +4,50 @@ import DiscordKitCore /// Represents a Discord Text Channel, and contains convenience methods for working with them. public class TextChannel: GuildChannel { /// The last message sent in this channel. It may not represent a valid message. - public let lastMessage: Message? + public var lastMessage: Message? { + get async throws { + if let lastMessageID = lastMessageID { + let coreMessage = try? await rest.getChannelMsg(id: coreChannel.id, msgID: lastMessageID) + if let coreMessage = coreMessage { + return await Message(from: coreMessage, rest: rest) + } else { + return nil + } + } else { + return nil + } + } + } /// The id of the last message sent in this channel. It may not point to a valid message. public let lastMessageID: Snowflake? // fileprivate(set) var members: [Member]? = nil /// If the channel is marked as “not safe for work” or “age restricted”. public let nsfw: Bool /// All the threads that your bot can see. - public fileprivate(set) var threads: [TextChannel]? = nil + public var threads: [TextChannel]? { + get async throws { + let coreThreads = try await rest.getGuildChannels(id: coreChannel.guild_id!) + .compactMap({ try? $0.result.get() }).filter({ $0.type == .publicThread || $0.type == .privateThread }) + + return try await coreThreads.asyncMap({ try TextChannel(from: $0, rest: rest) }) + } + } /// The topic of the channel public let topic: String? /// The number of seconds a member must wait between sending messages in this channel. /// A value of 0 denotes that it is disabled. /// Bots and users with manage_channels or manage_messages bypass slowmode. public let slowmodeDelay: Int - private let rest: DiscordREST - internal override init(from channel: Channel, rest: DiscordREST) async throws { - if channel.type != .text { - throw GuildChannelError.BadChannelType - } + internal override init(from channel: Channel, rest: DiscordREST) throws { + if channel.type != .text { throw GuildChannelError.BadChannelType } nsfw = channel.nsfw ?? false slowmodeDelay = channel.rate_limit_per_user ?? 0 - lastMessageID = channel.last_message_id - if let lastMessageID = lastMessageID { - let coreMessage = try? await rest.getChannelMsg(id: channel.id, msgID: lastMessageID) - if let coreMessage = coreMessage { - lastMessage = await Message(from: coreMessage, rest: rest) - } else { - lastMessage = nil - } - } else { - lastMessage = nil - } topic = channel.topic - let coreThreads = try? await rest.getGuildChannels(id: channel.guild_id!) - .compactMap({ try? $0.result.get() }).filter({ $0.type == .publicThread || $0.type == .privateThread }) - - threads = try await coreThreads!.asyncMap({ try await TextChannel(from: $0, rest: rest) }) - - self.rest = rest - - try await super.init(from: channel, rest: rest) + try super.init(from: channel, rest: rest) } @@ -57,7 +56,7 @@ public class TextChannel: GuildChannel { /// - Throws: `GuildChannelError.BadChannelType` if the ID does not correlate with a text channel. public convenience init(from id: Snowflake) async throws { let coreChannel = try await Client.current!.rest.getChannel(id: id) - try await self.init(from: coreChannel, rest: Client.current!.rest) + try self.init(from: coreChannel, rest: Client.current!.rest) } } diff --git a/Sources/DiscordKitBot/BotObjects/Guild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift index 5d89fc7bb..fce670a21 100644 --- a/Sources/DiscordKitBot/BotObjects/Guild.swift +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -8,7 +8,7 @@ import Foundation import DiscordKitCore -public class Guild { +public class Guild: Identifiable { /// The guild's Snowflake ID. public let id: Snowflake /// The guild's name. @@ -19,22 +19,21 @@ public class Guild { public let splash: HashedAsset? /// The guild's discovery splash asset. public let discoverySplash: HashedAsset? - /// The bot's member object. - public let me: Member /// The ID of the guild's owner. public let ownerID: Snowflake /// The member that owns the guild. - public fileprivate(set) var owner: Member + public var owner: Member? { + get async throws { + return try await Member(guildID: id, userID: ownerID) + } + } /// The time that the guild was created. - public let createdAt: Date - /// The AFK Voice Channel. - public fileprivate(set) var afkChannel: GuildChannel? = nil + public var createdAt: Date { id.creationTime() } /// The number of seconds until someone is moved to the afk channel. public let afkTimeout: Int /// If the guild has widgets enabled. public let widgetEnabled: Bool? - /// The widget channel in the guild. - public fileprivate(set) var widgetChannel: GuildChannel? = nil + /// The verification level required in the guild. public let verificationLevel: VerificationLevel /// The guild's explicit content filter. @@ -43,25 +42,14 @@ public class Guild { public let features: [GuildFeature] /// The guild's MFA requirement level. public let mfaLevel: MFALevel - /// The guild's system message channel. - public fileprivate(set) var systemChannel: GuildChannel? = nil - /// The guild's rules channel. - public fileprivate(set) var rulesChannel: GuildChannel? = nil /// If the guild is a "large" guild. public let large: Bool? /// If the guild is unavailable due to an outage. public let unavailable: Bool? - /// A list of the guild's members. - public let members: [Member]? /// The number of members in this guild, if available. May be out of date. /// /// Note: Due to Discord API restrictions, you must have the `Intents.members` intent for this number to be up-to-date and accurate. public let memberCount: Int? - /// If the guild is "chunked" - /// - /// A chunked guild means that ``memberCount`` is equal to the number of members stored in the internal ``members`` cache. - /// If this value is false, you should request for offline members. - public fileprivate(set) var chunked: Bool = false /// The maximum number of members that can join this guild. public let maxMembers: Int? /// The maximum number of presences for the guild. @@ -69,7 +57,14 @@ public class Guild { /// The guild's vanity URL code. public let vanityURLCode: String? /// The guild's vanity invite URL. - public fileprivate(set) var vanityURL: URL? = nil + public var vanityURL: URL? { + get { + if let vanity_url_code = vanityURLCode { + return URL(string: "https://discord.gg/\(vanity_url_code)") + } + return nil + } + } /// The guild's description. public let description: String? /// The guild's banner asset. @@ -80,10 +75,10 @@ public class Guild { public let premiumSubscriptionCount: Int? /// The preferred locale of the guild. Defaults to `en-US`. public let preferredLocale: DiscordKitCore.Locale - /// The channel in the guild where mods and admins receive notices from discord. - public fileprivate(set) var publicUpdatesChannel: GuildChannel? = nil /// The maximum number of users that can be in a video channel. public let maxVideoChannelUsers: Int? + /// The maximum number of users that can be in a stage video channel. + public let maxStageVideoUsers: Int? /// The approximate number of members in the guild. Only available in some contexts. public let approximateMemberCount: Int? // Approximate number of members in this guild, returned from the GET /guilds/ endpoint when with_counts is true /// The approximate number of online and active members in the guild. Only available in some contexts. @@ -91,13 +86,133 @@ public class Guild { /// The guild's NSFW level. public let nsfwLevel: NSFWLevel /// The stage instances in the guild that are currently running. - public let stageInstances: [StageInstance]? + public let stageInstances: [StageInstance] /// The scheduled events in the guild. public let scheduledEvents: [GuildScheduledEvent]? /// If the guild has the server boost progress bar enabled. public let premiumProgressBarEnabled: Bool - internal init(guild: DiscordKitCore.Guild, rest: DiscordREST) async throws { + private var coreChannels: [Channel] { + get async throws { + try await rest.getGuildChannels(id: id).compactMap({ try? $0.result.get() }) + } + } + /// The channels in this guild. + public var channels: [GuildChannel] { + get async throws { + try await coreChannels.asyncCompactMap({ try? GuildChannel(from: $0, rest: rest) }) + } + } + /// The text channels in this guild. + public var textChannels: [TextChannel] { + get async throws { + try await coreChannels.filter({ $0.type == .text }).asyncCompactMap({ try? TextChannel(from: $0, rest: rest) }) + } + } + /// The voice channels in the guild. + public var voiceChannels: [GuildChannel] { + get async throws { + try await coreChannels.filter({ $0.type == .voice }).asyncCompactMap({ try? GuildChannel(from: $0, rest: rest) }) + } + } + /// The categories in this guild. + public var categories: [CategoryChannel] { + get async throws { + try await coreChannels.filter({ $0.type == .category }).asyncCompactMap({ try? CategoryChannel(from: $0, rest: rest) }) + } + } + /// The forum channels in this guild. + public var forums: [GuildChannel] { + get async throws { + try await coreChannels.filter({ $0.type == .forum }).asyncCompactMap({ try? GuildChannel(from: $0, rest: rest) }) + } + } + /// The stage channels in this guild. + public var stages: [GuildChannel] { + get async throws { + try await coreChannels.filter({ $0.type == .stageVoice }).asyncCompactMap({ try? GuildChannel(from: $0, rest: rest) }) + } + } + /// The AFK Voice Channel. + public var afkChannel: GuildChannel? { + get async throws { + if let afk_channel_id = coreGuild.afk_channel_id { + return try await channels.first(identifiedBy: afk_channel_id) + } + return nil + } + } + /// The widget channel in the guild. + public var widgetChannel: GuildChannel? { + get async throws { + if let widget_channel_id = coreGuild.widget_channel_id { + return try await channels.first(identifiedBy: widget_channel_id) + } + return nil + } + } + /// The guild's rules channel. + public var rulesChannel: GuildChannel? { + get async throws { + if let rules_channel_id = coreGuild.rules_channel_id { + return try await channels.first(identifiedBy: rules_channel_id) + } + return nil + } + } + /// The guild's system message channel. + public var systemChannel: GuildChannel? { + get async throws { + if let system_channel_id = coreGuild.system_channel_id { + return try await channels.first(identifiedBy: system_channel_id) + } + return nil + } + } + /// The channel in the guild where mods and admins receive notices from discord. + public var publicUpdatesChannel: GuildChannel? { + get async throws { + if let public_updates_channel_id = coreGuild.public_updates_channel_id { + return try await channels.first(identifiedBy: public_updates_channel_id) + } + return nil + } + } + + private var coreMembers: [DiscordKitCore.Member] { + get async throws { + try await rest.listGuildMembers(id) + } + } + + /// A list of the guild's members. + public var members: [Member] { + get async throws { + return try await coreMembers.map({ Member(from: $0, rest: rest)}) + } + } + /// If the guild is "chunked" + /// + /// A chunked guild means that ``memberCount`` is equal to the number of members in ``members``. + /// If this value is false, you should request for offline members. + public var chunked: Bool { + get async throws { + try await memberCount == members.count + } + } + /// The bot's member object. + public var me: Member { + get async throws { + return Member(from: try await rest.getGuildMember(guild: id), rest: rest) + } + } + + private let coreGuild: DiscordKitCore.Guild + private var rest: DiscordREST + + internal init(guild: DiscordKitCore.Guild, rest: DiscordREST) { + self.coreGuild = guild + self.rest = rest id = guild.id name = guild.name icon = guild.icon @@ -121,58 +236,21 @@ public class Guild { premiumSubscriptionCount = guild.premium_subscription_count preferredLocale = guild.preferred_locale maxVideoChannelUsers = guild.max_video_channel_users + maxStageVideoUsers = guild.max_stage_video_channel_users approximateMemberCount = guild.approximate_member_count approximatePresenceCount = guild.approximate_presence_count nsfwLevel = guild.nsfw_level - stageInstances = guild.stage_instances + stageInstances = guild.stage_instances ?? [] scheduledEvents = guild.guild_scheduled_events premiumProgressBarEnabled = guild.premium_progress_bar_enabled maxPresences = guild.max_presences - - if let afk_channel_id = guild.afk_channel_id { - afkChannel = try? await GuildChannel(from: afk_channel_id) - } - - if let widget_channel_id = guild.widget_channel_id { - widgetChannel = try? await GuildChannel(from: widget_channel_id) - } - - if let rules_channel_id = guild.rules_channel_id { - rulesChannel = try? await GuildChannel(from: rules_channel_id) - } - - if let system_channel_id = guild.system_channel_id { - systemChannel = try? await GuildChannel(from: system_channel_id) - } - - if let public_updates_channel_id = guild.public_updates_channel_id { - publicUpdatesChannel = try? await GuildChannel(from: public_updates_channel_id) - } - - if let vanity_url_code = vanityURLCode { - vanityURL = URL(string: "https://discord.gg/\(vanity_url_code)") - } - - owner = try await Member(guildID: id, userID: ownerID) - - let coreMembers: [DiscordKitCore.Member] = try await rest.listGuildMembers(id) - members = coreMembers.map({ Member(from: $0, rest: rest)}) - - if let members = members { - chunked = memberCount == members.count - } - - createdAt = id.creationTime() - - let coreMe = try await rest.getGuildMember(guild: id) - me = Member(from: coreMe, rest: rest) } /// Get a guild object from a guild ID. /// - Parameter id: The ID of the guild you want. public convenience init(id: Snowflake) async throws { let coreGuild = try await Client.current!.rest.getGuild(id: id) - try await self.init(guild: coreGuild, rest: Client.current!.rest) + self.init(guild: coreGuild, rest: Client.current!.rest) } } diff --git a/Sources/DiscordKitBot/BotObjects/Message.swift b/Sources/DiscordKitBot/BotObjects/Message.swift index 4d44818e6..7b86f52e2 100644 --- a/Sources/DiscordKitBot/BotObjects/Message.swift +++ b/Sources/DiscordKitBot/BotObjects/Message.swift @@ -9,19 +9,29 @@ import Foundation import DiscordKitCore /// A Discord message, with convenience methods. -public struct Message { +public struct Message: Identifiable { /// ID of the message public let id: Snowflake /// Channel the message was sent in - // public let channel: Channel + public var channel: TextChannel { + get async throws { + return try await TextChannel(from: coreMessage.channel_id) + } + } /// ID of the channel the message was sent in - public let channelID: Snowflake + private let channelID: Snowflake /// The guild the message was sent in - public fileprivate(set) var guild: Guild? = nil - //public let guildID: Snowflake? + public var guild: Guild? { + get async throws { + if let guildID = coreMessage.guild_id { + return try await Guild(id: guildID) + } + return nil + } + } /// The author of this message (not guaranteed to be a valid user, see discussion) /// @@ -35,7 +45,14 @@ public struct Message { public var author: User /// Member properties for this message's author - public var member: Member? + public var member: Member? { + get async throws { + if let messageMember = coreMessage.member { + return Member(from: messageMember, rest: rest) + } + return nil + } + } /// Contents of the message /// @@ -134,16 +151,15 @@ public struct Message { public var jumpURL: URL? // The REST handler associated with this message, used for message actions - fileprivate weak var rest: DiscordREST? + private var rest: DiscordREST + + private var coreMessage: DiscordKitCore.Message internal init(from message: DiscordKitCore.Message, rest: DiscordREST) async { content = message.content channelID = message.channel_id id = message.id author = message.author - if let messageMember = message.member { - member = Member(from: messageMember, rest: rest) - } timestamp = message.timestamp editedTimestamp = message.edited_timestamp tts = message.tts @@ -170,10 +186,7 @@ public struct Message { call = message.call self.rest = rest - - if let guildID = message.guild_id { - guild = try? await Guild(id: guildID) - } + self.coreMessage = message // jumpURL = nil } @@ -184,19 +197,19 @@ public extension Message { /// /// - Parameter content: The content of the reply message func reply(_ content: String) async throws -> DiscordKitBot.Message { - let coreMessage = try await rest!.createChannelMsg( + let coreMessage = try await rest.createChannelMsg( message: .init(content: content, message_reference: .init(message_id: id), components: []), id: channelID ) - return await Message(from: coreMessage, rest: rest!) + return await Message(from: coreMessage, rest: rest) } /// Deletes the message. /// /// You can always delete your own messages, but deleting other people's messages requires the `manage_messages` guild permission. func delete() async throws { - try await rest!.deleteMsg(id: channelID, msgID: id) + try await rest.deleteMsg(id: channelID, msgID: id) } /// Edits the message @@ -205,28 +218,28 @@ public extension Message { /// /// - Parameter content: The content of the edited message func edit(content: String?) async throws { - try await rest!.editMessage(channelID, id, DiscordKitCore.NewMessage(content: content)) + try await rest.editMessage(channelID, id, DiscordKitCore.NewMessage(content: content)) } /// Add a reaction emoji to the message. /// /// - Parameter emoji: The emote in the form `:emote_name:emote_id` func addReaction(emoji: String) async throws { - try await rest!.createReaction(channelID, id, emoji) + try await rest.createReaction(channelID, id, emoji) } /// Removes your own reaction from a message /// /// - Parameter emoji: The emote in the form `:emote_name:emote_id` func removeReaction(emoji: Snowflake) async throws { - try await rest!.deleteOwnReaction(channelID, id, emoji) + try await rest.deleteOwnReaction(channelID, id, emoji) } /// Clear all reactions from a message /// /// Requires the the `manage_messages` guild permission. func clearAllReactions() async throws { - try await rest!.deleteAllReactions(channelID, id) + try await rest.deleteAllReactions(channelID, id) } /// Clear all reactions from a message of a specific emoji /// @@ -234,7 +247,7 @@ public extension Message { /// /// - Parameter emoji: The emote in the form `:emote_name:emote_id` func clearAllReactions(for emoji: Snowflake) async throws { - try await rest!.deleteAllReactionsforEmoji(channelID, id, emoji) + try await rest.deleteAllReactionsforEmoji(channelID, id, emoji) } /// Starts a thread from the message @@ -242,29 +255,29 @@ public extension Message { /// Requires the `create_public_threads`` guild permission. func createThread(name: String, autoArchiveDuration: Int?, rateLimitPerUser: Int?) async throws -> Channel { let body = CreateThreadRequest(name: name, auto_archive_duration: autoArchiveDuration, rate_limit_per_user: rateLimitPerUser) - return try await rest!.startThreadfromMessage(channelID, id, body) + return try await rest.startThreadfromMessage(channelID, id, body) } /// Pins the message. /// /// Requires the `manage_messages` guild permission to do this in a non-private channel context. func pin() async throws { - try await rest!.pinMessage(channelID, id) + try await rest.pinMessage(channelID, id) } /// Unpins the message. /// /// Requires the `manage_messages` guild permission to do this in a non-private channel context. func unpin() async throws { - try await rest!.unpinMessage(channelID, id) + try await rest.unpinMessage(channelID, id) } /// Publishes a message in an announcement channel to it's followers. /// /// Requires the `SEND_MESSAGES` permission, if the bot sent the message, or the `MANAGE_MESSAGES` permission for all other messages func publish() async throws -> Message { - let coreMessage: DiscordKitCore.Message = try await rest!.crosspostMessage(channelID, id) - return await Message(from: coreMessage, rest: rest!) + let coreMessage: DiscordKitCore.Message = try await rest.crosspostMessage(channelID, id) + return await Message(from: coreMessage, rest: rest) } static func ==(lhs: Message, rhs: Message) -> Bool { diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index c4b90a327..5a84ad27c 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -211,21 +211,6 @@ enum AuthError: Error { case emptyToken } -extension AuthError: LocalizedError { - public var errorDescription: String? { - switch self { - case .invalidToken: - return NSLocalizedString("A user-friendly description of the error.", comment: "My error") - case .missingFile: - return NSLocalizedString("The file does not exist, or your bot does not have read access to it.", comment: "File access error.") - case .missingEnvVar: - return NSLocalizedString("The \"DISCORD_TOKEN\" environment variable was not found.", comment: "ENV VAR access error.") - case .emptyToken: - return NSLocalizedString("The token provided is empty.", comment: "Invalid token.") - } - } -} - // Gateway API extension Client { /// `true` if the bot is connected to discord and ready to do bot-like things. diff --git a/Sources/DiscordKitBot/Extensions/Sequence+.swift b/Sources/DiscordKitBot/Extensions/Sequence+.swift index 9c4f3b896..5b1ba75a9 100644 --- a/Sources/DiscordKitBot/Extensions/Sequence+.swift +++ b/Sources/DiscordKitBot/Extensions/Sequence+.swift @@ -10,4 +10,19 @@ extension Sequence { return values } + + func asyncCompactMap( + _ transform: (Element) async throws -> T? + ) async rethrows -> [T] { + var values = [T]() + + for element in self { + let transformed = try await transform(element) + if let transformed = transformed { + values.append(transformed) + } + } + + return values + } } \ No newline at end of file diff --git a/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift b/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift index f472dbcc1..cc70404cc 100644 --- a/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift +++ b/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift @@ -676,7 +676,7 @@ public extension RobustWebSocket { Self.log.trace("Outgoing Payload", metadata: [ "opcode": "\(opcode)", - "data": "\((T.self == GatewayIdentify.self ? nil : data))", // Don't log tokens. + "data": "\(String(describing: (T.self == GatewayIdentify.self ? nil : data)))", // Don't log tokens. "seq": "\(seq ?? -1)" ]) diff --git a/Sources/DiscordKitCore/Objects/Data/Guild.swift b/Sources/DiscordKitCore/Objects/Data/Guild.swift index a52c76d7a..f380eb746 100644 --- a/Sources/DiscordKitCore/Objects/Data/Guild.swift +++ b/Sources/DiscordKitCore/Objects/Data/Guild.swift @@ -38,7 +38,7 @@ public enum GuildFeature: String, Codable { } public struct Guild: GatewayData, Equatable, Identifiable { - public init(id: Snowflake, name: String, icon: String? = nil, icon_hash: String? = nil, splash: String? = nil, discovery_splash: String? = nil, owner: Bool? = nil, owner_id: Snowflake, permissions: String? = nil, region: String? = nil, afk_channel_id: Snowflake? = nil, afk_timeout: Int, widget_enabled: Bool? = nil, widget_channel_id: Snowflake? = nil, verification_level: VerificationLevel, default_message_notifications: MessageNotifLevel, explicit_content_filter: ExplicitContentFilterLevel, roles: [DecodableThrowable], emojis: [DecodableThrowable], features: [DecodableThrowable], mfa_level: MFALevel, application_id: Snowflake? = nil, system_channel_id: Snowflake? = nil, system_channel_flags: Int, rules_channel_id: Snowflake? = nil, joined_at: Date? = nil, large: Bool? = nil, unavailable: Bool? = nil, member_count: Int? = nil, voice_states: [VoiceState]? = nil, members: [Member]? = nil, channels: [Channel]? = nil, threads: [Channel]? = nil, presences: [PresenceUpdate]? = nil, max_presences: Int? = nil, max_members: Int? = nil, vanity_url_code: String? = nil, description: String? = nil, banner: String? = nil, premium_tier: PremiumLevel, premium_subscription_count: Int? = nil, preferred_locale: Locale, public_updates_channel_id: Snowflake? = nil, max_video_channel_users: Int? = nil, approximate_member_count: Int? = nil, approximate_presence_count: Int? = nil, welcome_screen: GuildWelcomeScreen? = nil, nsfw_level: NSFWLevel, stage_instances: [StageInstance]? = nil, stickers: [Sticker]? = nil, guild_scheduled_events: [GuildScheduledEvent]? = nil, premium_progress_bar_enabled: Bool) { + public init(id: Snowflake, name: String, icon: String? = nil, icon_hash: String? = nil, splash: String? = nil, discovery_splash: String? = nil, owner: Bool? = nil, owner_id: Snowflake, permissions: String? = nil, region: String? = nil, afk_channel_id: Snowflake? = nil, afk_timeout: Int, widget_enabled: Bool? = nil, widget_channel_id: Snowflake? = nil, verification_level: VerificationLevel, default_message_notifications: MessageNotifLevel, explicit_content_filter: ExplicitContentFilterLevel, roles: [DecodableThrowable], emojis: [DecodableThrowable], features: [DecodableThrowable], mfa_level: MFALevel, application_id: Snowflake? = nil, system_channel_id: Snowflake? = nil, system_channel_flags: Int, rules_channel_id: Snowflake? = nil, joined_at: Date? = nil, large: Bool? = nil, unavailable: Bool? = nil, member_count: Int? = nil, voice_states: [VoiceState]? = nil, members: [Member]? = nil, channels: [Channel]? = nil, threads: [Channel]? = nil, presences: [PresenceUpdate]? = nil, max_presences: Int? = nil, max_members: Int? = nil, vanity_url_code: String? = nil, description: String? = nil, banner: String? = nil, premium_tier: PremiumLevel, premium_subscription_count: Int? = nil, preferred_locale: Locale, public_updates_channel_id: Snowflake? = nil, max_stage_video_channel_users: Int? = nil, max_video_channel_users: Int? = nil, approximate_member_count: Int? = nil, approximate_presence_count: Int? = nil, welcome_screen: GuildWelcomeScreen? = nil, nsfw_level: NSFWLevel, stage_instances: [StageInstance]? = nil, stickers: [Sticker]? = nil, guild_scheduled_events: [GuildScheduledEvent]? = nil, premium_progress_bar_enabled: Bool) { self.id = id self.name = name self.icon = icon @@ -78,6 +78,7 @@ public struct Guild: GatewayData, Equatable, Identifiable { self.preferred_locale = preferred_locale self.public_updates_channel_id = public_updates_channel_id self.max_video_channel_users = max_video_channel_users + self.max_stage_video_channel_users = max_stage_video_channel_users self.approximate_member_count = approximate_member_count self.approximate_presence_count = approximate_presence_count self.welcome_screen = welcome_screen @@ -130,6 +131,7 @@ public struct Guild: GatewayData, Equatable, Identifiable { public let preferred_locale: Locale // Defaults to en-US public let public_updates_channel_id: Snowflake? public let max_video_channel_users: Int? + public let max_stage_video_channel_users: Int? public let approximate_member_count: Int? // Approximate number of members in this guild, returned from the GET /guilds/ endpoint when with_counts is true public let approximate_presence_count: Int? // Approximate number of non-offline members in this guild, returned from the GET /guilds/ endpoint when with_counts is true public let welcome_screen: GuildWelcomeScreen? From c6346613d0fa661d1d618bf32785b335b4d9941a Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Thu, 15 Jun 2023 15:53:12 +0000 Subject: [PATCH 25/41] fix incorrect bool check --- Sources/DiscordKitBot/BotObjects/Channels/Category.swift | 2 +- .../DiscordKitBot/BotObjects/Channels/GuildChannel.swift | 8 ++++---- Sources/DiscordKitBot/BotObjects/Channels/Text.swift | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift index b09016b31..a4234381e 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift @@ -36,7 +36,7 @@ public class CategoryChannel: GuildChannel { public let nsfw: Bool override init(from channel: DiscordKitCore.Channel, rest: DiscordREST) throws { - if channel.type != .category { throw GuildChannelError.BadChannelType } + if channel.type != .category { throw GuildChannelError.badChannelType } nsfw = channel.nsfw ?? false try super.init(from: channel, rest: rest) diff --git a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift index 725fbb86d..0c0e50b09 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift @@ -22,7 +22,7 @@ public class GuildChannel: Identifiable { if let guildID = coreChannel.guild_id { return try await Guild(id: guildID) } - throw GuildChannelError.NotAGuildChannel // This should be inaccessible + throw GuildChannelError.notAGuildChannel // This should be inaccessible } } // let jumpURL: URL @@ -50,7 +50,7 @@ public class GuildChannel: Identifiable { internal let coreChannel: DiscordKitCore.Channel internal init(from channel: DiscordKitCore.Channel, rest: DiscordREST) throws { - guard channel.guild_id == nil else { throw GuildChannelError.NotAGuildChannel } + guard channel.guild_id != nil else { throw GuildChannelError.notAGuildChannel } self.coreChannel = channel self.name = channel.name self.createdAt = channel.id.creationTime() @@ -127,7 +127,7 @@ public extension GuildChannel { } enum GuildChannelError: Error { - case BadChannelType - case NotAGuildChannel + case badChannelType + case notAGuildChannel } diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Text.swift b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift index 955a1008d..6029a4104 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Text.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift @@ -40,7 +40,7 @@ public class TextChannel: GuildChannel { public let slowmodeDelay: Int internal override init(from channel: Channel, rest: DiscordREST) throws { - if channel.type != .text { throw GuildChannelError.BadChannelType } + if channel.type != .text { throw GuildChannelError.badChannelType } nsfw = channel.nsfw ?? false slowmodeDelay = channel.rate_limit_per_user ?? 0 lastMessageID = channel.last_message_id From 63d889be75b53fd3eadbfffeb85c813f32290d54 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Fri, 16 Jun 2023 14:51:32 +0000 Subject: [PATCH 26/41] fix listGuildMembers --- .../DiscordKitBot/BotObjects/Channels/GuildChannel.swift | 5 ++++- Sources/DiscordKitBot/BotObjects/Channels/Text.swift | 2 +- Sources/DiscordKitBot/BotObjects/Guild.swift | 2 +- Sources/DiscordKitBot/BotObjects/Message.swift | 4 ++-- Sources/DiscordKitCore/REST/APIGuild.swift | 7 +++++-- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift index 0c0e50b09..edd3f2ae0 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift @@ -25,7 +25,9 @@ public class GuildChannel: Identifiable { throw GuildChannelError.notAGuildChannel // This should be inaccessible } } - // let jumpURL: URL + + /// A link that opens this channel in discord. + let jumpURL: URL /// A string you can put in message contents to mention the channel. public let mention: String /// The position of the channel in the Guild's channel list @@ -60,6 +62,7 @@ public class GuildChannel: Identifiable { self.rest = rest self.mention = "<#\(id)>" self.overwrites = channel.permission_overwrites + self.jumpURL = URL(string: "https://discord.com/channels/\(channel.guild_id!)/\(id)")! } /// Initialize an Channel using an ID. diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Text.swift b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift index 6029a4104..fa60df67e 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Text.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift @@ -20,7 +20,6 @@ public class TextChannel: GuildChannel { } /// The id of the last message sent in this channel. It may not point to a valid message. public let lastMessageID: Snowflake? - // fileprivate(set) var members: [Member]? = nil /// If the channel is marked as “not safe for work” or “age restricted”. public let nsfw: Bool /// All the threads that your bot can see. @@ -35,6 +34,7 @@ public class TextChannel: GuildChannel { /// The topic of the channel public let topic: String? /// The number of seconds a member must wait between sending messages in this channel. + /// /// A value of 0 denotes that it is disabled. /// Bots and users with manage_channels or manage_messages bypass slowmode. public let slowmodeDelay: Int diff --git a/Sources/DiscordKitBot/BotObjects/Guild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift index fce670a21..eba27823b 100644 --- a/Sources/DiscordKitBot/BotObjects/Guild.swift +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -185,7 +185,7 @@ public class Guild: Identifiable { } } - /// A list of the guild's members. + /// A list of the guild's first 50 members. public var members: [Member] { get async throws { return try await coreMembers.map({ Member(from: $0, rest: rest)}) diff --git a/Sources/DiscordKitBot/BotObjects/Message.swift b/Sources/DiscordKitBot/BotObjects/Message.swift index 7b86f52e2..c6c8a6ce0 100644 --- a/Sources/DiscordKitBot/BotObjects/Message.swift +++ b/Sources/DiscordKitBot/BotObjects/Message.swift @@ -148,7 +148,7 @@ public struct Message: Identifiable { public var call: CallMessageComponent? /// The url to jump to this message - public var jumpURL: URL? + public var jumpURL: URL // The REST handler associated with this message, used for message actions private var rest: DiscordREST @@ -188,7 +188,7 @@ public struct Message: Identifiable { self.rest = rest self.coreMessage = message - // jumpURL = nil + jumpURL = URL(string: "https://discord.com/channels/\(message.guild_id!)/\(message.channel_id)/\(id)")! } } diff --git a/Sources/DiscordKitCore/REST/APIGuild.swift b/Sources/DiscordKitCore/REST/APIGuild.swift index 398099fcd..e9f5344b9 100644 --- a/Sources/DiscordKitCore/REST/APIGuild.swift +++ b/Sources/DiscordKitCore/REST/APIGuild.swift @@ -113,10 +113,13 @@ public extension DiscordREST { /// /// > GET: `/guilds/{guild.id}/members` func listGuildMembers( - _ guildId: Snowflake + _ guildId: Snowflake, + _ limit: Int = 50, + _ after: Snowflake? ) async throws -> T { return try await getReq( - path: "guilds/\(guildId)/members" + path: "/guilds/\(guildId)/members", + query: [URLQueryItem(name: "limit", value: String(limit)), URLQueryItem(name: "after", value: after)] ) } /// Search Guild Members From 8c988426ef834464093bf3fad9521b7122f02b65 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Mon, 19 Jun 2023 13:02:12 +0000 Subject: [PATCH 27/41] use AsyncSequence for MemberList --- Sources/DiscordKitBot/BotObjects/Guild.swift | 112 ++++++++++++++++-- Sources/DiscordKitBot/BotObjects/Member.swift | 14 ++- .../DiscordKitBot/Objects/GuildBanEntry.swift | 6 + Sources/DiscordKitCore/REST/APIGuild.swift | 6 +- 4 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 Sources/DiscordKitBot/Objects/GuildBanEntry.swift diff --git a/Sources/DiscordKitBot/BotObjects/Guild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift index eba27823b..8c8e6915b 100644 --- a/Sources/DiscordKitBot/BotObjects/Guild.swift +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -179,27 +179,29 @@ public class Guild: Identifiable { } } - private var coreMembers: [DiscordKitCore.Member] { + private var coreMembers: CoreMemberList { get async throws { - try await rest.listGuildMembers(id) + return CoreMemberList(limit: 50, guildID: id) } } /// A list of the guild's first 50 members. - public var members: [Member] { + public var members: AsyncMapSequence { get async throws { - return try await coreMembers.map({ Member(from: $0, rest: rest)}) + return try await coreMembers.map({ Member(from: $0, rest: self.rest)}) } } /// If the guild is "chunked" /// /// A chunked guild means that ``memberCount`` is equal to the number of members in ``members``. /// If this value is false, you should request for offline members. - public var chunked: Bool { - get async throws { - try await memberCount == members.count - } - } + // public var chunked: Bool { + // get async throws { + // try await memberCount == members.count + // } + // } + + /// The bot's member object. public var me: Member { get async throws { @@ -255,5 +257,97 @@ public class Guild: Identifiable { } public extension Guild { + /// Bans the member from the guild. + /// - Parameters: + /// - userID: The Snowflake ID of the user to ban. + /// - messageDeleteSeconds: The number of seconds worth of messages to delete from the user in the guild. Defaults to `86400` (1 day) if no value is passed. The minimum value is `0` and the maximum value is `604800` (7 days). + func ban(_ userID: Snowflake, deleteMessageSeconds: Int = 86400) async throws { + try await rest.createGuildBan(id, userID, ["delete_message_seconds":deleteMessageSeconds]) + } + + /// Bans the member from the guild. + /// - Parameters: + /// - userID: The Snowflake ID of the user to ban. + /// - deleteMessageDays: The number of days worth of messages to delete from the user in the guild. Defaults to `1` if no value is passed. The minimum value is `0` and the maximum value is `7`. + func ban(_ userID: Snowflake, deleteMessageDays: Int = 1) async throws { + try await rest.createGuildBan(id, userID, ["delete_message_days":deleteMessageDays]) + } + + /// Unbans a user from the guild. + /// - Parameter userID: The Snowflake ID of the user. + func unban(_ userID: Snowflake) async throws { + try await rest.removeGuildBan(id, userID) + } +} + +struct BansList: AsyncSequence { + typealias Element = GuildBanEntry + let limit: Int + let guildID: Snowflake + + struct AsyncIterator: AsyncIteratorProtocol { + let limit: Int + var after: Snowflake? = nil + var buffer: [GuildBanEntry] = [] + var currentIndex: Int = 0 + let rest = Client.current!.rest + let guildID: Snowflake + + mutating func next() async -> GuildBanEntry? { + if currentIndex > buffer.count { + let tmpBuffer: [GuildBanEntry]? = try? await rest.getGuildBans(guildID, [URLQueryItem(name: "limit", value: String(limit)), URLQueryItem(name: "after", value: after)]) + if let tmpBuffer = tmpBuffer { + buffer = tmpBuffer + currentIndex = 0 + after = tmpBuffer.last?.user.id + } else { + return nil + } + } + let result = buffer[currentIndex] + currentIndex += 1 + return result + } + } + + func makeAsyncIterator() -> AsyncIterator { + return AsyncIterator(limit: limit, guildID: guildID) + } +} + +public struct CoreMemberList: AsyncSequence { + public typealias Element = DiscordKitCore.Member + let limit: Int + let guildID: Snowflake + + public struct AsyncIterator: AsyncIteratorProtocol { + let limit: Int + var after: Snowflake? = nil + var buffer: [DiscordKitCore.Member] = [] + var currentIndex: Int = 0 + let rest = Client.current!.rest + let guildID: Snowflake + + public mutating func next() async throws -> DiscordKitCore.Member? { + if currentIndex > buffer.count { + let tmpBuffer: [DiscordKitCore.Member]? = try await rest.listGuildMembers(guildID, limit, after) + if let tmpBuffer = tmpBuffer { + buffer = tmpBuffer + currentIndex = 0 + after = tmpBuffer.last?.user?.id + } else { + return nil + } + } + + let result = buffer[currentIndex] + currentIndex += 1 + return result + } + } + + public func makeAsyncIterator() -> AsyncIterator { + return AsyncIterator(limit: limit, guildID: guildID) + } } diff --git a/Sources/DiscordKitBot/BotObjects/Member.swift b/Sources/DiscordKitBot/BotObjects/Member.swift index d4559af2b..676612e3b 100644 --- a/Sources/DiscordKitBot/BotObjects/Member.swift +++ b/Sources/DiscordKitBot/BotObjects/Member.swift @@ -96,9 +96,17 @@ public extension Member { } /// Bans the member from the guild. - /// - Parameter messageDeleteSeconds: The number of seconds worth of messages to delete from the user in the guild. Defaults to `0` if not value is passed. The minimum value is `0` and the maximum value is `604800` (7 days). - func ban(messageDeleteSeconds: Int = 0) async throws { - try await rest.createGuildBan(guildID!, user!.id, ["delete_message_seconds":messageDeleteSeconds]) + /// - Parameter messageDeleteSeconds: The number of seconds worth of messages to delete from the user in the guild. + /// Defaults to `86400` (1 day) if no value is passed. The minimum value is `0` and the maximum value is `604800` (7 days). + func ban(deleteMessageSeconds: Int = 86400) async throws { + try await rest.createGuildBan(guildID!, user!.id, ["delete_message_seconds":deleteMessageSeconds]) + } + + /// Bans the member from the guild. + /// - Parameter messageDeleteSeconds: The number of seconds worth of messages to delete from the user in the guild. + /// Defaults to `1` if no value is passed. The minimum value is `0` and the maximum value is `7`. + func ban(deleteMessageDays: Int = 1) async throws { + try await rest.createGuildBan(guildID!, user!.id, ["delete_message_days":deleteMessageDays]) } /// Deletes the ban for this member. diff --git a/Sources/DiscordKitBot/Objects/GuildBanEntry.swift b/Sources/DiscordKitBot/Objects/GuildBanEntry.swift new file mode 100644 index 000000000..9a111e2a2 --- /dev/null +++ b/Sources/DiscordKitBot/Objects/GuildBanEntry.swift @@ -0,0 +1,6 @@ +import DiscordKitCore + +struct GuildBanEntry: Codable { + let reason: String + let user: User +} \ No newline at end of file diff --git a/Sources/DiscordKitCore/REST/APIGuild.swift b/Sources/DiscordKitCore/REST/APIGuild.swift index e9f5344b9..75a31be41 100644 --- a/Sources/DiscordKitCore/REST/APIGuild.swift +++ b/Sources/DiscordKitCore/REST/APIGuild.swift @@ -221,10 +221,12 @@ public extension DiscordREST { /// /// > GET: `/guilds/{guild.id}/bans` func getGuildBans( - _ guildId: Snowflake + _ guildId: Snowflake, + _ query: [URLQueryItem] ) async throws -> T { return try await getReq( - path: "guilds/\(guildId)/bans" + path: "guilds/\(guildId)/bans", + query: query ) } /// Get Guild Ban From f09152602026a505ee073c2f6ca731c7e819ff5e Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Mon, 19 Jun 2023 13:54:48 +0000 Subject: [PATCH 28/41] Add PaginatedList<> --- Sources/DiscordKitBot/BotObjects/Guild.swift | 89 +++---------------- .../DiscordKitBot/Objects/GuildBanEntry.swift | 2 +- .../DiscordKitBot/Util/PaginatedList.swift | 43 +++++++++ Sources/DiscordKitCore/REST/APIGuild.swift | 9 +- 4 files changed, 60 insertions(+), 83 deletions(-) create mode 100644 Sources/DiscordKitBot/Util/PaginatedList.swift diff --git a/Sources/DiscordKitBot/BotObjects/Guild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift index 8c8e6915b..52c355840 100644 --- a/Sources/DiscordKitBot/BotObjects/Guild.swift +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -179,16 +179,16 @@ public class Guild: Identifiable { } } - private var coreMembers: CoreMemberList { - get async throws { - return CoreMemberList(limit: 50, guildID: id) + private var coreMembers: PaginatedList { + get { + return PaginatedList(pageFetch: { try await self.rest.listGuildMembers(self.id, $0!) }, afterGetter: { $0.user!.id }) } } /// A list of the guild's first 50 members. - public var members: AsyncMapSequence { - get async throws { - return try await coreMembers.map({ Member(from: $0, rest: self.rest)}) + public var members: AsyncMapSequence, Member> { + get { + return coreMembers.map({ Member(from: $0, rest: self.rest)}) } } /// If the guild is "chunked" @@ -200,7 +200,11 @@ public class Guild: Identifiable { // try await memberCount == members.count // } // } - + public var bans: PaginatedList { + get { + return PaginatedList(pageFetch: { try await self.rest.getGuildBans(self.id, $0!)}, afterGetter: { $0.user.id }) + } + } /// The bot's member object. public var me: Member { @@ -280,74 +284,3 @@ public extension Guild { } } -struct BansList: AsyncSequence { - typealias Element = GuildBanEntry - let limit: Int - let guildID: Snowflake - - struct AsyncIterator: AsyncIteratorProtocol { - let limit: Int - var after: Snowflake? = nil - var buffer: [GuildBanEntry] = [] - var currentIndex: Int = 0 - let rest = Client.current!.rest - let guildID: Snowflake - - mutating func next() async -> GuildBanEntry? { - if currentIndex > buffer.count { - let tmpBuffer: [GuildBanEntry]? = try? await rest.getGuildBans(guildID, [URLQueryItem(name: "limit", value: String(limit)), URLQueryItem(name: "after", value: after)]) - if let tmpBuffer = tmpBuffer { - buffer = tmpBuffer - currentIndex = 0 - after = tmpBuffer.last?.user.id - } else { - return nil - } - } - - let result = buffer[currentIndex] - currentIndex += 1 - return result - } - } - - func makeAsyncIterator() -> AsyncIterator { - return AsyncIterator(limit: limit, guildID: guildID) - } -} - -public struct CoreMemberList: AsyncSequence { - public typealias Element = DiscordKitCore.Member - let limit: Int - let guildID: Snowflake - - public struct AsyncIterator: AsyncIteratorProtocol { - let limit: Int - var after: Snowflake? = nil - var buffer: [DiscordKitCore.Member] = [] - var currentIndex: Int = 0 - let rest = Client.current!.rest - let guildID: Snowflake - - public mutating func next() async throws -> DiscordKitCore.Member? { - if currentIndex > buffer.count { - let tmpBuffer: [DiscordKitCore.Member]? = try await rest.listGuildMembers(guildID, limit, after) - if let tmpBuffer = tmpBuffer { - buffer = tmpBuffer - currentIndex = 0 - after = tmpBuffer.last?.user?.id - } else { - return nil - } - } - - let result = buffer[currentIndex] - currentIndex += 1 - return result - } - } - - public func makeAsyncIterator() -> AsyncIterator { - return AsyncIterator(limit: limit, guildID: guildID) - } -} diff --git a/Sources/DiscordKitBot/Objects/GuildBanEntry.swift b/Sources/DiscordKitBot/Objects/GuildBanEntry.swift index 9a111e2a2..8a7336b0b 100644 --- a/Sources/DiscordKitBot/Objects/GuildBanEntry.swift +++ b/Sources/DiscordKitBot/Objects/GuildBanEntry.swift @@ -1,6 +1,6 @@ import DiscordKitCore -struct GuildBanEntry: Codable { +public struct GuildBanEntry: Codable { let reason: String let user: User } \ No newline at end of file diff --git a/Sources/DiscordKitBot/Util/PaginatedList.swift b/Sources/DiscordKitBot/Util/PaginatedList.swift new file mode 100644 index 000000000..36fb93350 --- /dev/null +++ b/Sources/DiscordKitBot/Util/PaginatedList.swift @@ -0,0 +1,43 @@ +import DiscordKitCore + +public struct PaginatedList : AsyncSequence { + let pageFetch: (Snowflake?) async throws -> [Element] + let afterGetter: (Element) -> Snowflake + + public struct AsyncIterator: AsyncIteratorProtocol { + let pageFetch: (Snowflake?) async throws -> [Element] + let afterGetter: (Element) -> Snowflake + + private var buffer: [Element] = [] + private var currentIndex: Int = 0 + private var after: Snowflake? = nil + + public mutating func next() async throws -> Element? { + if currentIndex >= buffer.count || buffer.isEmpty { + let tmpBuffer: [Element] = try await pageFetch(after) + guard !tmpBuffer.isEmpty else { return nil } + + buffer = tmpBuffer + currentIndex = 0 + if let last = tmpBuffer.last { + after = afterGetter(last) + } + } + + let result = buffer[currentIndex] + currentIndex += 1 + return result + } + + public init(_ pageFetch: @escaping (Snowflake?) async throws -> [Element], _ afterGetter: @escaping (Element) -> Snowflake) { + self.pageFetch = pageFetch + self.afterGetter = afterGetter + } + } + + public typealias Element = Element + + public func makeAsyncIterator() -> AsyncIterator { + return AsyncIterator(pageFetch, afterGetter) + } +} \ No newline at end of file diff --git a/Sources/DiscordKitCore/REST/APIGuild.swift b/Sources/DiscordKitCore/REST/APIGuild.swift index 75a31be41..52de05e39 100644 --- a/Sources/DiscordKitCore/REST/APIGuild.swift +++ b/Sources/DiscordKitCore/REST/APIGuild.swift @@ -114,8 +114,8 @@ public extension DiscordREST { /// > GET: `/guilds/{guild.id}/members` func listGuildMembers( _ guildId: Snowflake, - _ limit: Int = 50, - _ after: Snowflake? + _ after: Snowflake?, + _ limit: Int = 50 ) async throws -> T { return try await getReq( path: "/guilds/\(guildId)/members", @@ -222,11 +222,12 @@ public extension DiscordREST { /// > GET: `/guilds/{guild.id}/bans` func getGuildBans( _ guildId: Snowflake, - _ query: [URLQueryItem] + _ after: Snowflake?, + _ limit: Int = 50 ) async throws -> T { return try await getReq( path: "guilds/\(guildId)/bans", - query: query + query: [URLQueryItem(name: "limit", value: String(limit)), URLQueryItem(name: "after", value: after)] ) } /// Get Guild Ban From c1a7dfcc6d283d11530a700f187435eee753d97d Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Mon, 19 Jun 2023 13:59:00 +0000 Subject: [PATCH 29/41] remove force try --- Sources/DiscordKitBot/BotObjects/Guild.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/DiscordKitBot/BotObjects/Guild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift index 52c355840..9dec5c9c5 100644 --- a/Sources/DiscordKitBot/BotObjects/Guild.swift +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -181,7 +181,7 @@ public class Guild: Identifiable { private var coreMembers: PaginatedList { get { - return PaginatedList(pageFetch: { try await self.rest.listGuildMembers(self.id, $0!) }, afterGetter: { $0.user!.id }) + return PaginatedList(pageFetch: { try await self.rest.listGuildMembers(self.id, $0) }, afterGetter: { $0.user!.id }) } } @@ -202,7 +202,7 @@ public class Guild: Identifiable { // } public var bans: PaginatedList { get { - return PaginatedList(pageFetch: { try await self.rest.getGuildBans(self.id, $0!)}, afterGetter: { $0.user.id }) + return PaginatedList(pageFetch: { try await self.rest.getGuildBans(self.id, $0)}, afterGetter: { $0.user.id }) } } From 96ba1453c8316390e96ef10d274d495e8b497b8a Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Mon, 19 Jun 2023 14:02:11 +0000 Subject: [PATCH 30/41] fix invalid form body --- Sources/DiscordKitCore/REST/APIGuild.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/DiscordKitCore/REST/APIGuild.swift b/Sources/DiscordKitCore/REST/APIGuild.swift index 52de05e39..3035aa764 100644 --- a/Sources/DiscordKitCore/REST/APIGuild.swift +++ b/Sources/DiscordKitCore/REST/APIGuild.swift @@ -117,9 +117,13 @@ public extension DiscordREST { _ after: Snowflake?, _ limit: Int = 50 ) async throws -> T { + var query = [URLQueryItem(name: "limit", value: String(limit))] + if let after = after { + query.append(URLQueryItem(name: "after", value: after)) + } return try await getReq( path: "/guilds/\(guildId)/members", - query: [URLQueryItem(name: "limit", value: String(limit)), URLQueryItem(name: "after", value: after)] + query: query ) } /// Search Guild Members @@ -225,9 +229,13 @@ public extension DiscordREST { _ after: Snowflake?, _ limit: Int = 50 ) async throws -> T { + var query = [URLQueryItem(name: "limit", value: String(limit))] + if let after = after { + query.append(URLQueryItem(name: "after", value: after)) + } return try await getReq( path: "guilds/\(guildId)/bans", - query: [URLQueryItem(name: "limit", value: String(limit)), URLQueryItem(name: "after", value: after)] + query: query ) } /// Get Guild Ban From 047c1f70e97cfad6965880758692e310a4de9ccb Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Mon, 19 Jun 2023 14:12:12 +0000 Subject: [PATCH 31/41] GuildBanEntry --- Sources/DiscordKitBot/Objects/GuildBanEntry.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/DiscordKitBot/Objects/GuildBanEntry.swift b/Sources/DiscordKitBot/Objects/GuildBanEntry.swift index 8a7336b0b..46bf49677 100644 --- a/Sources/DiscordKitBot/Objects/GuildBanEntry.swift +++ b/Sources/DiscordKitBot/Objects/GuildBanEntry.swift @@ -1,6 +1,6 @@ import DiscordKitCore public struct GuildBanEntry: Codable { - let reason: String + let reason: String? let user: User } \ No newline at end of file From 472fb14fe0c709c8b09ed27f496904dd55e635e6 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Mon, 19 Jun 2023 14:55:48 +0000 Subject: [PATCH 32/41] code documentation --- Sources/DiscordKitBot/BotObjects/Guild.swift | 21 ++---- .../DiscordKitBot/Util/PaginatedList.swift | 43 ----------- .../Util/PaginatedSequence.swift | 74 +++++++++++++++++++ 3 files changed, 81 insertions(+), 57 deletions(-) delete mode 100644 Sources/DiscordKitBot/Util/PaginatedList.swift create mode 100644 Sources/DiscordKitBot/Util/PaginatedSequence.swift diff --git a/Sources/DiscordKitBot/BotObjects/Guild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift index 9dec5c9c5..123675afd 100644 --- a/Sources/DiscordKitBot/BotObjects/Guild.swift +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -179,30 +179,23 @@ public class Guild: Identifiable { } } - private var coreMembers: PaginatedList { + private var coreMembers: PaginatedSequence { get { - return PaginatedList(pageFetch: { try await self.rest.listGuildMembers(self.id, $0) }, afterGetter: { $0.user!.id }) + return PaginatedSequence({ try await self.rest.listGuildMembers(self.id, $0) }, { $0.user!.id }) } } /// A list of the guild's first 50 members. - public var members: AsyncMapSequence, Member> { + public var members: AsyncMapSequence, Member> { get { return coreMembers.map({ Member(from: $0, rest: self.rest)}) } } - /// If the guild is "chunked" - /// - /// A chunked guild means that ``memberCount`` is equal to the number of members in ``members``. - /// If this value is false, you should request for offline members. - // public var chunked: Bool { - // get async throws { - // try await memberCount == members.count - // } - // } - public var bans: PaginatedList { + + /// A list of users that have been banned from this guild. + public var bans: PaginatedSequence { get { - return PaginatedList(pageFetch: { try await self.rest.getGuildBans(self.id, $0)}, afterGetter: { $0.user.id }) + return PaginatedSequence({ try await self.rest.getGuildBans(self.id, $0)}, { $0.user.id }) } } diff --git a/Sources/DiscordKitBot/Util/PaginatedList.swift b/Sources/DiscordKitBot/Util/PaginatedList.swift deleted file mode 100644 index 36fb93350..000000000 --- a/Sources/DiscordKitBot/Util/PaginatedList.swift +++ /dev/null @@ -1,43 +0,0 @@ -import DiscordKitCore - -public struct PaginatedList : AsyncSequence { - let pageFetch: (Snowflake?) async throws -> [Element] - let afterGetter: (Element) -> Snowflake - - public struct AsyncIterator: AsyncIteratorProtocol { - let pageFetch: (Snowflake?) async throws -> [Element] - let afterGetter: (Element) -> Snowflake - - private var buffer: [Element] = [] - private var currentIndex: Int = 0 - private var after: Snowflake? = nil - - public mutating func next() async throws -> Element? { - if currentIndex >= buffer.count || buffer.isEmpty { - let tmpBuffer: [Element] = try await pageFetch(after) - guard !tmpBuffer.isEmpty else { return nil } - - buffer = tmpBuffer - currentIndex = 0 - if let last = tmpBuffer.last { - after = afterGetter(last) - } - } - - let result = buffer[currentIndex] - currentIndex += 1 - return result - } - - public init(_ pageFetch: @escaping (Snowflake?) async throws -> [Element], _ afterGetter: @escaping (Element) -> Snowflake) { - self.pageFetch = pageFetch - self.afterGetter = afterGetter - } - } - - public typealias Element = Element - - public func makeAsyncIterator() -> AsyncIterator { - return AsyncIterator(pageFetch, afterGetter) - } -} \ No newline at end of file diff --git a/Sources/DiscordKitBot/Util/PaginatedSequence.swift b/Sources/DiscordKitBot/Util/PaginatedSequence.swift new file mode 100644 index 000000000..3d1a3ba7f --- /dev/null +++ b/Sources/DiscordKitBot/Util/PaginatedSequence.swift @@ -0,0 +1,74 @@ +import DiscordKitCore + +/// Paginated data from Discord's API. +/// +/// This is used whenever we need to fetch data from Discord's API in chunks. You can access the data using a `for-in` loop. +/// For example, the following code will print the username of every member in a guild. +/// +/// ```swift +/// for try await member: Member in guild.members { +/// print(member.user!.username) +/// } +/// ``` +/// +/// We handle all of the paging code internally, so there's nothing you have to worry about. +public struct PaginatedSequence : AsyncSequence { + private let pageFetch: (Snowflake?) async throws -> [Element] + private let snowflakeGetter: (Element) -> Snowflake + + /// Create a new PaginatedList. + /// + /// As an example, here's the implementation for the server Member List: + /// ```swift + /// PaginatedList({ try await self.rest.listGuildMembers(self.id, $0) }, { $0.user!.id }) + /// ``` + /// For the `pageFetch` parameter, I provided a function that returns the first 50 `Member` objects after a specific user ID. + /// I passed the provided Snowflake as the after value, so that discord provides the 50 `Member`s after that ID. + /// + /// For the `afterGetter`, I simply look the provided `Element`, and transformed it to get the User ID. + /// + /// - Parameters: + /// - pageFetch: The api method that gets the paginated data. Use the `Snowflake` as the `after` value. + /// - snowflakeGetter: A method that takes the incoming element and transforms it into the Snowflake ID needed for pagination. + internal init(_ pageFetch: @escaping (Snowflake?) async throws -> [Element], _ snowflakeGetter: @escaping (Element) -> Snowflake) { + self.pageFetch = pageFetch + self.snowflakeGetter = snowflakeGetter + } + + public struct AsyncIterator: AsyncIteratorProtocol { + private let pageFetch: (Snowflake?) async throws -> [Element] + private let snowflakeGetter: (Element) -> Snowflake + + private var buffer: [Element] = [] + private var currentIndex: Int = 0 + private var after: Snowflake? = nil + + public mutating func next() async throws -> Element? { + if currentIndex >= buffer.count || buffer.isEmpty { + let tmpBuffer: [Element] = try await pageFetch(after) + guard !tmpBuffer.isEmpty else { return nil } + + buffer = tmpBuffer + currentIndex = 0 + if let last = tmpBuffer.last { + after = snowflakeGetter(last) + } + } + + let result = buffer[currentIndex] + currentIndex += 1 + return result + } + + internal init(_ pageFetch: @escaping (Snowflake?) async throws -> [Element], _ afterGetter: @escaping (Element) -> Snowflake) { + self.pageFetch = pageFetch + self.snowflakeGetter = afterGetter + } + } + + public typealias Element = Element + + public func makeAsyncIterator() -> AsyncIterator { + return AsyncIterator(pageFetch, snowflakeGetter) + } +} \ No newline at end of file From 164f840e987f83d7e176ca6667f90d46cb7647ff Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Mon, 19 Jun 2023 15:06:20 +0000 Subject: [PATCH 33/41] fix lint errors --- .../BotObjects/Channels/Category.swift | 2 +- .../BotObjects/Channels/Forum.swift | 2 +- .../BotObjects/Channels/GuildChannel.swift | 1 - .../BotObjects/Channels/Stage.swift | 2 +- .../BotObjects/Channels/Text.swift | 4 +-- .../BotObjects/Channels/Voice.swift | 2 +- Sources/DiscordKitBot/BotObjects/Guild.swift | 29 +++++++------------ Sources/DiscordKitBot/BotObjects/Member.swift | 8 ++--- .../DiscordKitBot/BotObjects/Message.swift | 4 +-- .../DiscordKitBot/Extensions/Sequence+.swift | 2 +- Sources/DiscordKitBot/NotificationNames.swift | 2 +- .../Objects/CreateChannelInviteReq.swift | 2 +- .../Objects/CreateGuildChannelReq.swift | 2 +- .../Objects/EditChannelPermissionsReq.swift | 2 +- .../DiscordKitBot/Objects/GuildBanEntry.swift | 2 +- .../Util/PaginatedSequence.swift | 6 ++-- .../Gateway/DecompressionEngine.swift | 1 + .../Gateway/RobustWebSocket.swift | 1 + .../DiscordKitCore/Objects/Data/Guild.swift | 1 + .../Objects/Data/Snowflake.swift | 2 +- .../Objects/Gateway/GatewayIO.swift | 1 + 21 files changed, 36 insertions(+), 42 deletions(-) diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift index a4234381e..6238759e6 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift @@ -50,4 +50,4 @@ public class CategoryChannel: GuildChannel { try self.init(from: coreChannel, rest: Client.current!.rest) } -} \ No newline at end of file +} diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Forum.swift b/Sources/DiscordKitBot/BotObjects/Channels/Forum.swift index e4a2bd673..dc6619e39 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Forum.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Forum.swift @@ -4,4 +4,4 @@ import DiscordKitCore // TODO: Implement this. // public class ForumChannel: GuildChannel { -// } \ No newline at end of file +// } diff --git a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift index edd3f2ae0..cb2df01dc 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift @@ -133,4 +133,3 @@ enum GuildChannelError: Error { case badChannelType case notAGuildChannel } - diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Stage.swift b/Sources/DiscordKitBot/BotObjects/Channels/Stage.swift index ade5bb57d..21c03d9de 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Stage.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Stage.swift @@ -4,4 +4,4 @@ import DiscordKitCore // TODO: Implement this // public class StageChannel: GuildChannel { -// } \ No newline at end of file +// } diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Text.swift b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift index fa60df67e..f3cec315c 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Text.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift @@ -119,7 +119,7 @@ public extension TextChannel { /// - Parameter messages: An array of ``Message``s to delete. func deleteMessages(messages: [Message]) async throws { let snowflakes = messages.map({ $0.id }) - try await rest.bulkDeleteMessages(id, ["messages":snowflakes]) + try await rest.bulkDeleteMessages(id, ["messages": snowflakes]) } -} \ No newline at end of file +} diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Voice.swift b/Sources/DiscordKitBot/BotObjects/Channels/Voice.swift index d868a3462..07b11d50d 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Voice.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Voice.swift @@ -4,4 +4,4 @@ import DiscordKitCore // TODO: Implement this. // public class VoiceChannel: GuildChannel { -// } \ No newline at end of file +// } diff --git a/Sources/DiscordKitBot/BotObjects/Guild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift index 123675afd..90fa41cc2 100644 --- a/Sources/DiscordKitBot/BotObjects/Guild.swift +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -58,12 +58,10 @@ public class Guild: Identifiable { public let vanityURLCode: String? /// The guild's vanity invite URL. public var vanityURL: URL? { - get { - if let vanity_url_code = vanityURLCode { - return URL(string: "https://discord.gg/\(vanity_url_code)") - } - return nil + if let vanity_url_code = vanityURLCode { + return URL(string: "https://discord.gg/\(vanity_url_code)") } + return nil } /// The guild's description. public let description: String? @@ -180,27 +178,21 @@ public class Guild: Identifiable { } private var coreMembers: PaginatedSequence { - get { - return PaginatedSequence({ try await self.rest.listGuildMembers(self.id, $0) }, { $0.user!.id }) - } + return PaginatedSequence({ try await self.rest.listGuildMembers(self.id, $0) }, { $0.user!.id }) } /// A list of the guild's first 50 members. public var members: AsyncMapSequence, Member> { - get { - return coreMembers.map({ Member(from: $0, rest: self.rest)}) - } + return coreMembers.map({ Member(from: $0, rest: self.rest)}) } - + /// A list of users that have been banned from this guild. public var bans: PaginatedSequence { - get { - return PaginatedSequence({ try await self.rest.getGuildBans(self.id, $0)}, { $0.user.id }) - } + return PaginatedSequence({ try await self.rest.getGuildBans(self.id, $0)}, { $0.user.id }) } /// The bot's member object. - public var me: Member { + public var me: Member { // swiftlint:disable:this identifier_name get async throws { return Member(from: try await rest.getGuildMember(guild: id), rest: rest) } @@ -259,7 +251,7 @@ public extension Guild { /// - userID: The Snowflake ID of the user to ban. /// - messageDeleteSeconds: The number of seconds worth of messages to delete from the user in the guild. Defaults to `86400` (1 day) if no value is passed. The minimum value is `0` and the maximum value is `604800` (7 days). func ban(_ userID: Snowflake, deleteMessageSeconds: Int = 86400) async throws { - try await rest.createGuildBan(id, userID, ["delete_message_seconds":deleteMessageSeconds]) + try await rest.createGuildBan(id, userID, ["delete_message_seconds": deleteMessageSeconds]) } /// Bans the member from the guild. @@ -267,7 +259,7 @@ public extension Guild { /// - userID: The Snowflake ID of the user to ban. /// - deleteMessageDays: The number of days worth of messages to delete from the user in the guild. Defaults to `1` if no value is passed. The minimum value is `0` and the maximum value is `7`. func ban(_ userID: Snowflake, deleteMessageDays: Int = 1) async throws { - try await rest.createGuildBan(id, userID, ["delete_message_days":deleteMessageDays]) + try await rest.createGuildBan(id, userID, ["delete_message_days": deleteMessageDays]) } /// Unbans a user from the guild. @@ -276,4 +268,3 @@ public extension Guild { try await rest.removeGuildBan(id, userID) } } - diff --git a/Sources/DiscordKitBot/BotObjects/Member.swift b/Sources/DiscordKitBot/BotObjects/Member.swift index 676612e3b..ed6c62eea 100644 --- a/Sources/DiscordKitBot/BotObjects/Member.swift +++ b/Sources/DiscordKitBot/BotObjects/Member.swift @@ -87,7 +87,7 @@ public extension Member { /// Applies a time out to a member until the specified time. /// - Parameter time: The time that the timeout ends. func timeout(time: Date) async throws { - try await rest.editGuildMember(guildID!, user!.id, ["communication_disabled_until" : time]) + try await rest.editGuildMember(guildID!, user!.id, ["communication_disabled_until": time]) } /// Kicks the member from the guild. @@ -99,14 +99,14 @@ public extension Member { /// - Parameter messageDeleteSeconds: The number of seconds worth of messages to delete from the user in the guild. /// Defaults to `86400` (1 day) if no value is passed. The minimum value is `0` and the maximum value is `604800` (7 days). func ban(deleteMessageSeconds: Int = 86400) async throws { - try await rest.createGuildBan(guildID!, user!.id, ["delete_message_seconds":deleteMessageSeconds]) + try await rest.createGuildBan(guildID!, user!.id, ["delete_message_seconds": deleteMessageSeconds]) } /// Bans the member from the guild. /// - Parameter messageDeleteSeconds: The number of seconds worth of messages to delete from the user in the guild. /// Defaults to `1` if no value is passed. The minimum value is `0` and the maximum value is `7`. func ban(deleteMessageDays: Int = 1) async throws { - try await rest.createGuildBan(guildID!, user!.id, ["delete_message_days":deleteMessageDays]) + try await rest.createGuildBan(guildID!, user!.id, ["delete_message_days": deleteMessageDays]) } /// Deletes the ban for this member. @@ -122,6 +122,6 @@ public extension Member { /// /// - Returns: The newly created DM Channel func createDM() async throws -> Channel { - return try await rest.createDM(["recipient_id":user!.id]) + return try await rest.createDM(["recipient_id": user!.id]) } } diff --git a/Sources/DiscordKitBot/BotObjects/Message.swift b/Sources/DiscordKitBot/BotObjects/Message.swift index c6c8a6ce0..e2231742d 100644 --- a/Sources/DiscordKitBot/BotObjects/Message.swift +++ b/Sources/DiscordKitBot/BotObjects/Message.swift @@ -280,11 +280,11 @@ public extension Message { return await Message(from: coreMessage, rest: rest) } - static func ==(lhs: Message, rhs: Message) -> Bool { + static func == (lhs: Message, rhs: Message) -> Bool { return lhs.id == rhs.id } - static func !=(lhs: Message, rhs: Message) -> Bool { + static func != (lhs: Message, rhs: Message) -> Bool { return lhs.id != rhs.id } } diff --git a/Sources/DiscordKitBot/Extensions/Sequence+.swift b/Sources/DiscordKitBot/Extensions/Sequence+.swift index 5b1ba75a9..5cce81c29 100644 --- a/Sources/DiscordKitBot/Extensions/Sequence+.swift +++ b/Sources/DiscordKitBot/Extensions/Sequence+.swift @@ -25,4 +25,4 @@ extension Sequence { return values } -} \ No newline at end of file +} diff --git a/Sources/DiscordKitBot/NotificationNames.swift b/Sources/DiscordKitBot/NotificationNames.swift index d4599b5ef..552f1936c 100644 --- a/Sources/DiscordKitBot/NotificationNames.swift +++ b/Sources/DiscordKitBot/NotificationNames.swift @@ -11,6 +11,6 @@ public extension NSNotification.Name { static let ready = Self("dk-ready") static let messageCreate = Self("dk-msg-create") - + static let guildMemberAdd = Self("dk-guild-member-add") } diff --git a/Sources/DiscordKitBot/Objects/CreateChannelInviteReq.swift b/Sources/DiscordKitBot/Objects/CreateChannelInviteReq.swift index 3581b3fd7..8953a4158 100644 --- a/Sources/DiscordKitBot/Objects/CreateChannelInviteReq.swift +++ b/Sources/DiscordKitBot/Objects/CreateChannelInviteReq.swift @@ -3,4 +3,4 @@ struct CreateChannelInviteReq: Codable { let max_users: Int let temporary: Bool let unique: Bool -} \ No newline at end of file +} diff --git a/Sources/DiscordKitBot/Objects/CreateGuildChannelReq.swift b/Sources/DiscordKitBot/Objects/CreateGuildChannelReq.swift index 4b9fce48a..5c6528e5a 100644 --- a/Sources/DiscordKitBot/Objects/CreateGuildChannelReq.swift +++ b/Sources/DiscordKitBot/Objects/CreateGuildChannelReq.swift @@ -17,4 +17,4 @@ struct CreateGuildChannelRed: Codable { // let default_reaction_emoji: // let available_tags: // let default_sort_order: -} \ No newline at end of file +} diff --git a/Sources/DiscordKitBot/Objects/EditChannelPermissionsReq.swift b/Sources/DiscordKitBot/Objects/EditChannelPermissionsReq.swift index 5812c6c07..a47400a03 100644 --- a/Sources/DiscordKitBot/Objects/EditChannelPermissionsReq.swift +++ b/Sources/DiscordKitBot/Objects/EditChannelPermissionsReq.swift @@ -4,4 +4,4 @@ struct EditChannelPermissionsReq: Codable { let allow: Permissions? let deny: Permissions? let type: PermOverwriteType -} \ No newline at end of file +} diff --git a/Sources/DiscordKitBot/Objects/GuildBanEntry.swift b/Sources/DiscordKitBot/Objects/GuildBanEntry.swift index 46bf49677..e5b0b56b8 100644 --- a/Sources/DiscordKitBot/Objects/GuildBanEntry.swift +++ b/Sources/DiscordKitBot/Objects/GuildBanEntry.swift @@ -3,4 +3,4 @@ import DiscordKitCore public struct GuildBanEntry: Codable { let reason: String? let user: User -} \ No newline at end of file +} diff --git a/Sources/DiscordKitBot/Util/PaginatedSequence.swift b/Sources/DiscordKitBot/Util/PaginatedSequence.swift index 3d1a3ba7f..0fd621510 100644 --- a/Sources/DiscordKitBot/Util/PaginatedSequence.swift +++ b/Sources/DiscordKitBot/Util/PaginatedSequence.swift @@ -12,7 +12,7 @@ import DiscordKitCore /// ``` /// /// We handle all of the paging code internally, so there's nothing you have to worry about. -public struct PaginatedSequence : AsyncSequence { +public struct PaginatedSequence: AsyncSequence { private let pageFetch: (Snowflake?) async throws -> [Element] private let snowflakeGetter: (Element) -> Snowflake @@ -41,7 +41,7 @@ public struct PaginatedSequence : AsyncSequence { private var buffer: [Element] = [] private var currentIndex: Int = 0 - private var after: Snowflake? = nil + private var after: Snowflake? public mutating func next() async throws -> Element? { if currentIndex >= buffer.count || buffer.isEmpty { @@ -71,4 +71,4 @@ public struct PaginatedSequence : AsyncSequence { public func makeAsyncIterator() -> AsyncIterator { return AsyncIterator(pageFetch, snowflakeGetter) } -} \ No newline at end of file +} diff --git a/Sources/DiscordKitCore/Gateway/DecompressionEngine.swift b/Sources/DiscordKitCore/Gateway/DecompressionEngine.swift index dca94722b..32a4e5a3a 100644 --- a/Sources/DiscordKitCore/Gateway/DecompressionEngine.swift +++ b/Sources/DiscordKitCore/Gateway/DecompressionEngine.swift @@ -71,6 +71,7 @@ public class DecompressionEngine { } public extension DecompressionEngine { + // swiftlint:disable:next function_body_length fileprivate func decompress(_ data: Data) -> Data { guard !decompressing else { Self.log.warning("Another decompression is currently taking place, skipping") diff --git a/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift b/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift index cc70404cc..2a7d7edd4 100644 --- a/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift +++ b/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift @@ -245,6 +245,7 @@ public class RobustWebSocket: NSObject { #endif } + // swiftlint:disable:next function_body_length private func connect() { guard !explicitlyClosed else { return } #if canImport(WebSocket) diff --git a/Sources/DiscordKitCore/Objects/Data/Guild.swift b/Sources/DiscordKitCore/Objects/Data/Guild.swift index f380eb746..c718a12c9 100644 --- a/Sources/DiscordKitCore/Objects/Data/Guild.swift +++ b/Sources/DiscordKitCore/Objects/Data/Guild.swift @@ -38,6 +38,7 @@ public enum GuildFeature: String, Codable { } public struct Guild: GatewayData, Equatable, Identifiable { + // swiftlint:disable:next function_body_length public init(id: Snowflake, name: String, icon: String? = nil, icon_hash: String? = nil, splash: String? = nil, discovery_splash: String? = nil, owner: Bool? = nil, owner_id: Snowflake, permissions: String? = nil, region: String? = nil, afk_channel_id: Snowflake? = nil, afk_timeout: Int, widget_enabled: Bool? = nil, widget_channel_id: Snowflake? = nil, verification_level: VerificationLevel, default_message_notifications: MessageNotifLevel, explicit_content_filter: ExplicitContentFilterLevel, roles: [DecodableThrowable], emojis: [DecodableThrowable], features: [DecodableThrowable], mfa_level: MFALevel, application_id: Snowflake? = nil, system_channel_id: Snowflake? = nil, system_channel_flags: Int, rules_channel_id: Snowflake? = nil, joined_at: Date? = nil, large: Bool? = nil, unavailable: Bool? = nil, member_count: Int? = nil, voice_states: [VoiceState]? = nil, members: [Member]? = nil, channels: [Channel]? = nil, threads: [Channel]? = nil, presences: [PresenceUpdate]? = nil, max_presences: Int? = nil, max_members: Int? = nil, vanity_url_code: String? = nil, description: String? = nil, banner: String? = nil, premium_tier: PremiumLevel, premium_subscription_count: Int? = nil, preferred_locale: Locale, public_updates_channel_id: Snowflake? = nil, max_stage_video_channel_users: Int? = nil, max_video_channel_users: Int? = nil, approximate_member_count: Int? = nil, approximate_presence_count: Int? = nil, welcome_screen: GuildWelcomeScreen? = nil, nsfw_level: NSFWLevel, stage_instances: [StageInstance]? = nil, stickers: [Sticker]? = nil, guild_scheduled_events: [GuildScheduledEvent]? = nil, premium_progress_bar_enabled: Bool) { self.id = id self.name = name diff --git a/Sources/DiscordKitCore/Objects/Data/Snowflake.swift b/Sources/DiscordKitCore/Objects/Data/Snowflake.swift index 973fcdc4a..ce326e0df 100644 --- a/Sources/DiscordKitCore/Objects/Data/Snowflake.swift +++ b/Sources/DiscordKitCore/Objects/Data/Snowflake.swift @@ -21,4 +21,4 @@ public extension Snowflake { // Convert from ms to sec, because Date wants sec, but discord provides ms return Date(timeInterval: Double(snowflakeTimestamp) / 1000, since: discordEpoch) } -} \ No newline at end of file +} diff --git a/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift b/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift index 2c8412420..1e5c02e82 100644 --- a/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift +++ b/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift @@ -194,6 +194,7 @@ public struct GatewayIncoming: Decodable { case unknown } + // swiftlint:disable:next function_body_length public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) let action = try values.decode(GatewayIncomingOpcodes.self, forKey: .opcode) From 072775249fd300c871ff4d45d3e20b78a1a9810c Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Mon, 19 Jun 2023 15:13:30 +0000 Subject: [PATCH 34/41] more linting fixes --- Sources/DiscordKitCore/Gateway/DecompressionEngine.swift | 1 - Sources/DiscordKitCore/Objects/Data/Guild.swift | 1 - Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift | 1 - 3 files changed, 3 deletions(-) diff --git a/Sources/DiscordKitCore/Gateway/DecompressionEngine.swift b/Sources/DiscordKitCore/Gateway/DecompressionEngine.swift index 32a4e5a3a..dca94722b 100644 --- a/Sources/DiscordKitCore/Gateway/DecompressionEngine.swift +++ b/Sources/DiscordKitCore/Gateway/DecompressionEngine.swift @@ -71,7 +71,6 @@ public class DecompressionEngine { } public extension DecompressionEngine { - // swiftlint:disable:next function_body_length fileprivate func decompress(_ data: Data) -> Data { guard !decompressing else { Self.log.warning("Another decompression is currently taking place, skipping") diff --git a/Sources/DiscordKitCore/Objects/Data/Guild.swift b/Sources/DiscordKitCore/Objects/Data/Guild.swift index c718a12c9..f380eb746 100644 --- a/Sources/DiscordKitCore/Objects/Data/Guild.swift +++ b/Sources/DiscordKitCore/Objects/Data/Guild.swift @@ -38,7 +38,6 @@ public enum GuildFeature: String, Codable { } public struct Guild: GatewayData, Equatable, Identifiable { - // swiftlint:disable:next function_body_length public init(id: Snowflake, name: String, icon: String? = nil, icon_hash: String? = nil, splash: String? = nil, discovery_splash: String? = nil, owner: Bool? = nil, owner_id: Snowflake, permissions: String? = nil, region: String? = nil, afk_channel_id: Snowflake? = nil, afk_timeout: Int, widget_enabled: Bool? = nil, widget_channel_id: Snowflake? = nil, verification_level: VerificationLevel, default_message_notifications: MessageNotifLevel, explicit_content_filter: ExplicitContentFilterLevel, roles: [DecodableThrowable], emojis: [DecodableThrowable], features: [DecodableThrowable], mfa_level: MFALevel, application_id: Snowflake? = nil, system_channel_id: Snowflake? = nil, system_channel_flags: Int, rules_channel_id: Snowflake? = nil, joined_at: Date? = nil, large: Bool? = nil, unavailable: Bool? = nil, member_count: Int? = nil, voice_states: [VoiceState]? = nil, members: [Member]? = nil, channels: [Channel]? = nil, threads: [Channel]? = nil, presences: [PresenceUpdate]? = nil, max_presences: Int? = nil, max_members: Int? = nil, vanity_url_code: String? = nil, description: String? = nil, banner: String? = nil, premium_tier: PremiumLevel, premium_subscription_count: Int? = nil, preferred_locale: Locale, public_updates_channel_id: Snowflake? = nil, max_stage_video_channel_users: Int? = nil, max_video_channel_users: Int? = nil, approximate_member_count: Int? = nil, approximate_presence_count: Int? = nil, welcome_screen: GuildWelcomeScreen? = nil, nsfw_level: NSFWLevel, stage_instances: [StageInstance]? = nil, stickers: [Sticker]? = nil, guild_scheduled_events: [GuildScheduledEvent]? = nil, premium_progress_bar_enabled: Bool) { self.id = id self.name = name diff --git a/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift b/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift index 1e5c02e82..2c8412420 100644 --- a/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift +++ b/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift @@ -194,7 +194,6 @@ public struct GatewayIncoming: Decodable { case unknown } - // swiftlint:disable:next function_body_length public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) let action = try values.decode(GatewayIncomingOpcodes.self, forKey: .opcode) From ed237c84fb3925ff983166c591b4bc3175728545 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Mon, 19 Jun 2023 15:54:33 +0000 Subject: [PATCH 35/41] add Guild class description --- Sources/DiscordKitBot/BotObjects/Guild.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/DiscordKitBot/BotObjects/Guild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift index 90fa41cc2..67967eaf8 100644 --- a/Sources/DiscordKitBot/BotObjects/Guild.swift +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -8,6 +8,7 @@ import Foundation import DiscordKitCore +/// Represents a Discord Server, aka Guild. public class Guild: Identifiable { /// The guild's Snowflake ID. public let id: Snowflake From 891c250ebd7673d135d7956325f8aa400b84198e Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Wed, 28 Jun 2023 08:22:34 -0400 Subject: [PATCH 36/41] Snowflake.creationTime() now returns an optional value Co-authored-by: CryptoAlgo <64193267+cryptoAlgorithm@users.noreply.github.com> --- Sources/DiscordKitCore/Objects/Data/Snowflake.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/DiscordKitCore/Objects/Data/Snowflake.swift b/Sources/DiscordKitCore/Objects/Data/Snowflake.swift index ce326e0df..fcc707dd9 100644 --- a/Sources/DiscordKitCore/Objects/Data/Snowflake.swift +++ b/Sources/DiscordKitCore/Objects/Data/Snowflake.swift @@ -10,9 +10,9 @@ import Foundation public typealias Snowflake = String public extension Snowflake { - func creationTime() -> Date { + func creationTime() -> Date? { // Convert to a unsigned integer - let snowflake = UInt64(self)! + guard let snowflake = UInt64(self) else { return nil } // shifts the bits so that only the first 42 are used let snowflakeTimestamp = snowflake >> 22 // Discord snowflake timestamps start from the first second of 2015 From 3e22a77c3ada92e69e89d822d61ecc66e7d5bb29 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Wed, 28 Jun 2023 12:29:33 +0000 Subject: [PATCH 37/41] switch to guard statements for login error checking --- Sources/DiscordKitBot/Client.swift | 14 +++++--------- Sources/DiscordKitCore/REST/APIRequest.swift | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 5a84ad27c..bb172b1af 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -150,7 +150,7 @@ public final class Client { /// - ``login(token:)`` public func login(filePath: String) throws { let token = try String(contentsOfFile: filePath).trimmingCharacters(in: .whitespacesAndNewlines) - if token.isEmpty { + guard !token.isEmpty else { throw AuthError.emptyToken } login(token: token) @@ -176,14 +176,10 @@ public final class Client { /// - ``login(token:)`` public func login() throws { let token = ProcessInfo.processInfo.environment["DISCORD_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines) - if let token = token { - if token.isEmpty { - throw AuthError.emptyToken - } - login(token: token) - } else { - throw AuthError.missingEnvVar - } + guard let token = token else { throw AuthError.missingEnvVar } + guard !token.isEmpty else { throw AuthError.emptyToken } + login(token: token) + } /// Disconnect from the gateway, undoes ``login(token:)`` diff --git a/Sources/DiscordKitCore/REST/APIRequest.swift b/Sources/DiscordKitCore/REST/APIRequest.swift index 09501c1dd..1e83032a3 100644 --- a/Sources/DiscordKitCore/REST/APIRequest.swift +++ b/Sources/DiscordKitCore/REST/APIRequest.swift @@ -174,7 +174,7 @@ public extension DiscordREST { ) } - /// Make a `POST` request to the Discord REST APIfor endpoints + /// Make a `POST` request to the Discord REST API for endpoints /// that require no payload func postReq( path: String From 77a64d0e8f47a68710f5e3a080b43d95d149198c Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Wed, 28 Jun 2023 12:31:43 +0000 Subject: [PATCH 38/41] createdAt is now optional --- Sources/DiscordKitBot/BotObjects/Guild.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/DiscordKitBot/BotObjects/Guild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift index 67967eaf8..6be367b8b 100644 --- a/Sources/DiscordKitBot/BotObjects/Guild.swift +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -29,7 +29,7 @@ public class Guild: Identifiable { } } /// The time that the guild was created. - public var createdAt: Date { id.creationTime() } + public var createdAt: Date? { id.creationTime() } /// The number of seconds until someone is moved to the afk channel. public let afkTimeout: Int /// If the guild has widgets enabled. From c3c1b83eb97bd4a9012715797cfeee7425262946 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Thu, 6 Jul 2023 14:10:41 +0000 Subject: [PATCH 39/41] Fixed a crash when creating a jumpURL to a message --- .../BotObjects/Channels/Category.swift | 10 ++--- .../BotObjects/Channels/GuildChannel.swift | 14 +++--- .../BotObjects/Channels/Text.swift | 34 +++++++-------- Sources/DiscordKitBot/BotObjects/Guild.swift | 30 ++++++------- Sources/DiscordKitBot/BotObjects/Member.swift | 22 +++++----- .../DiscordKitBot/BotObjects/Message.swift | 43 ++++++++++--------- 6 files changed, 78 insertions(+), 75 deletions(-) diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift index 6238759e6..1e1756891 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift @@ -5,31 +5,31 @@ import DiscordKitCore public class CategoryChannel: GuildChannel { private var coreChannels: [DiscordKitCore.Channel] { get async throws { - try await rest.getGuildChannels(id: coreChannel.guild_id!).compactMap({ try $0.result.get() }).filter({ $0.parent_id == id }) + try await rest!.getGuildChannels(id: coreChannel.guild_id!).compactMap({ try $0.result.get() }).filter({ $0.parent_id == id }) } } /// All the channels in the category. public var channels: [GuildChannel] { get async throws { - return try await coreChannels.asyncMap({ try GuildChannel(from: $0, rest: rest) }) + return try await coreChannels.asyncMap({ try GuildChannel(from: $0, rest: rest!) }) } } /// The text channels in the category. public var textChannels: [TextChannel] { get async throws { - return try await coreChannels.filter({ $0.type == .text }).asyncMap({ try TextChannel(from: $0, rest: rest) }) + return try await coreChannels.filter({ $0.type == .text }).asyncMap({ try TextChannel(from: $0, rest: rest!) }) } } /// The voice channels in the category. public var voiceChannels: [GuildChannel] { get async throws { - return try await coreChannels.filter({ $0.type == .voice }).asyncMap({ try TextChannel(from: $0, rest: rest) }) + return try await coreChannels.filter({ $0.type == .voice }).asyncMap({ try TextChannel(from: $0, rest: rest!) }) } } /// The stage channels in the category. public var stageChannels: [GuildChannel] { get async throws { - return try await coreChannels.filter({ $0.type == .stageVoice }).asyncMap({ try TextChannel(from: $0, rest: rest) }) + return try await coreChannels.filter({ $0.type == .stageVoice }).asyncMap({ try TextChannel(from: $0, rest: rest!) }) } } /// If the category is marked as nsfw. diff --git a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift index cb2df01dc..9f9bac941 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift @@ -48,7 +48,7 @@ public class GuildChannel: Identifiable { /// The `Snowflake` ID of the channel. public let id: Snowflake - internal let rest: DiscordREST + internal weak var rest: DiscordREST? internal let coreChannel: DiscordKitCore.Channel internal init(from channel: DiscordKitCore.Channel, rest: DiscordREST) throws { @@ -84,7 +84,7 @@ public extension GuildChannel { /// - Returns: The newly created `Invite`. func createInvite(maxAge: Int = 0, maxUsers: Int = 0, temporary: Bool = false, unique: Bool = false) async throws -> Invite { let body = CreateChannelInviteReq(max_age: maxAge, max_users: maxUsers, temporary: temporary, unique: unique) - return try await rest.createChannelInvite(id, body) + return try await rest!.createChannelInvite(id, body) } /// Deletes the channel. See discussion for warnings. @@ -93,13 +93,13 @@ public extension GuildChannel { /// > /// > In contrast, when used with a private message, it is possible to undo the action by opening a private message with the recipient again. func delete() async throws { - try await rest.deleteChannel(id: id) + try await rest!.deleteChannel(id: id) } /// Gets all the invites for the current channel. /// - Returns: An Array of `Invite`s for the current channel. func invites() async throws -> [Invite] { - return try await rest.getChannelInvites(id) + return try await rest!.getChannelInvites(id) } /// Clones a channel, with the only difference being the name. @@ -107,8 +107,8 @@ public extension GuildChannel { /// - Returns: The newly cloned channel. func clone(name: String) async throws -> GuildChannel { let body = CreateGuildChannelRed(name: name, type: coreChannel.type, topic: coreChannel.topic, bitrate: coreChannel.bitrate, user_limit: coreChannel.user_limit, rate_limit_per_user: coreChannel.rate_limit_per_user, position: coreChannel.position, permission_overwrites: coreChannel.permission_overwrites, parent_id: coreChannel.parent_id, nsfw: coreChannel.nsfw, rtc_region: coreChannel.rtc_region, video_quality_mode: coreChannel.video_quality_mode, default_auto_archive_duration: coreChannel.default_auto_archive_duration) - let newCh: DiscordKitCore.Channel = try await rest.createGuildChannel(guild.id, body) - return try GuildChannel(from: newCh, rest: rest) + let newCh: DiscordKitCore.Channel = try await rest!.createGuildChannel(guild.id, body) + return try GuildChannel(from: newCh, rest: rest!) } /// Gets the permission overrides for a specific member. @@ -125,7 +125,7 @@ public extension GuildChannel { /// - deny: The permissions you want to deny, use array notation to pass multiple func setPermissions(for member: Member, allow: Permissions, deny: Permissions) async throws { let body = EditChannelPermissionsReq(allow: allow, deny: deny, type: .member) - try await rest.editChannelPermissions(id, member.user!.id, body) + try await rest!.editChannelPermissions(id, member.user!.id, body) } } diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Text.swift b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift index f3cec315c..35c1d4a9f 100644 --- a/Sources/DiscordKitBot/BotObjects/Channels/Text.swift +++ b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift @@ -7,9 +7,9 @@ public class TextChannel: GuildChannel { public var lastMessage: Message? { get async throws { if let lastMessageID = lastMessageID { - let coreMessage = try? await rest.getChannelMsg(id: coreChannel.id, msgID: lastMessageID) + let coreMessage = try? await rest!.getChannelMsg(id: coreChannel.id, msgID: lastMessageID) if let coreMessage = coreMessage { - return await Message(from: coreMessage, rest: rest) + return await Message(from: coreMessage, rest: rest!) } else { return nil } @@ -25,10 +25,10 @@ public class TextChannel: GuildChannel { /// All the threads that your bot can see. public var threads: [TextChannel]? { get async throws { - let coreThreads = try await rest.getGuildChannels(id: coreChannel.guild_id!) + let coreThreads = try await rest!.getGuildChannels(id: coreChannel.guild_id!) .compactMap({ try? $0.result.get() }).filter({ $0.type == .publicThread || $0.type == .privateThread }) - return try await coreThreads.asyncMap({ try TextChannel(from: $0, rest: rest) }) + return try await coreThreads.asyncMap({ try TextChannel(from: $0, rest: rest!) }) } } /// The topic of the channel @@ -65,16 +65,16 @@ public extension TextChannel { /// - Parameter id: The `Snowflake` ID of the message /// - Returns: The ``Message`` asked for. func getMessage(_ id: Snowflake) async throws -> Message { - let coreMessage = try await rest.getChannelMsg(id: self.id, msgID: id) - return await Message(from: coreMessage, rest: rest) + let coreMessage = try await rest!.getChannelMsg(id: self.id, msgID: id) + return await Message(from: coreMessage, rest: rest!) } /// Retrieve message history starting from the most recent message in the channel. /// - Parameter limit: The number of messages to retrieve. If not provided, it defaults to 50. /// - Returns: The last `limit` messages sent in the channel. func getMessageHistory(limit: Int = 50) async throws -> [Message] { - let coreMessages = try await rest.getChannelMsgs(id: id, limit: limit) - return await coreMessages.asyncMap({ await Message(from: $0, rest: rest) }) + let coreMessages = try await rest!.getChannelMsgs(id: id, limit: limit) + return await coreMessages.asyncMap({ await Message(from: $0, rest: rest!) }) } /// Retrieve message history surrounding a certain message. @@ -83,8 +83,8 @@ public extension TextChannel { /// - limit: The number of messages to retrieve. If not provided, it defaults to 50. /// - Returns: An array of ``Message``s around the message provided. func getMessageHistory(around: Message, limit: Int = 50) async throws -> [Message] { - let coreMessages = try await rest.getChannelMsgs(id: id, limit: limit, around: around.id) - return await coreMessages.asyncMap({ await Message(from: $0, rest: rest) }) + let coreMessages = try await rest!.getChannelMsgs(id: id, limit: limit, around: around.id) + return await coreMessages.asyncMap({ await Message(from: $0, rest: rest!) }) } /// Retrieve message before after a certain message. @@ -93,8 +93,8 @@ public extension TextChannel { /// - limit: The number of messages to retrieve. If not provided, it defaults to 50. /// - Returns: An array of ``Message``s before the message provided. func getMessageHistory(before: Message, limit: Int = 50) async throws -> [Message] { - let coreMessages = try await rest.getChannelMsgs(id: id, limit: limit, before: before.id) - return await coreMessages.asyncMap({ await Message(from: $0, rest: rest) }) + let coreMessages = try await rest!.getChannelMsgs(id: id, limit: limit, before: before.id) + return await coreMessages.asyncMap({ await Message(from: $0, rest: rest!) }) } /// Retrieve message after after a certain message. @@ -103,23 +103,23 @@ public extension TextChannel { /// - limit: The number of messages to retrieve. If not provided, it defaults to 50. /// - Returns: An array of ``Message``s after the message provided func getMessageHistory(after: Message, limit: Int = 50) async throws -> [Message] { - let coreMessages = try await rest.getChannelMsgs(id: id, limit: limit, after: after.id) - return await coreMessages.asyncMap({ await Message(from: $0, rest: rest) }) + let coreMessages = try await rest!.getChannelMsgs(id: id, limit: limit, after: after.id) + return await coreMessages.asyncMap({ await Message(from: $0, rest: rest!) }) } /// Sends a message in the channel. /// - Parameter message: The message to send /// - Returns: The sent message. func send(message: NewMessage) async throws -> Message { - let coreMessage = try await rest.createChannelMsg(message: message, id: id) - return await Message(from: coreMessage, rest: rest) + let coreMessage = try await rest!.createChannelMsg(message: message, id: id) + return await Message(from: coreMessage, rest: rest!) } /// Bulk delete messages in this channel. /// - Parameter messages: An array of ``Message``s to delete. func deleteMessages(messages: [Message]) async throws { let snowflakes = messages.map({ $0.id }) - try await rest.bulkDeleteMessages(id, ["messages": snowflakes]) + try await rest!.bulkDeleteMessages(id, ["messages": snowflakes]) } } diff --git a/Sources/DiscordKitBot/BotObjects/Guild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift index 6be367b8b..bf26a9682 100644 --- a/Sources/DiscordKitBot/BotObjects/Guild.swift +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -93,43 +93,43 @@ public class Guild: Identifiable { private var coreChannels: [Channel] { get async throws { - try await rest.getGuildChannels(id: id).compactMap({ try? $0.result.get() }) + try await rest!.getGuildChannels(id: id).compactMap({ try? $0.result.get() }) } } /// The channels in this guild. public var channels: [GuildChannel] { get async throws { - try await coreChannels.asyncCompactMap({ try? GuildChannel(from: $0, rest: rest) }) + try await coreChannels.asyncCompactMap({ try? GuildChannel(from: $0, rest: rest!) }) } } /// The text channels in this guild. public var textChannels: [TextChannel] { get async throws { - try await coreChannels.filter({ $0.type == .text }).asyncCompactMap({ try? TextChannel(from: $0, rest: rest) }) + try await coreChannels.filter({ $0.type == .text }).asyncCompactMap({ try? TextChannel(from: $0, rest: rest!) }) } } /// The voice channels in the guild. public var voiceChannels: [GuildChannel] { get async throws { - try await coreChannels.filter({ $0.type == .voice }).asyncCompactMap({ try? GuildChannel(from: $0, rest: rest) }) + try await coreChannels.filter({ $0.type == .voice }).asyncCompactMap({ try? GuildChannel(from: $0, rest: rest!) }) } } /// The categories in this guild. public var categories: [CategoryChannel] { get async throws { - try await coreChannels.filter({ $0.type == .category }).asyncCompactMap({ try? CategoryChannel(from: $0, rest: rest) }) + try await coreChannels.filter({ $0.type == .category }).asyncCompactMap({ try? CategoryChannel(from: $0, rest: rest!) }) } } /// The forum channels in this guild. public var forums: [GuildChannel] { get async throws { - try await coreChannels.filter({ $0.type == .forum }).asyncCompactMap({ try? GuildChannel(from: $0, rest: rest) }) + try await coreChannels.filter({ $0.type == .forum }).asyncCompactMap({ try? GuildChannel(from: $0, rest: rest!) }) } } /// The stage channels in this guild. public var stages: [GuildChannel] { get async throws { - try await coreChannels.filter({ $0.type == .stageVoice }).asyncCompactMap({ try? GuildChannel(from: $0, rest: rest) }) + try await coreChannels.filter({ $0.type == .stageVoice }).asyncCompactMap({ try? GuildChannel(from: $0, rest: rest!) }) } } /// The AFK Voice Channel. @@ -179,28 +179,28 @@ public class Guild: Identifiable { } private var coreMembers: PaginatedSequence { - return PaginatedSequence({ try await self.rest.listGuildMembers(self.id, $0) }, { $0.user!.id }) + return PaginatedSequence({ try await self.rest!.listGuildMembers(self.id, $0) }, { $0.user!.id }) } /// A list of the guild's first 50 members. public var members: AsyncMapSequence, Member> { - return coreMembers.map({ Member(from: $0, rest: self.rest)}) + return coreMembers.map({ Member(from: $0, rest: self.rest!)}) } /// A list of users that have been banned from this guild. public var bans: PaginatedSequence { - return PaginatedSequence({ try await self.rest.getGuildBans(self.id, $0)}, { $0.user.id }) + return PaginatedSequence({ try await self.rest!.getGuildBans(self.id, $0)}, { $0.user.id }) } /// The bot's member object. public var me: Member { // swiftlint:disable:this identifier_name get async throws { - return Member(from: try await rest.getGuildMember(guild: id), rest: rest) + return Member(from: try await rest!.getGuildMember(guild: id), rest: rest!) } } private let coreGuild: DiscordKitCore.Guild - private var rest: DiscordREST + private weak var rest: DiscordREST? internal init(guild: DiscordKitCore.Guild, rest: DiscordREST) { self.coreGuild = guild @@ -252,7 +252,7 @@ public extension Guild { /// - userID: The Snowflake ID of the user to ban. /// - messageDeleteSeconds: The number of seconds worth of messages to delete from the user in the guild. Defaults to `86400` (1 day) if no value is passed. The minimum value is `0` and the maximum value is `604800` (7 days). func ban(_ userID: Snowflake, deleteMessageSeconds: Int = 86400) async throws { - try await rest.createGuildBan(id, userID, ["delete_message_seconds": deleteMessageSeconds]) + try await rest!.createGuildBan(id, userID, ["delete_message_seconds": deleteMessageSeconds]) } /// Bans the member from the guild. @@ -260,12 +260,12 @@ public extension Guild { /// - userID: The Snowflake ID of the user to ban. /// - deleteMessageDays: The number of days worth of messages to delete from the user in the guild. Defaults to `1` if no value is passed. The minimum value is `0` and the maximum value is `7`. func ban(_ userID: Snowflake, deleteMessageDays: Int = 1) async throws { - try await rest.createGuildBan(id, userID, ["delete_message_days": deleteMessageDays]) + try await rest!.createGuildBan(id, userID, ["delete_message_days": deleteMessageDays]) } /// Unbans a user from the guild. /// - Parameter userID: The Snowflake ID of the user. func unban(_ userID: Snowflake) async throws { - try await rest.removeGuildBan(id, userID) + try await rest!.removeGuildBan(id, userID) } } diff --git a/Sources/DiscordKitBot/BotObjects/Member.swift b/Sources/DiscordKitBot/BotObjects/Member.swift index ed6c62eea..bbc1c8407 100644 --- a/Sources/DiscordKitBot/BotObjects/Member.swift +++ b/Sources/DiscordKitBot/BotObjects/Member.swift @@ -28,7 +28,7 @@ public class Member { /// The Snowflake ID of the guild this member is a part of. public let guildID: Snowflake? - fileprivate var rest: DiscordREST + private weak var rest: DiscordREST? internal init(from member: DiscordKitCore.Member, rest: DiscordREST) { user = member.user @@ -61,7 +61,7 @@ public extension Member { /// Changes the nickname of this member in the guild. /// - Parameter nickname: The new nickname for this member. func changeNickname(_ nickname: String) async throws { - try await rest.editGuildMember(guildID!, user!.id, ["nick": nickname]) + try await rest!.editGuildMember(guildID!, user!.id, ["nick": nickname]) } /// Adds a guild role to this member. @@ -69,49 +69,49 @@ public extension Member { func addRole(_ role: Snowflake) async throws { var roles = roles roles.append(role) - try await rest.editGuildMember(guildID!, user!.id, ["roles": roles]) + try await rest!.editGuildMember(guildID!, user!.id, ["roles": roles]) } /// Removes a guild role from a member. /// - Parameter role: The Snowflake ID of the role to remove. func removeRole(_ role: Snowflake) async throws { - try await rest.removeGuildMemberRole(guildID!, user!.id, role) + try await rest!.removeGuildMemberRole(guildID!, user!.id, role) } /// Removes all roles from a member. func removeAllRoles() async throws { let empty: [Snowflake] = [] - try await rest.editGuildMember(guildID!, user!.id, ["roles": empty]) + try await rest!.editGuildMember(guildID!, user!.id, ["roles": empty]) } /// Applies a time out to a member until the specified time. /// - Parameter time: The time that the timeout ends. func timeout(time: Date) async throws { - try await rest.editGuildMember(guildID!, user!.id, ["communication_disabled_until": time]) + try await rest!.editGuildMember(guildID!, user!.id, ["communication_disabled_until": time]) } /// Kicks the member from the guild. func kick() async throws { - try await rest.removeGuildMember(guildID!, user!.id) + try await rest!.removeGuildMember(guildID!, user!.id) } /// Bans the member from the guild. /// - Parameter messageDeleteSeconds: The number of seconds worth of messages to delete from the user in the guild. /// Defaults to `86400` (1 day) if no value is passed. The minimum value is `0` and the maximum value is `604800` (7 days). func ban(deleteMessageSeconds: Int = 86400) async throws { - try await rest.createGuildBan(guildID!, user!.id, ["delete_message_seconds": deleteMessageSeconds]) + try await rest!.createGuildBan(guildID!, user!.id, ["delete_message_seconds": deleteMessageSeconds]) } /// Bans the member from the guild. /// - Parameter messageDeleteSeconds: The number of seconds worth of messages to delete from the user in the guild. /// Defaults to `1` if no value is passed. The minimum value is `0` and the maximum value is `7`. func ban(deleteMessageDays: Int = 1) async throws { - try await rest.createGuildBan(guildID!, user!.id, ["delete_message_days": deleteMessageDays]) + try await rest!.createGuildBan(guildID!, user!.id, ["delete_message_days": deleteMessageDays]) } /// Deletes the ban for this member. func unban() async throws { - try await rest.removeGuildBan(guildID!, user!.id) + try await rest!.removeGuildBan(guildID!, user!.id) } /// Creates a DM with this user. @@ -122,6 +122,6 @@ public extension Member { /// /// - Returns: The newly created DM Channel func createDM() async throws -> Channel { - return try await rest.createDM(["recipient_id": user!.id]) + return try await rest!.createDM(["recipient_id": user!.id]) } } diff --git a/Sources/DiscordKitBot/BotObjects/Message.swift b/Sources/DiscordKitBot/BotObjects/Message.swift index e2231742d..ab90c32b2 100644 --- a/Sources/DiscordKitBot/BotObjects/Message.swift +++ b/Sources/DiscordKitBot/BotObjects/Message.swift @@ -48,7 +48,7 @@ public struct Message: Identifiable { public var member: Member? { get async throws { if let messageMember = coreMessage.member { - return Member(from: messageMember, rest: rest) + return Member(from: messageMember, rest: rest!) } return nil } @@ -102,7 +102,7 @@ public struct Message: Identifiable { /// Type of message /// /// Refer to ``MessageType`` for possible values. - public let type: MessageType + public let type: MessageType? /// Sent with Rich Presence-related chat embeds public var activity: MessageActivity? @@ -148,10 +148,15 @@ public struct Message: Identifiable { public var call: CallMessageComponent? /// The url to jump to this message - public var jumpURL: URL + public var jumpURL: URL? { + get { + guard let guild_id = coreMessage.guild_id else { return nil } + return URL(string: "https://discord.com/channels/\(guild_id)/\(coreMessage.channel_id)/\(id)") + } + } // The REST handler associated with this message, used for message actions - private var rest: DiscordREST + private weak var rest: DiscordREST? private var coreMessage: DiscordKitCore.Message @@ -172,7 +177,7 @@ public struct Message: Identifiable { reactions = message.reactions pinned = message.pinned webhookID = message.webhook_id - type = MessageType(message.type)! + type = MessageType(message.type) activity = message.activity application = message.application application_id = message.application_id @@ -187,8 +192,6 @@ public struct Message: Identifiable { self.rest = rest self.coreMessage = message - - jumpURL = URL(string: "https://discord.com/channels/\(message.guild_id!)/\(message.channel_id)/\(id)")! } } @@ -197,19 +200,19 @@ public extension Message { /// /// - Parameter content: The content of the reply message func reply(_ content: String) async throws -> DiscordKitBot.Message { - let coreMessage = try await rest.createChannelMsg( + let coreMessage = try await rest!.createChannelMsg( message: .init(content: content, message_reference: .init(message_id: id), components: []), id: channelID ) - return await Message(from: coreMessage, rest: rest) + return await Message(from: coreMessage, rest: rest!) } /// Deletes the message. /// /// You can always delete your own messages, but deleting other people's messages requires the `manage_messages` guild permission. func delete() async throws { - try await rest.deleteMsg(id: channelID, msgID: id) + try await rest!.deleteMsg(id: channelID, msgID: id) } /// Edits the message @@ -218,28 +221,28 @@ public extension Message { /// /// - Parameter content: The content of the edited message func edit(content: String?) async throws { - try await rest.editMessage(channelID, id, DiscordKitCore.NewMessage(content: content)) + try await rest!.editMessage(channelID, id, DiscordKitCore.NewMessage(content: content)) } /// Add a reaction emoji to the message. /// /// - Parameter emoji: The emote in the form `:emote_name:emote_id` func addReaction(emoji: String) async throws { - try await rest.createReaction(channelID, id, emoji) + try await rest!.createReaction(channelID, id, emoji) } /// Removes your own reaction from a message /// /// - Parameter emoji: The emote in the form `:emote_name:emote_id` func removeReaction(emoji: Snowflake) async throws { - try await rest.deleteOwnReaction(channelID, id, emoji) + try await rest!.deleteOwnReaction(channelID, id, emoji) } /// Clear all reactions from a message /// /// Requires the the `manage_messages` guild permission. func clearAllReactions() async throws { - try await rest.deleteAllReactions(channelID, id) + try await rest!.deleteAllReactions(channelID, id) } /// Clear all reactions from a message of a specific emoji /// @@ -247,7 +250,7 @@ public extension Message { /// /// - Parameter emoji: The emote in the form `:emote_name:emote_id` func clearAllReactions(for emoji: Snowflake) async throws { - try await rest.deleteAllReactionsforEmoji(channelID, id, emoji) + try await rest!.deleteAllReactionsforEmoji(channelID, id, emoji) } /// Starts a thread from the message @@ -255,29 +258,29 @@ public extension Message { /// Requires the `create_public_threads`` guild permission. func createThread(name: String, autoArchiveDuration: Int?, rateLimitPerUser: Int?) async throws -> Channel { let body = CreateThreadRequest(name: name, auto_archive_duration: autoArchiveDuration, rate_limit_per_user: rateLimitPerUser) - return try await rest.startThreadfromMessage(channelID, id, body) + return try await rest!.startThreadfromMessage(channelID, id, body) } /// Pins the message. /// /// Requires the `manage_messages` guild permission to do this in a non-private channel context. func pin() async throws { - try await rest.pinMessage(channelID, id) + try await rest!.pinMessage(channelID, id) } /// Unpins the message. /// /// Requires the `manage_messages` guild permission to do this in a non-private channel context. func unpin() async throws { - try await rest.unpinMessage(channelID, id) + try await rest!.unpinMessage(channelID, id) } /// Publishes a message in an announcement channel to it's followers. /// /// Requires the `SEND_MESSAGES` permission, if the bot sent the message, or the `MANAGE_MESSAGES` permission for all other messages func publish() async throws -> Message { - let coreMessage: DiscordKitCore.Message = try await rest.crosspostMessage(channelID, id) - return await Message(from: coreMessage, rest: rest) + let coreMessage: DiscordKitCore.Message = try await rest!.crosspostMessage(channelID, id) + return await Message(from: coreMessage, rest: rest!) } static func == (lhs: Message, rhs: Message) -> Bool { From aea160de00e9554a3357a3d2388d327c598df289 Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Thu, 13 Jul 2023 12:36:27 +0000 Subject: [PATCH 40/41] Add additional properties to CommandData --- .../ApplicationCommand/CommandData.swift | 28 +++++++++++++++++-- Sources/DiscordKitBot/Client.swift | 2 +- .../Objects/Data/Interaction.swift | 6 ++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift b/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift index f4a77501b..784c6ef95 100644 --- a/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift +++ b/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift @@ -11,7 +11,7 @@ import DiscordKitCore /// Provides methods to get parameters of and respond to application command interactions public class CommandData { internal init( - optionValues: [OptionData], + commandData: Interaction.Data.AppCommandData, rest: DiscordREST, applicationID: String, interactionID: Snowflake, token: String ) { self.rest = rest @@ -19,7 +19,8 @@ public class CommandData { self.interactionID = interactionID self.applicationID = applicationID - self.optionValues = Self.unwrapOptionDatas(optionValues) + self.optionValues = Self.unwrapOptionDatas(commandData.options ?? []) + self.commandData = commandData } /// A private reference to the active rest handler for handling actions @@ -33,6 +34,8 @@ public class CommandData { /// Values of options in this command private let optionValues: [String: OptionData] + /// The raw command data + private let commandData: Interaction.Data.AppCommandData /// If this reply has already been deferred fileprivate var hasReplied = false @@ -42,6 +45,27 @@ public class CommandData { let token: String /// The ID of this interaction public let interactionID: Snowflake + /// The guild member that sent the interaction + public var member: Member? { + get { + guard let coreMember = commandData.member, let rest = rest else { return nil } + return Member(from: coreMember, rest: rest) + } + } + + public var guild: Guild? { + get async { + guard let guild_id = commandData.guildID else { return nil } + return try? await Guild(id: guild_id) + } + } + + public var channel: GuildChannel? { + get async { + guard let channelID = commandData.channelID else { return nil } + return try? await GuildChannel(from: channelID) + } + } fileprivate static func unwrapOptionDatas(_ options: [OptionData]) -> [String: OptionData] { var optValues: [String: OptionData] = [:] diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index bb172b1af..729435dcf 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -218,7 +218,7 @@ extension Client { Self.logger.trace("Invoking application handler", metadata: ["command.name": "\(commandData.name)"]) Task { await handler(.init( - optionValues: commandData.options ?? [], + commandData: commandData, rest: rest, applicationID: applicationID!, interactionID: id, token: token )) } diff --git a/Sources/DiscordKitCore/Objects/Data/Interaction.swift b/Sources/DiscordKitCore/Objects/Data/Interaction.swift index 415398048..10a052482 100644 --- a/Sources/DiscordKitCore/Objects/Data/Interaction.swift +++ b/Sources/DiscordKitCore/Objects/Data/Interaction.swift @@ -63,6 +63,12 @@ public struct Interaction: Decodable { public let type: Int /// Options of command (present if the command has options) public let options: [OptionData]? + /// The member that sent the interaction + public let member: Member? + /// The ID of the guild the interaction was sent from + public let guildID: Snowflake? + /// The channel the interaction was sent from + public let channelID: Snowflake? /// The data representing one option public struct OptionData: Codable { From 499ba173344b63bca7df2e543a986671cf850b1d Mon Sep 17 00:00:00 2001 From: Andrew Glaze Date: Thu, 13 Jul 2023 12:52:27 +0000 Subject: [PATCH 41/41] fix getting additional properties in CommandData --- .../ApplicationCommand/CommandData.swift | 12 ++++++------ Sources/DiscordKitBot/Client.swift | 6 +++--- .../DiscordKitCore/Objects/Data/Interaction.swift | 6 ------ 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift b/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift index 784c6ef95..57e33ca48 100644 --- a/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift +++ b/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift @@ -11,7 +11,7 @@ import DiscordKitCore /// Provides methods to get parameters of and respond to application command interactions public class CommandData { internal init( - commandData: Interaction.Data.AppCommandData, + commandData: Interaction.Data.AppCommandData, interaction: Interaction, rest: DiscordREST, applicationID: String, interactionID: Snowflake, token: String ) { self.rest = rest @@ -20,7 +20,7 @@ public class CommandData { self.applicationID = applicationID self.optionValues = Self.unwrapOptionDatas(commandData.options ?? []) - self.commandData = commandData + self.interaction = interaction } /// A private reference to the active rest handler for handling actions @@ -35,7 +35,7 @@ public class CommandData { /// Values of options in this command private let optionValues: [String: OptionData] /// The raw command data - private let commandData: Interaction.Data.AppCommandData + private let interaction: Interaction /// If this reply has already been deferred fileprivate var hasReplied = false @@ -48,21 +48,21 @@ public class CommandData { /// The guild member that sent the interaction public var member: Member? { get { - guard let coreMember = commandData.member, let rest = rest else { return nil } + guard let coreMember = interaction.member, let rest = rest else { return nil } return Member(from: coreMember, rest: rest) } } public var guild: Guild? { get async { - guard let guild_id = commandData.guildID else { return nil } + guard let guild_id = interaction.guildID else { return nil } return try? await Guild(id: guild_id) } } public var channel: GuildChannel? { get async { - guard let channelID = commandData.channelID else { return nil } + guard let channelID = interaction.channelID else { return nil } return try? await GuildChannel(from: channelID) } } diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index 729435dcf..59ecf7d51 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -213,12 +213,12 @@ extension Client { public var isReady: Bool { gateway?.sessionOpen == true } /// Invoke the handler associated with the respective commands - private func invokeCommandHandler(_ commandData: Interaction.Data.AppCommandData, id: Snowflake, token: String) { + private func invokeCommandHandler(_ commandData: Interaction.Data.AppCommandData, interaction: Interaction, id: Snowflake, token: String) { if let handler = appCommandHandlers[commandData.id] { Self.logger.trace("Invoking application handler", metadata: ["command.name": "\(commandData.name)"]) Task { await handler(.init( - commandData: commandData, + commandData: commandData, interaction: interaction, rest: rest, applicationID: applicationID!, interactionID: id, token: token )) } @@ -251,7 +251,7 @@ extension Client { // Handle interactions based on type switch interaction.data { case .applicationCommand(let commandData): - invokeCommandHandler(commandData, id: interaction.id, token: interaction.token) + invokeCommandHandler(commandData, interaction: interaction, id: interaction.id, token: interaction.token) case .messageComponent(let componentData): print("Component interaction: \(componentData.custom_id)") default: break diff --git a/Sources/DiscordKitCore/Objects/Data/Interaction.swift b/Sources/DiscordKitCore/Objects/Data/Interaction.swift index 10a052482..415398048 100644 --- a/Sources/DiscordKitCore/Objects/Data/Interaction.swift +++ b/Sources/DiscordKitCore/Objects/Data/Interaction.swift @@ -63,12 +63,6 @@ public struct Interaction: Decodable { public let type: Int /// Options of command (present if the command has options) public let options: [OptionData]? - /// The member that sent the interaction - public let member: Member? - /// The ID of the guild the interaction was sent from - public let guildID: Snowflake? - /// The channel the interaction was sent from - public let channelID: Snowflake? /// The data representing one option public struct OptionData: Codable {