diff --git a/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift b/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift index 360766262..10c231550 100644 --- a/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift +++ b/Sources/DiscordKitBot/ApplicationCommand/CommandData.swift @@ -12,14 +12,21 @@ import DiscordKitCore public class CommandData { internal init( optionValues: [OptionData], - rest: DiscordREST, applicationID: String, interactionID: Snowflake, token: String + rest: DiscordREST, + applicationID: String, + guildID: Snowflake?, + interactionID: Snowflake, + token: String, + resolved: ResolvedData? ) { self.rest = rest self.token = token + self.guildID = guildID self.interactionID = interactionID self.applicationID = applicationID self.optionValues = Self.unwrapOptionDatas(optionValues) + self.resolved = resolved } /// A private reference to the active rest handler for handling actions @@ -40,9 +47,14 @@ public class CommandData { // MARK: Parameters for executing callbacks /// The token to use when carrying out actions with this interaction let token: String + + public let guildID: Snowflake? + /// The ID of this interaction public let interactionID: Snowflake + public let resolved: ResolvedData? + fileprivate static func unwrapOptionDatas(_ options: [OptionData]) -> [String: OptionData] { var optValues: [String: OptionData] = [:] for optionValue in options { @@ -85,6 +97,24 @@ public extension CommandData { /// The wrapped value of an option typealias OptionData = Interaction.Data.AppCommandData.OptionData + typealias ResolvedData = Interaction.Data.AppCommandData.ResolvedData +} + +public extension CommandData { + func subGroup(name: String) -> CommandData? { + guard let option = optionValues[name], option.type == .subCommandGroup else { return nil } + guard let options = option.options else { return nil } + guard let rest = self.rest else { return nil } + return CommandData( + optionValues: options, + rest: rest, + applicationID: self.applicationID, + guildID: self.guildID, + interactionID: self.interactionID, + token: self.token, + resolved: self.resolved + ) + } } // MARK: - Callback APIs diff --git a/Sources/DiscordKitBot/ApplicationCommand/Option/ChannelOption.swift b/Sources/DiscordKitBot/ApplicationCommand/Option/ChannelOption.swift new file mode 100644 index 000000000..012978336 --- /dev/null +++ b/Sources/DiscordKitBot/ApplicationCommand/Option/ChannelOption.swift @@ -0,0 +1,30 @@ +// +// ChannelOption.swift +// +// +// Created by Elizabeth on 02/10/2025. +// + +import Foundation +import DiscordKitCore + +/// An option for an application command that accepts a server channel +public struct ChannelOption: CommandOption { + public init(_ name: String, description: String, `required`: Bool? = nil, channel_types: [ChannelType]? = nil) { + type = .channel + + self.required = `required` + self.name = name + self.description = description + self.channel_types = channel_types + } + + public var type: CommandOptionType + + public var required: Bool? + + public let name: String + public let description: String + + public let channel_types: [ChannelType]? +} diff --git a/Sources/DiscordKitBot/ApplicationCommand/Option/SubCommandGroup.swift b/Sources/DiscordKitBot/ApplicationCommand/Option/SubCommandGroup.swift new file mode 100644 index 000000000..e88daa264 --- /dev/null +++ b/Sources/DiscordKitBot/ApplicationCommand/Option/SubCommandGroup.swift @@ -0,0 +1,69 @@ +// +// SubCommandGroup.swift +// +// +// Created by Elizabeth on 11/02/2025. +// + +import DiscordKitCore +import Foundation + +public struct SubCommandGroup: CommandOption { + /// Create a sub-command, optionally with an array of options + public init(_ name: String, description: String, options: [SubCommand]? = nil) { + type = .subCommandGroup + + self.name = name + self.description = description + self.options = options + } + + /// Create a sub-command with options built by an ``OptionBuilder`` + public init( + _ name: String, description: String, @SubCommandOptionBuilder options: () -> [SubCommand] + ) { + self.init(name, description: description, options: options()) + } + + public let type: CommandOptionType + + public let name: String + + public let description: String + + public var required: Bool? + + /// If this command is a subcommand or subcommand group type, these nested options will be its parameters + public let options: [SubCommand]? + + enum CodingKeys: CodingKey { + case type + case name + case description + case required + case options + } + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container( + keyedBy: SubCommand.CodingKeys.self) + + try container.encode(self.type, forKey: SubCommand.CodingKeys.type) + try container.encode(self.name, forKey: SubCommand.CodingKeys.name) + try container.encode(self.description, forKey: SubCommand.CodingKeys.description) + try container.encodeIfPresent(self.required, forKey: SubCommand.CodingKeys.required) + if let options = options { + var optContainer = container.nestedUnkeyedContainer(forKey: .options) + for option in options { + try optContainer.encode(option) + } + } + } +} + +@resultBuilder +public struct SubCommandOptionBuilder { + public static func buildBlock(_ components: SubCommand...) -> [SubCommand] { + components + } +} diff --git a/Sources/DiscordKitBot/ApplicationCommand/Option/UserOption.swift b/Sources/DiscordKitBot/ApplicationCommand/Option/UserOption.swift new file mode 100644 index 000000000..65a96ba88 --- /dev/null +++ b/Sources/DiscordKitBot/ApplicationCommand/Option/UserOption.swift @@ -0,0 +1,36 @@ +// +// UserOption.swift +// +// +// Created by Elizabeth (lizclipse) on 09/08/2023. +// + +import Foundation +import DiscordKitCore + +public struct UserOption: CommandOption { + public init(_ name: String, description: String, `required`: Bool? = nil, choices: [AppCommandOptionChoice]? = nil, minLength: Int? = nil, maxLength: Int? = nil, autocomplete: Bool? = nil) { + type = .user + + self.required = `required` + self.choices = choices + self.name = name + self.description = description + self.autocomplete = autocomplete + } + + public var type: CommandOptionType + + public var required: Bool? + + /// Choices for the user to pick from + /// + /// > Important: There can be a max of 25 choices. + public let choices: [AppCommandOptionChoice]? + + public let name: String + public let description: String + + /// If autocomplete interactions are enabled for this option + public let autocomplete: Bool? +} diff --git a/Sources/DiscordKitBot/BotMessage.swift b/Sources/DiscordKitBot/BotMessage.swift index 847aa1158..59e83e997 100644 --- a/Sources/DiscordKitBot/BotMessage.swift +++ b/Sources/DiscordKitBot/BotMessage.swift @@ -1,12 +1,12 @@ // // BotMessage.swift -// +// // // Created by Vincent Kwok on 22/11/22. // -import Foundation import DiscordKitCore +import Foundation /// A Discord message, with convenience methods /// @@ -14,27 +14,62 @@ import DiscordKitCore /// > 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 + public var id: Snowflake { return inner.id } + public var channelID: Snowflake { return inner.channel_id } + public var guildID: Snowflake? { return inner.guild_id } + public var author: User { return inner.author } + public var member: Member? { return inner.member } + public var timestamp: Date { return inner.timestamp } + public var editedTimestamp: Date? { return inner.edited_timestamp } + public var tts: Bool { return inner.tts } + public var mentionEveryone: Bool { return inner.mention_everyone } + public var mentions: [User] { return inner.mentions } + public var mentionRoles: [Snowflake] { return inner.mention_roles } + public var mentionChannels: [ChannelMention]? { return inner.mention_channels } + public var attachments: [Attachment] { return inner.attachments } + public var embeds: [Embed] { return inner.embeds } + public var reactions: [Reaction]? { return inner.reactions } + public var nonce: Nonce? { return inner.nonce } + public var pinned: Bool { return inner.pinned } + public var webhookId: Snowflake? { return inner.webhook_id } + public var type: MessageType { return inner.type } + public var activity: MessageActivity? { return inner.activity } + public var application: Application? { return inner.application } + public var applicationId: Snowflake? { return inner.application_id } + public var messageReference: MessageReference? { return inner.message_reference } + public var flags: Int? { return inner.flags } + public var referencedMessage: BotMessage? { + guard let ref = inner.referenced_message else { return nil } + return Self(from: ref, rest: self.rest!) + } + public var interaction: MessageInteraction? { return inner.interaction } + public var thread: Channel? { return inner.thread } + public var components: [MessageComponent]? { return inner.components } + public var stickerItems: [StickerItem]? { return inner.sticker_items } + public var call: CallMessageComponent? { return inner.call } + public var content: String { return inner.content } + + public let inner: Message // 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.inner = message self.rest = rest } } -public extension BotMessage { - func reply(_ content: String) async throws -> Message { +extension BotMessage { + public func reply(_ content: String) async throws -> Message { return try await rest!.createChannelMsg( - message: .init(content: content, message_reference: .init(message_id: id), components: []), + message: .init( + content: content, message_reference: .init(message_id: id), components: []), id: channelID ) } + + public func mentions(_ userID: Snowflake) -> Bool { + return mentions.first(identifiedBy: userID) != nil + } } diff --git a/Sources/DiscordKitBot/Client.swift b/Sources/DiscordKitBot/Client.swift index d95a411dc..9e36c979f 100644 --- a/Sources/DiscordKitBot/Client.swift +++ b/Sources/DiscordKitBot/Client.swift @@ -108,13 +108,18 @@ 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, id: Snowflake, token: String, guildID: Snowflake?) { if let handler = appCommandHandlers[commandData.id] { Self.logger.trace("Invoking application handler", metadata: ["command.name": "\(commandData.name)"]) Task { await handler(.init( optionValues: commandData.options ?? [], - rest: rest, applicationID: applicationID!, interactionID: id, token: token + rest: rest, + applicationID: applicationID!, + guildID: guildID, + interactionID: id, + token: token, + resolved: commandData.resolved )) } } @@ -143,7 +148,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, id: interaction.id, token: interaction.token, guildID: interaction.guildID) 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 415398048..9e0cd4705 100644 --- a/Sources/DiscordKitCore/Objects/Data/Interaction.swift +++ b/Sources/DiscordKitCore/Objects/Data/Interaction.swift @@ -61,6 +61,8 @@ public struct Interaction: Decodable { public let name: String /// Type of command public let type: Int + /// Resolved references for things like user and channels + public let resolved: ResolvedData? /// Options of command (present if the command has options) public let options: [OptionData]? @@ -72,6 +74,8 @@ public struct Interaction: Decodable { case integer(Int) case double(Double) case boolean(Bool) // Discord docs are disappointing + case user(Snowflake) + case channel(String) public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() @@ -80,6 +84,8 @@ public struct Interaction: Decodable { case .integer(let val): try container.encode(val) case .double(let val): try container.encode(val) case .boolean(let val): try container.encode(val) + case .user(let val): try container.encode(val) + case .channel(let val): try container.encode(val) } } @@ -87,8 +93,11 @@ public struct Interaction: Decodable { /// /// - Returns: The string value of a certain option if it is present and is of type `String`, otherwise `nil` public func value() -> String? { - guard case let .string(val) = self else { return nil } - return val + if case let .string(val) = self { return val } + if case let .user(val) = self { return val } + if case let .channel(val) = self { return val } + + return nil } /// Get the wrapped `Int` value /// @@ -145,10 +154,16 @@ public struct Interaction: Decodable { case .number: value = .double(try container.decode(Double.self, forKey: .value)) case .boolean: value = .boolean(try container.decode(Bool.self, forKey: .value)) case .string: value = .string(try container.decode(String.self, forKey: .value)) + case .user: value = .user(try container.decode(Snowflake.self, forKey: .value)) + case .channel: value = .channel(try container.decode(String.self, forKey: .value)) default: value = nil } } } + + public struct ResolvedData: Codable { + public let channels: [Snowflake: Channel]? + } } /// The data payload for message component interactions