Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion Sources/DiscordKitBot/ApplicationCommand/CommandData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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]?
}
Original file line number Diff line number Diff line change
@@ -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<SubCommand.CodingKeys> = 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
}
}
36 changes: 36 additions & 0 deletions Sources/DiscordKitBot/ApplicationCommand/Option/UserOption.swift
Original file line number Diff line number Diff line change
@@ -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?
}
59 changes: 47 additions & 12 deletions Sources/DiscordKitBot/BotMessage.swift
Original file line number Diff line number Diff line change
@@ -1,40 +1,75 @@
//
// BotMessage.swift
//
//
//
// Created by Vincent Kwok on 22/11/22.
//

import Foundation
import DiscordKitCore
import Foundation

/// 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
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
}
}
11 changes: 8 additions & 3 deletions Sources/DiscordKitBot/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
))
}
}
Expand Down Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions Sources/DiscordKitCore/Objects/Data/Interaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]?

Expand All @@ -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()
Expand All @@ -80,15 +84,20 @@ 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)
}
}

/// Get the wrapped `String` value
///
/// - Returns: The string value of a certain option if it is present and is of type `String`, otherwise `nil`
public func value() -> String? {
guard case let .string(val) = self else { return nil }
return val
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
///
Expand Down Expand Up @@ -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
Expand Down