From 4dda55238a85efb98de71df86c4103b33ef9626b Mon Sep 17 00:00:00 2001 From: Mahdi Bahrami Date: Sun, 12 Feb 2023 20:04:12 +0330 Subject: [PATCH] React-to-Role (#28) --- README.md | 84 +- .../DiscordClient/DefaultDiscordClient.swift | 33 +- Sources/DiscordClient/DiscordClient.swift | 326 ++++- Sources/DiscordClient/Endpoint.swift | 281 +++- Sources/DiscordClient/HTTP Models.swift | 29 + Sources/DiscordClient/HTTPRateLimiter.swift | 39 +- .../DiscordGateway/BotGatewayManager.swift | 21 +- Sources/DiscordGateway/DiscordCache.swift | 165 ++- Sources/DiscordGateway/GatewayManager.swift | 2 +- Sources/DiscordGateway/ReactToRole.swift | 716 ++++++++++ Sources/DiscordGateway/SerialQueue.swift | 10 +- .../+DiscordGlobalConfiguration.swift | 2 +- Sources/DiscordLogger/+LoggingSystem.swift | 4 +- Sources/DiscordLogger/DiscordLogHandler.swift | 35 +- Sources/DiscordLogger/DiscordLogManager.swift | 58 +- .../DiscordModels/Types/DiscordChannel.swift | 38 +- Sources/DiscordModels/Types/Emoji.swift | 70 + Sources/DiscordModels/Types/Gateway.swift | 6 +- Sources/DiscordModels/Types/Guild.swift | 1 + Sources/DiscordModels/Types/RequestBody.swift | 58 +- Sources/DiscordModels/Types/Shared.swift | 2 +- .../Utils/MultipartEncodable.swift | 16 + Tests/DiscordBMTests/DiscordCache.swift | 2 +- Tests/DiscordBMTests/DiscordLogger.swift | 102 +- Tests/DiscordBMTests/DiscordModels.swift | 16 + Tests/DiscordBMTests/HTTPRateLimiter.swift | 36 +- Tests/IntegrationTests/+XCTestCase.swift | 15 + Tests/IntegrationTests/Constants.swift | 3 + Tests/IntegrationTests/DiscordClient.swift | 274 +++- Tests/IntegrationTests/GatwayConnection.swift | 10 +- .../IntegrationTests/PermissionChecker.swift | 8 +- Tests/IntegrationTests/ReactToRole.swift | 1237 +++++++++++++++++ 32 files changed, 3414 insertions(+), 285 deletions(-) create mode 100644 Sources/DiscordGateway/ReactToRole.swift create mode 100644 Tests/IntegrationTests/+XCTestCase.swift create mode 100644 Tests/IntegrationTests/ReactToRole.swift diff --git a/README.md b/README.md index d4d33298..8993aead 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,8 @@ let bot = BotGatewayManager( /// Make an instance like above let bot: BotGatewayManager = ... -/// Add event handlers Task { + /// Add event handlers await bot.addEventHandler { event in switch event.data { case let .messageCreate(message): @@ -201,19 +201,18 @@ import Logging /// Configure the Discord Logging Manager. DiscordGlobalConfiguration.logManager = DiscordLogManager( - httpClient: HTTP_CLIENT_YOU_MADE_IN_PREVIOUS_STEPS, - configuration: .init(fallbackLogger: Logger( - label: "DiscordBMFallback", - factory: StreamLogHandler.standardOutput(label:metadataProvider:) - )) + httpClient: HTTP_CLIENT_YOU_MADE_IN_PREVIOUS_STEPS ) -/// Bootstrap the `LoggingSystem`. After this, all your `Logger`s will automagically start using `DiscordLogHandler`. -LoggingSystem.bootstrapWithDiscordLogger( - /// The address to send the logs to. You can easily create a webhook using Discord client apps. - address: try .url(WEBHOOK_URL), - makeMainLogHandler: StreamLogHandler.standardOutput(label:metadataProvider:) -) +Task { + /// Bootstrap the `LoggingSystem`. After this, all your `Logger`s will automagically start using `DiscordLogHandler`. + await LoggingSystem.bootstrapWithDiscordLogger( + /// The address to send the logs to. + /// You can easily create a webhook using Discord client apps. + address: try .url(WEBHOOK_URL), + makeMainLogHandler: StreamLogHandler.standardOutput(label:metadataProvider:) + ) +} /// Make sure you haven't called `LoggingSystem.bootstrap` anywhere else, because you can only call it once. /// For example Vapor's templates use `LoggingSystem.bootstrap` on boot, and you need to remove that. ``` @@ -225,10 +224,6 @@ Read `DiscordLogManager.Configuration.init` documentation for full info. DiscordGlobalConfiguration.logManager = DiscordLogManager( httpClient: HTTP_CLIENT_YOU_MADE_IN_PREVIOUS_STEPS, configuration: .init( - fallbackLogger: Logger( - label: "DiscordBMFallback", - factory: StreamLogHandler.standardOutput(label:metadataProvider:) - ), aliveNotice: .init( address: try .url(WEBHOOK_URL), /// If nil, DiscordLogger will only send 1 "I'm alive" notice, on boot. @@ -244,7 +239,7 @@ DiscordGlobalConfiguration.logManager = DiscordLogManager( .critical: .role("970723029262942248"), ], extraMetadata: [.warning, .error, .critical], - disabledLogLevels: [.debug, .trace], + disabledLogLevels: [.debug, .trace], disabledInDebug: true ) ) @@ -301,6 +296,61 @@ print("Guild name is:", aGuild.name) +### React-To-Role +
+ Click to expand + +`DiscordBM` can automatically assign a role to members when they react to a message with specific emojis: + +```swift +let handler = try await ReactToRoleHandler( + gatewayManager: GatewayManager_YOU_MADE_IN_PREVIOUS_STEPS, + /// Your DiscordCache. This is not necessary (you can pass `nil`) + /// Only helpful if the cache has `guilds` and/or `guildMembers` intents enabled + cache: cache, + /// The role-creation payload + role: .init( + name: "cool-gang", + color: .green + ), + guildId: THE_GUILD_ID_OF_THE_MESSAGE_YOU_CREATED, + channelId: THE_CHANNEL_ID_OF_THE_MESSAGE_YOU_CREATED, + messageId: THE_MESSAGE_ID_OF_THE_MESSAGE_YOU_CREATED, + /// The list of reactions to get the role for + reactions: [.unicodeEmoji("🐔")] +) +``` + +After this, anyone reacting with `🐔` to the message will be assigned the role. +There are a bunch more options, take a look at the other `ReactToRoleHandler` initializers for more info. + +#### Behavior +The handler will: +* Verify the message exists at all, and throws an error in the initializer if not. +* React to the message as the bot-user with all the reactions you specified. +* Re-create the role if it's removed or doesn't exist. +* Stop working if you use `await handler.stop()`. +* Re-start working again if you use `try await handler.start()`. + +#### Persistence +If you need to persist the handler somewhere: +* You only need to persist handler's `configuration`, which is `Codable`. +* You need to update the configuration you saved, whenever it's changed. + To become notified of configuration changes, you should use the `onConfigurationChanged` parameter in initializers: + +```swift +let handler = try await ReactToRoleHandler( + . + . + . + onConfigurationChanged: { configuration in + await saveToDatabase(configuration: configuration) + } +) +``` + +
+ ## Testability
Click to expand diff --git a/Sources/DiscordClient/DefaultDiscordClient.swift b/Sources/DiscordClient/DefaultDiscordClient.swift index b3d595ac..f3dd3a9a 100644 --- a/Sources/DiscordClient/DefaultDiscordClient.swift +++ b/Sources/DiscordClient/DefaultDiscordClient.swift @@ -59,8 +59,31 @@ public struct DefaultDiscordClient: DiscordClient { } func checkRateLimitsAllowRequest(to endpoint: Endpoint) async throws { - if await !rateLimiter.shouldRequest(to: endpoint) { + switch await rateLimiter.shouldRequest(to: endpoint) { + case .true: return + case .false: throw DiscordClientError.rateLimited(url: "\(endpoint.urlDescription)") + case let .after(after): + if let backoff = configuration.retryPolicy?.backoff, + case let .basedOnHeaders(maxAllowed, _, _) = backoff { + if let maxAllowed = maxAllowed { + if after <= maxAllowed { + logger.warning("HTTP bucket is exhausted. Will wait \(after) seconds before making the request") + let nanos = UInt64(after * 1_000_000_000) + try await Task.sleep(nanoseconds: nanos) + await rateLimiter.addGlobalRateLimitRecord() + } else { + throw DiscordClientError.rateLimited(url: "\(endpoint.urlDescription)") + } + } else { + logger.warning("HTTP bucket is exhausted. Will wait \(after) seconds before making the request") + let nanos = UInt64(after * 1_000_000_000) + try await Task.sleep(nanoseconds: nanos) + await rateLimiter.addGlobalRateLimitRecord() + } + } else { + throw DiscordClientError.rateLimited(url: "\(endpoint.urlDescription)") + } } } @@ -450,12 +473,12 @@ public struct ClientConfiguration { case basedOnHeaders( maxAllowed: Double?, retryIfGreater: Bool = false, - else: Backoff? + else: Backoff? = nil ) public static var `default`: Backoff { .basedOnHeaders( - maxAllowed: 10, + maxAllowed: 5, retryIfGreater: false, else: .exponential(base: 0.2, coefficient: 0.5, rate: 2, upToTimes: 10) ) @@ -520,7 +543,7 @@ public struct ClientConfiguration { /// Only retries status code 429, 500 and 502 once. @inlinable public static var `default`: RetryPolicy { - RetryPolicy(statuses: [.tooManyRequests, .internalServerError, .badGateway]) + RetryPolicy() } /// - Parameters: @@ -529,7 +552,7 @@ public struct ClientConfiguration { /// - backoff: The backoff configuration, to wait a some amount of time /// _after_ a failed request. public init( - statuses: Set, + statuses: Set = [.tooManyRequests, .internalServerError, .badGateway], maxRetries: Int = 1, backoff: Backoff? = .default ) { diff --git a/Sources/DiscordClient/DiscordClient.swift b/Sources/DiscordClient/DiscordClient.swift index c6da6ad1..8ce7cad5 100644 --- a/Sources/DiscordClient/DiscordClient.swift +++ b/Sources/DiscordClient/DiscordClient.swift @@ -1,8 +1,9 @@ +import DiscordModels import NIOHTTP1 import NIOCore import Foundation -public protocol DiscordClient { +public protocol DiscordClient: Sendable { var appId: String? { get } func send(request: DiscordHTTPRequest) async throws -> DiscordHTTPResponse @@ -26,6 +27,15 @@ public extension DiscordClient { return DiscordClientResponse(httpResponse: response) } + @inlinable + func send( + request: DiscordHTTPRequest, + fallbackFileName: String + ) async throws -> DiscordCDNResponse { + let response = try await self.send(request: request) + return DiscordCDNResponse(httpResponse: response, fallbackFileName: fallbackFileName) + } + @inlinable func send( request: DiscordHTTPRequest, @@ -52,6 +62,8 @@ public enum DiscordClientError: Error { case badStatusCode(DiscordHTTPResponse) /// The body of the response was empty. case emptyBody(DiscordHTTPResponse) + /// Couldn't find a content-type header. + case noContentTypeHeader(DiscordHTTPResponse) /// You need to provide an `appId`. /// Either via the function arguments or the DiscordClient initializer. case appIdParameterRequired @@ -240,6 +252,7 @@ public extension DiscordClient { return try await self.send(request: .init(to: endpoint)) } + /// The `channelId` could be a thread-id as well. /// https://discord.com/developers/docs/resources/channel#create-message @inlinable func createMessage( @@ -250,6 +263,7 @@ public extension DiscordClient { return try await self.sendMultipart(request: .init(to: endpoint), payload: payload) } + /// The `channelId` could be a thread-id as well. /// https://discord.com/developers/docs/resources/channel#edit-message @inlinable func editMessage( @@ -261,6 +275,7 @@ public extension DiscordClient { return try await self.sendMultipart(request: .init(to: endpoint), payload: payload) } + /// The `channelId` could be a thread-id as well. /// https://discord.com/developers/docs/resources/channel#delete-message @inlinable func deleteMessage( @@ -324,6 +339,13 @@ public extension DiscordClient { )) } + /// https://discord.com/developers/docs/resources/guild#get-guild-roles + @inlinable + func getGuildRoles(id: String) async throws -> DiscordClientResponse<[Role]> { + let endpoint = Endpoint.getGuildRoles(id: id) + return try await self.send(request: .init(to: endpoint)) + } + /// https://discord.com/developers/docs/resources/channel#get-channel @inlinable func getChannel(id: String) async throws -> DiscordClientResponse { @@ -409,15 +431,93 @@ public extension DiscordClient { /// https://discord.com/developers/docs/resources/channel#create-reaction @inlinable - func addReaction( + func createReaction( + channelId: String, + messageId: String, + emoji: Reaction + ) async throws -> DiscordHTTPResponse { + let endpoint = Endpoint.createReaction( + channelId: channelId, + messageId: messageId, + emoji: emoji.urlPathDescription + ) + return try await self.send(request: .init(to: endpoint)) + } + + /// https://discord.com/developers/docs/resources/channel#delete-own-reaction + @inlinable + func deleteOwnReaction( channelId: String, messageId: String, - emoji: String + emoji: Reaction ) async throws -> DiscordHTTPResponse { - let endpoint = Endpoint.addReaction( + let endpoint = Endpoint.deleteOwnReaction( channelId: channelId, messageId: messageId, - emoji: emoji + emoji: emoji.urlPathDescription + ) + return try await self.send(request: .init(to: endpoint)) + } + + /// https://discord.com/developers/docs/resources/channel#delete-user-reaction + @inlinable + func deleteUserReaction( + channelId: String, + messageId: String, + emoji: Reaction, + userId: String + ) async throws -> DiscordHTTPResponse { + let endpoint = Endpoint.deleteUserReaction( + channelId: channelId, + messageId: messageId, + emoji: emoji.urlPathDescription, + userId: userId + ) + return try await self.send(request: .init(to: endpoint)) + } + + /// https://discord.com/developers/docs/resources/channel#get-reactions + @inlinable + func getReactions( + channelId: String, + messageId: String, + emoji: Reaction, + after: String? = nil, + limit: Int? = nil + ) async throws -> DiscordClientResponse<[DiscordUser]> { + try checkInBounds(name: "limit", value: limit, lowerBound: 1, upperBound: 1_000) + let endpoint = Endpoint.getReactions( + channelId: channelId, + messageId: messageId, + emoji: emoji.urlPathDescription + ) + return try await self.send(request: .init(to: endpoint)) + } + + /// https://discord.com/developers/docs/resources/channel#delete-all-reactions + @inlinable + func deleteAllReactions( + channelId: String, + messageId: String + ) async throws -> DiscordHTTPResponse { + let endpoint = Endpoint.deleteAllReactions( + channelId: channelId, + messageId: messageId + ) + return try await self.send(request: .init(to: endpoint)) + } + + /// https://discord.com/developers/docs/resources/channel#delete-all-reactions-for-emoji + @inlinable + func deleteAllReactionsForEmoji( + channelId: String, + messageId: String, + emoji: Reaction + ) async throws -> DiscordHTTPResponse { + let endpoint = Endpoint.deleteAllReactionsForEmoji( + channelId: channelId, + messageId: messageId, + emoji: emoji.urlPathDescription ) return try await self.send(request: .init(to: endpoint)) } @@ -723,4 +823,220 @@ public extension DiscordClient { queries: [("thread_id", threadId)] )) } + + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNCustomEmoji(emojiId: String) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNCustomEmoji(emojiId: emojiId) + return try await self.send(request: .init(to: endpoint), fallbackFileName: emojiId) + } + + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNGuildIcon(guildId: String, icon: String) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNGuildIcon(guildId: guildId, icon: icon) + return try await self.send(request: .init(to: endpoint), fallbackFileName: icon) + } + + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNGuildSplash(guildId: String, splash: String) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNGuildSplash(guildId: guildId, splash: splash) + return try await self.send(request: .init(to: endpoint), fallbackFileName: splash) + } + + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNGuildDiscoverySplash( + guildId: String, + splash: String + ) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNGuildDiscoverySplash(guildId: guildId, splash: splash) + return try await self.send(request: .init(to: endpoint), fallbackFileName: splash) + } + + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNGuildBanner(guildId: String, banner: String) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNGuildBanner(guildId: guildId, banner: banner) + return try await self.send(request: .init(to: endpoint), fallbackFileName: banner) + } + + /// Untested function. + /// If it didn't work, try to append `.png` to the end of `banner`. + /// If you are using this endpoint successfully, please open an issue and let me know what + /// info you pass to the function, so I can fix the function and add it to the tests. + /// (CDN data are _mostly_ public) + /// + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNUserBanner(userId: String, banner: String) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNUserBanner(userId: userId, banner: banner) + return try await self.send(request: .init(to: endpoint), fallbackFileName: banner) + } + + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNDefaultUserAvatar(discriminator: Int) async throws -> DiscordCDNResponse { + /// `discriminator % 5` is what Discord says. + let modulo = "\(discriminator % 5)" + let endpoint = Endpoint.CDNDefaultUserAvatar(discriminator: modulo) + return try await self.send( + request: .init(to: endpoint), + fallbackFileName: "\(discriminator)" + ) + } + + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNUserAvatar(userId: String, avatar: String) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNUserAvatar(userId: userId, avatar: avatar) + return try await self.send(request: .init(to: endpoint), fallbackFileName: avatar) + } + + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNGuildMemberAvatar( + guildId: String, + userId: String, + avatar: String + ) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNGuildMemberAvatar( + guildId: guildId, + userId: userId, + avatar: avatar + ) + return try await self.send(request: .init(to: endpoint), fallbackFileName: avatar) + } + + /// Untested function. + /// If it didn't work, try to append `.png` to the end of `icon`. + /// If you are using this endpoint successfully, please open an issue and let me know what + /// info you pass to the function, so I can fix the function and add it to the tests. + /// (CDN data are _mostly_ public) + /// + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNApplicationIcon(appId: String, icon: String) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNApplicationIcon(appId: appId, icon: icon) + return try await self.send(request: .init(to: endpoint), fallbackFileName: icon) + } + + /// Untested function. + /// If it didn't work, try to append `.png` to the end of `cover`. + /// If you are using this endpoint successfully, please open an issue and let me know what + /// info you pass to the function, so I can fix the function and add it to the tests. + /// (CDN data are _mostly_ public) + /// + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNApplicationCover(appId: String, cover: String) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNApplicationCover(appId: appId, cover: cover) + return try await self.send(request: .init(to: endpoint), fallbackFileName: cover) + } + + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNApplicationAsset(appId: String, assetId: String) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNApplicationAsset(appId: appId, assetId: assetId) + return try await self.send(request: .init(to: endpoint), fallbackFileName: assetId) + } + + /// Untested function. + /// If it didn't work, try to append `.png` to the end of `icon`. + /// If you are using this endpoint successfully, please open an issue and let me know what + /// info you pass to the function, so I can fix the function and add it to the tests. + /// (CDN data are _mostly_ public) + /// + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNAchievementIcon( + appId: String, + achievementId: String, + icon: String + ) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNAchievementIcon( + appId: appId, + achievementId: achievementId, + icon: icon + ) + return try await self.send(request: .init(to: endpoint), fallbackFileName: icon) + } + + /// Untested function. + /// If it didn't work, try to append `.png` to the end of `assetId`. + /// If you are using this endpoint successfully, please open an issue and let me know what + /// info you pass to the function, so I can fix the function and add it to the tests. + /// (CDN data are _mostly_ public) + /// + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNStickerPackBanner(assetId: String) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNStickerPackBanner(assetId: assetId) + return try await self.send(request: .init(to: endpoint), fallbackFileName: assetId) + } + + /// Untested function. + /// If it didn't work, try to append `.png` to the end of `icon`. + /// If you are using this endpoint successfully, please open an issue and let me know what + /// info you pass to the function, so I can fix the function and add it to the tests. + /// (CDN data are _mostly_ public) + /// + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNTeamIcon(teamId: String, icon: String) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNTeamIcon(teamId: teamId, icon: icon) + return try await self.send(request: .init(to: endpoint), fallbackFileName: icon) + } + + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNSticker(stickerId: String) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNSticker(stickerId: stickerId) + return try await self.send(request: .init(to: endpoint), fallbackFileName: stickerId) + } + + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNRoleIcon(roleId: String, icon: String) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNRoleIcon(roleId: roleId, icon: icon) + return try await self.send(request: .init(to: endpoint), fallbackFileName: icon) + } + + /// Untested function. + /// If it didn't work, try to append `.png` to the end of `cover`. + /// If you are using this endpoint successfully, please open an issue and let me know what + /// info you pass to the function, so I can fix the function and add it to the tests. + /// (CDN data are _mostly_ public) + /// + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNGuildScheduledEventCover( + eventId: String, + cover: String + ) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNGuildScheduledEventCover(eventId: eventId, cover: cover) + return try await self.send(request: .init(to: endpoint), fallbackFileName: cover) + } + + /// Untested function. + /// If it didn't work, try to append `.png` to the end of `banner`. + /// If you are using this endpoint successfully, please open an issue and let me know what + /// info you pass to the function, so I can fix the function and add it to the tests. + /// (CDN data are _mostly_ public) + /// + /// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + @inlinable + func getCDNGuildMemberBanner( + guildId: String, + userId: String, + banner: String + ) async throws -> DiscordCDNResponse { + let endpoint = Endpoint.CDNGuildMemberBanner( + guildId: guildId, + userId: userId, + banner: banner + ) + return try await self.send(request: .init(to: endpoint), fallbackFileName: banner) + } } diff --git a/Sources/DiscordClient/Endpoint.swift b/Sources/DiscordClient/Endpoint.swift index 355a04de..665b5953 100644 --- a/Sources/DiscordClient/Endpoint.swift +++ b/Sources/DiscordClient/Endpoint.swift @@ -7,17 +7,38 @@ public enum CacheableEndpointIdentity: Int, Sendable, Hashable, CustomStringConv case getFollowupInteractionResponse case getApplicationGlobalCommands case getGuild + case getGuildRoles case searchGuildMembers case getGuildMember case getChannel case getChannelMessages case getChannelMessage case getGuildAuditLogs + case getReactions case getChannelWebhooks case getGuildWebhooks case getWebhook1 case getWebhook2 case getWebhookMessage + case CDNCustomEmoji + case CDNGuildIcon + case CDNGuildSplash + case CDNGuildDiscoverySplash + case CDNGuildBanner + case CDNUserBanner + case CDNDefaultUserAvatar + case CDNUserAvatar + case CDNGuildMemberAvatar + case CDNApplicationIcon + case CDNApplicationCover + case CDNApplicationAsset + case CDNAchievementIcon + case CDNStickerPackBanner + case CDNTeamIcon + case CDNSticker + case CDNRoleIcon + case CDNGuildScheduledEventCover + case CDNGuildMemberBanner public var description: String { switch self { @@ -27,17 +48,38 @@ public enum CacheableEndpointIdentity: Int, Sendable, Hashable, CustomStringConv case .getFollowupInteractionResponse: return "getFollowupInteractionResponse" case .getApplicationGlobalCommands: return "getApplicationGlobalCommands" case .getGuild: return "getGuild" + case .getGuildRoles: return "getGuildRoles" case .searchGuildMembers: return "searchGuildMembers" case .getGuildMember: return "getGuildMember" case .getChannel: return "getChannel" case .getChannelMessages: return "getChannelMessages" case .getChannelMessage: return "getChannelMessage" case .getGuildAuditLogs: return "getGuildAuditLogs" + case .getReactions: return "getReactions" case .getChannelWebhooks: return "getChannelWebhooks" case .getGuildWebhooks: return "getGuildWebhooks" case .getWebhook1: return "getWebhook1" case .getWebhook2: return "getWebhook2" case .getWebhookMessage: return "getWebhookMessage" + case .CDNCustomEmoji: return "CDNCustomEmoji" + case .CDNGuildIcon: return "CDNGuildIcon" + case .CDNGuildSplash: return "CDNGuildSplash" + case .CDNGuildDiscoverySplash: return "CDNGuildDiscoverySplash" + case .CDNGuildBanner: return "CDNGuildBanner" + case .CDNUserBanner: return "CDNUserBanner" + case .CDNDefaultUserAvatar: return "CDNDefaultUserAvatar" + case .CDNUserAvatar: return "CDNUserAvatar" + case .CDNGuildMemberAvatar: return "CDNGuildMemberAvatar" + case .CDNApplicationIcon: return "CDNApplicationIcon" + case .CDNApplicationCover: return "CDNApplicationCover" + case .CDNApplicationAsset: return "CDNApplicationAsset" + case .CDNAchievementIcon: return "CDNAchievementIcon" + case .CDNStickerPackBanner: return "CDNStickerPackBanner" + case .CDNTeamIcon: return "CDNTeamIcon" + case .CDNSticker: return "CDNSticker" + case .CDNRoleIcon: return "CDNRoleIcon" + case .CDNGuildScheduledEventCover: return "CDNGuildScheduledEventCover" + case .CDNGuildMemberBanner: return "CDNGuildMemberBanner" } } @@ -60,6 +102,7 @@ public enum CacheableEndpointIdentity: Int, Sendable, Hashable, CustomStringConv case .getApplicationGlobalCommands: self = .getApplicationGlobalCommands case .deleteApplicationGlobalCommand: return nil case .getGuild: self = .getGuild + case .getGuildRoles: self = .getGuildRoles case .searchGuildMembers: self = .searchGuildMembers case .getGuildMember: self = .getGuildMember case .getChannel: self = .getChannel @@ -71,7 +114,12 @@ public enum CacheableEndpointIdentity: Int, Sendable, Hashable, CustomStringConv case .addGuildMemberRole: return nil case .removeGuildMemberRole: return nil case .getGuildAuditLogs: self = .getGuildAuditLogs - case .addReaction: return nil + case .createReaction: return nil + case .deleteOwnReaction: return nil + case .deleteUserReaction: return nil + case .getReactions: self = .getReactions + case .deleteAllReactions: return nil + case .deleteAllReactionsForEmoji: return nil case .createDM: return nil case .createWebhook: return nil case .getChannelWebhooks: self = .getChannelWebhooks @@ -86,6 +134,25 @@ public enum CacheableEndpointIdentity: Int, Sendable, Hashable, CustomStringConv case .getWebhookMessage: self = .getWebhookMessage case .editWebhookMessage: return nil case .deleteWebhookMessage: return nil + case .CDNCustomEmoji: self = .CDNCustomEmoji + case .CDNGuildIcon: self = .CDNGuildIcon + case .CDNGuildSplash: self = .CDNGuildSplash + case .CDNGuildDiscoverySplash: self = .CDNGuildDiscoverySplash + case .CDNGuildBanner: self = .CDNGuildBanner + case .CDNUserBanner: self = .CDNUserBanner + case .CDNDefaultUserAvatar: self = .CDNDefaultUserAvatar + case .CDNUserAvatar: self = .CDNUserAvatar + case .CDNGuildMemberAvatar: self = .CDNGuildMemberAvatar + case .CDNApplicationIcon: self = .CDNApplicationIcon + case .CDNApplicationCover: self = .CDNApplicationCover + case .CDNApplicationAsset: self = .CDNApplicationAsset + case .CDNAchievementIcon: self = .CDNAchievementIcon + case .CDNStickerPackBanner: self = .CDNStickerPackBanner + case .CDNTeamIcon: self = .CDNTeamIcon + case .CDNSticker: self = .CDNSticker + case .CDNRoleIcon: self = .CDNRoleIcon + case .CDNGuildScheduledEventCover: self = .CDNGuildScheduledEventCover + case .CDNGuildMemberBanner: self = .CDNGuildMemberBanner } } } @@ -113,6 +180,7 @@ public enum Endpoint: Sendable { case deleteApplicationGlobalCommand(appId: String, id: String) case getGuild(id: String) + case getGuildRoles(id: String) case searchGuildMembers(id: String) case getGuildMember(id: String, userId: String) @@ -128,7 +196,12 @@ public enum Endpoint: Sendable { case removeGuildMemberRole(guildId: String, userId: String, roleId: String) case getGuildAuditLogs(guildId: String) - case addReaction(channelId: String, messageId: String, emoji: String) + case createReaction(channelId: String, messageId: String, emoji: String) + case deleteOwnReaction(channelId: String, messageId: String, emoji: String) + case deleteUserReaction(channelId: String, messageId: String, emoji: String, userId: String) + case getReactions(channelId: String, messageId: String, emoji: String) + case deleteAllReactions(channelId: String, messageId: String) + case deleteAllReactionsForEmoji(channelId: String, messageId: String, emoji: String) case createDM @@ -146,6 +219,26 @@ public enum Endpoint: Sendable { case editWebhookMessage(id: String, token: String, messageId: String) case deleteWebhookMessage(id: String, token: String, messageId: String) + case CDNCustomEmoji(emojiId: String) + case CDNGuildIcon(guildId: String, icon: String) + case CDNGuildSplash(guildId: String, splash: String) + case CDNGuildDiscoverySplash(guildId: String, splash: String) + case CDNGuildBanner(guildId: String, banner: String) + case CDNUserBanner(userId: String, banner: String) + case CDNDefaultUserAvatar(discriminator: String) + case CDNUserAvatar(userId: String, avatar: String) + case CDNGuildMemberAvatar(guildId: String, userId: String, avatar: String) + case CDNApplicationIcon(appId: String, icon: String) + case CDNApplicationCover(appId: String, cover: String) + case CDNApplicationAsset(appId: String, assetId: String) + case CDNAchievementIcon(appId: String, achievementId: String, icon: String) + case CDNStickerPackBanner(assetId: String) + case CDNTeamIcon(teamId: String, icon: String) + case CDNSticker(stickerId: String) + case CDNRoleIcon(roleId: String, icon: String) + case CDNGuildScheduledEventCover(eventId: String, cover: String) + case CDNGuildMemberBanner(guildId: String, userId: String, banner: String) + var urlSuffix: String { let suffix: String switch self { @@ -183,6 +276,8 @@ public enum Endpoint: Sendable { suffix = "applications/\(appId)/commands/\(id)" case let .getGuild(id): suffix = "guilds/\(id)" + case let .getGuildRoles(id): + suffix = "guilds/\(id)/roles" case let .searchGuildMembers(id): suffix = "guilds/\(id)/members/search" case let .getGuildMember(id, userId): @@ -205,8 +300,18 @@ public enum Endpoint: Sendable { suffix = "guilds/\(guildId)/members/\(userId)/roles/\(roleId)" case let .getGuildAuditLogs(guildId): suffix = "guilds/\(guildId)/audit-logs" - case let .addReaction(channelId, messageId, emoji): + case let .createReaction(channelId, messageId, emoji): + suffix = "channels/\(channelId)/messages/\(messageId)/reactions/\(emoji)/@me" + case let .deleteOwnReaction(channelId, messageId, emoji): suffix = "channels/\(channelId)/messages/\(messageId)/reactions/\(emoji)/@me" + case let .deleteUserReaction(channelId, messageId, emoji, userId): + suffix = "channels/\(channelId)/messages/\(messageId)/reactions/\(emoji)/\(userId)" + case let .getReactions(channelId, messageId, emoji): + suffix = "channels/\(channelId)/messages/\(messageId)/reactions/\(emoji)" + case let .deleteAllReactions(channelId, messageId): + suffix = "channels/\(channelId)/messages/\(messageId)/reactions" + case let .deleteAllReactionsForEmoji(channelId, messageId, emoji): + suffix = "channels/\(channelId)/messages/\(messageId)/reactions/\(emoji)" case .createDM: suffix = "users/@me/channels" case let .createWebhook(channelId): @@ -230,6 +335,44 @@ public enum Endpoint: Sendable { suffix = "webhooks/\(id)/\(token)/messages/\(messageId)" case let .deleteWebhookMessage(id, token, messageId): suffix = "webhooks/\(id)/\(token)/messages/\(messageId)" + case let .CDNCustomEmoji(emojiId): + suffix = "emojis/\(emojiId)" + case let .CDNGuildIcon(guildId, icon): + suffix = "icons/\(guildId)/\(icon)" + case let .CDNGuildSplash(guildId, splash): + suffix = "splashes/\(guildId)/\(splash)" + case let .CDNGuildDiscoverySplash(guildId, splash): + suffix = "discovery-splashes/\(guildId)/\(splash)" + case let .CDNGuildBanner(guildId, banner): + suffix = "banners/\(guildId)/\(banner)" + case let .CDNUserBanner(userId, banner): + suffix = "banners/\(userId)/\(banner)" + case let .CDNDefaultUserAvatar(discriminator): + suffix = "embed/avatars/\(discriminator).png" /// Needs `.png` + case let .CDNUserAvatar(userId, avatar): + suffix = "avatars/\(userId)/\(avatar)" + case let .CDNGuildMemberAvatar(guildId, userId, avatar): + suffix = "guilds/\(guildId)/users/\(userId)/avatars/\(avatar)" + case let .CDNApplicationIcon(appId, icon): + suffix = "app-icons/\(appId)/\(icon)" + case let .CDNApplicationCover(appId, cover): + suffix = "app-icons/\(appId)/\(cover)" + case let .CDNApplicationAsset(appId, assetId): + suffix = "app-assets/\(appId)/\(assetId)" + case let .CDNAchievementIcon(appId, achievementId, icon): + suffix = "app-assets/\(appId)/achievements/\(achievementId)/icons/\(icon)" + case let .CDNStickerPackBanner(assetId): + suffix = "app-assets/710982414301790216/store/\(assetId)" + case let .CDNTeamIcon(teamId, icon): + suffix = "team-icons/\(teamId)/\(icon)" + case let .CDNSticker(stickerId): + suffix = "stickers/\(stickerId).png" /// Needs `.png` + case let .CDNRoleIcon(roleId, icon): + suffix = "role-icons/\(roleId)/\(icon)" + case let .CDNGuildScheduledEventCover(eventId, cover): + suffix = "guild-events/\(eventId)/\(cover)" + case let .CDNGuildMemberBanner(guildId, userId, banner): + suffix = "guilds/\(guildId)/users/\(userId)/banners/\(banner)" } return suffix.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? suffix } @@ -237,7 +380,7 @@ public enum Endpoint: Sendable { /// Doesn't expose secret url path parameters. var urlSuffixDescription: String { switch self { - case .getGateway, .getGatewayBot, .createInteractionResponse, .getInteractionResponse, .editInteractionResponse, .deleteInteractionResponse, .postFollowupInteractionResponse, .getFollowupInteractionResponse, .editFollowupInteractionResponse, .deleteFollowupInteractionResponse, .createMessage, .editMessage, .deleteMessage, .createApplicationGlobalCommand, .getApplicationGlobalCommands, .deleteApplicationGlobalCommand, .getGuild, .searchGuildMembers, .getGuildMember, .getChannel, .getChannelMessages, .getChannelMessage, .leaveGuild, .createGuildRole, .deleteGuildRole, .addGuildMemberRole, .removeGuildMemberRole, .getGuildAuditLogs, .addReaction, .createDM, .createWebhook, .getChannelWebhooks, .getGuildWebhooks, .getWebhook1, .modifyWebhook1, .deleteWebhook1: + case .getGateway, .getGatewayBot, .createInteractionResponse, .getInteractionResponse, .editInteractionResponse, .deleteInteractionResponse, .postFollowupInteractionResponse, .getFollowupInteractionResponse, .editFollowupInteractionResponse, .deleteFollowupInteractionResponse, .createMessage, .editMessage, .deleteMessage, .createApplicationGlobalCommand, .getApplicationGlobalCommands, .deleteApplicationGlobalCommand, .getGuild, .getGuildRoles, .searchGuildMembers, .getGuildMember, .getChannel, .getChannelMessages, .getChannelMessage, .leaveGuild, .createGuildRole, .deleteGuildRole, .addGuildMemberRole, .removeGuildMemberRole, .getGuildAuditLogs, .createReaction, .deleteOwnReaction, .deleteUserReaction, .getReactions, .deleteAllReactions, .deleteAllReactionsForEmoji, .createDM, .createWebhook, .getChannelWebhooks, .getGuildWebhooks, .getWebhook1, .modifyWebhook1, .deleteWebhook1, .CDNCustomEmoji, .CDNGuildIcon, .CDNGuildSplash, .CDNGuildDiscoverySplash, .CDNGuildBanner, .CDNUserBanner, .CDNDefaultUserAvatar, .CDNUserAvatar, .CDNGuildMemberAvatar, .CDNApplicationIcon, .CDNApplicationCover, .CDNApplicationAsset, .CDNAchievementIcon, .CDNStickerPackBanner, .CDNTeamIcon, .CDNSticker, .CDNRoleIcon, .CDNGuildScheduledEventCover, .CDNGuildMemberBanner: return self.urlSuffix case let .getWebhook2(id, token), let .modifyWebhook2(id, token), @@ -253,13 +396,20 @@ public enum Endpoint: Sendable { } } + var urlPrefix: String { + switch self.isCDNEndpoint { + case true: return "https://cdn.discordapp.com/" + case false: return "https://discord.com/api/v\(DiscordGlobalConfiguration.apiVersion)/" + } + } + var url: String { - "https://discord.com/api/v\(DiscordGlobalConfiguration.apiVersion)/" + urlSuffix + urlPrefix + urlSuffix } /// Doesn't expose secret url path parameters. var urlDescription: String { - "https://discord.com/api/v\(DiscordGlobalConfiguration.apiVersion)/" + urlSuffixDescription + urlPrefix + urlSuffixDescription } var httpMethod: HTTPMethod { @@ -281,6 +431,7 @@ public enum Endpoint: Sendable { case .getApplicationGlobalCommands: return .GET case .deleteApplicationGlobalCommand: return .DELETE case .getGuild: return .GET + case .getGuildRoles: return .GET case .searchGuildMembers: return .GET case .getGuildMember: return .GET case .getChannel: return .GET @@ -292,7 +443,12 @@ public enum Endpoint: Sendable { case .addGuildMemberRole: return .PUT case .removeGuildMemberRole: return .DELETE case .getGuildAuditLogs: return .GET - case .addReaction: return .PUT + case .createReaction: return .PUT + case .deleteOwnReaction: return .DELETE + case .deleteUserReaction: return .DELETE + case .getReactions: return .GET + case .deleteAllReactions: return .DELETE + case .deleteAllReactionsForEmoji: return .DELETE case .createDM: return .POST case .createWebhook: return .POST case .getChannelWebhooks: return .GET @@ -307,6 +463,34 @@ public enum Endpoint: Sendable { case .getWebhookMessage: return .GET case .editWebhookMessage: return .PATCH case .deleteWebhookMessage: return .DELETE + case .CDNCustomEmoji: return .GET + case .CDNGuildIcon: return .GET + case .CDNGuildSplash: return .GET + case .CDNGuildDiscoverySplash: return .GET + case .CDNGuildBanner: return .GET + case .CDNUserBanner: return .GET + case .CDNDefaultUserAvatar: return .GET + case .CDNUserAvatar: return .GET + case .CDNGuildMemberAvatar: return .GET + case .CDNApplicationIcon: return .GET + case .CDNApplicationCover: return .GET + case .CDNApplicationAsset: return .GET + case .CDNAchievementIcon: return .GET + case .CDNStickerPackBanner: return .GET + case .CDNTeamIcon: return .GET + case .CDNSticker: return .GET + case .CDNRoleIcon: return .GET + case .CDNGuildScheduledEventCover: return .GET + case .CDNGuildMemberBanner: return .GET + } + } + + var isCDNEndpoint: Bool { + switch self { + case .getGateway, .getGatewayBot, .createInteractionResponse, .getInteractionResponse, .editInteractionResponse, .deleteInteractionResponse, .postFollowupInteractionResponse, .getFollowupInteractionResponse, .editFollowupInteractionResponse, .deleteFollowupInteractionResponse, .createMessage, .editMessage, .deleteMessage, .createApplicationGlobalCommand, .getApplicationGlobalCommands, .deleteApplicationGlobalCommand, .getGuild, .getGuildRoles, .searchGuildMembers, .getGuildMember, .getChannel, .getChannelMessages, .getChannelMessage, .leaveGuild, .createGuildRole, .deleteGuildRole, .addGuildMemberRole, .removeGuildMemberRole, .getGuildAuditLogs, .createReaction, .deleteOwnReaction, .deleteUserReaction, .getReactions, .deleteAllReactions, .deleteAllReactionsForEmoji, .createDM, .createWebhook, .getChannelWebhooks, .getGuildWebhooks, .getWebhook1, .getWebhook2, .modifyWebhook1, .modifyWebhook2, .deleteWebhook1, .deleteWebhook2, .executeWebhook, .getWebhookMessage, .editWebhookMessage, .deleteWebhookMessage: + return false + case .CDNCustomEmoji, .CDNGuildIcon, .CDNGuildSplash, .CDNGuildDiscoverySplash, .CDNGuildBanner, .CDNUserBanner, .CDNDefaultUserAvatar, .CDNUserAvatar, .CDNGuildMemberAvatar, .CDNApplicationIcon, .CDNApplicationCover, .CDNApplicationAsset, .CDNAchievementIcon, .CDNStickerPackBanner, .CDNTeamIcon, .CDNSticker, .CDNRoleIcon, .CDNGuildScheduledEventCover, .CDNGuildMemberBanner: + return true } } @@ -316,7 +500,7 @@ public enum Endpoint: Sendable { switch self { case .createInteractionResponse, .getInteractionResponse, .editInteractionResponse, .deleteInteractionResponse, .postFollowupInteractionResponse, .getFollowupInteractionResponse, .editFollowupInteractionResponse, .deleteFollowupInteractionResponse: return false - case .getGateway, .getGatewayBot, .createMessage, .editMessage, .deleteMessage, .createApplicationGlobalCommand, .getApplicationGlobalCommands, .deleteApplicationGlobalCommand, .getGuild, .searchGuildMembers, .getGuildMember, .getChannel, .getChannelMessages, .getChannelMessage, .leaveGuild, .createGuildRole, .deleteGuildRole, .addGuildMemberRole, .removeGuildMemberRole, .getGuildAuditLogs, .addReaction, .createDM, .createWebhook, .getChannelWebhooks, .getGuildWebhooks, .getWebhook1, .getWebhook2, .modifyWebhook1, .modifyWebhook2, .deleteWebhook1, .deleteWebhook2, .executeWebhook, .getWebhookMessage, .editWebhookMessage, .deleteWebhookMessage: + case .getGateway, .getGatewayBot, .createMessage, .editMessage, .deleteMessage, .createApplicationGlobalCommand, .getApplicationGlobalCommands, .deleteApplicationGlobalCommand, .getGuild, .getGuildRoles, .searchGuildMembers, .getGuildMember, .getChannel, .getChannelMessages, .getChannelMessage, .leaveGuild, .createGuildRole, .deleteGuildRole, .addGuildMemberRole, .removeGuildMemberRole, .getGuildAuditLogs, .createReaction, .deleteOwnReaction, .deleteUserReaction, .getReactions, .deleteAllReactions, .deleteAllReactionsForEmoji, .createDM, .createWebhook, .getChannelWebhooks, .getGuildWebhooks, .getWebhook1, .getWebhook2, .modifyWebhook1, .modifyWebhook2, .deleteWebhook1, .deleteWebhook2, .executeWebhook, .getWebhookMessage, .editWebhookMessage, .deleteWebhookMessage, .CDNCustomEmoji, .CDNGuildIcon, .CDNGuildSplash, .CDNGuildDiscoverySplash, .CDNGuildBanner, .CDNUserBanner, .CDNDefaultUserAvatar, .CDNUserAvatar, .CDNGuildMemberAvatar, .CDNApplicationIcon, .CDNApplicationCover, .CDNApplicationAsset, .CDNAchievementIcon, .CDNStickerPackBanner, .CDNTeamIcon, .CDNSticker, .CDNRoleIcon, .CDNGuildScheduledEventCover, .CDNGuildMemberBanner: return true } } @@ -325,9 +509,9 @@ public enum Endpoint: Sendable { /// contains some kind of authorization token. Like half of the webhook endpoints. var requiresAuthorizationHeader: Bool { switch self { - case .getGateway, .getGatewayBot, .createInteractionResponse, .getInteractionResponse, .editInteractionResponse, .deleteInteractionResponse, .postFollowupInteractionResponse, .getFollowupInteractionResponse, .editFollowupInteractionResponse, .deleteFollowupInteractionResponse, .createMessage, .editMessage, .deleteMessage, .createApplicationGlobalCommand, .getApplicationGlobalCommands, .deleteApplicationGlobalCommand, .getGuild, .searchGuildMembers, .getGuildMember, .getChannel, .getChannelMessages, .getChannelMessage, .leaveGuild, .createGuildRole, .deleteGuildRole, .addGuildMemberRole, .removeGuildMemberRole, .getGuildAuditLogs, .addReaction, .createDM, .createWebhook, .getChannelWebhooks, .getGuildWebhooks, .getWebhook1, .modifyWebhook1, .deleteWebhook1: + case .getGateway, .getGatewayBot, .createInteractionResponse, .getInteractionResponse, .editInteractionResponse, .deleteInteractionResponse, .postFollowupInteractionResponse, .getFollowupInteractionResponse, .editFollowupInteractionResponse, .deleteFollowupInteractionResponse, .createMessage, .editMessage, .deleteMessage, .createApplicationGlobalCommand, .getApplicationGlobalCommands, .deleteApplicationGlobalCommand, .getGuild, .getGuildRoles, .searchGuildMembers, .getGuildMember, .getChannel, .getChannelMessages, .getChannelMessage, .leaveGuild, .createGuildRole, .deleteGuildRole, .addGuildMemberRole, .removeGuildMemberRole, .getGuildAuditLogs, .createReaction, .deleteOwnReaction, .deleteUserReaction, .getReactions, .deleteAllReactions, .deleteAllReactionsForEmoji, .createDM, .createWebhook, .getChannelWebhooks, .getGuildWebhooks, .getWebhook1, .modifyWebhook1, .deleteWebhook1: return true - case .getWebhook2, .modifyWebhook2, .deleteWebhook2, .executeWebhook, .getWebhookMessage, .editWebhookMessage, .deleteWebhookMessage: + case .getWebhook2, .modifyWebhook2, .deleteWebhook2, .executeWebhook, .getWebhookMessage, .editWebhookMessage, .deleteWebhookMessage, .CDNCustomEmoji, .CDNGuildIcon, .CDNGuildSplash, .CDNGuildDiscoverySplash, .CDNGuildBanner, .CDNUserBanner, .CDNDefaultUserAvatar, .CDNUserAvatar, .CDNGuildMemberAvatar, .CDNApplicationIcon, .CDNApplicationCover, .CDNApplicationAsset, .CDNAchievementIcon, .CDNStickerPackBanner, .CDNTeamIcon, .CDNSticker, .CDNRoleIcon, .CDNGuildScheduledEventCover, .CDNGuildMemberBanner: return false } } @@ -351,32 +535,57 @@ public enum Endpoint: Sendable { case .getApplicationGlobalCommands: return 15 case .deleteApplicationGlobalCommand: return 16 case .getGuild: return 17 - case .searchGuildMembers: return 18 - case .getGuildMember: return 19 - case .getChannel: return 20 - case .getChannelMessages: return 21 - case .getChannelMessage: return 22 - case .leaveGuild: return 23 - case .createGuildRole: return 24 - case .deleteGuildRole: return 25 - case .addGuildMemberRole: return 26 - case .removeGuildMemberRole: return 27 - case .getGuildAuditLogs: return 28 - case .addReaction: return 29 - case .createDM: return 30 - case .createWebhook: return 31 - case .getChannelWebhooks: return 32 - case .getGuildWebhooks: return 33 - case .getWebhook1: return 34 - case .getWebhook2: return 35 - case .modifyWebhook1: return 36 - case .modifyWebhook2: return 37 - case .deleteWebhook1: return 38 - case .deleteWebhook2: return 39 - case .executeWebhook: return 40 - case .getWebhookMessage: return 41 - case .editWebhookMessage: return 42 - case .deleteWebhookMessage: return 43 + case .getGuildRoles: return 18 + case .searchGuildMembers: return 19 + case .getGuildMember: return 20 + case .getChannel: return 21 + case .getChannelMessages: return 22 + case .getChannelMessage: return 23 + case .leaveGuild: return 24 + case .createGuildRole: return 25 + case .deleteGuildRole: return 26 + case .addGuildMemberRole: return 27 + case .removeGuildMemberRole: return 28 + case .getGuildAuditLogs: return 29 + case .createReaction: return 30 + case .deleteOwnReaction: return 31 + case .deleteUserReaction: return 32 + case .getReactions: return 33 + case .deleteAllReactions: return 34 + case .deleteAllReactionsForEmoji: return 35 + case .createDM: return 36 + case .createWebhook: return 37 + case .getChannelWebhooks: return 38 + case .getGuildWebhooks: return 39 + case .getWebhook1: return 40 + case .getWebhook2: return 41 + case .modifyWebhook1: return 42 + case .modifyWebhook2: return 43 + case .deleteWebhook1: return 44 + case .deleteWebhook2: return 45 + case .executeWebhook: return 46 + case .getWebhookMessage: return 47 + case .editWebhookMessage: return 48 + case .deleteWebhookMessage: return 49 + case .CDNCustomEmoji: return 50 + case .CDNGuildIcon: return 51 + case .CDNGuildSplash: return 52 + case .CDNGuildDiscoverySplash: return 53 + case .CDNGuildBanner: return 54 + case .CDNUserBanner: return 55 + case .CDNDefaultUserAvatar: return 56 + case .CDNUserAvatar: return 57 + case .CDNGuildMemberAvatar: return 58 + case .CDNApplicationIcon: return 59 + case .CDNApplicationCover: return 60 + case .CDNApplicationAsset: return 61 + case .CDNAchievementIcon: return 62 + case .CDNStickerPackBanner: return 63 + case .CDNTeamIcon: return 64 + case .CDNSticker: return 65 + case .CDNRoleIcon: return 66 + case .CDNGuildScheduledEventCover: return 67 + case .CDNGuildMemberBanner: return 68 } } } diff --git a/Sources/DiscordClient/HTTP Models.swift b/Sources/DiscordClient/HTTP Models.swift index 6a1c1cbd..016be82a 100644 --- a/Sources/DiscordClient/HTTP Models.swift +++ b/Sources/DiscordClient/HTTP Models.swift @@ -1,4 +1,5 @@ @preconcurrency import AsyncHTTPClient +import DiscordModels import NIOHTTP1 import struct NIOCore.ByteBuffer import Foundation @@ -108,3 +109,31 @@ public struct DiscordClientResponse: Sendable where C: Codable { try httpResponse.decode(as: C.self) } } + +public struct DiscordCDNResponse: Sendable { + public let httpResponse: DiscordHTTPResponse + public let fallbackFileName: String + + public init(httpResponse: DiscordHTTPResponse, fallbackFileName: String) { + self.httpResponse = httpResponse + self.fallbackFileName = fallbackFileName + } + + @inlinable + public func guardIsSuccessfulResponse() throws { + try self.httpResponse.guardIsSuccessfulResponse() + } + + @inlinable + public func getFile(preferredName: String? = nil) throws -> RawFile { + try self.guardIsSuccessfulResponse() + guard let body = self.httpResponse.body else { + throw DiscordClientError.emptyBody(httpResponse) + } + guard let contentType = self.httpResponse.headers.first(name: "Content-Type") else { + throw DiscordClientError.noContentTypeHeader(httpResponse) + } + let name = preferredName ?? fallbackFileName + return RawFile(data: body, nameNoExtension: name, contentType: contentType) + } +} diff --git a/Sources/DiscordClient/HTTPRateLimiter.swift b/Sources/DiscordClient/HTTPRateLimiter.swift index cf02289f..8d954721 100644 --- a/Sources/DiscordClient/HTTPRateLimiter.swift +++ b/Sources/DiscordClient/HTTPRateLimiter.swift @@ -35,11 +35,16 @@ actor HTTPRateLimiter { self.reset = reset } - func shouldRequest() -> Bool { + func shouldRequest() -> ShouldRequestResponse { if remaining > 0 { - return true + return .true } else { - return reset < Date().timeIntervalSince1970 + let wait = reset - Date().timeIntervalSince1970 + if wait > 0 { + return .after(wait) + } else { + return .true + } } } @@ -128,7 +133,7 @@ actor HTTPRateLimiter { } } - private func addGlobalRateLimitRecord() { + func addGlobalRateLimitRecord() { let globalId = self.currentGlobalRateLimitId() if self.requestsThisSecond.id == globalId { self.requestsThisSecond.count += 1 @@ -137,31 +142,41 @@ actor HTTPRateLimiter { } } + enum ShouldRequestResponse { + case `true` + case `false` + /// Need to wait some seconds + case after(Double) + } + /// Should request to the endpoint or not. /// This also adds a record to the global rate-limit, so if this returns true, /// you should make sure the request is sent, or otherwise this rate-limiter's /// global rate-limit will be less than the max amount and might not allow you /// to make too many requests per second, when it should. - func shouldRequest(to endpoint: Endpoint) -> Bool { - guard minutelyInvalidRequestsLimitAllows() else { return false } + func shouldRequest(to endpoint: Endpoint) -> ShouldRequestResponse { + guard minutelyInvalidRequestsLimitAllows() else { return .false } if endpoint.countsAgainstGlobalRateLimit { - guard globalRateLimitAllows() else { return false } + guard globalRateLimitAllows() else { return .false } } if let bucketId = self.endpoints[endpoint.id], let bucket = self.buckets[bucketId] { - if bucket.shouldRequest() { + switch bucket.shouldRequest() { + case .true: self.addGlobalRateLimitRecord() - return true - } else { + return .true + case .false: return .false + case let .after(after): + /// Need to manually call `addGlobalRateLimitRecord()` when doing the request. logger.warning("Hit HTTP Bucket rate-limit.", metadata: [ "label": .string(label), "endpointId": .stringConvertible(endpoint.id), "bucket": .stringConvertible(bucket) ]) - return false + return .after(after) } } else { - return true + return .true } } diff --git a/Sources/DiscordGateway/BotGatewayManager.swift b/Sources/DiscordGateway/BotGatewayManager.swift index 53f2f430..52d00f1f 100644 --- a/Sources/DiscordGateway/BotGatewayManager.swift +++ b/Sources/DiscordGateway/BotGatewayManager.swift @@ -90,11 +90,9 @@ public actor BotGatewayManager: GatewayManager { /// - identifyPayload: The identification payload that is sent to Discord. public init( eventLoopGroup: EventLoopGroup, - httpClient: HTTPClient, client: any DiscordClient, maxFrameSize: Int = 1 << 31, compression: Bool = true, - appId: String? = nil, identifyPayload: Gateway.Identify ) { self.eventLoopGroup = eventLoopGroup @@ -275,12 +273,18 @@ public actor BotGatewayManager: GatewayManager { logger.debug("Will disconnect", metadata: [ "connectionId": .stringConvertible(self.connectionId.load(ordering: .relaxed)) ]) + if self._state.load(ordering: .relaxed) == .stopped { + logger.debug("Already disconnected", metadata: [ + "connectionId": .stringConvertible(self.connectionId.load(ordering: .relaxed)) + ]) + return + } self.connectionId.wrappingIncrement(ordering: .relaxed) - await connectionBackoff.resetTryCount() - self.closeWebSocket(ws: self.ws) self._state.store(.stopped, ordering: .relaxed) self.isFirstConnection = true + await connectionBackoff.resetTryCount() await self.sendQueue.reset() + self.closeWebSocket(ws: self.ws) } } @@ -566,7 +570,8 @@ extension BotGatewayManager { connectionId: UInt? = nil, tryCount: Int = 0 ) { - self.sendQueue.perform { [self] in + self.sendQueue.perform { [weak self] in + guard let self = self else { return } Task { if let connectionId = connectionId, self.connectionId.load(ordering: .relaxed) != connectionId { @@ -579,7 +584,7 @@ extension BotGatewayManager { do { data = try DiscordGlobalConfiguration.encoder.encode(payload) } catch { - logger.error("Could not encode payload. This is a library issue, please report on https://github.com/MahdiBM/DiscordBM/issues", metadata: [ + self.logger.error("Could not encode payload. This is a library issue, please report on https://github.com/MahdiBM/DiscordBM/issues", metadata: [ "payload": .string("\(payload)"), "opcode": .stringConvertible(opcode), "connectionId": .stringConvertible(self.connectionId.load(ordering: .relaxed)) @@ -594,7 +599,7 @@ extension BotGatewayManager { opcode: .init(encodedWebSocketOpcode: opcode)! ) } catch { - logger.error("Could not send payload through websocket", metadata: [ + self.logger.error("Could not send payload through websocket. This is warning if something goes wrong, not necessarily an error", metadata: [ "error": "\(error)", "payload": .string("\(payload)"), "opcode": .stringConvertible(opcode), @@ -602,7 +607,7 @@ extension BotGatewayManager { ]) } } else { - logger.warning("Trying to send through ws when a connection is not established", metadata: [ + self.logger.warning("Trying to send through ws when a connection is not established", metadata: [ "payload": .string("\(payload)"), "state": .stringConvertible(self._state.load(ordering: .relaxed)), "connectionId": .stringConvertible(self.connectionId.load(ordering: .relaxed)) diff --git a/Sources/DiscordGateway/DiscordCache.swift b/Sources/DiscordGateway/DiscordCache.swift index 6979c75d..86ce3ec0 100644 --- a/Sources/DiscordGateway/DiscordCache.swift +++ b/Sources/DiscordGateway/DiscordCache.swift @@ -6,39 +6,57 @@ import OrderedCollections @dynamicMemberLookup public actor DiscordCache { - public enum Intents: Sendable, ExpressibleByArrayLiteral { + public typealias Intents = AllOrSome + public typealias Guilds = AllOrSome + public typealias Channels = AllOrSome + + public enum AllOrSome: Sendable, ExpressibleByArrayLiteral + where Some: Hashable & Sendable { /// Will cache all events. case all /// Will cache the events related to the specified intents. - case specific(Set) + case some(Set) + + public init(arrayLiteral elements: Some...) { + self = .some(Set(elements)) + } - public init(arrayLiteral elements: Gateway.Intent...) { - self = .specific(Set(elements)) + public init(_ elements: S) where S: Sequence, S.Element == Some { + self = .some(.init(elements)) } - public init(_ elements: S) where S: Sequence, S.Element == Gateway.Intent { - self = .specific(.init(elements)) + func contains(_ value: Some) -> Bool { + switch self { + case .all: return true + case let .some(values): return values.contains(value) + } } } public enum RequestMembers: Sendable { case disabled /// Only requests members. - case enabled + case enabled(Guilds) /// Requests all members as well as their presences. - case enabledWithPresences + case enabledWithPresences(Guilds) - var isEnabled: Bool { + public static var enabled: RequestMembers { .enabled(.all) } + + public static var enabledWithPresences: RequestMembers { .enabledWithPresences(.all) } + + func isEnabled(for guildId: String) -> Bool { switch self { case .disabled: return false - case .enabled, .enabledWithPresences: return true + case let .enabled(guilds), let .enabledWithPresences(guilds): + return guilds.contains(guildId) } } - var wantsPresences: Bool { + func wantsPresences(for guildId: String) -> Bool { switch self { case .disabled, .enabled: return false - case .enabledWithPresences: return true + case let .enabledWithPresences(guilds): + return guilds.contains(guildId) } } } @@ -49,25 +67,37 @@ public actor DiscordCache { case `default` /// Caches messages, replaces edited messages with the new message, /// moves deleted messages to another property of the storage. - case saveDeleted + case saveDeleted(Guilds, Channels) /// Caches messages, replaces edited messages with the new message but moves old messages /// to another property of the storage, removes deleted messages from storage. - case saveEditHistory + case saveEditHistory(Guilds, Channels) /// Caches messages, replaces edited messages with the new message but moves old messages /// to another property of the storage, moves deleted messages to another property of /// the storage. - case saveEditHistoryAndDeleted + case saveEditHistoryAndDeleted(Guilds, Channels) + + public static var saveDeleted: MessageCachingPolicy { .saveDeleted(.all, .all) } + + public static var saveEditHistory: MessageCachingPolicy { .saveEditHistory(.all, .all) } - var shouldSaveDeleted: Bool { + public static var saveEditHistoryAndDeleted: MessageCachingPolicy { + .saveEditHistoryAndDeleted(.all, .all) + } + + func shouldSaveDeleted(guildId: String?, channelId: String) -> Bool { switch self { - case .saveDeleted, .saveEditHistoryAndDeleted: return true + case let .saveDeleted(guilds, channels), + let .saveEditHistoryAndDeleted(guilds, channels): + return guildId.map { guilds.contains($0) } ?? channels.contains(channelId) case .default, .saveEditHistory: return false } } - var shouldSaveHistory: Bool { + func shouldSaveHistory(guildId: String?, channelId: String) -> Bool { switch self { - case .saveEditHistory, .saveEditHistoryAndDeleted: return true + case let .saveEditHistory(guilds, channels), + let .saveEditHistoryAndDeleted(guilds, channels): + return guildId.map { guilds.contains($0) } ?? channels.contains(channelId) case .default, .saveDeleted: return false } } @@ -77,8 +107,8 @@ public actor DiscordCache { /// /// Note: The limit policy is applied with a small tolerance, so you can't count on /// the limits being applied right-away. Realistically this should not matter anyway, - /// as the point of this is just to preserve memory. - public enum ItemsLimitPolicy: @unchecked Sendable { + /// as the point of this is to just preserve memory. + public enum ItemsLimit: @unchecked Sendable { /// `guilds`, `channels` and `botUser` are intentionally excluded. /// For `guilds` and `channels`, Discord only sends a limited amount that are related @@ -99,7 +129,7 @@ public actor DiscordCache { case constant(Int) case custom([Path: Int]) - public static let `default` = ItemsLimitPolicy.constant(100_000) + public static let `default` = ItemsLimit.constant(100_000) func limit(for path: Path) -> Int? { switch self { @@ -120,7 +150,7 @@ public actor DiscordCache { return max(10, Int(powed)) case let .custom(custom): guard let minimum = custom.map(\.value).min() else { - fatalError("It's meaningless for 'ItemsLimitPolicy.custom' to be empty. Please use `ItemsLimitPolicy.disabled` instead") + fatalError("It's meaningless for 'ItemsLimit.custom' to be empty. Please use `ItemsLimit.disabled` instead") } let powed = pow(1/2, Double(minimum)) return max(10, Int(powed)) @@ -142,7 +172,7 @@ public actor DiscordCache { } } - /// Using `OrderedDictionary` for those which can be affected by the `ItemsLimitPolicy` + /// Using `OrderedDictionary` for those which can be affected by the `ItemsLimit` /// so we can remove the oldest items. /// `[GuildID: Guild]` @@ -172,6 +202,8 @@ public actor DiscordCache { public var autoModerationExecutions: OrderedDictionary = [:] /// `[CommandID (or ApplicationID): Permissions]` public var applicationCommandPermissions: OrderedDictionary = [:] + /// The current bot-application. + public var application: PartialApplication? /// The current bot user. public var botUser: DiscordUser? @@ -187,6 +219,7 @@ public actor DiscordCache { autoModerationRules: OrderedDictionary = [:], autoModerationExecutions: OrderedDictionary = [:], applicationCommandPermissions: OrderedDictionary = [:], + application: PartialApplication? = nil, botUser: DiscordUser? = nil ) { self.guilds = guilds @@ -200,6 +233,7 @@ public actor DiscordCache { self.autoModerationRules = autoModerationRules self.autoModerationExecutions = autoModerationExecutions self.applicationCommandPermissions = applicationCommandPermissions + self.application = application self.botUser = botUser } } @@ -218,7 +252,7 @@ public actor DiscordCache { /// How to cache messages. let messageCachingPolicy: MessageCachingPolicy /// Keeps the storage from using too much memory. Removes the oldest items. - let itemsLimitPolicy: ItemsLimitPolicy + let itemsLimit: ItemsLimit /// How often to check and enforce the limit above. private let checkForLimitEvery: Int /// The storage of cached stuff. @@ -242,40 +276,47 @@ public actor DiscordCache { /// parameter specifies. Must have `guildMembers` and `guildPresences` intents enabled /// depending on what you want. /// - messageCachingPolicy: How to cache messages. - /// - itemsLimitPolicy: Keeps the storage from using too much memory. Removes the oldest items. + /// - itemsLimit: Keeps the storage from using too much memory. Removes the oldest items. /// - storage: The storage of cached stuff. You usually don't need to provide this parameter. public init( gatewayManager: any GatewayManager, intents: Intents, requestAllMembers: RequestMembers, messageCachingPolicy: MessageCachingPolicy = .default, - itemsLimitPolicy: ItemsLimitPolicy = .default, + itemsLimit: ItemsLimit = .default, storage: Storage = Storage() ) async { self.gatewayManager = gatewayManager - self.intents = intents + self.intents = DiscordCache.calculateIntentsIntersection( + gatewayManager: gatewayManager, + intents: intents + ) self.requestMembers = requestAllMembers self.messageCachingPolicy = messageCachingPolicy - self.itemsLimitPolicy = itemsLimitPolicy - self.checkForLimitEvery = itemsLimitPolicy.calculateCheckForLimitEvery() + self.itemsLimit = itemsLimit + self.checkForLimitEvery = itemsLimit.calculateCheckForLimitEvery() self.storage = storage + await gatewayManager.addEventHandler(handleEvent) } private func handleEvent(_ event: Gateway.Event) { guard intentsAllowCaching(event: event) else { return } switch event.data { - case .none, .heartbeat, .identify, .hello, .ready, .resume, .resumed, .invalidSession, .requestGuildMembers, .requestPresenceUpdate, .requestVoiceStateUpdate, .interactionCreate: + case .none, .heartbeat, .identify, .hello, .resume, .resumed, .invalidSession, .requestGuildMembers, .requestPresenceUpdate, .requestVoiceStateUpdate, .interactionCreate: break + case let .ready(ready): + self.application = ready.application + self.botUser = ready.user case let .guildCreate(guildCreate): self.guilds[guildCreate.id] = guildCreate - if requestMembers.isEnabled { + if requestMembers.isEnabled(for: guildCreate.id) { Task { await gatewayManager.requestGuildMembersChunk(payload: .init( guild_id: guildCreate.id, query: "", limit: 0, - presences: requestMembers.wantsPresences, + presences: requestMembers.wantsPresences(for: guildCreate.id), user_ids: nil, nonce: nil )) @@ -525,7 +566,10 @@ public actor DiscordCache { if let idx = self.messages[message.channel_id]? .firstIndex(where: { $0.id == message.id }) { self.messages[message.channel_id]![idx].update(with: message) - if messageCachingPolicy.shouldSaveHistory { + if messageCachingPolicy.shouldSaveHistory( + guildId: message.guild_id, + channelId: message.channel_id + ) { self.editedMessages[message.channel_id, default: [:]][message.id, default: []].append( self.messages[message.channel_id]![idx] ) @@ -535,8 +579,15 @@ public actor DiscordCache { if let idx = self.messages[message.channel_id]? .firstIndex(where: { $0.id == message.id }) { let deleted = self.messages[message.channel_id]?.remove(at: idx) - if messageCachingPolicy.shouldSaveDeleted, let deleted = deleted { - if messageCachingPolicy.shouldSaveHistory { + if messageCachingPolicy.shouldSaveDeleted( + guildId: message.guild_id, + channelId: message.channel_id + ), + let deleted = deleted { + if messageCachingPolicy.shouldSaveHistory( + guildId: message.guild_id, + channelId: message.channel_id + ) { let history = self.editedMessages[message.channel_id]?[message.id] ?? [] self.deletedMessages[message.channel_id, default: [:]][message.id, default: []].append( contentsOf: history @@ -552,8 +603,14 @@ public actor DiscordCache { self.messages[bulkDelete.channel_id]?.removeAll { message in let shouldBeRemoved = bulkDelete.ids.contains(message.id) if shouldBeRemoved { - if messageCachingPolicy.shouldSaveDeleted { - if messageCachingPolicy.shouldSaveHistory { + if messageCachingPolicy.shouldSaveDeleted( + guildId: message.guild_id, + channelId: message.channel_id + ) { + if messageCachingPolicy.shouldSaveHistory( + guildId: message.guild_id, + channelId: message.channel_id + ) { let history = self.editedMessages[message.channel_id]?[message.id] ?? [] self.deletedMessages[message.channel_id, default: [:]][message.id, default: []].append( contentsOf: history @@ -661,7 +718,7 @@ public actor DiscordCache { switch intents { case .all: return true - case let .specific(intents): + case let .some(intents): let correspondingIntents = data.correspondingIntents if correspondingIntents.isEmpty { return true @@ -676,10 +733,10 @@ public actor DiscordCache { private var itemsLimitCounter = 0 private func checkItemsLimit() { - if case .disabled = itemsLimitPolicy { return } + if case .disabled = itemsLimit { return } itemsLimitCounter &+= 1 if itemsLimitCounter % checkForLimitEvery == 0 { - switch itemsLimitPolicy { + switch itemsLimit { case .disabled: return case let .constant(constant): if self.auditLogs.count > constant { @@ -768,6 +825,32 @@ public actor DiscordCache { } } + static func calculateIntentsIntersection( + gatewayManager manager: any GatewayManager, + intents: Intents + ) -> Intents { + if let managerIntents = (manager as? BotGatewayManager)?.identifyPayload.intents.values { + switch intents { + case .all: + return .some(managerIntents) + case let .some(intents): + let extraIntents = intents.filter { + !managerIntents.contains($0) + } + if !extraIntents.isEmpty { + DiscordGlobalConfiguration.makeLogger("DiscordCache").warning( + "'DiscordCache' contains intents that are not present in the 'BotGatewayManager'. This might cause events of some requested intents to not be cached", metadata: [ + "extraIntents": .stringConvertible(extraIntents) + ] + ) + } + return .some(intents.intersection(managerIntents)) + } + } else { + return intents + } + } + #if DEBUG func _tests_modifyStorage(_ block: (inout Storage) -> Void) { block(&self.storage) diff --git a/Sources/DiscordGateway/GatewayManager.swift b/Sources/DiscordGateway/GatewayManager.swift index 39b28f41..fb1224eb 100644 --- a/Sources/DiscordGateway/GatewayManager.swift +++ b/Sources/DiscordGateway/GatewayManager.swift @@ -11,7 +11,7 @@ public protocol DiscordActor: AnyObject { } #endif public protocol GatewayManager: DiscordActor { - /// A client to send requests to Discord. + /// The client to send requests to Discord with. nonisolated var client: any DiscordClient { get } /// This gateway manager's identifier. nonisolated var id: Int { get } diff --git a/Sources/DiscordGateway/ReactToRole.swift b/Sources/DiscordGateway/ReactToRole.swift new file mode 100644 index 00000000..316ac8e5 --- /dev/null +++ b/Sources/DiscordGateway/ReactToRole.swift @@ -0,0 +1,716 @@ +import DiscordModels +import DiscordClient +import Logging +#if os(Linux) +@preconcurrency import Foundation +#else +import Foundation +#endif + +/// Handles react-to-a-message-to-get-a-role. +public actor ReactToRoleHandler { + + /// This configuration must be codable-backward-compatible. + public struct Configuration: Sendable, Codable { + public let id: UUID + public var createRole: RequestBody.CreateGuildRole + public let guildId: String + public let channelId: String + public let messageId: String + public let reactions: [Reaction] + public let grantOnStart: Bool + fileprivate(set) public var roleId: String? + + /// - Parameters: + /// - id: The unique id of this configuration. + /// - createRole: The role-creation payload. + /// - guildId: The guild-id of the message. + /// - channelId: The channel-id of the message. + /// - messageId: The message-id. + /// - reactions: The reactions to grant the role for. + /// - grantOnStart: Grant the role to those who already reacted but don't have it, + /// on start. **NOTE**: Only recommended if you use a `DiscordCache` with `guilds` and + /// `guildMembers` intents enabled. Checking each member's roles requires an API request + /// and if you don't provide a cache, those API requests have a chance to overwhelm + /// Discord's rate-limits for you app. + /// - roleId: The role-id, only if it's already been created. + public init( + id: UUID, + createRole: RequestBody.CreateGuildRole, + guildId: String, + channelId: String, + messageId: String, + reactions: [Reaction], + grantOnStart: Bool = false, + roleId: String? = nil + ) { + self.id = id + self.createRole = createRole + self.guildId = guildId + self.channelId = channelId + self.messageId = messageId + self.reactions = reactions + self.grantOnStart = grantOnStart + self.roleId = roleId + } + + func hasChanges(comparedTo other: Configuration) -> Bool { + self.roleId != other.roleId || + self.createRole != other.createRole + } + } + + public enum Error: Swift.Error { + case messageIsInaccessible(messageId: String, channelId: String, previousError: Swift.Error) + case roleIsInaccessible(id: String, previousError: Swift.Error?) + } + + /// Handles the requests which can be done using either a cache (if available), or a client. + struct RequestHandler: Sendable { + let cache: DiscordCache? + let client: any DiscordClient + let logger: Logger + let guildId: String + let guildMembersEnabled: Bool + + init( + cache: DiscordCache?, + client: any DiscordClient, + logger: Logger, + guildId: String + ) { + self.cache = cache + self.client = client + self.logger = logger + self.guildId = guildId + self.guildMembersEnabled = cache?.requestMembers.isEnabled(for: guildId) ?? false + } + + func cacheWithIntents(_ intents: Gateway.Intent...) -> DiscordCache? { + if let cache = cache, + intents.allSatisfy({ cache.intents.contains($0) }) { + return cache + } else { + return nil + } + } + + func getRole(id: String) async throws -> Role { + if let cache = cacheWithIntents(.guilds) { + if let role = await cache.guilds[guildId]?.roles.first(where: { $0.id == id }) { + return role + } else { + throw Error.roleIsInaccessible(id: id, previousError: nil) + } + } else { + do { + if let role = try await client + .getGuildRoles(id: guildId) + .decode() + .first(where: { $0.id == id }) { + return role + } else { + throw Error.roleIsInaccessible(id: id, previousError: nil) + } + } catch { + throw Error.roleIsInaccessible(id: id, previousError: error) + } + } + } + + func getRoleIfExists(role: RequestBody.CreateGuildRole) async throws -> Role? { + if let cache = cacheWithIntents(.guilds) { + if let role = await cache.guilds[guildId]?.roles.first(where: { + $0.name == role.name && + $0.color == role.color && + $0.permissions.values.sorted(by: { $0.rawValue > $1.rawValue }) + == (role.permissions?.values ?? []).sorted(by: { $0.rawValue > $1.rawValue }) + }) { + return role + } else { + return nil + } + } else { + return try await client + .getGuildRoles(id: guildId) + .decode() + .first { + $0.name == role.name && + $0.color == role.color && + $0.permissions.values.sorted(by: { $0.rawValue > $1.rawValue }) + == (role.permissions?.values ?? []).sorted(by: { $0.rawValue > $1.rawValue }) + } + } + } + + func guildHasFeature(_ feature: Guild.Feature) async -> Bool { + if let cache = cacheWithIntents(.guilds) { + let guild = await cache.guilds[guildId] + return guild?.features.contains(feature) ?? false + } else { + do { + return try await client + .getGuild(id: guildId) + .decode() + .features + .contains(feature) + } catch { + logger.report(error: error) + return false + } + } + } + + /// Defaults to `true` if it can't know. + func memberHasRole(roleId: String, userId: String) async -> Bool { + if self.guildMembersEnabled, + let cache = cacheWithIntents(.guilds, .guildMembers) { + let guild = await cache.guilds[guildId] + return guild?.members + .first(where: { $0.user?.id == userId })? + .roles + .contains(roleId) ?? true + } else { + do { + return try await client.getGuildMember(guildId: guildId, userId: userId) + .decode() + .roles + .contains(roleId) + } catch { + logger.report(error: error) + return true + } + } + } + } + + /// The state of a `ReactToRoleHandler`. + public enum State { + /// The instance have just been created + case created + /// Completely working + case running + /// Stopped working + case stopped + } + + let gatewayManager: any GatewayManager + var client: any DiscordClient { gatewayManager.client } + let requestHandler: RequestHandler + var logger: Logger + /// `[Reaction: Set]` + /// Used to remove role from members only if they have no remaining acceptable reaction + /// the message. Also assign role only if this is their first acceptable reaction. + var currentReactions: [Reaction: Set] = [:] + /// To avoid role-creation race-conditions + var lockedCreatingRole = false + private(set) public var state = State.created + + /// The configuration. + /// + /// For persistence, you should save the `configuration` somewhere (It's `Codable`), + /// and reload it the next time you need it. + /// Using `onConfigurationChanged` you can get notified when `configuration` changes. + private(set) public var configuration: Configuration { + didSet { + if oldValue.hasChanges(comparedTo: self.configuration) { + Task { + await self.onConfigurationChanged?(self.configuration) + } + } + } + } + + let onConfigurationChanged: ((Configuration) async -> Void)? + let onLifecycleEnd: ((Configuration) async -> Void)? + + /// - Parameters: + /// - gatewayManager: The `GatewayManager`/`bot` to listen for events from. + /// - cache: The `DiscordCache`. Preferred to have, but not necessary. + /// - configuration: The configuration. + /// - onConfigurationChanged: Hook for getting notified of configuration changes. + /// - onLifecycleEnd: Hook for getting notified when this handler no longer serves a purpose. + /// For example when the target message is deleted. + public init( + gatewayManager: any GatewayManager, + cache: DiscordCache?, + configuration: Configuration, + onConfigurationChanged: ((Configuration) async -> Void)? = nil, + onLifecycleEnd: ((Configuration) async -> Void)? = nil + ) async throws { + self.gatewayManager = gatewayManager + self.logger = DiscordGlobalConfiguration.makeLogger("ReactToRole") + logger[metadataKey: "id"] = "\(configuration.id.uuidString)" + self.requestHandler = .init( + cache: cache, + client: gatewayManager.client, + logger: logger, + guildId: configuration.guildId + ) + self.configuration = configuration + self.onConfigurationChanged = onConfigurationChanged + self.onLifecycleEnd = onLifecycleEnd + await gatewayManager.addEventHandler(self.handleEvent) + try await self.verify_populateReactions_start_react() + } + + /// Note: The role will be created only if a role matching the + /// name, color and permissions doesn't exist. + /// + /// - Parameters: + /// - gatewayManager: The `GatewayManager`/`bot` to listen for events from. + /// - cache: The `DiscordCache`. Preferred to have, but not necessary. + /// - roleName: The name of the role you want to be assigned. + /// - roleUnicodeEmoji: The role-emoji. Only affects guilds with the `roleIcons` feature. + /// - roleColor: The color of the role. + /// - rolePermissions: The permissions the role should have. + /// - guildId: The guild id. + /// - channelId: The channel id where the message exists. + /// - messageId: The message id. + /// - grantOnStart: Grant the role to those who already reacted but don't have it, + /// on start. **NOTE**: Only recommended if you use a `DiscordCache` with `guilds` and + /// `guildMembers` intents enabled. Checking each member's roles requires an API request + /// and if you don't provide a cache, those API requests have a chance to overwhelm + /// Discord's rate-limits for you app. + /// - reactions: What reactions to get the role with. + /// - onConfigurationChanged: Hook for getting notified of configuration changes. + /// - onLifecycleEnd: Hook for getting notified when this handler no longer serves a purpose. + /// For example when the target message is deleted. + public init( + gatewayManager: any GatewayManager, + cache: DiscordCache?, + role: RequestBody.CreateGuildRole, + guildId: String, + channelId: String, + messageId: String, + grantOnStart: Bool = false, + reactions: [Reaction], + onConfigurationChanged: ((Configuration) async -> Void)? = nil, + onLifecycleEnd: ((Configuration) async -> Void)? = nil + ) async throws { + self.gatewayManager = gatewayManager + let id = UUID() + self.logger = DiscordGlobalConfiguration.makeLogger("ReactToRole") + logger[metadataKey: "id"] = "\(id.uuidString)" + self.requestHandler = .init( + cache: cache, + client: gatewayManager.client, + logger: logger, + guildId: guildId + ) + self.configuration = .init( + id: id, + createRole: role, + guildId: guildId, + channelId: channelId, + messageId: messageId, + reactions: reactions, + grantOnStart: grantOnStart, + roleId: nil + ) + self.onConfigurationChanged = onConfigurationChanged + self.onLifecycleEnd = onLifecycleEnd + await gatewayManager.addEventHandler(self.handleEvent) + try await self.verify_populateReactions_start_react() + } + + /// - Parameters: + /// - gatewayManager: The `GatewayManager`/`bot` to listen for events from. + /// - cache: The `DiscordCache`. Preferred to have, but not necessary. + /// - existingRoleId: Existing role-id to assign. + /// - guildId: The guild id. + /// - channelId: The channel id where the message exists. + /// - messageId: The message id. + /// - grantOnStart: Grant the role to those who already reacted but don't have it, + /// on start. **NOTE**: Only recommended if you use a `DiscordCache` with `guilds` and + /// `guildMembers` intents enabled. Checking each member's roles requires an API request + /// and if you don't provide a cache, those API requests have a chance to overwhelm + /// Discord's rate-limits for you app. + /// - reactions: What reactions to get the role with. + /// - onConfigurationChanged: Hook for getting notified of configuration changes. + /// - onLifecycleEnd: Hook for getting notified when this handler no longer serves a purpose. + /// For example when the target message is deleted. + public init( + gatewayManager: any GatewayManager, + cache: DiscordCache?, + existingRoleId: String, + guildId: String, + channelId: String, + messageId: String, + grantOnStart: Bool = false, + reactions: [Reaction], + onConfigurationChanged: ((Configuration) async -> Void)? = nil, + onLifecycleEnd: ((Configuration) async -> Void)? = nil + ) async throws { + self.gatewayManager = gatewayManager + let id = UUID() + self.logger = DiscordGlobalConfiguration.makeLogger("ReactToRole") + logger[metadataKey: "id"] = "\(id.uuidString)" + self.requestHandler = .init( + cache: cache, + client: gatewayManager.client, + logger: logger, + guildId: guildId + ) + let role = try await self.requestHandler.getRole(id: existingRoleId) + let createRole = try await RequestBody.CreateGuildRole( + role: role, + client: gatewayManager.client + ) + self.configuration = .init( + id: id, + createRole: createRole, + guildId: guildId, + channelId: channelId, + messageId: messageId, + reactions: reactions, + grantOnStart: grantOnStart, + roleId: role.id + ) + self.onConfigurationChanged = onConfigurationChanged + self.onLifecycleEnd = onLifecycleEnd + await gatewayManager.addEventHandler(self.handleEvent) + try await self.verify_populateReactions_start_react() + } + + private func handleEvent(_ event: Gateway.Event) { + guard state == .running else { return } + switch event.data { + case let .messageReactionAdd(payload): + self.onReactionAdd(payload) + case let .messageReactionRemove(payload): + self.onReactionRemove(payload) + case let .messageReactionRemoveAll(payload): + self.onReactionRemoveAll(payload) + case let .messageReactionRemoveEmoji(payload): + self.onReactionRemoveEmoji(payload) + case let .guildRoleCreate(payload): + self.onRoleCreate(payload) + case let .guildRoleDelete(payload): + self.onRoleDelete(payload) + case let .messageDelete(payload): + self.onMessageDelete(payload) + case let .guildDelete(payload): + self.onGuildDelete(payload) + default: break + } + } + + /// Stop responding to gateway events. + public func stop() { + self.state = .stopped + self.currentReactions.removeAll() + } + + /// Re-start responding to gateway events. + public func restart() async throws { + try await self.verify_populateReactions_start_react() + } + + func endLifecycle() { + self.state = .stopped + self.currentReactions.removeAll() + Task { + await self.onLifecycleEnd?(self.configuration) + } + } + + func onReactionAdd(_ reaction: Gateway.MessageReactionAdd) { + if reaction.message_id == self.configuration.messageId, + reaction.channel_id == self.configuration.channelId, + reaction.guild_id == self.configuration.guildId, + self.configuration.reactions.contains(where: { $0.is(reaction.emoji) }) { + Task { + do { + let emojiReaction = try Reaction(emoji: reaction.emoji) + let alreadyReacted = self.currentReactions.values + .contains(where: { $0.contains(reaction.user_id) }) + self.currentReactions[emojiReaction, default: []].insert(reaction.user_id) + if !alreadyReacted { + await self.addRoleToMember(userId: reaction.user_id) + } + } catch { + logger.report(error: error) + } + } + } + } + + func onReactionRemove(_ reaction: Gateway.MessageReactionRemove) { + if reaction.message_id == self.configuration.messageId, + reaction.channel_id == self.configuration.channelId, + reaction.guild_id == self.configuration.guildId, + self.configuration.reactions.contains(where: { $0.is(reaction.emoji) }), + self.configuration.roleId != nil { + self.checkAndRemoveRoleFromUser(emoji: reaction.emoji, userId: reaction.user_id) + } + } + + func checkAndRemoveRoleFromUser(emoji: PartialEmoji, userId: String) { + Task { + do { + let emojiReaction = try Reaction(emoji: emoji) + if let idx = self.currentReactions[emojiReaction]? + .firstIndex(of: userId) { + self.currentReactions[emojiReaction]?.remove(at: idx) + } + /// If there is no acceptable reaction remaining, remove the role from the user. + if !currentReactions.values.contains(where: { $0.contains(userId) }) { + await self.removeRoleFromMember(userId: userId) + } + } catch { + logger.report(error: error) + } + } + } + + func onReactionRemoveAll(_ payload: Gateway.MessageReactionRemoveAll) { + if payload.message_id == self.configuration.messageId, + payload.channel_id == self.configuration.channelId, + payload.guild_id == self.configuration.guildId { + /// Doesn't remove roles + self.currentReactions.removeAll() + } + } + + func onReactionRemoveEmoji(_ payload: Gateway.MessageReactionRemoveEmoji) { + if payload.message_id == self.configuration.messageId, + payload.channel_id == self.configuration.channelId, + payload.guild_id == self.configuration.guildId { + do { + let reaction = try Reaction(emoji: payload.emoji) + self.currentReactions.removeValue(forKey: reaction) + } catch { + logger.report(error: error) + } + } + } + + func onRoleCreate(_ role: Gateway.GuildRole) { + if self.configuration.roleId == nil, + role.guild_id == self.configuration.guildId, + role.role.name == self.configuration.createRole.name { + Task { + do { + self.configuration.createRole = try await .init( + role: role.role, + client: client + ) + self.configuration.roleId = role.role.id + } catch { + logger.report(error: error) + } + } + } + } + + func onRoleDelete(_ role: Gateway.GuildRoleDelete) { + if let roleId = self.configuration.roleId, + role.guild_id == self.configuration.guildId, + role.role_id == roleId { + self.configuration.roleId = nil + } + } + + func onMessageDelete(_ message: Gateway.MessageDelete) { + if message.id == self.configuration.messageId, + message.channel_id == self.configuration.channelId, + message.guild_id == self.configuration.guildId { + self.endLifecycle() + } + } + + func onGuildDelete(_ guild: UnavailableGuild) { + if guild.id == self.configuration.guildId { + self.endLifecycle() + } + } + + func getRoleId() async -> String? { + if let roleId = self.configuration.roleId { + return roleId + } else { + await self.setOrCreateRole() + return self.configuration.roleId + } + } + + func addRoleToMember(userId: String) async { + guard userId != client.appId else { return } + guard let roleId = await getRoleId() else { + self.logger.warning("Can't get a role to grant the member", metadata: [ + "userId": .string(userId) + ]) + return + } + await self.addRoleToMember(roleId: roleId, userId: userId) + } + + func addRoleToMember(roleId: String, userId: String) async { + do { + try await client.addGuildMemberRole( + guildId: self.configuration.guildId, + userId: userId, + roleId: roleId + ).guardIsSuccessfulResponse() + } catch { + self.logger.report(error: error) + } + } + + func setOrCreateRole() async { + do { + if let role = try await requestHandler.getRoleIfExists( + role: self.configuration.createRole + ) { + self.configuration.createRole = try await .init( + role: role, + client: client + ) + self.configuration.roleId = role.id + } else { + await createRole() + } + } catch { + self.logger.report(error: error) + } + } + + func createRole() async { + guard !self.lockedCreatingRole else { return } + self.lockedCreatingRole = true + defer { self.lockedCreatingRole = false } + + var role = self.configuration.createRole + if await !requestHandler.guildHasFeature(.roleIcons) { + role.unicode_emoji = nil + role.icon = nil + } + do { + let role = try await client.createGuildRole( + guildId: self.configuration.guildId, + payload: role + ).decode() + self.configuration.createRole = try await .init( + role: role, + client: client + ) + self.configuration.roleId = role.id + } catch { + self.logger.report(error: error) + } + } + + func removeRoleFromMember(userId: String) async { + guard userId != client.appId, + let roleId = self.configuration.roleId + else { return } + do { + try await client.removeGuildMemberRole( + guildId: self.configuration.guildId, + userId: userId, + roleId: roleId + ).guardIsSuccessfulResponse() + } catch { + self.logger.report(error: error) + } + } + + func verify_populateReactions_start_react() async throws { + /// Verify message exists + let message: Gateway.MessageCreate + do { + message = try await client.getChannelMessage( + channelId: configuration.channelId, + messageId: configuration.messageId + ).decode() + } catch { + throw Error.messageIsInaccessible( + messageId: configuration.messageId, + channelId: configuration.channelId, + previousError: error + ) + } + /// Populate reactions + for reaction in self.configuration.reactions { + let reactionsUsers = try await client.getReactions( + channelId: self.configuration.channelId, + messageId: self.configuration.messageId, + emoji: reaction + ).decode() + self.currentReactions[reaction] = Set(reactionsUsers.map(\.id)) + } + /// Start taking action on Gateway events + self.state = .running + /// If members don't have the role, give it to them + if configuration.grantOnStart, + let roleId = await self.getRoleId() { + let users = self.currentReactions.values + .reduce(into: Set(), { $0.formUnion($1) }) + for user in users { + if await !requestHandler.memberHasRole(roleId: roleId, userId: user) { + await self.addRoleToMember(roleId: roleId, userId: user) + } + } + } + /// React to message + let me = message.reactions?.filter(\.me).map(\.emoji) ?? [] + let remaining = configuration.reactions.filter { reaction in + !me.contains(where: { reaction.is($0) }) + } + for reaction in remaining { + do { + try await client.createReaction( + channelId: self.configuration.channelId, + messageId: self.configuration.messageId, + emoji: reaction + ).guardIsSuccessfulResponse() + } catch { + self.logger.report(error: error) + } + } + } +} + +//MARK: + Logger +private extension Logger { + func report(error: Error, function: String = #function, line: UInt = #line) { + self.error("'ReactToRoleHandler' failed", metadata: [ + "error": "\(error)" + ], function: function, line: line) + } +} + +//MARK: + CreateGuildRole +private extension RequestBody.CreateGuildRole { + init(role: Role, client: any DiscordClient) async throws { + self = .init( + name: role.name, + permissions: Array(role.permissions.values), + color: role.color, + hoist: role.hoist, + icon: nil, + unicode_emoji: role.unicode_emoji, + mentionable: role.mentionable + ) + if let icon = role.icon { + let file = try await client.getCDNRoleIcon( + roleId: role.id, + icon: icon + ).getFile() + self.icon = .init(file: file) + } + } + + static func != (lhs: Self, rhs: Self) -> Bool { + !(lhs.name == rhs.name && + (lhs.permissions?.values ?? []).sorted(by: { $0.rawValue > $1.rawValue }) + == (rhs.permissions?.values ?? []).sorted(by: { $0.rawValue > $1.rawValue }) && + lhs.color == rhs.color && + lhs.hoist == rhs.hoist && + lhs.icon?.file.data == rhs.icon?.file.data && + lhs.icon?.file.filename == rhs.icon?.file.filename && + lhs.unicode_emoji == rhs.unicode_emoji && + lhs.mentionable == rhs.mentionable) + } +} diff --git a/Sources/DiscordGateway/SerialQueue.swift b/Sources/DiscordGateway/SerialQueue.swift index 7d9d55c4..d195d913 100644 --- a/Sources/DiscordGateway/SerialQueue.swift +++ b/Sources/DiscordGateway/SerialQueue.swift @@ -6,9 +6,6 @@ actor SerialQueue { var lastSend: Date let waitTime: TimeAmount - var logger: Logger { - DiscordGlobalConfiguration.makeLogger("DiscordSerialQueue") - } init(waitTime: TimeAmount) { /// Setting `lastSend` to sometime in the past that is not way too far. @@ -48,9 +45,10 @@ actor SerialQueue { do { try await Task.sleep(nanoseconds: UInt64(wait.nanoseconds)) } catch { - logger.warning("Unexpected SerialQueue failure", metadata: [ - "error": "\(error)" - ]) + DiscordGlobalConfiguration.makeLogger("DiscordSerialQueue").warning( + "Unexpected SerialQueue failure", + metadata: [ "error": "\(error)"] + ) return } self.perform(task) diff --git a/Sources/DiscordLogger/+DiscordGlobalConfiguration.swift b/Sources/DiscordLogger/+DiscordGlobalConfiguration.swift index 40e2660f..536c2e86 100644 --- a/Sources/DiscordLogger/+DiscordGlobalConfiguration.swift +++ b/Sources/DiscordLogger/+DiscordGlobalConfiguration.swift @@ -1,7 +1,7 @@ import DiscordCore extension DiscordGlobalConfiguration { - private static var _logManager: DiscordLogManager? + static var _logManager: DiscordLogManager? /// The manager of logging to Discord. /// You must initialize this, if you want to use `DiscordLogHandler`. diff --git a/Sources/DiscordLogger/+LoggingSystem.swift b/Sources/DiscordLogger/+LoggingSystem.swift index 763bfdef..18152249 100644 --- a/Sources/DiscordLogger/+LoggingSystem.swift +++ b/Sources/DiscordLogger/+LoggingSystem.swift @@ -16,7 +16,7 @@ extension LoggingSystem { level: Logger.Level = .info, metadataProvider: Logger.MetadataProvider? = nil, makeMainLogHandler: @escaping (String, Logger.MetadataProvider?) -> LogHandler - ) { + ) async { LoggingSystem._bootstrap({ label, metadataProvider in var otherHandler = makeMainLogHandler(label, metadataProvider) otherHandler.logLevel = level @@ -31,6 +31,8 @@ extension LoggingSystem { ]) return handler }, metadataProvider: metadataProvider) + /// If the log-manager is not yet set, then when it's set it'll use this new logger anyway. + await DiscordGlobalConfiguration._logManager?.renewFallbackLogger() } private static func _bootstrap( diff --git a/Sources/DiscordLogger/DiscordLogHandler.swift b/Sources/DiscordLogger/DiscordLogHandler.swift index a2dae41e..8085fb56 100644 --- a/Sources/DiscordLogger/DiscordLogHandler.swift +++ b/Sources/DiscordLogger/DiscordLogHandler.swift @@ -7,6 +7,7 @@ public struct DiscordLogHandler: LogHandler { /// The label of this log handler. public let label: String + let preparedLabel: String /// The address to send the logs to. let address: WebhookAddress /// See `LogHandler.metadata`. @@ -25,6 +26,7 @@ public struct DiscordLogHandler: LogHandler { metadataProvider: Logger.MetadataProvider? = nil ) { self.label = label + self.preparedLabel = prepare(label, maxCount: 100) self.address = address self.logLevel = level self.metadata = [:] @@ -89,29 +91,34 @@ public struct DiscordLogHandler: LogHandler { } let embed = Embed( - title: prepare("\(message)"), + title: prepare("\(message)", maxCount: 255), timestamp: Date(), color: config.colors[level], - footer: .init(text: prepare(self.label)), - fields: Array(allMetadata.sorted(by: { $0.key > $1.key }).compactMap { - key, value -> Embed.Field? in - let value = "\(value)" - if key.isEmpty || value.isEmpty { return nil } - return .init(name: prepare(key), value: prepare(value)) - }.maxCount(25)) + footer: .init(text: self.preparedLabel), + fields: Array(allMetadata.sorted(by: { $0.key > $1.key }) + .map({ (key: $0.key, value: "\($0.value)") }) + .filter({ !($0.key.isEmpty || $0.value.isEmpty) }) + .maxCount(25) + .map({ key, value in + Embed.Field( + name: prepare(key, maxCount: 25), + value: prepare(value, maxCount: 200) + ) + }) + ) ) Task { await logManager.include(address: address, embed: embed, level: level) } } - - private func prepare(_ text: String) -> String { - let escaped = DiscordUtils.escapingSpecialCharacters(text, forChannelType: .text) - return String(escaped.unicodeScalars.maxCount(250)) - } +} + +private func prepare(_ text: String, maxCount: Int) -> String { + let escaped = DiscordUtils.escapingSpecialCharacters(text, forChannelType: .text) + return String(escaped.unicodeScalars.maxCount(maxCount)) } private extension Collection { - func maxCount(_ count: Int) -> Self.SubSequence { + func maxCount(_ count: Int) -> SubSequence { let delta = (self.count - count) let dropCount = delta > 0 ? delta : 0 return self.dropLast(dropCount) diff --git a/Sources/DiscordLogger/DiscordLogManager.swift b/Sources/DiscordLogger/DiscordLogManager.swift index 408b53c8..1175dd71 100644 --- a/Sources/DiscordLogger/DiscordLogManager.swift +++ b/Sources/DiscordLogger/DiscordLogManager.swift @@ -63,7 +63,6 @@ public actor DiscordLogManager { let frequency: TimeAmount let aliveNotice: AliveNotice? - let fallbackLogger: Logger let mentions: [Logger.Level: [String]] let colors: [Logger.Level: DiscordColor] let excludeMetadata: Set @@ -75,7 +74,6 @@ public actor DiscordLogManager { /// - Parameters: /// - frequency: The frequency of the log-sendings. e.g. if its set to 30s, logs will only be sent once-in-30s. Should not be lower than 10s, because of Discord rate-limits. /// - aliveNotice: Configuration for sending "I am alive" messages every once in a while. Note that alive notices are delayed until it's been `interval`-time past last message. - /// - fallbackLogger: The logger to use when `DiscordLogger` errors. You should use a log handler that logs to a main place like stdout. /// e.g. `Logger(label: "Fallback", factory: StreamLogHandler.standardOutput(label:))` /// - mentions: ID of users/roles to be mentioned for each log-level. /// - colors: Color of the embeds to be used for each log-level. @@ -86,7 +84,6 @@ public actor DiscordLogManager { /// - maxStoredLogsCount: If there are more logs than this count, the log manager will start removing the oldest un-sent logs to prevent memory leaks. public init( frequency: TimeAmount = .seconds(20), - fallbackLogger: Logger, aliveNotice: AliveNotice? = nil, mentions: [Logger.Level: Mention] = [:], colors: [Logger.Level: DiscordColor] = [ @@ -98,14 +95,13 @@ public actor DiscordLogManager { .notice: .green, .info: .blue, ], - excludeMetadata: Set = [.trace], + excludeMetadata: Set = [], extraMetadata: Set = [], disabledLogLevels: Set = [], disabledInDebug: Bool = false, maxStoredLogsCount: Int = 1_000 ) { self.frequency = frequency - self.fallbackLogger = fallbackLogger self.aliveNotice = aliveNotice self.mentions = mentions.mapValues { $0.toMentionStrings() } self.colors = colors @@ -136,27 +132,34 @@ public actor DiscordLogManager { private var logs: [WebhookAddress: [Log]] = [:] private var sendLogsTasks: [WebhookAddress: Task] = [:] + var fallbackLogger = Logger(label: "DBM.LogManager") private var aliveNoticeTask: Task? public init( httpClient: HTTPClient, - configuration: Configuration + configuration: Configuration = Configuration() ) { /// Will only ever send requests to a webhook endpoint /// which doesn't need/use neither `token` nor `appId`. self.client = DefaultDiscordClient(httpClient: httpClient, token: "", appId: nil) self.configuration = configuration - Task { await self.startAliveNotices() } + Task { [weak self] in await self?.startAliveNotices() } } public init( client: any DiscordClient, - configuration: Configuration + configuration: Configuration = Configuration() ) { self.client = client self.configuration = configuration - Task { await self.startAliveNotices() } + Task { [weak self] in await self?.startAliveNotices() } + } + + /// To use after logging-system's bootstrap + /// Or if you want to change it to something else (do it after bootstrap or it'll be overridden) + public func renewFallbackLogger(to new: Logger? = nil) { + self.fallbackLogger = new ?? Logger(label: "DBM.LogManager") } func include(address: WebhookAddress, embed: Embed, level: Logger.Level) { @@ -172,17 +175,22 @@ public actor DiscordLogManager { #if DEBUG if configuration.disabledInDebug { return } #endif - if self.logs[address]?.isEmpty != false { + switch self.logs[address]?.isEmpty { + case .none: + self.logs[address] = [] setUpSendLogsTask(address: address) + case .some(true): + setUpSendLogsTask(address: address) + case .some(false): break } - self.logs[address, default: []].append(.init( + + self.logs[address]!.append(.init( embed: embed, level: level, isFirstAliveNotice: isFirstAliveNotice )) - let count = logs[address]!.count - if count > configuration.maxStoredLogsCount { + if logs[address]!.count > configuration.maxStoredLogsCount { logs[address]!.removeFirst() } } @@ -247,10 +255,10 @@ public actor DiscordLogManager { private func performLogSend(address: WebhookAddress) async throws { let logs = getMaxAmountOfLogsAndFlush(address: address) - try await sendLogs(logs, address: address) if self.logs[address]?.isEmpty != false { self.sendLogsTasks[address]?.cancel() } + try await sendLogs(logs, address: address) } private func getMaxAmountOfLogsAndFlush(address: WebhookAddress) -> [Log] { @@ -259,21 +267,14 @@ public actor DiscordLogManager { guard var iterator = self.logs[address]?.makeIterator() else { return [] } + func lengthSum() -> Int { goodLogs.map(\.embed.contentLength).reduce(into: 0, +=) } + while goodLogs.count < 10, let log = iterator.next(), - (goodLogs.map(\.embed.contentLength).reduce(into: 0, +=) + log.embed.contentLength) < 6_000 - { + (lengthSum() + log.embed.contentLength) <= 6_000 { goodLogs.append(log) } - /// Will get stuck if the first log is alone more than the limit length. - if goodLogs.isEmpty, (self.logs[address]?.first?.embed.contentLength ?? 0) > 6_000 { - let first = self.logs[address]!.removeFirst() - logWarning("First log alone is more than the limit length. This will not cause much problems but it is a library issue. Please report on https://github.com/MahdiBM/DiscordBM/issue with full context", - metadata: ["log": "\(first)"]) - return self.getMaxAmountOfLogsAndFlush(address: address) - } - self.logs[address] = Array(self.logs[address]?.dropFirst(goodLogs.count) ?? []) return goodLogs @@ -319,8 +320,7 @@ public actor DiscordLogManager { do { try response.guardIsSuccessfulResponse() } catch { - logWarning("Received error from Discord after sending logs. This is a library issue. Please report on https://github.com/MahdiBM/DiscordBM/issue with full context", - metadata: ["error": "\(error)", "payload": "\(payload)"]) + logWarning("Received error from Discord after sending logs. This might be a library issue. Please report on https://github.com/MahdiBM/DiscordBM/issue with full context", metadata: ["error": "\(error)", "payload": "\(payload)"]) } } @@ -330,7 +330,7 @@ public actor DiscordLogManager { function: String = #function, line: UInt = #line ) { - self.configuration.fallbackLogger.log( + self.fallbackLogger.log( level: .warning, message, metadata: metadata, @@ -345,5 +345,9 @@ public actor DiscordLogManager { func _tests_getLogs() -> [WebhookAddress: [Log]] { self.logs } + + func _tests_getMaxAmountOfLogsAndFlush(address: WebhookAddress) -> [Log] { + self.getMaxAmountOfLogsAndFlush(address: address) + } #endif } diff --git a/Sources/DiscordModels/Types/DiscordChannel.swift b/Sources/DiscordModels/Types/DiscordChannel.swift index 1f88a408..fe6dd109 100644 --- a/Sources/DiscordModels/Types/DiscordChannel.swift +++ b/Sources/DiscordModels/Types/DiscordChannel.swift @@ -282,42 +282,6 @@ extension DiscordChannel { } } -extension DiscordChannel { - /// An attachment object, but for sending. - /// https://discord.com/developers/docs/resources/channel#attachment-object - public struct AttachmentSend: Sendable, Codable, Validatable { - /// When sending, `id` is the index of this attachment in the `files` you provide. - public var id: String - public var filename: String? - public var description: String? - public var content_type: String? - public var size: Int? - public var url: String? - public var proxy_url: String? - public var height: Int? - public var width: Int? - public var ephemeral: Bool? - - /// `index` is the index of this attachment in the `files` you provide. - public init(index: UInt, filename: String? = nil, description: String? = nil, content_type: String? = nil, size: Int? = nil, url: String? = nil, proxy_url: String? = nil, height: Int? = nil, width: Int? = nil, ephemeral: Bool? = nil) { - self.id = "\(index)" - self.filename = filename - self.description = description - self.content_type = content_type - self.size = size - self.url = url - self.proxy_url = proxy_url - self.height = height - self.width = width - self.ephemeral = ephemeral - } - - public func validate() throws { - try validateCharacterCountDoesNotExceed(description, max: 1_024, name: "description") - } - } -} - extension DiscordChannel { /// Partial ``Channel.Message`` object. public struct PartialMessage: Sendable, Codable { @@ -574,7 +538,7 @@ public struct Embed: Sendable, Codable, Validatable { /// The length that matters towards the Discord limit (currently 6000 across all embeds). public var contentLength: Int { let fields = fields?.reduce(into: 0) { - $0 = $1.name.unicodeScalars.count + $1.value.unicodeScalars.count + $0 += $1.name.unicodeScalars.count + $1.value.unicodeScalars.count } ?? 0 return (title?.unicodeScalars.count ?? 0) + (description?.unicodeScalars.count ?? 0) + diff --git a/Sources/DiscordModels/Types/Emoji.swift b/Sources/DiscordModels/Types/Emoji.swift index 88f0d0d7..cfc22c64 100644 --- a/Sources/DiscordModels/Types/Emoji.swift +++ b/Sources/DiscordModels/Types/Emoji.swift @@ -24,3 +24,73 @@ public struct PartialEmoji: Sendable, Codable { self.version = version } } + +/// A reaction emoji. +public struct Reaction: Sendable, Hashable, Codable { + + private enum Base: Sendable, Codable, Hashable { + case unicodeEmoji(String) + case guildEmoji(name: String?, id: String) + } + + private let base: Base + + public var urlPathDescription: String { + switch self.base { + case let .unicodeEmoji(emoji): return emoji + case let .guildEmoji(name, id): return "\(name ?? ""):\(id)" + } + } + + private init(base: Base) { + self.base = base + } + + public init(from decoder: Decoder) throws { + self.base = try .init(from: decoder) + } + + public func encode(to encoder: Encoder) throws { + try self.base.encode(to: encoder) + } + + public enum Error: Swift.Error { + case moreThan1Emoji(String, count: Int) + case notEmoji(String) + case cantRecognizeEmoji(PartialEmoji) + } + + /// Unicode emoji. The function verifies that your input is an emoji or not. + public static func unicodeEmoji(_ emoji: String) throws -> Reaction { + guard emoji.count == 1 else { + throw Error.moreThan1Emoji(emoji, count: emoji.unicodeScalars.count) + } + guard emoji.unicodeScalars.contains(where: \.properties.isEmoji) else { + throw Error.notEmoji(emoji) + } + return Reaction(base: .unicodeEmoji(emoji)) + } + + /// Custom discord guild emoji. + public static func guildEmoji(name: String?, id: String) -> Reaction { + Reaction(base: .guildEmoji(name: name, id: id)) + } + + public init(emoji: PartialEmoji) throws { + if let id = emoji.id { + self = .guildEmoji(name: emoji.name, id: id) + } else if let name = emoji.name { + self = try .unicodeEmoji(name) + } else { + throw Error.cantRecognizeEmoji(emoji) + } + } + + /// Is the same as the partial emoji? + public func `is`(_ emoji: PartialEmoji) -> Bool { + switch self.base { + case let .unicodeEmoji(unicode): return unicode == emoji.name + case let .guildEmoji(_, id): return id == emoji.id + } + } +} diff --git a/Sources/DiscordModels/Types/Gateway.swift b/Sources/DiscordModels/Types/Gateway.swift index 4c76558a..fea3e845 100644 --- a/Sources/DiscordModels/Types/Gateway.swift +++ b/Sources/DiscordModels/Types/Gateway.swift @@ -149,10 +149,8 @@ public struct Gateway: Sendable, Codable { return [.guilds] case .channelPinsUpdate: return [.guilds, .directMessages] - case .threadMembersUpdate: + case .threadMembersUpdate, .guildMemberAdd, .guildMemberRemove, .guildMemberUpdate: return [.guilds, .guildMembers] - case .guildMemberAdd, .guildMemberRemove, .guildMemberUpdate: - return [.guildMembers] case .guildBanAdd, .guildBanRemove, .guildAuditLogEntryCreate: return [.guildModeration] case .guildEmojisUpdate, .guildStickersUpdate: @@ -536,7 +534,7 @@ public struct Gateway: Sendable, Codable { } /// https://discord.com/developers/docs/topics/gateway#gateway-intents - public enum Intent: Int, Sendable, Codable { + public enum Intent: Int, Sendable, Codable, CaseIterable { case guilds = 0 case guildMembers = 1 case guildModeration = 2 diff --git a/Sources/DiscordModels/Types/Guild.swift b/Sources/DiscordModels/Types/Guild.swift index 89c6e182..f026b436 100644 --- a/Sources/DiscordModels/Types/Guild.swift +++ b/Sources/DiscordModels/Types/Guild.swift @@ -79,6 +79,7 @@ public struct Guild: Sendable, Codable { case threeDayThreadArchive = "THREE_DAY_THREAD_ARCHIVE" case ticketedEventsEnabled = "TICKETED_EVENTS_ENABLED" case vanityURL = "VANITY_URL" + case guildWebPageVanityUrl = "GUILD_WEB_PAGE_VANITY_URL" case verified = "VERIFIED" case vipRegions = "VIP_REGIONS" case welcomeScreenEnabled = "WELCOME_SCREEN_ENABLED" diff --git a/Sources/DiscordModels/Types/RequestBody.swift b/Sources/DiscordModels/Types/RequestBody.swift index cae883ca..21852c70 100644 --- a/Sources/DiscordModels/Types/RequestBody.swift +++ b/Sources/DiscordModels/Types/RequestBody.swift @@ -15,6 +15,40 @@ public enum RequestBody { public func validate() throws { } } + /// An attachment object, but for sending. + /// https://discord.com/developers/docs/resources/channel#attachment-object + public struct AttachmentSend: Sendable, Codable, Validatable { + /// When sending, `id` is the index of this attachment in the `files` you provide. + public var id: String + public var filename: String? + public var description: String? + public var content_type: String? + public var size: Int? + public var url: String? + public var proxy_url: String? + public var height: Int? + public var width: Int? + public var ephemeral: Bool? + + /// `index` is the index of this attachment in the `files` you provide. + public init(index: UInt, filename: String? = nil, description: String? = nil, content_type: String? = nil, size: Int? = nil, url: String? = nil, proxy_url: String? = nil, height: Int? = nil, width: Int? = nil, ephemeral: Bool? = nil) { + self.id = "\(index)" + self.filename = filename + self.description = description + self.content_type = content_type + self.size = size + self.url = url + self.proxy_url = proxy_url + self.height = height + self.width = width + self.ephemeral = ephemeral + } + + public func validate() throws { + try validateCharacterCountDoesNotExceed(description, max: 1_024, name: "description") + } + } + /// https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object public struct InteractionResponse: Sendable, Codable, MultipartEncodable, Validatable { @@ -44,7 +78,7 @@ public enum RequestBody { public var allowedMentions: DiscordChannel.AllowedMentions? public var flags: IntBitField? public var components: [Interaction.ActionRow]? - public var attachments: [DiscordChannel.AttachmentSend]? + public var attachments: [AttachmentSend]? public var files: [RawFile]? enum CodingKeys: String, CodingKey { @@ -57,7 +91,7 @@ public enum RequestBody { case attachments } - public init(tts: Bool? = nil, content: String? = nil, embeds: [Embed]? = nil, allowedMentions: DiscordChannel.AllowedMentions? = nil, flags: [DiscordChannel.Message.Flag]? = nil, components: [Interaction.ActionRow]? = nil, attachments: [DiscordChannel.AttachmentSend]? = nil, files: [RawFile]? = nil) { + public init(tts: Bool? = nil, content: String? = nil, embeds: [Embed]? = nil, allowedMentions: DiscordChannel.AllowedMentions? = nil, flags: [DiscordChannel.Message.Flag]? = nil, components: [Interaction.ActionRow]? = nil, attachments: [AttachmentSend]? = nil, files: [RawFile]? = nil) { self.tts = tts self.content = content self.embeds = embeds @@ -177,6 +211,10 @@ public enum RequestBody { public var unicode_emoji: String? public var mentionable: Bool? + /// `icon` and `unicode_emoji` require `roleIcons` guild feature, + /// which most guild don't have. + /// No fields are required. If you send an empty payload, you'll get a basic role + /// with a name like "new role". public init(name: String? = nil, permissions: [Permission]? = nil, color: DiscordColor? = nil, hoist: Bool? = nil, icon: ImageData? = nil, unicode_emoji: String? = nil, mentionable: Bool? = nil) { self.name = name self.permissions = permissions.map { .init($0) } @@ -203,7 +241,7 @@ public enum RequestBody { public var components: [Interaction.ActionRow]? public var sticker_ids: [String]? public var files: [RawFile]? - public var attachments: [DiscordChannel.AttachmentSend]? + public var attachments: [AttachmentSend]? public var flags: IntBitField? enum CodingKeys: String, CodingKey { @@ -218,7 +256,7 @@ public enum RequestBody { case flags } - public init(content: String? = nil, nonce: StringOrInt? = nil, tts: Bool? = nil, embeds: [Embed]? = nil, allowed_mentions: DiscordChannel.AllowedMentions? = nil, message_reference: DiscordChannel.Message.MessageReference? = nil, components: [Interaction.ActionRow]? = nil, sticker_ids: [String]? = nil, files: [RawFile]? = nil, attachments: [DiscordChannel.AttachmentSend]? = nil, flags: [DiscordChannel.Message.Flag]? = nil) { + public init(content: String? = nil, nonce: StringOrInt? = nil, tts: Bool? = nil, embeds: [Embed]? = nil, allowed_mentions: DiscordChannel.AllowedMentions? = nil, message_reference: DiscordChannel.Message.MessageReference? = nil, components: [Interaction.ActionRow]? = nil, sticker_ids: [String]? = nil, files: [RawFile]? = nil, attachments: [AttachmentSend]? = nil, flags: [DiscordChannel.Message.Flag]? = nil) { self.content = content self.nonce = nonce self.tts = tts @@ -273,7 +311,7 @@ public enum RequestBody { public var allowed_mentions: DiscordChannel.AllowedMentions? public var components: [Interaction.ActionRow]? public var files: [RawFile]? - public var attachments: [DiscordChannel.AttachmentSend]? + public var attachments: [AttachmentSend]? enum CodingKeys: String, CodingKey { case content @@ -284,7 +322,7 @@ public enum RequestBody { case attachments } - public init(content: String? = nil, embeds: [Embed]? = nil, flags: [DiscordChannel.Message.Flag]? = nil, allowed_mentions: DiscordChannel.AllowedMentions? = nil, components: [Interaction.ActionRow]? = nil, files: [RawFile]? = nil, attachments: [DiscordChannel.AttachmentSend]? = nil) { + public init(content: String? = nil, embeds: [Embed]? = nil, flags: [DiscordChannel.Message.Flag]? = nil, allowed_mentions: DiscordChannel.AllowedMentions? = nil, components: [Interaction.ActionRow]? = nil, files: [RawFile]? = nil, attachments: [AttachmentSend]? = nil) { self.content = content self.embeds = embeds self.flags = flags.map { .init($0) } @@ -327,7 +365,7 @@ public enum RequestBody { public var allowed_mentions: DiscordChannel.AllowedMentions? public var components: [Interaction.ActionRow]? public var files: [RawFile]? - public var attachments: [DiscordChannel.AttachmentSend]? + public var attachments: [AttachmentSend]? public var flags: IntBitField? public var thread_name: String? @@ -344,7 +382,7 @@ public enum RequestBody { case thread_name } - public init(content: String? = nil, username: String? = nil, avatar_url: String? = nil, tts: Bool? = nil, embeds: [Embed]? = nil, allowed_mentions: DiscordChannel.AllowedMentions? = nil, components: [Interaction.ActionRow]? = nil, files: [RawFile]? = nil, attachments: [DiscordChannel.AttachmentSend]? = nil, flags: IntBitField? = nil, thread_name: String? = nil) { + public init(content: String? = nil, username: String? = nil, avatar_url: String? = nil, tts: Bool? = nil, embeds: [Embed]? = nil, allowed_mentions: DiscordChannel.AllowedMentions? = nil, components: [Interaction.ActionRow]? = nil, files: [RawFile]? = nil, attachments: [AttachmentSend]? = nil, flags: IntBitField? = nil, thread_name: String? = nil) { self.content = content self.username = username self.avatar_url = avatar_url @@ -436,7 +474,7 @@ public enum RequestBody { public var allowed_mentions: DiscordChannel.AllowedMentions? public var components: [Interaction.ActionRow]? public var files: [RawFile]? - public var attachments: [DiscordChannel.AttachmentSend]? + public var attachments: [AttachmentSend]? enum CodingKeys: String, CodingKey { case content @@ -446,7 +484,7 @@ public enum RequestBody { case attachments } - public init(content: String? = nil, embeds: [Embed]? = nil, allowed_mentions: DiscordChannel.AllowedMentions? = nil, components: [Interaction.ActionRow]? = nil, files: [RawFile]? = nil, attachments: [DiscordChannel.AttachmentSend]? = nil) { + public init(content: String? = nil, embeds: [Embed]? = nil, allowed_mentions: DiscordChannel.AllowedMentions? = nil, components: [Interaction.ActionRow]? = nil, files: [RawFile]? = nil, attachments: [AttachmentSend]? = nil) { self.content = content self.embeds = embeds self.allowed_mentions = allowed_mentions diff --git a/Sources/DiscordModels/Types/Shared.swift b/Sources/DiscordModels/Types/Shared.swift index bee4f1ed..2240a927 100644 --- a/Sources/DiscordModels/Types/Shared.swift +++ b/Sources/DiscordModels/Types/Shared.swift @@ -624,7 +624,7 @@ extension TolerantDecodeDate: @unchecked Sendable { } #endif /// A dynamic color type that decode/encodes itself as an integer which Discord expects. -public struct DiscordColor: Sendable, Codable, ExpressibleByIntegerLiteral { +public struct DiscordColor: Sendable, Codable, Equatable, ExpressibleByIntegerLiteral { public let value: Int diff --git a/Sources/DiscordModels/Utils/MultipartEncodable.swift b/Sources/DiscordModels/Utils/MultipartEncodable.swift index e9305456..d3f366ee 100644 --- a/Sources/DiscordModels/Utils/MultipartEncodable.swift +++ b/Sources/DiscordModels/Utils/MultipartEncodable.swift @@ -73,6 +73,22 @@ public struct RawFile: Sendable, Encodable, MultipartPartConvertible { self.filename = filename } + + /// - Parameters: + /// - data: The file's contents. + /// - nameNoExtension: The file's name without the extension. + /// - contentType: The content type header containing the file's extension. + public init(data: ByteBuffer, nameNoExtension: String, contentType: String) { + self.data = data + let format = fileExtensionMediaTypeMapping.first(where: { + "\($0.value.0)/\($0.value.1)" == contentType + }) ?? fileExtensionMediaTypeMapping.first(where: { + $0.key == contentType + }) + let `extension` = format.map({ ".\($0.key)" }) ?? "" + self.filename = nameNoExtension + `extension` + } + public var multipart: MultipartPart? { var part = MultipartPart(headers: [:], body: .init(self.data.readableBytesView)) if let type = type { diff --git a/Tests/DiscordBMTests/DiscordCache.swift b/Tests/DiscordBMTests/DiscordCache.swift index 42cb4a39..3f4d2b12 100644 --- a/Tests/DiscordBMTests/DiscordCache.swift +++ b/Tests/DiscordBMTests/DiscordCache.swift @@ -10,7 +10,7 @@ class DiscordCacheTests: XCTestCase { gatewayManager: FakeGatewayManager(), intents: .all, requestAllMembers: .enabledWithPresences, - itemsLimitPolicy: .constant(10), + itemsLimit: .constant(10), storage: storage ) diff --git a/Tests/DiscordBMTests/DiscordLogger.swift b/Tests/DiscordBMTests/DiscordLogger.swift index 7c818b63..3b1398cf 100644 --- a/Tests/DiscordBMTests/DiscordLogger.swift +++ b/Tests/DiscordBMTests/DiscordLogger.swift @@ -27,8 +27,7 @@ class DiscordLoggerTests: XCTestCase { DiscordGlobalConfiguration.logManager = DiscordLogManager( client: self.client, configuration: .init( - frequency: .seconds(1), - fallbackLogger: Logger(label: "", factory: SwiftLogNoOpLogHandler.init), + frequency: .seconds(5), mentions: [ .trace: .role("33333333"), .notice: .user("22222222"), @@ -44,18 +43,18 @@ class DiscordLoggerTests: XCTestCase { ) logger.log(level: .trace, "Testing!") /// To make sure logs arrive in order. - try await Task.sleep(nanoseconds: 100_000_000) + try await Task.sleep(nanoseconds: 50_000_000) logger.log(level: .notice, "Testing! 2") /// To make sure logs arrive in order. - try await Task.sleep(nanoseconds: 100_000_000) + try await Task.sleep(nanoseconds: 50_000_000) logger.log(level: .notice, "Testing! 3", metadata: ["1": "2"]) /// To make sure logs arrive in order. - try await Task.sleep(nanoseconds: 100_000_000) + try await Task.sleep(nanoseconds: 50_000_000) logger.log(level: .warning, "Testing! 4") let expectation = XCTestExpectation(description: "log") self.client.expectation = expectation - wait(for: [expectation], timeout: 2) + wait(for: [expectation], timeout: 6) let anyPayload = self.client.payloads.first let payload = try XCTUnwrap(anyPayload as? RequestBody.ExecuteWebhook) @@ -72,7 +71,7 @@ class DiscordLoggerTests: XCTestCase { XCTAssertEqual(embed.title, "Testing!") let now = Date().timeIntervalSince1970 let timestamp = embed.timestamp?.date.timeIntervalSince1970 ?? 0 - XCTAssertTrue(((now-2)...(now+2)).contains(timestamp)) + XCTAssertTrue(((now-10)...(now+10)).contains(timestamp)) XCTAssertEqual(embed.color?.value, DiscordColor.brown.value) XCTAssertEqual(embed.footer?.text, "test") XCTAssertEqual(embed.fields?.count, 0) @@ -83,7 +82,7 @@ class DiscordLoggerTests: XCTestCase { XCTAssertEqual(embed.title, "Testing! 2") let now = Date().timeIntervalSince1970 let timestamp = embed.timestamp?.date.timeIntervalSince1970 ?? 0 - XCTAssertTrue(((now-2)...(now+2)).contains(timestamp)) + XCTAssertTrue(((now-10)...(now+10)).contains(timestamp)) XCTAssertEqual(embed.color?.value, DiscordColor.green.value) XCTAssertEqual(embed.footer?.text, "test") XCTAssertEqual(embed.fields?.count, 0) @@ -94,7 +93,7 @@ class DiscordLoggerTests: XCTestCase { XCTAssertEqual(embed.title, "Testing! 3") let now = Date().timeIntervalSince1970 let timestamp = embed.timestamp?.date.timeIntervalSince1970 ?? 0 - XCTAssertTrue(((now-2)...(now+2)).contains(timestamp)) + XCTAssertTrue(((now-10)...(now+10)).contains(timestamp)) XCTAssertEqual(embed.color?.value, DiscordColor.green.value) XCTAssertEqual(embed.footer?.text, "test") let fields = try XCTUnwrap(embed.fields) @@ -110,7 +109,7 @@ class DiscordLoggerTests: XCTestCase { XCTAssertEqual(embed.title, "Testing! 4") let now = Date().timeIntervalSince1970 let timestamp = embed.timestamp?.date.timeIntervalSince1970 ?? 0 - XCTAssertTrue(((now-2)...(now+2)).contains(timestamp)) + XCTAssertTrue(((now-10)...(now+10)).contains(timestamp)) XCTAssertEqual(embed.color?.value, DiscordColor.orange.value) XCTAssertEqual(embed.footer?.text, "test") } @@ -121,7 +120,6 @@ class DiscordLoggerTests: XCTestCase { client: self.client, configuration: .init( frequency: .milliseconds(100), - fallbackLogger: Logger(label: "", factory: SwiftLogNoOpLogHandler.init), excludeMetadata: [.trace] ) ) @@ -152,7 +150,6 @@ class DiscordLoggerTests: XCTestCase { client: self.client, configuration: .init( frequency: .milliseconds(100), - fallbackLogger: Logger(label: "", factory: SwiftLogNoOpLogHandler.init), disabledLogLevels: [.debug] ) ) @@ -184,7 +181,6 @@ class DiscordLoggerTests: XCTestCase { client: self.client, configuration: .init( frequency: .seconds(10), - fallbackLogger: Logger(label: "", factory: SwiftLogNoOpLogHandler.init), maxStoredLogsCount: 100 ) ) @@ -217,7 +213,6 @@ class DiscordLoggerTests: XCTestCase { client: self.client, configuration: .init( frequency: .milliseconds(100), - fallbackLogger: Logger(label: "", factory: SwiftLogNoOpLogHandler.init), disabledInDebug: true ) ) @@ -240,7 +235,6 @@ class DiscordLoggerTests: XCTestCase { client: self.client, configuration: .init( frequency: .milliseconds(100), - fallbackLogger: Logger(label: "", factory: SwiftLogNoOpLogHandler.init), extraMetadata: [.info] ) ) @@ -281,7 +275,6 @@ class DiscordLoggerTests: XCTestCase { client: self.client, configuration: .init( frequency: .milliseconds(100), - fallbackLogger: Logger(label: "", factory: SwiftLogNoOpLogHandler.init), extraMetadata: [.warning] ) ) @@ -324,8 +317,7 @@ class DiscordLoggerTests: XCTestCase { DiscordGlobalConfiguration.logManager = DiscordLogManager( client: self.client, configuration: .init( - frequency: .milliseconds(800), - fallbackLogger: Logger(label: "", factory: SwiftLogNoOpLogHandler.init), + frequency: .milliseconds(700), aliveNotice: .init( address: try .url(webhookUrl), interval: .seconds(6), @@ -427,10 +419,7 @@ class DiscordLoggerTests: XCTestCase { func testFrequency() async throws { DiscordGlobalConfiguration.logManager = DiscordLogManager( client: self.client, - configuration: .init( - frequency: .seconds(5), - fallbackLogger: Logger(label: "", factory: SwiftLogNoOpLogHandler.init) - ) + configuration: .init(frequency: .seconds(5)) ) let logger = DiscordLogHandler.multiplexLogger( @@ -512,15 +501,65 @@ class DiscordLoggerTests: XCTestCase { } } + /// This tests worst-case scenario of having too much text in the logs. + func testDoesNotExceedDiscordLengthLimits() async throws { + DiscordGlobalConfiguration.logManager = DiscordLogManager( + client: self.client, + configuration: .init(frequency: .seconds(60)) + ) + + let chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".map { $0 } + func longString() -> String { + String((0..<6_500).map { _ in chars[chars.indices.randomElement()!] }) + } + + let address = try WebhookAddress.url(webhookUrl) + let logger = DiscordLogHandler.multiplexLogger( + label: longString(), + address: address, + level: .trace, + makeMainLogHandler: { _, _ in SwiftLogNoOpLogHandler() } + ) + + func randomLevel() -> Logger.Level { Logger.Level.allCases.randomElement()! } + func longMessage() -> Logger.Message { + .init(stringLiteral: longString()) + } + func longMetadata() -> Logger.Metadata { + .init(uniqueKeysWithValues: (0..<50).map { _ in + (longString(), Logger.MetadataValue.string(longString())) + }) + } + + /// Wait for the log-manager to start basically. + try await Task.sleep(nanoseconds: 2_000_000_000) + + for _ in 0..<30 { + logger.log(level: randomLevel(), longMessage(), metadata: longMetadata()) + } + + /// To make sure the logs make it to the log-manager's storage. + try await Task.sleep(nanoseconds: 2_000_000_000) + + let all = await DiscordGlobalConfiguration.logManager._tests_getLogs()[address]! + XCTAssertEqual(all.count, 30) + for embed in all.map(\.embed) { + XCTAssertNoThrow(try embed.validate()) + } + + let logs = await DiscordGlobalConfiguration.logManager + ._tests_getMaxAmountOfLogsAndFlush(address: address) + XCTAssertEqual(logs.count, 1) + let lengthSum = logs.map(\.embed.contentLength).reduce(into: 0, +=) + XCTAssertEqual(lengthSum, 5_980) + } + func testBootstrap() async throws { DiscordGlobalConfiguration.logManager = DiscordLogManager( client: self.client, - configuration: .init( - frequency: .milliseconds(100), - fallbackLogger: Logger(label: "", factory: SwiftLogNoOpLogHandler.init) - ) + configuration: .init(frequency: .milliseconds(100)) ) - LoggingSystem.bootstrapWithDiscordLogger( + await LoggingSystem.bootstrapWithDiscordLogger( address: try .url(webhookUrl), level: .error, makeMainLogHandler: { _, _ in SwiftLogNoOpLogHandler() } @@ -547,10 +586,7 @@ class DiscordLoggerTests: XCTestCase { func testMetadataProviders() async throws { DiscordGlobalConfiguration.logManager = DiscordLogManager( client: self.client, - configuration: .init( - frequency: .milliseconds(100), - fallbackLogger: Logger(label: "", factory: SwiftLogNoOpLogHandler.init) - ) + configuration: .init(frequency: .milliseconds(100)) ) let simpleTraceIDMetadataProvider = Logger.MetadataProvider { guard let traceID = TraceNamespace.simpleTraceID else { @@ -558,7 +594,7 @@ class DiscordLoggerTests: XCTestCase { } return ["simple-trace-id": .string(traceID)] } - LoggingSystem.bootstrapWithDiscordLogger( + await LoggingSystem.bootstrapWithDiscordLogger( address: try .url(webhookUrl), metadataProvider: simpleTraceIDMetadataProvider, makeMainLogHandler: { _, _ in SwiftLogNoOpLogHandler() } @@ -592,7 +628,7 @@ class DiscordLoggerTests: XCTestCase { } } -private class FakeDiscordClient: DiscordClient { +private class FakeDiscordClient: DiscordClient, @unchecked Sendable { let appId: String? = "11111111" diff --git a/Tests/DiscordBMTests/DiscordModels.swift b/Tests/DiscordBMTests/DiscordModels.swift index d212f63e..284bddd3 100644 --- a/Tests/DiscordBMTests/DiscordModels.swift +++ b/Tests/DiscordBMTests/DiscordModels.swift @@ -187,6 +187,22 @@ class DiscordModelsTests: XCTestCase { XCTAssertEqual(address2.id, expectedId) XCTAssertEqual(address2.token, expectedToken) } + + func testReaction() throws { + XCTAssertNoThrow(try Reaction.unicodeEmoji("❤️")) + XCTAssertNoThrow(try Reaction.unicodeEmoji("✅")) + XCTAssertNoThrow(try Reaction.unicodeEmoji("🇮🇷")) + XCTAssertNoThrow(try Reaction.unicodeEmoji("❌")) + XCTAssertNoThrow(try Reaction.unicodeEmoji("🆔")) + XCTAssertNoThrow(try Reaction.unicodeEmoji("📲")) + XCTAssertNoThrow(try Reaction.unicodeEmoji("🛳️")) + XCTAssertNoThrow(try Reaction.unicodeEmoji("🧂")) + XCTAssertNoThrow(try Reaction.unicodeEmoji("🌫️")) + XCTAssertNoThrow(try Reaction.unicodeEmoji("👌🏿")) + XCTAssertNoThrow(try Reaction.unicodeEmoji("😀")) + XCTAssertThrowsError(try Reaction.unicodeEmoji("😀a")) + XCTAssertThrowsError(try Reaction.unicodeEmoji("😀😀")) + } } private let base64EncodedImageString = #"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAAOCAMAAAD+MweGAAADAFBMVEUAAAAAAFUAAKoAAP8AJAAAJFUAJKoAJP8ASQAASVUASaoASf8AbQAAbVUAbaoAbf8AkgAAklUAkqoAkv8AtgAAtlUAtqoAtv8A2wAA21UA26oA2/8A/wAA/1UA/6oA//8kAAAkAFUkAKokAP8kJAAkJFUkJKokJP8kSQAkSVUkSaokSf8kbQAkbVUkbaokbf8kkgAkklUkkqokkv8ktgAktlUktqoktv8k2wAk21Uk26ok2/8k/wAk/1Uk/6ok//9JAABJAFVJAKpJAP9JJABJJFVJJKpJJP9JSQBJSVVJSapJSf9JbQBJbVVJbapJbf9JkgBJklVJkqpJkv9JtgBJtlVJtqpJtv9J2wBJ21VJ26pJ2/9J/wBJ/1VJ/6pJ//9tAABtAFVtAKptAP9tJABtJFVtJKptJP9tSQBtSVVtSaptSf9tbQBtbVVtbaptbf9tkgBtklVtkqptkv9ttgBttlVttqpttv9t2wBt21Vt26pt2/9t/wBt/1Vt/6pt//+SAACSAFWSAKqSAP+SJACSJFWSJKqSJP+SSQCSSVWSSaqSSf+SbQCSbVWSbaqSbf+SkgCSklWSkqqSkv+StgCStlWStqqStv+S2wCS21WS26qS2/+S/wCS/1WS/6qS//+2AAC2AFW2AKq2AP+2JAC2JFW2JKq2JP+2SQC2SVW2Saq2Sf+2bQC2bVW2baq2bf+2kgC2klW2kqq2kv+2tgC2tlW2tqq2tv+22wC221W226q22/+2/wC2/1W2/6q2///bAADbAFXbAKrbAP/bJADbJFXbJKrbJP/bSQDbSVXbSarbSf/bbQDbbVXbbarbbf/bkgDbklXbkqrbkv/btgDbtlXbtqrbtv/b2wDb21Xb26rb2//b/wDb/1Xb/6rb////AAD/AFX/AKr/AP//JAD/JFX/JKr/JP//SQD/SVX/Sar/Sf//bQD/bVX/bar/bf//kgD/klX/kqr/kv//tgD/tlX/tqr/tv//2wD/21X/26r/2////wD//1X//6r////qm24uAAAA1ElEQVR42h1PMW4CQQwc73mlFJGCQChFIp0Rh0RBGV5AFUXKC/KPfCFdqryEgoJ8IX0KEF64q0PPnow3jT2WxzNj+gAgAGfvvDdCQIHoSnGYcGDE2nH92DoRqTYJ2bTcsKgqhIi47VdgAWNmwFSFA1UAAT2sSFcnq8a3x/zkkJrhaHT3N+hD3aH7ZuabGHX7bsSMhxwTJLr3evf1e0nBVcwmqcTZuatKoJaB7dSHjTZdM0G1HBTWefly//q2EB7/BEvk5vmzeQaJ7/xKPImpzv8/s4grhAxHl0DsqGUAAAAASUVORK5CYII="# diff --git a/Tests/DiscordBMTests/HTTPRateLimiter.swift b/Tests/DiscordBMTests/HTTPRateLimiter.swift index 2872979a..f956445b 100644 --- a/Tests/DiscordBMTests/HTTPRateLimiter.swift +++ b/Tests/DiscordBMTests/HTTPRateLimiter.swift @@ -29,7 +29,7 @@ class HTTPRateLimiterTests: XCTestCase { status: .ok ) let shouldRequest = await rateLimiter.shouldRequest(to: endpoint) - XCTAssertEqual(shouldRequest, true) + XCTAssertEqual(shouldRequest, .true) } func testBucketExhausted() async throws { @@ -47,7 +47,11 @@ class HTTPRateLimiterTests: XCTestCase { status: .ok ) let shouldRequest = await rateLimiter.shouldRequest(to: endpoint) - XCTAssertEqual(shouldRequest, false) + switch shouldRequest { + case .after: break + default: + XCTFail("\(shouldRequest) was not a '.after'") + } } /// Bucket is exhausted but the we've already past `x-ratelimit-reset`. @@ -66,7 +70,7 @@ class HTTPRateLimiterTests: XCTestCase { status: .ok ) let shouldRequest = await rateLimiter.shouldRequest(to: endpoint) - XCTAssertEqual(shouldRequest, true) + XCTAssertEqual(shouldRequest, .true) } func testBucketAllowsButReachedGlobalInvalidRequests() async throws { @@ -82,7 +86,7 @@ class HTTPRateLimiterTests: XCTestCase { /// Still only 499 invalid requests, so should allow requests. do { let shouldRequest = await rateLimiter.shouldRequest(to: endpoint) - XCTAssertEqual(shouldRequest, true) + XCTAssertEqual(shouldRequest, .true) } await rateLimiter.include( @@ -93,7 +97,7 @@ class HTTPRateLimiterTests: XCTestCase { /// Now 1000 invalid requests, so should NOT allow requests. do { let shouldRequest = await rateLimiter.shouldRequest(to: endpoint) - XCTAssertEqual(shouldRequest, false) + XCTAssertEqual(shouldRequest, .false) } } @@ -105,19 +109,19 @@ class HTTPRateLimiterTests: XCTestCase { /// Still only 49 requests, so should allow requests. do { let shouldRequest = await rateLimiter.shouldRequest(to: endpoint) - XCTAssertEqual(shouldRequest, true) + XCTAssertEqual(shouldRequest, .true) } /// Now 50 invalid requests, so should NOT allow requests. do { let shouldRequest = await rateLimiter.shouldRequest(to: endpoint) - XCTAssertEqual(shouldRequest, false) + XCTAssertEqual(shouldRequest, .false) } /// Interactions endpoints are not limited by the global rate limit, so should allow requests. do { let shouldRequest = await rateLimiter.shouldRequest(to: interactionEndpoint) - XCTAssertEqual(shouldRequest, true) + XCTAssertEqual(shouldRequest, .true) } } @@ -128,6 +132,20 @@ class HTTPRateLimiterTests: XCTestCase { status: .ok ) let shouldRequest = await rateLimiter.shouldRequest(to: endpoint) - XCTAssertEqual(shouldRequest, true) + XCTAssertEqual(shouldRequest, .true) + } +} + +extension HTTPRateLimiter.ShouldRequestResponse: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + if case .true = lhs, case .true = rhs { + return true + } else if case .false = lhs, case .false = rhs { + return true + } else if case let .after(lhs) = lhs, case let .after(rhs) = rhs { + return lhs == rhs + } else { + return false + } } } diff --git a/Tests/IntegrationTests/+XCTestCase.swift b/Tests/IntegrationTests/+XCTestCase.swift new file mode 100644 index 00000000..0c3666c2 --- /dev/null +++ b/Tests/IntegrationTests/+XCTestCase.swift @@ -0,0 +1,15 @@ +import XCTest + +extension XCTestCase { + func XCTAssertNoAsyncThrow( + _ block: () async throws -> Void, + file: StaticString = #filePath, + line: UInt = #line + ) async { + do { + try await block() + } catch { + XCTFail("Block threw: \(error)", file: file, line: line) + } + } +} diff --git a/Tests/IntegrationTests/Constants.swift b/Tests/IntegrationTests/Constants.swift index 927a31a4..242c9b97 100644 --- a/Tests/IntegrationTests/Constants.swift +++ b/Tests/IntegrationTests/Constants.swift @@ -17,4 +17,7 @@ enum Constants { static let perm2ChannelId = "1069614568466305135" static let perm3ChannelId = "1069615830851145798" static let secondAccountId = "966330655069843457" + static let reactionChannelId = "1073282726750330889" + static let threadsChannelId = "1074227452987052052" + static let threadId = "1074227495592800356" } diff --git a/Tests/IntegrationTests/DiscordClient.swift b/Tests/IntegrationTests/DiscordClient.swift index 53786988..93adea8c 100644 --- a/Tests/IntegrationTests/DiscordClient.swift +++ b/Tests/IntegrationTests/DiscordClient.swift @@ -1,4 +1,5 @@ @testable import DiscordBM +import DiscordClient import AsyncHTTPClient import Atomics import NIOCore @@ -18,8 +19,8 @@ class DiscordClientTests: XCTestCase { ) } - override func tearDown() { - try! self.httpClient.syncShutdown() + override func tearDown() async throws { + try await httpClient.shutdown() } /// Just here so you know. @@ -47,6 +48,20 @@ class DiscordClientTests: XCTestCase { } func testMessageSendDelete() async throws { + + /// Cleanup: Get channel messages and delete messages by the bot itself, if any + /// Makes this test resilient to failing because it has failed the last time + let allOldMessages = try await client.getChannelMessages( + channelId: Constants.channelId + ).decode() + + for message in allOldMessages where message.author?.id == Constants.botId { + try await client.deleteMessage( + channelId: message.channel_id, + messageId: message.id + ).guardIsSuccessfulResponse() + } + /// Create let text = "Testing! \(Date())" let message = try await client.createMessage( @@ -71,14 +86,58 @@ class DiscordClientTests: XCTestCase { XCTAssertEqual(edited.embeds.first?.description, newText) XCTAssertEqual(edited.channel_id, Constants.channelId) - /// Add Reaction - let reactionResponse = try await client.addReaction( + /// Add 4 Reactions + let reactions = ["🚀", "🤠", "👀", "❤️"] + for reaction in reactions { + let reactionResponse = try await client.createReaction( + channelId: Constants.channelId, + messageId: message.id, + emoji: .unicodeEmoji(reaction) + ) + + XCTAssertEqual(reactionResponse.status, .noContent) + } + + let deleteOwnReactionResponse = try await client.deleteOwnReaction( + channelId: Constants.channelId, + messageId: message.id, + emoji: .unicodeEmoji(reactions[0]) + ) + + XCTAssertEqual(deleteOwnReactionResponse.status, .noContent) + + try await client.deleteUserReaction( + channelId: Constants.channelId, + messageId: message.id, + emoji: .unicodeEmoji(reactions[1]), + userId: Constants.botId + ).guardIsSuccessfulResponse() + + let getReactionsResponse = try await client.getReactions( + channelId: Constants.channelId, + messageId: message.id, + emoji: .unicodeEmoji(reactions[2]) + ).decode() + + XCTAssertEqual(getReactionsResponse.count, 1) + + let reactionUser = try XCTUnwrap(getReactionsResponse.first) + XCTAssertEqual(reactionUser.id, Constants.botId) + + let deleteAllReactionsForEmojiResponse = try await client.deleteAllReactionsForEmoji( channelId: Constants.channelId, messageId: message.id, - emoji: "🚀" + emoji: .unicodeEmoji(reactions[2]) ) - XCTAssertEqual(reactionResponse.status, .noContent) + XCTAssertEqual(deleteAllReactionsForEmojiResponse.status, .noContent) + + let deleteAllReactionsResponse = try await client.deleteAllReactions( + channelId: Constants.channelId, + messageId: message.id + ) + + XCTAssertEqual(deleteAllReactionsResponse.status, .noContent) /// Get the message again let retrievedMessage = try await client.getChannelMessage( @@ -90,6 +149,7 @@ class DiscordClientTests: XCTestCase { XCTAssertEqual(retrievedMessage.content, edited.content) XCTAssertEqual(retrievedMessage.channel_id, edited.channel_id) XCTAssertEqual(retrievedMessage.embeds.first?.description, edited.embeds.first?.description) + XCTAssertFalse(retrievedMessage.reactions?.isEmpty == false) /// Get channel messages let allMessages = try await client.getChannelMessages( @@ -278,6 +338,12 @@ class DiscordClientTests: XCTestCase { XCTAssertEqual(role.unicode_emoji, rolePayload.unicode_emoji) XCTAssertEqual(role.mentionable, rolePayload.mentionable) + /// Get guild roles + let guildRoles = try await client.getGuildRoles(id: Constants.guildId).decode() + let rolesWithName = guildRoles.filter({ $0.name == role.name }) + XCTAssertGreaterThanOrEqual(rolesWithName.count, 1) + + /// Add role to member let memberRoleAdditionResponse = try await client.addGuildMemberRole( guildId: Constants.guildId, userId: Constants.personalId, @@ -333,6 +399,39 @@ class DiscordClientTests: XCTestCase { XCTAssertEqual(message.channel_id, response.id) } + func testThreads() async throws { + /// Create + let text = "Testing! \(Date())" + let message = try await client.createMessage( + channelId: Constants.threadId, + payload: .init(content: text) + ).decode() + + XCTAssertEqual(message.content, text) + XCTAssertEqual(message.channel_id, Constants.threadId) + + /// Edit + let newText = "Edit Testing! \(Date())" + let edited = try await client.editMessage( + channelId: Constants.threadId, + messageId: message.id, + payload: .init(embeds: [ + .init(description: newText) + ]) + ).decode() + + XCTAssertEqual(edited.content, text) + XCTAssertEqual(edited.embeds.first?.description, newText) + XCTAssertEqual(edited.channel_id, Constants.threadId) + + /// Delete + try await client.deleteMessage( + channelId: Constants.threadId, + messageId: message.id, + reason: "Random reason " + UUID().uuidString + ).guardIsSuccessfulResponse() + } + func testWebhooks() async throws { let image1 = ByteBuffer(data: resource(name: "discordbm-logo.png")) let image2 = ByteBuffer(data: resource(name: "1kb.png")) @@ -524,6 +623,157 @@ class DiscordClientTests: XCTestCase { XCTAssertNoThrow(try delete2.guardIsSuccessfulResponse()) } + /// Couldn't find test-cases for some of the functions + func testCDN() async throws { + await XCTAssertNoAsyncThrow { + let file = try await self.client.getCDNCustomEmoji( + emojiId: "1073704788400820324" + ).getFile() + XCTAssertGreaterThan(file.data.readableBytes, 10) + XCTAssertEqual(file.extension, "png") + XCTAssertEqual(file.filename, "1073704788400820324.png") + } + + await XCTAssertNoAsyncThrow { + let file = try await self.client.getCDNGuildIcon( + guildId: "922186320275722322", + icon: "a_6367dd2460a846748ad133206c910da5" + ).getFile(preferredName: "guildIcon") + XCTAssertGreaterThan(file.data.readableBytes, 10) + XCTAssertEqual(file.extension, "gif") + XCTAssertEqual(file.filename, "guildIcon.gif") + } + + await XCTAssertNoAsyncThrow { + let file = try await self.client.getCDNGuildSplash( + guildId: "922186320275722322", + splash: "276ba186b5208a74344706941eb7fe8d" + ).getFile() + XCTAssertGreaterThan(file.data.readableBytes, 10) + } + + await XCTAssertNoAsyncThrow { + let file = try await self.client.getCDNGuildDiscoverySplash( + guildId: "922186320275722322", + splash: "178be4921b08b761d9d9d6117c6864e2" + ).getFile() + XCTAssertGreaterThan(file.data.readableBytes, 10) + } + + await XCTAssertNoAsyncThrow { + let file = try await self.client.getCDNGuildBanner( + guildId: "922186320275722322", + banner: "6e2e4d93e102a997cc46d15c28b0dfa0" + ).getFile() + XCTAssertGreaterThan(file.data.readableBytes, 10) + } + +// await XCTAssertNoAsyncThrow { +// let file = try await client.getCDNUserBanner( +// userId: String, +// banner: String +// ).getFile() +// XCTAssertGreaterThan(file.data.readableBytes, 10) +// } + + await XCTAssertNoAsyncThrow { + let file = try await self.client.getCDNDefaultUserAvatar( + discriminator: 0517 + ).getFile() + XCTAssertGreaterThan(file.data.readableBytes, 10) + XCTAssertEqual(file.extension, "png") + } + + await XCTAssertNoAsyncThrow { + let file = try await self.client.getCDNUserAvatar( + userId: "290483761559240704", + avatar: "2df0a0198e00ba23bf2dc728c4db94d9" + ).getFile() + XCTAssertGreaterThan(file.data.readableBytes, 10) + } + + await XCTAssertNoAsyncThrow { + let file = try await client.getCDNGuildMemberAvatar( + guildId: "922186320275722322", + userId: "816681064855502868", + avatar: "b94e12ce3debd281000d5291eec2b502" + ).getFile() + XCTAssertGreaterThan(file.data.readableBytes, 10) + } + +// await XCTAssertNoAsyncThrow { +// let file = try await client.getCDNApplicationIcon( +// appId: String, icon: String +// ).getFile() +// XCTAssertGreaterThan(file.data.readableBytes, 10) +// } +// +// await XCTAssertNoAsyncThrow { +// let file = try await client.getCDNApplicationCover( +// appId: String, cover: String +// ).getFile() +// XCTAssertGreaterThan(file.data.readableBytes, 10) +// } + + await XCTAssertNoAsyncThrow { + let file = try await client.getCDNApplicationAsset( + appId: "401518684763586560", + assetId: "920476458709819483" + ).getFile() + XCTAssertGreaterThan(file.data.readableBytes, 10) + } + +// await XCTAssertNoAsyncThrow { +// let file = try await client.getCDNAchievementIcon( +// appId: String, achievementId: String, icon: String +// ).getFile() +// XCTAssertGreaterThan(file.data.readableBytes, 10) +// } + +// await XCTAssertNoAsyncThrow { +// let file = try await client.getCDNStickerPackBanner( +// assetId: String +// ).getFile() +// XCTAssertGreaterThan(file.data.readableBytes, 10) +// } + +// await XCTAssertNoAsyncThrow { +// let file = try await client.getCDNTeamIcon( +// teamId: String, icon: String +// ).getFile() +// XCTAssertGreaterThan(file.data.readableBytes, 10) +// } + + await XCTAssertNoAsyncThrow { + let file = try await self.client.getCDNSticker( + stickerId: "975144332535406633" + ).getFile() + XCTAssertGreaterThan(file.data.readableBytes, 10) + } + + await XCTAssertNoAsyncThrow { + let file = try await self.client.getCDNRoleIcon( + roleId: "984557789999407214", + icon: "2cba6c72f7abd52885359054e09ab7a2" + ).getFile() + XCTAssertGreaterThan(file.data.readableBytes, 10) + } + +// await XCTAssertNoAsyncThrow { +// let file = try await client.getCDNGuildScheduledEventCover( +// eventId: String, cover: String +// ).getFile() +// XCTAssertGreaterThan(file.data.readableBytes, 10) +// } +// +// await XCTAssertNoAsyncThrow { +// let file = try await client.getCDNGuildMemberBanner( +// guildId: String, userId: String, banner: String +// ).getFile() +// XCTAssertGreaterThan(file.data.readableBytes, 10) +// } + } + func testMultipartPayload() async throws { let image = ByteBuffer(data: resource(name: "discordbm-logo.png")) @@ -584,13 +834,20 @@ class DiscordClientTests: XCTestCase { let count = 50 let container = Container(targetCounter: count) + let client: any DiscordClient = DefaultDiscordClient( + httpClient: httpClient, + token: Constants.token, + appId: Constants.botId, + configuration: .init(retryPolicy: nil) + ) + let isFirstRequest = ManagedAtomic(false) Task { for _ in 0.. (any GatewayManager, DiscordCache) { + let bot = BotGatewayManager( + eventLoopGroup: httpClient.eventLoopGroup, + client: client ?? self.client, + compression: true, + identifyPayload: .init( + token: Constants.token, + presence: .init( + activities: [.init(name: "Testing!", type: .competing)], + status: .invisible, + afk: false + ), + intents: Gateway.Intent.allCases + ) + ) + + let expectation = expectation(description: "Connected") + + await bot.addEventHandler { event in + if case .ready = event.data { + expectation.fulfill() + } + } + + let cache = await DiscordCache( + gatewayManager: bot, + intents: .all, + requestAllMembers: .enabledWithPresences + ) + + Task { await bot.connect() } + wait(for: [expectation], timeout: 10) + + /// So cache is populated + try await Task.sleep(nanoseconds: 5_000_000_000) + + return (bot, cache) + } + + func debugDescription(_ roles: [Role]) -> String { + "\(roles.map({ (id: $0.id, name: $0.name) }))" + } +} + +private actor FakeGatewayManager: GatewayManager { + nonisolated let client: DiscordClient + nonisolated let id: Int = 0 + nonisolated let state: GatewayState = .stopped + func connect() async { } + func requestGuildMembersChunk(payload: Gateway.RequestGuildMembers) async { } + func updatePresence(payload: Gateway.Identify.Presence) async { } + func updateVoiceState(payload: VoiceStateUpdate) async { } + func addEventHandler(_ handler: @escaping (Gateway.Event) -> Void) async { } + func addEventParseFailureHandler(_ handler: @escaping (Error, ByteBuffer) -> Void) async { } + func disconnect() async { } + + init(client: DiscordClient) { + self.client = client + } +}