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/.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/ApplicationCommand/CommandData.swift b/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift index 360766262..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( - optionValues: [OptionData], + commandData: Interaction.Data.AppCommandData, interaction: Interaction, 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.interaction = interaction } /// 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 interaction: Interaction /// 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 = interaction.member, let rest = rest else { return nil } + return Member(from: coreMember, rest: rest) + } + } + + public var guild: Guild? { + get async { + 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 = interaction.channelID else { return nil } + return try? await GuildChannel(from: channelID) + } + } fileprivate static func unwrapOptionDatas(_ options: [OptionData]) -> [String: OptionData] { var optValues: [String: OptionData] = [:] @@ -133,7 +157,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/BotMessage.swift b/Sources/DiscordKitBot/BotMessage.swift deleted file mode 100644 index 847aa1158..000000000 --- a/Sources/DiscordKitBot/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 - ) - } -} diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Category.swift b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift new file mode 100644 index 000000000..1e1756891 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/Category.swift @@ -0,0 +1,53 @@ +import Foundation +import DiscordKitCore + +/// Represents a channel category in a Guild. +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 }) + } + } + /// All the channels in the category. + 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 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) throws { + if channel.type != .category { throw GuildChannelError.badChannelType } + nsfw = channel.nsfw ?? false + + try super.init(from: channel, rest: rest) + } + + /// 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 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..dc6619e39 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/Forum.swift @@ -0,0 +1,7 @@ +import Foundation +import DiscordKitCore + +// TODO: Implement this. +// public class ForumChannel: GuildChannel { + +// } diff --git a/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift new file mode 100644 index 000000000..9f9bac941 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/GuildChannel.swift @@ -0,0 +1,135 @@ +import Foundation +import DiscordKitCore + +/// 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 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 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 + } + } + + /// 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 + 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 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 + + internal weak var rest: DiscordREST? + internal let coreChannel: DiscordKitCore.Channel + + 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() + position = channel.position + type = channel.type + id = channel.id + 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. + /// - 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 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 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.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, 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) + } +} + +enum GuildChannelError: Error { + case badChannelType + case notAGuildChannel +} diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Stage.swift b/Sources/DiscordKitBot/BotObjects/Channels/Stage.swift new file mode 100644 index 000000000..21c03d9de --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/Stage.swift @@ -0,0 +1,7 @@ +import Foundation +import DiscordKitCore + +// TODO: Implement this +// public class StageChannel: GuildChannel { + +// } diff --git a/Sources/DiscordKitBot/BotObjects/Channels/Text.swift b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift new file mode 100644 index 000000000..35c1d4a9f --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/Text.swift @@ -0,0 +1,125 @@ +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. + 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? + /// 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 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 + + 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 + + topic = channel.topic + + try super.init(from: channel, rest: rest) + + } + + /// 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) + try self.init(from: coreChannel, rest: Client.current!.rest) + } +} + +public extension TextChannel { + /// 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!) }) + } + + /// 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!) }) + } + + /// 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!) }) + } + + /// 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/Channels/Voice.swift b/Sources/DiscordKitBot/BotObjects/Channels/Voice.swift new file mode 100644 index 000000000..07b11d50d --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Channels/Voice.swift @@ -0,0 +1,7 @@ +import Foundation +import DiscordKitCore + +// TODO: Implement this. +// public class VoiceChannel: GuildChannel { + +// } diff --git a/Sources/DiscordKitBot/BotObjects/Guild.swift b/Sources/DiscordKitBot/BotObjects/Guild.swift new file mode 100644 index 000000000..bf26a9682 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Guild.swift @@ -0,0 +1,271 @@ +// +// File.swift +// +// +// Created by Andrew Glaze on 6/4/23. +// + +import Foundation +import DiscordKitCore + +/// Represents a Discord Server, aka Guild. +public class Guild: Identifiable { + /// The guild's Snowflake ID. + public let id: Snowflake + /// The guild's name. + public let name: String + /// 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 var owner: Member? { + get async throws { + return try await Member(guildID: id, userID: ownerID) + } + } + /// The time that the guild was created. + 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 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 + /// If the guild is a "large" guild. + public let large: Bool? + /// If the guild is unavailable due to an outage. + public let 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 let memberCount: Int? + /// 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. + public var vanityURL: URL? { + 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. + 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 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. + 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 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 + + 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: PaginatedSequence { + 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!)}) + } + + /// 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 }) + } + + /// 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!) + } + } + + private let coreGuild: DiscordKitCore.Guild + private weak 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 + splash = guild.splash + 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 + memberCount = guild.member_count + maxMembers = guild.max_members + vanityURLCode = guild.vanity_url_code + description = guild.description + banner = guild.banner + premiumTier = guild.premium_tier + 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 ?? [] + scheduledEvents = guild.guild_scheduled_events + premiumProgressBarEnabled = guild.premium_progress_bar_enabled + maxPresences = guild.max_presences + } + + /// 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) + self.init(guild: coreGuild, rest: Client.current!.rest) + } +} + +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) + } +} diff --git a/Sources/DiscordKitBot/BotObjects/Member.swift b/Sources/DiscordKitBot/BotObjects/Member.swift new file mode 100644 index 000000000..bbc1c8407 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Member.swift @@ -0,0 +1,127 @@ +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? + /// 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 + /// 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? + /// 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? + + private 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 + + 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]) + } + + /// 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]) + } + + /// 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) + } + + /// 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]) + } + + /// Kicks the member from the guild. + func kick() async throws { + 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]) + } + + /// 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. + 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. + /// 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 newly created DM Channel + func createDM() async throws -> Channel { + return try await rest!.createDM(["recipient_id": user!.id]) + } +} diff --git a/Sources/DiscordKitBot/BotObjects/Message.swift b/Sources/DiscordKitBot/BotObjects/Message.swift new file mode 100644 index 000000000..ab90c32b2 --- /dev/null +++ b/Sources/DiscordKitBot/BotObjects/Message.swift @@ -0,0 +1,399 @@ +// +// BotMessage.swift +// +// +// Created by Vincent Kwok on 22/11/22. +// + +import Foundation +import DiscordKitCore + +/// A Discord message, with convenience methods. +public struct Message: Identifiable { + /// ID of the message + public let id: Snowflake + + /// Channel the message was sent in + public var channel: TextChannel { + get async throws { + return try await TextChannel(from: coreMessage.channel_id) + } + } + + /// ID of the channel the message was sent in + private let channelID: Snowflake + + /// The guild the message was sent in + 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) + /// + /// 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: Member? { + get async throws { + if let messageMember = coreMessage.member { + return Member(from: messageMember, rest: rest!) + } + return nil + } + } + + /// 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 + public var attachments: [Attachment] + + /// Any embedded content + 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. ``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: DiscordKitCore.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? { + 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 weak 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 + 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 = MessageType(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 + self.coreMessage = message + } +} + +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) + } + + /// 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) + } +} diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index d95a411dc..59ecf7d51 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -9,10 +9,15 @@ 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() + /// 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 fileprivate var gateway: RobustWebSocket? @@ -23,11 +28,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 + /// 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) @@ -39,7 +60,16 @@ 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? + /// 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 @@ -51,39 +81,105 @@ public final class Client { // Init event wrappers ready = .init(.ready, notificationCenter: notificationCenter) messageCreate = .init(.messageCreate, notificationCenter: notificationCenter) + guildMemberAdd = .init(.guildMemberAdd, notificationCenter: notificationCenter) } deinit { 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, - /// > 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. + /// + /// ## 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.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) + + 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. Any API calls made before this method is ran will fail. + /// + /// > 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. + /// + /// - 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) + guard !token.isEmpty else { + 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. + /// variable, and calls ``login(token:)`` if it was found. Any API calls made before this method is ran will fail. + /// + /// > 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'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. + /// `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!) + 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:)`` @@ -100,20 +196,29 @@ public final class Client { rest.setToken(token: nil) applicationID = nil user = nil + Client.current = nil } } +enum AuthError: Error { + case invalidToken + case missingFile + case missingEnvVar + case emptyToken +} + // 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 - 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( - optionValues: commandData.options ?? [], + commandData: commandData, interaction: interaction, rest: rest, applicationID: applicationID!, interactionID: id, token: token )) } @@ -128,6 +233,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)", @@ -136,18 +242,23 @@ extension Client { ready.emit() } case .messageCreate(let message): - let botMessage = BotMessage(from: message, rest: rest) - messageCreate.emit(value: botMessage) + Task { + let botMessage = await Message(from: message, rest: rest) + messageCreate.emit(value: botMessage) + } case .interaction(let interaction): Self.logger.trace("Received interaction", metadata: ["interaction.id": "\(interaction.id)"]) // Handle interactions based on type switch interaction.data { case .applicationCommand(let commandData): - invokeCommandHandler(commandData, id: interaction.id, token: interaction.token) + invokeCommandHandler(commandData, interaction: interaction, id: interaction.id, token: interaction.token) case .messageComponent(let componentData): print("Component interaction: \(componentData.custom_id)") default: break } + case .guildMemberAdd(let member): + let botMember = Member(from: member, rest: rest) + guildMemberAdd.emit(value: botMember) default: break } @@ -181,4 +292,8 @@ public extension Client { appCommandHandlers[registeredCommand.id] = command.handler } } + + 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 new file mode 100644 index 000000000..c44d2680f --- /dev/null +++ b/Sources/DiscordKitBot/Documentation.docc/DiscordKitBot.md @@ -0,0 +1,61 @@ +# ``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 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`` +- ``CategoryChannel`` + +### 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 000000000..3b41742b2 Binary files /dev/null and b/Sources/DiscordKitBot/Documentation.docc/Resources/documentation-art/discordkit-icon@2x.png differ diff --git a/Sources/DiscordKitBot/Extensions/Sequence+.swift b/Sources/DiscordKitBot/Extensions/Sequence+.swift new file mode 100644 index 000000000..5cce81c29 --- /dev/null +++ b/Sources/DiscordKitBot/Extensions/Sequence+.swift @@ -0,0 +1,28 @@ +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 + } + + 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 + } +} diff --git a/Sources/DiscordKitBot/NotificationNames.swift b/Sources/DiscordKitBot/NotificationNames.swift index e87757f9d..552f1936c 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/DiscordKitBot/Objects/CreateChannelInviteReq.swift b/Sources/DiscordKitBot/Objects/CreateChannelInviteReq.swift new file mode 100644 index 000000000..8953a4158 --- /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 +} diff --git a/Sources/DiscordKitBot/Objects/CreateGuildChannelReq.swift b/Sources/DiscordKitBot/Objects/CreateGuildChannelReq.swift new file mode 100644 index 000000000..5c6528e5a --- /dev/null +++ b/Sources/DiscordKitBot/Objects/CreateGuildChannelReq.swift @@ -0,0 +1,20 @@ +import DiscordKitCore + +struct CreateGuildChannelRed: Codable { + let name: String + let type: ChannelType? + let topic: String? + let bitrate: Int? + let user_limit: Int? + let rate_limit_per_user: Int? + let position: Int? + let permission_overwrites: [PermOverwrite]? + let parent_id: Snowflake? + let nsfw: Bool? + let rtc_region: String? + let video_quality_mode: VideoQualityMode? + let default_auto_archive_duration: Int? + // let default_reaction_emoji: + // let available_tags: + // let default_sort_order: +} diff --git a/Sources/DiscordKitBot/Objects/CreateThreadRequest.swift b/Sources/DiscordKitBot/Objects/CreateThreadRequest.swift new file mode 100644 index 000000000..d6dd3c330 --- /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? +} diff --git a/Sources/DiscordKitBot/Objects/EditChannelPermissionsReq.swift b/Sources/DiscordKitBot/Objects/EditChannelPermissionsReq.swift new file mode 100644 index 000000000..a47400a03 --- /dev/null +++ b/Sources/DiscordKitBot/Objects/EditChannelPermissionsReq.swift @@ -0,0 +1,7 @@ +import DiscordKitCore + +struct EditChannelPermissionsReq: Codable { + let allow: Permissions? + let deny: Permissions? + let type: PermOverwriteType +} diff --git a/Sources/DiscordKitBot/Objects/GuildBanEntry.swift b/Sources/DiscordKitBot/Objects/GuildBanEntry.swift new file mode 100644 index 000000000..e5b0b56b8 --- /dev/null +++ b/Sources/DiscordKitBot/Objects/GuildBanEntry.swift @@ -0,0 +1,6 @@ +import DiscordKitCore + +public struct GuildBanEntry: Codable { + let reason: String? + let user: User +} 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/DiscordKitBot/Util/PaginatedSequence.swift b/Sources/DiscordKitBot/Util/PaginatedSequence.swift new file mode 100644 index 000000000..0fd621510 --- /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? + + 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) + } +} diff --git a/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift b/Sources/DiscordKitCore/Gateway/RobustWebSocket.swift index 4c69d0511..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) @@ -276,6 +277,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 @@ -675,7 +677,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? 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/Data/Snowflake.swift b/Sources/DiscordKitCore/Objects/Data/Snowflake.swift index a8da6fb56..fcc707dd9 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 + 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 + 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) + } +} diff --git a/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift b/Sources/DiscordKitCore/Objects/Gateway/GatewayIO.swift index d69107630..2c8412420 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(DiscordKitCore.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) diff --git a/Sources/DiscordKitCore/REST/APIChannel.swift b/Sources/DiscordKitCore/REST/APIChannel.swift index f4601d6b8..a387762d2 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( @@ -103,16 +117,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` @@ -201,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 ) @@ -213,12 +225,12 @@ public extension DiscordREST { /// Edit Channel Permissions /// /// > 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 ) diff --git a/Sources/DiscordKitCore/REST/APIGuild.swift b/Sources/DiscordKitCore/REST/APIGuild.swift index 398099fcd..3035aa764 100644 --- a/Sources/DiscordKitCore/REST/APIGuild.swift +++ b/Sources/DiscordKitCore/REST/APIGuild.swift @@ -113,10 +113,17 @@ public extension DiscordREST { /// /// > GET: `/guilds/{guild.id}/members` func listGuildMembers( - _ guildId: Snowflake + _ guildId: Snowflake, + _ 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" + path: "/guilds/\(guildId)/members", + query: query ) } /// Search Guild Members @@ -218,10 +225,17 @@ public extension DiscordREST { /// /// > GET: `/guilds/{guild.id}/bans` func getGuildBans( - _ guildId: Snowflake + _ guildId: Snowflake, + _ 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" + path: "guilds/\(guildId)/bans", + query: query ) } /// Get Guild Ban diff --git a/Sources/DiscordKitCore/REST/APIRequest.swift b/Sources/DiscordKitCore/REST/APIRequest.swift index 880725215..1e83032a3 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 API for 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 {