diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9ff147f..6533e89c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,21 +15,19 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Swift 6.2 + - name: Install Swift 6.2 (macOS / Ubuntu) + if: matrix.os != 'windows-latest' + uses: swift-actions/setup-swift@v2 + with: + swift-version: "6.2" + + - name: Install Swift 6.2 (Windows) + if: matrix.os == 'windows-latest' uses: compnerd/gha-setup-swift@main with: source: swift.org swift-version: swift-6.2-release swift-build: 6.2-RELEASE - - name: Run tests (Unix) - if: matrix.os != 'windows-latest' - run: | - chmod +x scripts/run-tests.sh || true - ./scripts/run-tests.sh - - - name: Run tests (Windows) - if: matrix.os == 'windows-latest' - shell: pwsh - run: | - .\scripts\run-tests.ps1 + - name: Run tests + run: swift test diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 90ca93e2..ba990f0c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,11 +20,9 @@ jobs: uses: actions/configure-pages@v4 - name: Install Swift 6.2 - uses: compnerd/gha-setup-swift@main + uses: swift-actions/setup-swift@v2 with: - source: swift.org - swift-version: swift-6.2-release - swift-build: 6.2-RELEASE + swift-version: "6.2" - name: Generate Documentation run: | diff --git a/.gitignore b/.gitignore index d6789b04..008992ef 100644 --- a/.gitignore +++ b/.gitignore @@ -83,5 +83,5 @@ zya/bug.txt Fails/ failed/ -audit-*.md +audit*.md GAP_ANALYSIS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b64b2d6b..2d95dd83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,112 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.0] - 2026-07-08 + +### Overview +SwiftDisc 2.6.0 is a developer-ergonomics and infrastructure release. This audit-driven release focuses on making the library easier to use at every API surface level while ensuring the underlying machinery is robust, testable, and future-proof. No voice support is planned or will ever be added. + +### Added +- **Documentation audit** — comprehensive doc comments added across Models (VanityURL, GuildWidgetSettings, GuildPreview, NewMemberWelcome, StageInstance, ScheduledEventUser, Template, RoleMemberCount, Application, AppInstallations, Monetization, Onboarding) +- **Gateway intent support** — `GatewayIntents` type and property on `DiscordConfiguration` for event subscription configuration + +### Changed +- **Unified builder pattern** — all builders now follow the non-mutating `var c = self; c.x = v; return c` pattern for consistency +- **`@discardableResult` audit** — consistently applied across all mutation/creation methods on `DiscordClient` +- **Method naming standardization** — established `get`/`list`/`create`/`update`/`delete`/`send` conventions across all REST endpoints +- **GatewayClient** — `close()` changed from `internal` to `public` for API symmetry with `disconnect()` +- **GatewayClient** — `validatePrivilegedIntents` renamed to `logPrivilegedIntentWarnings` for accuracy +- **HTTPClient** — multipart methods consolidated into shared `multipartRequest(method:)` helper eliminating ~90% duplication +- **HTTPClient** — `makeRouteKey` fixed to correctly identify major parameters for multi-segment paths +- **Cache** — emoji storage changed from `[GuildID: TimedValue<[Emoji]>]` to `[EmojiID: TimedValue]` for granular per-emoji eviction parity with roles +- **Cache** — `ensureChannelStub(id:)` now accepts an optional `ChannelType` parameter instead of always defaulting to `.text` +- **JSONCoders** — shared encoder/decoder now configurable via `DiscordConfiguration` +- **MessagePayload** — fixed field forwarding in `send(to:_:)`, `edit(channelId:messageId:_:)`, and `respond(to:with:deferred:)` — all fields now correctly forwarded regardless of file attachment presence +- **GatewayClient** — `resumeGatewayUrl` expiry corrected from 7 days to 24 hours to match Discord's actual expiration +- **GatewayClient** — `missedHeartbeatAckCount` now correctly managed only by the sending loop (not double-incremented on OP1-triggered heartbeats) +- **LabelBuilder, RadioGroupBuilder, CheckboxGroupBuilder, CheckboxBuilder, FileUploadBuilder** — marked as `@available(*, deprecated, message: "Not yet part of Discord API — experimental")` to prevent users from building rejected payloads +- **Snowflake** — changed from `@unchecked Sendable` to unconditional `extension Snowflake: Sendable {}` since `String` is inherently `Sendable` +- **RedactedToken** — `rawValue` access narrowed to `internal` for stronger token leak protection +- **Configuration** — `GatewayCompression` `.zstdStream` and `.zlibStream` cases added as opt-in (currently experimental) +- **Configuration** — `retryPolicy` property wired from `DiscordConfiguration` to `HTTPClient` +- **Configuration** — `presence` initial presence configuration (status, activities, afk) added +- **Configuration** — `userAgentSuffix` property added for custom User-Agent identification + +### Fixed +- **`Collectors` task leaks** — all 11 event collector methods now store and cancel their spawned tasks, preventing unbounded resource growth +- **`ComponentCollector` task leak** — same fix applied +- **`ViewManager` double-start race** — boolean flag set synchronously before spawning listening task +- **`ViewManager` detached task strong reference** — changed to `[weak self]` capture +- **`ViewManager` handler tasks** — grouped under a `TaskGroup` for collective cancellation +- **`ViewManager` repeated error-handling blocks** — extracted into shared helper method +- **`ViewManager.stop()`** — added public method to tear down listening task and unregister all views +- **`GatewayClient.connectReadyContinuation` double-resume** — guarded by `didResumeConnectReady` boolean flag +- **`GatewayClient.readLoop` decode error busy-loop** — added exponential backoff on decode failures +- **`GatewayClient` detached task cancellation** — `readLoop` task now cancelled when `connect()` task is cancelled +- **`HTTPClient.executeWithRetry` semaphore deadlock** — semaphore signal now uses direct continuation resume instead of `Task { ... }` in `defer {}` +- **`AsyncSemaphore` cancellation handling** — cancelled waiters now properly removed from the waiter list +- **`RateLimiter` global timestamp accounting** — timestamps now appended AFTER backoff completes, not before +- **`RateLimiter` duplicate header crash** — `lowercasedHeaders` changed to safe `reduce(into:)` pattern instead of `Dictionary(uniqueKeysWithValues:)` +- **`RateLimiter.clearBucket(routeKey:)** — now also clears the route-to-bucket mapping, preventing stale state +- **`DiscordClient.eventContinuation` data race** — `AsyncStream` continuation now initialized lazily from actor-isolated context +- **`DiscordClient` login task isolation** — `Task { [self] in }` changed to `Task.detached` with explicit `await` for all actor accesses +- **`DiscordClient._internalSetCurrentUserId` comment** — corrected from "Internal voice wiring" to "Internal: records the current bot user's ID after the READY event" +- **`MessagePayload.respond` 204 handling** — now accepts empty 204 No Content responses without crashing +- **`CommandRouter` quoted-argument parsing** — text commands now support `"hello world"` as single arguments +- **`EmbedBuilder` ISO8601DateFormatter** — now cached as a static let instead of allocating on every call +- **`Converters.discordOrange`** — corrected from `0xEB459E` (fuchsia) to `0xFEE75C` (Discord's actual orange) +- **`Converters` invite code validation** — expanded from 6-10 to 6-25 character range +- **`Converters` mention regexes** — tightened from `[0-9]{5,}` to `[0-9]{17,19}` to avoid false matches on non-ID numbers +- **`CooldownManager` auto-cleanup** — now starts lazily on first `setCooldown` call (was stuck at init) +- **`CooldownManager` compound key collision** — changed from `"\(command)::\(key)"` to `"\(command)\0\(key)"` with null-byte separator +- **`CooldownManager` strong reference cycle** — `cleanupTask` now uses `[weak self]` capture +- **`RetryPolicy` backoff jitter** — added `jitter: Double` (default 0.1 = 10% randomization) to prevent thundering herd +- **`Cache` message accumulation** — `EventDispatcher.messageUpdate` now updates in-place instead of appending +- **`JSONValue.number` precision** — large integer values (>2^53) now stored as `Int64` instead of `Double` to preserve snowflake precision +- **`OptionalField`** — added `Decodable` conformance for round-trip encoding/decoding support +- **`OptionalField` single-value encoder** — `.absent` now correctly omits the key in single-value containers (was encoding as `null`) +- **`WebhookClient` rate limiter** — changed from single global instance to per-instance rate limiter +- **`WebhookClient` hardcoded API version** — now reads from `DiscordConfiguration.apiVersion` +- **`DefaultsDiscordLogger` Sendable concurrency safety** — `print()` calls now wrapped with `await` for Swift 6 conformance +- **`StringSelectMenuBuilder` removed** — exact duplicate of `SelectMenuBuilder`; use `SelectMenuBuilder` directly +- **`User.username`** — changed from non-optional `String` to `String?` since Discord returns `null` for deleted users +- **`Interaction.version`** — changed from `Int?` to non-optional `Int` with default `1` +- **`SelectMenu.Option.emoji`** — changed from `String?` to `PartialEmoji?` (Discord API returns a partial emoji object, not a string) +- **`Webhook.type`, `Sticker.type`, `Sticker.format_type`, `StickerItem.format_type`, `Invite.type`, `Entitlement.type`** — migrated from raw `Int`/`Int?` to typed enums with `unknown` fallback cases +- **`Entitlement.subscription_id`** — changed from `String?` to `AppSubscriptionID` +- **`AppInstallation.id` and `AppSubscription.id`** — changed phantom type from the struct itself to dedicated tag types (`AppInstallationTag`, `AppSubscriptionTag`) +- **`UserPrimaryGuild.guild_id` and `identity_guild_id`** — changed from `String?` to `GuildID?` +- **`Invite.InviteChannel.type`** — changed from `Int?` to `ChannelType?` +- **`RadioGroup.RadioOption.init`** — parameter renamed from `isDefault` (stored as `default`) to `default` for naming consistency +- **`MessageComponents.defaultUsers()` and `defaultRoles()`** — now additive (accumulate) instead of each overwriting the previous value +- **`Message.referenced_message`** — `Box` conformance changed from `@unchecked Sendable` to conditional `extension Box: @unchecked Sendable where T: Sendable {}` +- **`PermissionBitset.all` mask** — updated to `(1 << 64) - 1` for forward-compatibility with new permission bits +- **`EventDispatcher` raw event** — now forwarded as `.raw(String, Data)` instead of silently discarded (`break`) +- **`RadioGroup` / `CheckboxGroup` / `Label` / `Checkbox`** `MessageComponent` decoding — unknown types now decoded as `.unknown(Data)` preserving raw data instead of lossy `.button` fallback +- **`Guild.init` compactMap** — changed to `map` since `GuildFeature.init(rawValue:)` never returns nil +- **`GatewayModels.Presence`** — populated from empty struct to full model with proper fields +- **`DiscordClient`** — split from single 4361-line file into domain-specific extensions (DiscordClient+Messages, +Guilds, +Channels, +Interactions, +Commands, +Monetization, +VoiceEvents) +- **`AHCTransport`** — changed from `@unchecked Sendable` to explicit `Sendable` conformance +- **`URLSessionTransport.LockedBox`** — replaced with `OSAllocatedUnfairLock` for safe concurrency +- **`URLSessionTransport.close()`** — now waits for close-frame handshake before calling `invalidateAndCancel()` +- **`AHCTransport.deinit`** — explicit `shutdown()` method added; `deinit` no longer calls `syncShutdown()` +- **`RegionRateLimiter`** — `ContinuousClock` used instead of `Date` for monotonic interval measurement +- **EventDispatcher** — added `onRawGatewayPayload` callback for low-level gateway access +- **EventDispatcher** — `sessionInvalidated` now clears the cache + +### Security +- **RedactedToken.rawValue** — narrowed to `internal` access, preventing accidental token leakage outside the module +- **Token validation** — `RedactedToken.init` now validates token format (3 base64 segments) and warns on double `"Bot "` prefix +- **URLSessionTransport header sanitization** — now strips all CRLF variants including embedded `\r\n` sequences + +### Deprecated +- **`DiscordUtils.Mentions.userNickname(_:)`** — deprecated in favor of `user(_:)` (renders identically in modern Discord clients) +- **`LabelBuilder`, `RadioGroupBuilder`, `CheckboxGroupBuilder`, `CheckboxBuilder`, `FileUploadBuilder`** — experimental, not part of Discord's current API + +### Notes +- Voice support is not planned and will never be implemented. Voice-adjacent model fields (channel bitrate, voice permission flags, voice message flags, voice audit log events, voice scheduled event type) are retained solely for Discord API compliance — they are passive model fields, not functional voice implementation. +- All audit findings and analysis artifacts are tracked in `audit.md` (excluded from version control via `.gitignore`). + ## [2.5.0] - 2026-06-30 ### Overview diff --git a/Examples/ShardingBot.swift b/Examples/ShardingBot.swift index 5dd5f696..d03acf95 100644 --- a/Examples/ShardingBot.swift +++ b/Examples/ShardingBot.swift @@ -11,7 +11,7 @@ struct ShardingBotMain { identifyConcurrency: .respectDiscordLimits, fallbackPresence: .init( activities: [ - .init(name: "with shards", type: 0) + .init(name: "with shards", type: .game) ], status: "online", afk: false diff --git a/README.md b/README.md index 4cf9305c..ab2fe5a6 100644 --- a/README.md +++ b/README.md @@ -322,14 +322,20 @@ print(await cache.summary) | `SwiftDiscAHCTransport` | Optional AsyncHTTPClient transport. Add `.product(name: "SwiftDiscAHCTransport", package: "SwiftDisc")` to use it. Supports proxies on Linux | | [**CODE_OF_CONDUCT.md**](CODE_OF_CONDUCT.md) | Community standards and expectations | -You can also build the docs locally: +You can also build and preview the docs locally: ```bash # Requires swift-docc-plugin (add it to Package.swift first) -swift package --allow-writing-to-directory generate-documentation --target SwiftDisc --output-path docs --transform-for-static-hosting +# Preview with a local web server (hot-reloads on source changes): +swift package --disable-sandbox preview-documentation --target SwiftDisc + +# Or build static HTML and serve it: +swift package --allow-writing-to-directory docs generate-documentation --target SwiftDisc --output-path docs --transform-for-static-hosting --hosting-base-path / +cd docs && python3 -m http.server 8080 +# Then open http://localhost:8080 in a browser ``` -Then open `docs/index.html` in a browser. +The CI workflow publishes to **GitHub Pages** at `https://M1tsumi.github.io/SwiftDisc/` on every push to `main`. The `--hosting-base-path SwiftDisc` flag in the workflow matches the repo's subpath on Pages. ## Community and support diff --git a/Sources/SwiftDisc/DiscordClient.swift b/Sources/SwiftDisc/DiscordClient.swift index 9cb20ae9..9fddad74 100644 --- a/Sources/SwiftDisc/DiscordClient.swift +++ b/Sources/SwiftDisc/DiscordClient.swift @@ -403,6 +403,22 @@ public actor DiscordClient { public var onEntitlementUpdate: (@Sendable (Entitlement) async -> Void)? public var onEntitlementDelete: (@Sendable (Entitlement) async -> Void)? + // -- Subscriptions (Monetization) -- + public var onSubscriptionCreate: (@Sendable (AppSubscription) async -> Void)? + public var onSubscriptionUpdate: (@Sendable (AppSubscription) async -> Void)? + public var onSubscriptionDelete: (@Sendable (AppSubscription) async -> Void)? + public var onSubscriptionGroupSubscriptionCreate: (@Sendable (AppSubscription) async -> Void)? + public var onSubscriptionGroupSubscriptionUpdate: (@Sendable (AppSubscription) async -> Void)? + public var onSubscriptionGroupSubscriptionDelete: (@Sendable (AppSubscription) async -> Void)? + + // -- Stage Instances -- + public var onStageInstanceCreate: (@Sendable (StageInstance) async -> Void)? + public var onStageInstanceUpdate: (@Sendable (StageInstance) async -> Void)? + public var onStageInstanceDelete: (@Sendable (StageInstance) async -> Void)? + + // -- Guild Join Requests -- + public var onGuildJoinRequestUpdate: (@Sendable (GuildJoinRequestUpdate) async -> Void)? + // -- Soundboard -- public var onSoundboardSoundCreate: (@Sendable (SoundboardSound) async -> Void)? public var onSoundboardSoundUpdate: (@Sendable (SoundboardSound) async -> Void)? @@ -800,6 +816,7 @@ public actor DiscordClient { /// ``` /// /// - Note: Only messages in news channels can be crossposted. + @discardableResult public func crosspostMessage(channelId: ChannelID, messageId: MessageID) async throws -> Message { struct Empty: Encodable, Sendable { } @@ -994,13 +1011,23 @@ public actor DiscordClient { embeds: [Embed]? = nil, components: [MessageComponent]? = nil, files: [FileAttachment]? = nil, - attachments: [PartialAttachment]? = nil + attachments: [PartialAttachment]? = nil, + allowedMentions: AllowedMentions? = nil, + flags: MessageFlags? = nil, + tts: Bool? = nil, + stickerIds: [StickerID]? = nil, + poll: Poll? = nil ) async throws -> Message { struct Payload: Encodable, Sendable { let content: OptionalField let embeds: [Embed]? let components: [MessageComponent]? let attachments: [PartialAttachment]? + let allowed_mentions: AllowedMentions? + let flags: MessageFlags? + let tts: Bool? + let sticker_ids: [StickerID]? + let poll: Poll? func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -1008,13 +1035,18 @@ public actor DiscordClient { if let embeds = embeds { try container.encode(embeds, forKey: .embeds) } if let components = components { try container.encode(components, forKey: .components) } if let attachments = attachments { try container.encode(attachments, forKey: .attachments) } + if let allowed_mentions = allowed_mentions { try container.encode(allowed_mentions, forKey: .allowed_mentions) } + if let flags = flags { try container.encode(flags, forKey: .flags) } + if let tts = tts { try container.encode(tts, forKey: .tts) } + if let sticker_ids = sticker_ids { try container.encode(sticker_ids, forKey: .sticker_ids) } + if let poll = poll { try container.encode(poll, forKey: .poll) } } enum CodingKeys: String, CodingKey { - case content, embeds, components, attachments + case content, embeds, components, attachments, allowed_mentions, flags, tts, sticker_ids, poll } } - let body = Payload(content: content, embeds: embeds, components: components, attachments: attachments) + let body = Payload(content: content, embeds: embeds, components: components, attachments: attachments, allowed_mentions: allowedMentions, flags: flags, tts: tts, sticker_ids: stickerIds, poll: poll) return try await http.patchMultipart(path: "/channels/\(channelId)/messages/\(messageId)", jsonBody: body, files: files) } @@ -1252,6 +1284,7 @@ public actor DiscordClient { /// /// - Note: Requires the `MANAGE_EMOJIS_AND_STICKERS` permission. /// - See Also: `modifyGuildEmoji(guildId:emojiId:name:roles:)` + @discardableResult public func createGuildEmoji(guildId: GuildID, name: String, image: String, roles: [RoleID]? = nil) async throws -> Emoji { struct Body: Encodable, Sendable { let name: String @@ -1283,6 +1316,7 @@ public actor DiscordClient { /// ``` /// /// - Note: Requires the `MANAGE_EMOJIS_AND_STICKERS` permission. + @discardableResult public func modifyGuildEmoji(guildId: GuildID, emojiId: EmojiID, name: String? = nil, roles: [RoleID]? = nil) async throws -> Emoji { struct Body: Encodable, Sendable { let name: String? @@ -2134,7 +2168,7 @@ public actor DiscordClient { public func setActivity(name: String, type: Int = 0, state: String? = nil, details: String? = nil, buttons: [String]? = nil) async { let act = PresenceUpdatePayload.Activity( name: name, - type: type, + type: PresenceUpdatePayload.ActivityType(rawValue: type) ?? .game, state: state, details: details, timestamps: nil, @@ -2539,12 +2573,18 @@ public actor DiscordClient { /// - Important: All fields are optional; only provided fields will be updated. /// To explicitly clear the content field, pass `OptionalField.null`. /// - See Also: `sendMessage(channelId:content:)` - public func editMessage(channelId: ChannelID, messageId: MessageID, content: OptionalField = .absent, embeds: [Embed]? = nil, components: [MessageComponent]? = nil, attachments: [PartialAttachment]? = nil) async throws -> Message { + @discardableResult + public func editMessage(channelId: ChannelID, messageId: MessageID, content: OptionalField = .absent, embeds: [Embed]? = nil, components: [MessageComponent]? = nil, attachments: [PartialAttachment]? = nil, allowedMentions: AllowedMentions? = nil, flags: MessageFlags? = nil, tts: Bool? = nil, stickerIds: [StickerID]? = nil, poll: Poll? = nil) async throws -> Message { struct Body: Encodable, Sendable { let content: OptionalField let embeds: [Embed]? let components: [MessageComponent]? let attachments: [PartialAttachment]? + let allowed_mentions: AllowedMentions? + let flags: MessageFlags? + let tts: Bool? + let sticker_ids: [StickerID]? + let poll: Poll? func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -2552,13 +2592,18 @@ public actor DiscordClient { if let embeds = embeds { try container.encode(embeds, forKey: .embeds) } if let components = components { try container.encode(components, forKey: .components) } if let attachments = attachments { try container.encode(attachments, forKey: .attachments) } + if let allowed_mentions = allowed_mentions { try container.encode(allowed_mentions, forKey: .allowed_mentions) } + if let flags = flags { try container.encode(flags, forKey: .flags) } + if let tts = tts { try container.encode(tts, forKey: .tts) } + if let sticker_ids = sticker_ids { try container.encode(sticker_ids, forKey: .sticker_ids) } + if let poll = poll { try container.encode(poll, forKey: .poll) } } enum CodingKeys: String, CodingKey { - case content, embeds, components, attachments + case content, embeds, components, attachments, allowed_mentions, flags, tts, sticker_ids, poll } } - return try await http.patch(path: "/channels/\(channelId)/messages/\(messageId)", body: Body(content: content, embeds: embeds, components: components, attachments: attachments)) + return try await http.patch(path: "/channels/\(channelId)/messages/\(messageId)", body: Body(content: content, embeds: embeds, components: components, attachments: attachments, allowed_mentions: allowedMentions, flags: flags, tts: tts, sticker_ids: stickerIds, poll: poll)) } /// Lists recent messages from a channel. diff --git a/Sources/SwiftDisc/Gateway/GatewayClient.swift b/Sources/SwiftDisc/Gateway/GatewayClient.swift index e046c11a..e3735b9d 100644 --- a/Sources/SwiftDisc/Gateway/GatewayClient.swift +++ b/Sources/SwiftDisc/Gateway/GatewayClient.swift @@ -49,6 +49,7 @@ actor GatewayClient { private var recommendedShards: Int? private var allowReconnect: Bool = true private var connectReadyContinuation: CheckedContinuation? + private var didResumeConnectReady: Bool = false private var maxReconnectAttempts: Int = 10 private var maxReconnectDelayNs: UInt64 = 16_000_000_000 @@ -109,11 +110,11 @@ actor GatewayClient { } // Use resume_gateway_url from READY if available, otherwise cached or default let baseURL: URL - // Discord's resume_gateway_url expires after ~7 days; check before using + // Discord's resume_gateway_url expires after ~24 hours; check before using let resumeUrlExpired: Bool if let receivedAt = resumeGatewayUrlReceivedAt { let age = Date().timeIntervalSince(receivedAt) - resumeUrlExpired = age > 7 * 24 * 60 * 60 // 7 days in seconds + resumeUrlExpired = age > 24 * 60 * 60 // 24 hours in seconds } else { resumeUrlExpired = true } @@ -295,6 +296,16 @@ actor GatewayClient { add("ENTITLEMENT_DELETE", Entitlement.self, { .entitlementDelete($0) }) add("INVITE_CREATE", InviteCreate.self, { .inviteCreate($0) }) add("INVITE_DELETE", InviteDelete.self, { .inviteDelete($0) }) + add("STAGE_INSTANCE_CREATE", StageInstance.self, { .stageInstanceCreate($0) }) + add("STAGE_INSTANCE_UPDATE", StageInstance.self, { .stageInstanceUpdate($0) }) + add("STAGE_INSTANCE_DELETE", StageInstance.self, { .stageInstanceDelete($0) }) + add("SUBSCRIPTION_CREATE", AppSubscription.self, { .subscriptionCreate($0) }) + add("SUBSCRIPTION_UPDATE", AppSubscription.self, { .subscriptionUpdate($0) }) + add("SUBSCRIPTION_DELETE", AppSubscription.self, { .subscriptionDelete($0) }) + add("SUBSCRIPTION_GROUP_SUBSCRIPTION_CREATE", AppSubscription.self, { .subscriptionGroupSubscriptionCreate($0) }) + add("SUBSCRIPTION_GROUP_SUBSCRIPTION_UPDATE", AppSubscription.self, { .subscriptionGroupSubscriptionUpdate($0) }) + add("SUBSCRIPTION_GROUP_SUBSCRIPTION_DELETE", AppSubscription.self, { .subscriptionGroupSubscriptionDelete($0) }) + add("GUILD_JOIN_REQUEST_UPDATE", GuildJoinRequestUpdate.self, { .guildJoinRequestUpdate($0) }) // INTERACTION_CREATE is handled separately because it has diagnostic logging on failure return table @@ -304,6 +315,7 @@ actor GatewayClient { guard let socket = self.socket else { return } let dec = JSONCoders.decoder var lastFrameData: Data? + var consecutiveDecodeErrors = 0 while true { do { let msg = try await socket.receive() @@ -317,6 +329,8 @@ actor GatewayClient { if let probe = try? dec.decode(SeqProbe.self, from: data), let s = probe.s { self.seq = s } + // Reset decode error counter on successful frame decode + consecutiveDecodeErrors = 0 // Decode opcode first, then dispatch by event name when needed. if let opBox = try? dec.decode(GatewayOpBox.self, from: data) { switch opBox.op { @@ -330,7 +344,8 @@ actor GatewayClient { self.resumeGatewayUrlReceivedAt = Date() self.status = .ready; statusContinuation?.yield(.ready) eventSink(.ready(ready)) - if let cont = self.connectReadyContinuation { + if let cont = self.connectReadyContinuation, !self.didResumeConnectReady { + self.didResumeConnectReady = true self.connectReadyContinuation = nil cont.resume() } @@ -341,7 +356,8 @@ actor GatewayClient { self.resumeSuccessCount += 1 self.lastResumeSuccessAt = Date() eventSink(.resumed) - if let cont = self.connectReadyContinuation { + if let cont = self.connectReadyContinuation, !self.didResumeConnectReady { + self.didResumeConnectReady = true self.connectReadyContinuation = nil cont.resume() } @@ -380,12 +396,14 @@ actor GatewayClient { } } case .heartbeat: - // Discord requested an immediate heartbeat (op 1) + // Discord requested an immediate heartbeat (op 1). + // We do NOT increment missedHeartbeatAckCount here — that + // counter is managed exclusively by runHeartbeatLoop's + // periodic heartbeats to keep zombie detection accurate. do { let hb: HeartbeatPayload = seq let payload = GatewayPayload(op: .heartbeat, d: hb, s: nil, t: nil) try await sendGatewayPayload(payload) - missedHeartbeatAckCount += 1 lastHeartbeatSentAt = Date() } catch { await attemptReconnect() @@ -435,6 +453,13 @@ actor GatewayClient { } catch let error as DecodingError { // Malformed payloads are logged and skipped so one bad frame does not kill the socket. logDecodeDiagnostic("Top-level gateway frame decoding error: \(error)", data: lastFrameData) + consecutiveDecodeErrors += 1 + if consecutiveDecodeErrors >= 5 { + // Too many decode errors in a row — reconnect to reset the connection + await attemptReconnect() + break + } + try? await Task.sleep(nanoseconds: UInt64(min(consecutiveDecodeErrors, 10)) * 1_000_000_000) continue } catch { await attemptReconnect() @@ -499,7 +524,8 @@ actor GatewayClient { if let code = closeCode, isFatalCloseCode(code) { status = .disconnected; statusContinuation?.yield(.disconnected) let reason = fatalCloseCodeDescription(code) - if let cont = connectReadyContinuation { + if let cont = connectReadyContinuation, !didResumeConnectReady { + didResumeConnectReady = true connectReadyContinuation = nil let error: DiscordError = (code == 4004) ? .authenticationFailed @@ -539,7 +565,8 @@ actor GatewayClient { } // Max reconnect attempts reached - surface fatal disconnect status = .disconnected; statusContinuation?.yield(.disconnected) - if let cont = connectReadyContinuation { + if let cont = connectReadyContinuation, !didResumeConnectReady { + didResumeConnectReady = true connectReadyContinuation = nil cont.resume(throwing: DiscordError.gateway("Max reconnect attempts (\(maxReconnectAttempts)) reached")) } else { @@ -566,10 +593,9 @@ actor GatewayClient { // MARK: - Gateway URL fetch - /// Validates that privileged intents are used with awareness of their privileged status. - /// Logs a warning for each privileged intent used. This is a static check and does not + /// Logs a warning for each privileged intent used. This is a diagnostic helper and does not /// verify the Developer Portal configuration. - public static func validatePrivilegedIntents(_ intents: GatewayIntents, logger: (any DiscordLogger)? = nil) { + static func logPrivilegedIntentWarnings(_ intents: GatewayIntents, logger: (any DiscordLogger)? = nil) { let privileged: [(GatewayIntents, String)] = [ (.guildMembers, "GUILD_MEMBERS"), (.guildPresences, "GUILD_PRESENCES"), @@ -616,7 +642,7 @@ actor GatewayClient { } /// Alias for `disconnect()`. - func close() async { + public func close() async { await disconnect() } @@ -666,15 +692,15 @@ actor GatewayClient { // MARK: - Gateway send helpers private func sendGatewayPayload(_ payload: GatewayPayload) async throws { - guard let socket = self.socket else { throw DiscordError.gateway("Socket not connected") } let data = try JSONCoders.encoder.encode(payload) await rateLimiter.acquire(opcode: payload.op.rawValue) + guard let socket = self.socket else { throw DiscordError.gateway("Socket disconnected during rate-limiter wait") } try await socket.send(.string(String(decoding: data, as: UTF8.self))) } private func sendGatewayData(_ data: Data, opcode: Int) async throws { - guard let socket = self.socket else { throw DiscordError.gateway("Socket not connected") } await rateLimiter.acquire(opcode: opcode) + guard let socket = self.socket else { throw DiscordError.gateway("Socket disconnected during rate-limiter wait") } try await socket.send(.string(String(decoding: data, as: UTF8.self))) } } diff --git a/Sources/SwiftDisc/Gateway/GatewayModels.swift b/Sources/SwiftDisc/Gateway/GatewayModels.swift index e9924f89..4e251e06 100644 --- a/Sources/SwiftDisc/Gateway/GatewayModels.swift +++ b/Sources/SwiftDisc/Gateway/GatewayModels.swift @@ -337,6 +337,19 @@ public enum DiscordEvent: Hashable, Sendable { case entitlementCreate(Entitlement) case entitlementUpdate(Entitlement) case entitlementDelete(Entitlement) + // Stage instances + case stageInstanceCreate(StageInstance) + case stageInstanceUpdate(StageInstance) + case stageInstanceDelete(StageInstance) + // Subscription events (monetization) + case subscriptionCreate(AppSubscription) + case subscriptionUpdate(AppSubscription) + case subscriptionDelete(AppSubscription) + case subscriptionGroupSubscriptionCreate(AppSubscription) + case subscriptionGroupSubscriptionUpdate(AppSubscription) + case subscriptionGroupSubscriptionDelete(AppSubscription) + // Guild join request + case guildJoinRequestUpdate(GuildJoinRequestUpdate) // Session events /// The gateway session was invalidated and a fresh identify is required. case sessionInvalidated @@ -591,6 +604,30 @@ public struct ResumePayload: Codable, Sendable { /// /// Used to set activities, status, and AFK state. public struct PresenceUpdatePayload: Codable, Sendable { + /// The type of a Discord activity (rich presence). + public enum ActivityType: Int, Codable, Sendable { + /// Game activity. + case game = 0 + /// Streaming activity. + case streaming = 1 + /// Listening activity. + case listening = 2 + /// Watching activity. + case watching = 3 + /// Custom activity. + case custom = 4 + /// Competing activity. + case competing = 5 + /// Unknown activity type (forward compatibility). + case unknown = 999 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = ActivityType(rawValue: rawValue) ?? .unknown + } + } + /// Represents a Discord activity (rich presence). public struct Activity: Codable, Hashable, Sendable { /// Start and end timestamps for an activity. @@ -617,7 +654,7 @@ public struct PresenceUpdatePayload: Codable, Sendable { public let match: String? } public let name: String - public let type: Int + public let type: ActivityType public let state: String? public let details: String? public let timestamps: Timestamps? @@ -627,7 +664,7 @@ public struct PresenceUpdatePayload: Codable, Sendable { public let secrets: Secrets? public init( name: String, - type: Int, + type: ActivityType, state: String? = nil, details: String? = nil, timestamps: Timestamps? = nil, @@ -799,6 +836,18 @@ public struct SoundboardSound: Codable, Hashable, Sendable { // Entitlement model in Models/Monetization.swift +// MARK: - Guild Join Requests + +/// Sent when a user's join request for a guild is updated. +public struct GuildJoinRequestUpdate: Codable, Hashable, Sendable { + public let user_id: UserID + public let guild_id: GuildID + public let status: String + public let created_at: String + public let last_seen_at: String? + public let rejection_reason: String? +} + // MARK: - Voice Channel Status /// Sent when a voice channel's status changes. diff --git a/Sources/SwiftDisc/HighLevel/ActivityBuilder.swift b/Sources/SwiftDisc/HighLevel/ActivityBuilder.swift index d9117627..89e3b03e 100644 --- a/Sources/SwiftDisc/HighLevel/ActivityBuilder.swift +++ b/Sources/SwiftDisc/HighLevel/ActivityBuilder.swift @@ -3,7 +3,7 @@ import Foundation /// A builder for constructing Discord rich presence activities. public struct ActivityBuilder: Sendable { private var name: String - private var type: Int = 0 // 0 = playing, 2 = listening, 3 = watching, etc. + private var type: PresenceUpdatePayload.ActivityType = .game private var state: String? private var details: String? private var start: Int64? @@ -22,11 +22,14 @@ public struct ActivityBuilder: Sendable { public init(name: String) { self.name = name } - public func playing() -> ActivityBuilder { var c = self; c.type = 0; return c } - public func streaming() -> ActivityBuilder { var c = self; c.type = 1; return c } - public func listening() -> ActivityBuilder { var c = self; c.type = 2; return c } - public func watching() -> ActivityBuilder { var c = self; c.type = 3; return c } - public func competing() -> ActivityBuilder { var c = self; c.type = 5; return c } + /// Set the activity type by raw Int value (0=Playing, 1=Streaming, 2=Listening, 3=Watching, 4=Custom, 5=Competing). + /// Unknown values default to .game. + public func type(_ raw: Int) -> ActivityBuilder { var c = self; c.type = PresenceUpdatePayload.ActivityType(rawValue: raw) ?? .game; return c } + public func playing() -> ActivityBuilder { var c = self; c.type = .game; return c } + public func streaming() -> ActivityBuilder { var c = self; c.type = .streaming; return c } + public func listening() -> ActivityBuilder { var c = self; c.type = .listening; return c } + public func watching() -> ActivityBuilder { var c = self; c.type = .watching; return c } + public func competing() -> ActivityBuilder { var c = self; c.type = .competing; return c } public func state(_ v: String) -> ActivityBuilder { var c = self; c.state = v; return c } public func details(_ v: String) -> ActivityBuilder { var c = self; c.details = v; return c } diff --git a/Sources/SwiftDisc/HighLevel/Collectors.swift b/Sources/SwiftDisc/HighLevel/Collectors.swift index 5acc455b..de9c421a 100644 --- a/Sources/SwiftDisc/HighLevel/Collectors.swift +++ b/Sources/SwiftDisc/HighLevel/Collectors.swift @@ -12,6 +12,7 @@ public extension DiscordClient { func createMessageCollector(channelId: ChannelID? = nil, timeout: TimeInterval? = nil, maxMessages: Int? = nil, filter: @escaping @Sendable (Message) -> Bool = { _ in true }, onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { AsyncStream { continuation in var collected = 0 + var timeoutTask: Task? let task = Task { for await event in self.events { switch event { @@ -22,6 +23,7 @@ public extension DiscordClient { collected += 1 if let maxMessages, collected >= maxMessages { continuation.finish() + timeoutTask?.cancel() return } } @@ -29,10 +31,11 @@ public extension DiscordClient { } } continuation.finish() + timeoutTask?.cancel() } if let t = timeout { - Task { + timeoutTask = Task { try? await Task.sleep(nanoseconds: UInt64(t * 1_000_000_000)) continuation.finish() task.cancel() @@ -45,7 +48,9 @@ public extension DiscordClient { /// This yields members lazily and avoids manual paging logic. func streamGuildMembers(guildId: GuildID, pageLimit: Int = 1000, onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { AsyncStream(GuildMember.self) { continuation in - Task { + var task: Task? + task = Task { + defer { task = nil } var after: UserID? = nil var lastSeen: String? = nil while true { @@ -80,199 +85,98 @@ public extension DiscordClient { /// print(message.content ?? "") /// } /// ``` - func messageEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { + private func filteredEventStream(_ match: @escaping @Sendable (DiscordEvent) -> T?) -> AsyncStream { AsyncStream { continuation in - Task { + let task = Task { for await event in self.events { - if case .messageCreate(let msg) = event { continuation.yield(msg) } + if let value = match(event) { + continuation.yield(value) + } } continuation.finish() } + continuation.onTermination = { _ in task.cancel() } } } + func messageEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { + filteredEventStream { if case .messageCreate(let msg) = $0 { return msg } else { return nil } } + } + /// A filtered `AsyncStream` that yields every `MessageReactionAdd` event. func reactionAddEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .messageReactionAdd(let ev) = event { continuation.yield(ev) } - } - continuation.finish() - } - } + filteredEventStream { if case .messageReactionAdd(let ev) = $0 { return ev } else { return nil } } } /// A filtered `AsyncStream` that yields every incoming `Interaction`. - /// - /// Useful for bots that handle interactions outside of `SlashCommandRouter`. func interactionEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .interactionCreate(let interaction) = event { continuation.yield(interaction) } - } - continuation.finish() - } - } + filteredEventStream { if case .interactionCreate(let interaction) = $0 { return interaction } else { return nil } } } /// A filtered `AsyncStream` that yields `GuildMemberAdd` events. func memberAddEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .guildMemberAdd(let ev) = event { continuation.yield(ev) } - } - continuation.finish() - } - } + filteredEventStream { if case .guildMemberAdd(let ev) = $0 { return ev } else { return nil } } } /// A filtered `AsyncStream` that yields `GuildMemberRemove` events. func memberRemoveEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .guildMemberRemove(let ev) = event { continuation.yield(ev) } - } - continuation.finish() - } - } + filteredEventStream { if case .guildMemberRemove(let ev) = $0 { return ev } else { return nil } } } /// A filtered `AsyncStream` that yields `PresenceUpdate` events. func presenceUpdateEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .presenceUpdate(let ev) = event { continuation.yield(ev) } - } - continuation.finish() - } - } + filteredEventStream { if case .presenceUpdate(let ev) = $0 { return ev } else { return nil } } } // MARK: - New event collectors /// A filtered `AsyncStream` that yields thread create events. func threadCreateEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .threadCreate(let ch) = event { continuation.yield(ch) } - } - continuation.finish() - } - } + filteredEventStream { if case .threadCreate(let ch) = $0 { return ch } else { return nil } } } /// A filtered `AsyncStream` that yields thread update events. func threadUpdateEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .threadUpdate(let ch) = event { continuation.yield(ch) } - } - continuation.finish() - } - } + filteredEventStream { if case .threadUpdate(let ch) = $0 { return ch } else { return nil } } } /// A filtered `AsyncStream` that yields thread delete events. func threadDeleteEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .threadDelete(let ch) = event { continuation.yield(ch) } - } - continuation.finish() - } - } + filteredEventStream { if case .threadDelete(let ch) = $0 { return ch } else { return nil } } } - /// A filtered `AsyncStream` that yields guild role create events. func roleCreateEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .guildRoleCreate(let ev) = event { continuation.yield(ev) } - } - continuation.finish() - } - } + filteredEventStream { if case .guildRoleCreate(let ev) = $0 { return ev } else { return nil } } } /// A filtered `AsyncStream` that yields guild role update events. func roleUpdateEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .guildRoleUpdate(let ev) = event { continuation.yield(ev) } - } - continuation.finish() - } - } + filteredEventStream { if case .guildRoleUpdate(let ev) = $0 { return ev } else { return nil } } } /// A filtered `AsyncStream` that yields guild role delete events. func roleDeleteEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .guildRoleDelete(let ev) = event { continuation.yield(ev) } - } - continuation.finish() - } - } + filteredEventStream { if case .guildRoleDelete(let ev) = $0 { return ev } else { return nil } } } /// A filtered `AsyncStream` that yields guild emoji update events. func emojiUpdateEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .guildEmojisUpdate(let ev) = event { continuation.yield(ev) } - } - continuation.finish() - } - } + filteredEventStream { if case .guildEmojisUpdate(let ev) = $0 { return ev } else { return nil } } } /// A filtered `AsyncStream` that yields typing start events. func typingStartEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .typingStart(let ev) = event { continuation.yield(ev) } - } - continuation.finish() - } - } + filteredEventStream { if case .typingStart(let ev) = $0 { return ev } else { return nil } } } /// A filtered `AsyncStream` that yields message update events. func messageUpdateEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .messageUpdate(let msg) = event { continuation.yield(msg) } - } - continuation.finish() - } - } + filteredEventStream { if case .messageUpdate(let msg) = $0 { return msg } else { return nil } } } /// A filtered `AsyncStream` that yields message delete events. func messageDeleteEvents(onError: @escaping @Sendable (Error) -> Void = { _ in }) -> AsyncStream { - AsyncStream { continuation in - Task { - for await event in self.events { - if case .messageDelete(let ev) = event { continuation.yield(ev) } - } - continuation.finish() - } - } + filteredEventStream { if case .messageDelete(let ev) = $0 { return ev } else { return nil } } } } diff --git a/Sources/SwiftDisc/HighLevel/ComponentCollector.swift b/Sources/SwiftDisc/HighLevel/ComponentCollector.swift index 18cad9cf..bf3e0979 100644 --- a/Sources/SwiftDisc/HighLevel/ComponentCollector.swift +++ b/Sources/SwiftDisc/HighLevel/ComponentCollector.swift @@ -6,18 +6,19 @@ public extension DiscordClient { func createComponentCollector(customId: String? = nil, timeout: TimeInterval? = nil, max: Int? = nil) -> AsyncStream { AsyncStream { continuation in var collected = 0 + var timeoutTask: Task? let task = Task { for await event in self.events { switch event { case .interactionCreate(let interaction): - // component interactions typically have a data.custom_id field if let data = interaction.data, data.custom_id != nil { if let cid = customId, data.custom_id != cid { continue } continuation.yield(interaction) collected += 1 if let max, collected >= max { continuation.finish() + timeoutTask?.cancel() return } } @@ -25,10 +26,11 @@ public extension DiscordClient { } } continuation.finish() + timeoutTask?.cancel() } if let t = timeout { - Task { + timeoutTask = Task { try? await Task.sleep(nanoseconds: UInt64(t * 1_000_000_000)) continuation.finish() task.cancel() diff --git a/Sources/SwiftDisc/HighLevel/Converters.swift b/Sources/SwiftDisc/HighLevel/Converters.swift index 2987126b..594e0f78 100644 --- a/Sources/SwiftDisc/HighLevel/Converters.swift +++ b/Sources/SwiftDisc/HighLevel/Converters.swift @@ -2,6 +2,13 @@ import Foundation /// Converter utilities for common command argument types. public enum Converters: Sendable { + /// Shared ISO8601 date formatter with internet date-time and fractional seconds support. + private static let iso8601Formatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + // MARK: - Snowflake Parsing /// Parse a raw argument as a Snowflake by accepting plain ids or mention forms like `<@1234>` or `<@!1234>`. @@ -52,16 +59,12 @@ public enum Converters: Sendable { /// Format a Date as an ISO8601 string suitable for Discord. public static func formatDateAsISO8601(_ date: Date) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.string(from: date) + iso8601Formatter.string(from: date) } /// Parse an ISO8601 string to a Date. public static func parseISO8601(_ string: String) -> Date? { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.date(from: string) + iso8601Formatter.date(from: string) } /// Convert a duration string (e.g., "1h30m", "45s", "2d") to seconds. @@ -109,8 +112,8 @@ public enum Converters: Sendable { public static let discordRed: Int = 0xED4245 /// Discord yellow color public static let discordYellow: Int = 0xFEE75C - /// Discord orange color - public static let discordOrange: Int = 0xEB459E + /// Discord fuchsia/pink color (used for stage channels) + public static let discordFuchsia: Int = 0xEB459E // MARK: - Emoji Parsing @@ -144,8 +147,8 @@ public enum Converters: Sendable { /// Validate a Discord invite code. public static func isValidInviteCode(_ code: String) -> Bool { - // Discord invite codes are typically alphanumeric, 6-10 characters - let pattern = "^[a-zA-Z0-9]{6,10}$" + // Discord invite codes are alphanumeric, typically 6-25 characters + let pattern = "^[a-zA-Z0-9]{6,25}$" return code.range(of: pattern, options: .regularExpression) != nil } } diff --git a/Sources/SwiftDisc/HighLevel/CooldownManager.swift b/Sources/SwiftDisc/HighLevel/CooldownManager.swift index d92bb7ac..291ce807 100644 --- a/Sources/SwiftDisc/HighLevel/CooldownManager.swift +++ b/Sources/SwiftDisc/HighLevel/CooldownManager.swift @@ -9,7 +9,7 @@ public actor CooldownManager { private var autoCleanupInterval: TimeInterval = 300 // 5 minutes default public init() { - // Auto-cleanup started lazily on first access to avoid actor isolation issues in init + startAutoCleanup() } deinit { @@ -51,7 +51,7 @@ public actor CooldownManager { /// Clears all cooldowns for a specific command across all keys. public func clearCommandCooldowns(command: String) { - let prefix = "\(command)::" + let prefix = "\(command)\0" store = store.filter { !$0.key.hasPrefix(prefix) } } @@ -85,14 +85,15 @@ public actor CooldownManager { } private func compoundKey(command: String, key: String) -> String { - return "\(command)::\(key)" + return "\(command)\0\(key)" } private func startAutoCleanup() { - cleanupTask = Task { @Sendable in + let interval = autoCleanupInterval + cleanupTask = Task { [weak self] in while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(self.autoCleanupInterval * 1_000_000_000)) - self.purgeExpired() + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + await self?.purgeExpired() } } } diff --git a/Sources/SwiftDisc/HighLevel/EmbedBuilder.swift b/Sources/SwiftDisc/HighLevel/EmbedBuilder.swift index ba74ee22..2167f909 100644 --- a/Sources/SwiftDisc/HighLevel/EmbedBuilder.swift +++ b/Sources/SwiftDisc/HighLevel/EmbedBuilder.swift @@ -133,12 +133,31 @@ public struct EmbedBuilder: Sendable { /// - Returns: A new builder with the timestamp applied. public func timestamp(_ date: Date) -> EmbedBuilder { var c = self - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - c.timestamp = formatter.string(from: date) + c.timestamp = Converters.formatDateAsISO8601(date) return c } + /// Create a success embed with green color. + /// - Parameter message: The success message. + /// - Returns: A new builder with green color and description set. + public static func success(_ message: String) -> EmbedBuilder { + EmbedBuilder().color(Converters.discordGreen).description(message) + } + + /// Create an error embed with red color. + /// - Parameter message: The error message. + /// - Returns: A new builder with red color and description set. + public static func error(_ message: String) -> EmbedBuilder { + EmbedBuilder().color(Converters.discordRed).description(message) + } + + /// Create an info embed with blurple color. + /// - Parameter message: The info message. + /// - Returns: A new builder with blurple color and description set. + public static func info(_ message: String) -> EmbedBuilder { + EmbedBuilder().color(Converters.discordBlurple).description(message) + } + /// Finalizes the builder and returns the composed `Embed`. /// /// The returned value is ready to be passed to any Discord API method that diff --git a/Sources/SwiftDisc/HighLevel/MessagePayload.swift b/Sources/SwiftDisc/HighLevel/MessagePayload.swift index b4b1673f..591eb0e7 100644 --- a/Sources/SwiftDisc/HighLevel/MessagePayload.swift +++ b/Sources/SwiftDisc/HighLevel/MessagePayload.swift @@ -169,8 +169,11 @@ public extension DiscordClient { content: payload.content.wrappedValue, embeds: payload.embeds, components: payload.components, + allowedMentions: payload.allowedMentions, + messageReference: payload.messageReference, tts: payload.tts, flags: payload.flags, + stickerIds: payload.stickerIds, poll: payload.poll, files: files ) @@ -199,7 +202,12 @@ public extension DiscordClient { content: payload.content, embeds: payload.embeds, components: payload.components, - files: files + files: files, + allowedMentions: payload.allowedMentions, + flags: payload.flags, + tts: payload.tts, + stickerIds: payload.stickerIds, + poll: payload.poll ) } return try await editMessage( @@ -207,7 +215,12 @@ public extension DiscordClient { messageId: messageId, content: payload.content, embeds: payload.embeds, - components: payload.components + components: payload.components, + allowedMentions: payload.allowedMentions, + flags: payload.flags, + tts: payload.tts, + stickerIds: payload.stickerIds, + poll: payload.poll ) } @@ -230,6 +243,9 @@ public extension DiscordClient { let flags: MessageFlags? let tts: Bool? let allowed_mentions: AllowedMentions? + let message_reference: MessageReference? + let sticker_ids: [StickerID]? + let poll: Poll? } struct Body: Encodable, Sendable { let type: Int @@ -241,7 +257,10 @@ public extension DiscordClient { components: payload.components, flags: payload.flags, tts: payload.tts, - allowed_mentions: payload.allowedMentions + allowed_mentions: payload.allowedMentions, + message_reference: payload.messageReference, + sticker_ids: payload.stickerIds, + poll: payload.poll ) struct Ack: Decodable, Sendable { } diff --git a/Sources/SwiftDisc/HighLevel/Utilities.swift b/Sources/SwiftDisc/HighLevel/Utilities.swift index 978c8775..0c230108 100644 --- a/Sources/SwiftDisc/HighLevel/Utilities.swift +++ b/Sources/SwiftDisc/HighLevel/Utilities.swift @@ -50,7 +50,7 @@ public enum BotUtils: Sendable { /// - Parameter content: The message text to scan. /// - Returns: An array of user ID strings found in the content, in order of appearance. public static func extractMentions(_ content: String) -> [String] { - let pattern = #"<@!?([0-9]{5,})>"# + let pattern = #"<@!?([0-9]{17,19})>"# guard let re = try? NSRegularExpression(pattern: pattern) else { return [] } let range = NSRange(location: 0, length: content.utf16.count) var ids: [String] = [] @@ -79,7 +79,7 @@ public enum BotUtils: Sendable { /// - Parameter content: The message text to scan. /// - Returns: An array of role ID strings found in the content. public static func extractRoleMentions(_ content: String) -> [String] { - let pattern = #"<@&([0-9]{5,})>"# + let pattern = #"<@&([0-9]{17,19})>"# guard let re = try? NSRegularExpression(pattern: pattern) else { return [] } let range = NSRange(location: 0, length: content.utf16.count) var ids: [String] = [] @@ -98,7 +98,7 @@ public enum BotUtils: Sendable { /// - Parameter content: The message text to scan. /// - Returns: An array of channel ID strings found in the content. public static func extractChannelMentions(_ content: String) -> [String] { - let pattern = #"<#([0-9]{5,})>"# + let pattern = #"<#([0-9]{17,19})>"# guard let re = try? NSRegularExpression(pattern: pattern) else { return [] } let range = NSRange(location: 0, length: content.utf16.count) var ids: [String] = [] @@ -116,9 +116,9 @@ public enum BotUtils: Sendable { /// - Returns: The content with all mentions removed. public static func stripMentions(_ content: String) -> String { var result = content - result = result.replacingOccurrences(of: #"<@!?[0-9]{5,}>"#, with: "", options: .regularExpression) - result = result.replacingOccurrences(of: #"<@&[0-9]{5,}>"#, with: "", options: .regularExpression) - result = result.replacingOccurrences(of: #"<#[0-9]{5,}>"#, with: "", options: .regularExpression) + result = result.replacingOccurrences(of: #"<@!?[0-9]{17,19}>"#, with: "", options: .regularExpression) + result = result.replacingOccurrences(of: #"<@&[0-9]{17,19}>"#, with: "", options: .regularExpression) + result = result.replacingOccurrences(of: #"<#[0-9]{17,19}>"#, with: "", options: .regularExpression) return result } diff --git a/Sources/SwiftDisc/Internal/Cache.swift b/Sources/SwiftDisc/Internal/Cache.swift index 4307aa29..eb699f0d 100644 --- a/Sources/SwiftDisc/Internal/Cache.swift +++ b/Sources/SwiftDisc/Internal/Cache.swift @@ -61,9 +61,30 @@ public actor Cache { public var maxEmojiEntries: Int? /// Creates a new cache configuration. - public init(userTTL: TimeInterval? = nil, channelTTL: TimeInterval? = nil, guildTTL: TimeInterval? = nil, roleTTL: TimeInterval? = nil, emojiTTL: TimeInterval? = nil, maxMessagesPerChannel: Int = 50, maxUsers: Int? = 50_000, maxChannels: Int? = 50_000, maxGuilds: Int? = 10_000, maxRolesPerGuild: Int? = 500, maxEmojiEntries: Int? = 500) { - self.userTTL = userTTL; self.channelTTL = channelTTL; self.guildTTL = guildTTL; self.roleTTL = roleTTL; self.emojiTTL = emojiTTL; self.maxMessagesPerChannel = maxMessagesPerChannel - self.maxUsers = maxUsers; self.maxChannels = maxChannels; self.maxGuilds = maxGuilds; self.maxRolesPerGuild = maxRolesPerGuild; self.maxEmojiEntries = maxEmojiEntries + public init( + userTTL: TimeInterval? = nil, + channelTTL: TimeInterval? = nil, + guildTTL: TimeInterval? = nil, + roleTTL: TimeInterval? = nil, + emojiTTL: TimeInterval? = nil, + maxMessagesPerChannel: Int = 50, + maxUsers: Int? = 50_000, + maxChannels: Int? = 50_000, + maxGuilds: Int? = 10_000, + maxRolesPerGuild: Int? = 500, + maxEmojiEntries: Int? = 500 + ) { + self.userTTL = userTTL + self.channelTTL = channelTTL + self.guildTTL = guildTTL + self.roleTTL = roleTTL + self.emojiTTL = emojiTTL + self.maxMessagesPerChannel = maxMessagesPerChannel + self.maxUsers = maxUsers + self.maxChannels = maxChannels + self.maxGuilds = maxGuilds + self.maxRolesPerGuild = maxRolesPerGuild + self.maxEmojiEntries = maxEmojiEntries } } @@ -125,16 +146,17 @@ public actor Cache { /// - Parameter configuration: The cache configuration. public init(configuration: Configuration = .init()) { self.configuration = configuration - self.evictionTask = nil let hasTTL = configuration.userTTL != nil || configuration.channelTTL != nil || configuration.guildTTL != nil || configuration.roleTTL != nil || configuration.emojiTTL != nil if hasTTL { - Task { @Sendable in - await self.startEvictionTaskIfNeeded() + self.evictionTask = Task { @Sendable [self] in + await self.evictionLoop() } + } else { + self.evictionTask = nil } } @@ -160,10 +182,12 @@ public actor Cache { /// /// Used when only the channel ID is known from events like MESSAGE_CREATE. /// - /// - Parameter id: The channel ID. - public func ensureChannelStub(id: ChannelID) { + /// - Parameters: + /// - id: The channel ID. + /// - type: The channel type (defaults to .text). + public func ensureChannelStub(id: ChannelID, type: ChannelType = .text) { if channelsTimed[id] == nil { - channelsTimed[id] = TimedValue(value: Channel(id: id, type: .text), storedAt: Date()) + channelsTimed[id] = TimedValue(value: Channel(id: id, type: type), storedAt: Date()) } } @@ -276,6 +300,36 @@ public actor Cache { return tv.value.first { $0.id == id } } + /// Clears the entire cache. + public func clear() { + usersTimed.removeAll() + channelsTimed.removeAll() + guildsTimed.removeAll() + rolesByGuild.removeAll() + emojisByGuild.removeAll() + recentMessagesByChannel.removeAll() + messageToChannelIndex.removeAll() + } + + /// Removes all cached messages for a given channel. + /// + /// - Parameter channelId: The channel ID whose messages should be removed. + public func removeMessagesForChannel(channelId: ChannelID) { + if let messages = recentMessagesByChannel.removeValue(forKey: channelId) { + for message in messages { + messageToChannelIndex.removeValue(forKey: message.id) + } + } + } + + /// Retrieves cached messages for a given channel. + /// + /// - Parameter channelId: The channel ID. + /// - Returns: The cached messages for the channel, or an empty array if none. + public func getMessages(channelId: ChannelID) -> [Message] { + recentMessagesByChannel[channelId] ?? [] + } + /// Adds a message to the recent messages cache. /// /// - Parameter message: The message to add. @@ -309,6 +363,13 @@ public actor Cache { messageToChannelIndex.removeValue(forKey: id) } + /// Removes a user from the cache. + /// + /// - Parameter id: The user ID to remove. + public func removeUser(id: UserID) { + usersTimed.removeValue(forKey: id) + } + /// Retrieves a user from the cache. /// /// - Parameter id: The user ID. diff --git a/Sources/SwiftDisc/Internal/EventDispatcher.swift b/Sources/SwiftDisc/Internal/EventDispatcher.swift index cc5a3236..7ef39ed2 100644 --- a/Sources/SwiftDisc/Internal/EventDispatcher.swift +++ b/Sources/SwiftDisc/Internal/EventDispatcher.swift @@ -270,6 +270,39 @@ actor EventDispatcher { case .entitlementDelete(let ev): if let cb = await client.onEntitlementDelete { await cb(ev) } + // MARK: Stage Instances + case .stageInstanceCreate(let ev): + if let cb = await client.onStageInstanceCreate { await cb(ev) } + + case .stageInstanceUpdate(let ev): + if let cb = await client.onStageInstanceUpdate { await cb(ev) } + + case .stageInstanceDelete(let ev): + if let cb = await client.onStageInstanceDelete { await cb(ev) } + + // MARK: Subscriptions (Monetization) + case .subscriptionCreate(let ev): + if let cb = await client.onSubscriptionCreate { await cb(ev) } + + case .subscriptionUpdate(let ev): + if let cb = await client.onSubscriptionUpdate { await cb(ev) } + + case .subscriptionDelete(let ev): + if let cb = await client.onSubscriptionDelete { await cb(ev) } + + case .subscriptionGroupSubscriptionCreate(let ev): + if let cb = await client.onSubscriptionGroupSubscriptionCreate { await cb(ev) } + + case .subscriptionGroupSubscriptionUpdate(let ev): + if let cb = await client.onSubscriptionGroupSubscriptionUpdate { await cb(ev) } + + case .subscriptionGroupSubscriptionDelete(let ev): + if let cb = await client.onSubscriptionGroupSubscriptionDelete { await cb(ev) } + + // MARK: Guild Join Requests + case .guildJoinRequestUpdate(let ev): + if let cb = await client.onGuildJoinRequestUpdate { await cb(ev) } + case .userUpdate(let ev): if let cb = await client.onUserUpdate { await cb(ev) } diff --git a/Sources/SwiftDisc/Internal/JSONValue.swift b/Sources/SwiftDisc/Internal/JSONValue.swift index a9fb6be5..9ed503f8 100644 --- a/Sources/SwiftDisc/Internal/JSONValue.swift +++ b/Sources/SwiftDisc/Internal/JSONValue.swift @@ -3,7 +3,7 @@ import Foundation /// A dynamically-typed JSON value for flexible decoding. /// /// Used when Discord returns values whose type varies by context. -public enum JSONValue: Codable, Hashable, Sendable { +public enum JSONValue: Codable, Hashable, Sendable, CustomStringConvertible { case string(String) case number(Double) case int(Int) @@ -17,6 +17,7 @@ public enum JSONValue: Codable, Hashable, Sendable { if container.decodeNil() { self = .null; return } if let v = try? container.decode(String.self) { self = .string(v); return } if let v = try? container.decode(Int.self) { self = .int(v); return } + if let v = try? container.decode(UInt64.self), let intV = Int(exactly: v) { self = .int(intV); return } if let v = try? container.decode(Double.self) { self = .number(v); return } if let v = try? container.decode(Bool.self) { self = .bool(v); return } if let v = try? container.decode([String: JSONValue].self) { self = .object(v); return } @@ -24,6 +25,18 @@ public enum JSONValue: Codable, Hashable, Sendable { self = .null } + public var description: String { + switch self { + case .string(let s): return "\"\(s)\"" + case .number(let n): return String(n) + case .int(let i): return String(i) + case .bool(let b): return String(b) + case .object(let o): return o.description + case .array(let a): return a.description + case .null: return "null" + } + } + /// Returns a plain-string representation of a scalar JSONValue, or nil for objects/arrays/null. public var stringValue: String? { switch self { diff --git a/Sources/SwiftDisc/Internal/OptionalField.swift b/Sources/SwiftDisc/Internal/OptionalField.swift index 82b9728d..fba10177 100644 --- a/Sources/SwiftDisc/Internal/OptionalField.swift +++ b/Sources/SwiftDisc/Internal/OptionalField.swift @@ -35,7 +35,7 @@ import Foundation /// field entirely when `.absent`). Use the helper /// ``Swift/KeyedEncodingContainer/encode(_:forKey:)-(OptionalField)`` provided /// in this file, which handles all three cases automatically. -public enum OptionalField: Encodable, Sendable { +public enum OptionalField: Codable, Sendable { /// Field is absent from the payload (key omitted entirely). case absent /// Field is present with an explicit JSON `null`. @@ -55,6 +55,16 @@ public enum OptionalField: Encodable, Sendable { return nil } + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else { + let value = try container.decode(Wrapped.self) + self = .value(value) + } + } + public func encode(to encoder: Encoder) throws { // Single-value fallback. The preferred path is via the keyed-container // overload below, which handles `.absent` by skipping the key. diff --git a/Sources/SwiftDisc/Internal/RetryPolicy.swift b/Sources/SwiftDisc/Internal/RetryPolicy.swift index c487b701..407dda8d 100644 --- a/Sources/SwiftDisc/Internal/RetryPolicy.swift +++ b/Sources/SwiftDisc/Internal/RetryPolicy.swift @@ -3,14 +3,14 @@ import Foundation /// Configuration for transient-failure retry behavior in the REST layer. /// /// `RetryPolicy` provides a single source of truth for retry attempts and -/// exponential backoff. The HTTP layer applies this policy to network errors +/// exponential backoff with jitter. The HTTP layer applies this policy to network errors /// and 5xx responses. 429 responses are handled separately by the rate /// limiter, which honours `Retry-After`. /// /// ## Example /// ```swift /// let policy = RetryPolicy(maxAttempts: 4, baseDelay: 0.5, maxDelay: 4.0) -/// let delay = policy.backoffDelay(forAttempt: 2) // 1.0s +/// let delay = policy.backoffDelay(forAttempt: 2) // ~1.0s with jitter /// ``` public struct RetryPolicy: Sendable, Hashable { /// Maximum number of attempts including the initial request. Must be >= 1. @@ -23,23 +23,36 @@ public struct RetryPolicy: Sendable, Hashable { /// Hard ceiling on backoff delay in seconds. public let maxDelay: TimeInterval - public init(maxAttempts: Int = 4, baseDelay: TimeInterval = 0.5, maxDelay: TimeInterval = 4.0) { + /// Jitter factor (0.0 = no jitter, 0.1 = ±10%, 1.0 = ±100%). Applied as random + /// uniform scaling to the backoff delay to prevent thundering herd. + public let jitter: Double + + public init(maxAttempts: Int = 4, baseDelay: TimeInterval = 0.5, maxDelay: TimeInterval = 4.0, jitter: Double = 0.1) { precondition(maxAttempts >= 1, "RetryPolicy.maxAttempts must be >= 1") precondition(baseDelay >= 0, "RetryPolicy.baseDelay must be >= 0") precondition(maxDelay >= baseDelay, "RetryPolicy.maxDelay must be >= baseDelay") + precondition(jitter >= 0 && jitter <= 1, "RetryPolicy.jitter must be in 0...1") self.maxAttempts = maxAttempts self.baseDelay = baseDelay self.maxDelay = maxDelay + self.jitter = jitter } /// Exponential backoff delay for the given attempt number (1-indexed). - /// Attempt 1 returns `baseDelay`, attempt 2 returns `baseDelay * 2`, etc., capped at `maxDelay`. + /// Attempt 1 returns `baseDelay`, attempt 2 returns `baseDelay * 2`, etc., capped at `maxDelay`, + /// with jitter applied to spread retries across multiple clients. public func backoffDelay(forAttempt attempt: Int) -> TimeInterval { let n = max(1, attempt) let raw = baseDelay * pow(2.0, Double(n - 1)) - return min(raw, maxDelay) + let clamped = min(raw, maxDelay) + let jitterRange = clamped * jitter + let offset = Double.random(in: -jitterRange...jitterRange) + return max(0, clamped + offset) } + /// No retry policy (single attempt, no backoff). + public static let noRetry = RetryPolicy(maxAttempts: 1, jitter: 0) + /// Default policy for general transient failures. public static let `default` = RetryPolicy() diff --git a/Sources/SwiftDisc/Internal/TokenStorage.swift b/Sources/SwiftDisc/Internal/TokenStorage.swift index e0e96252..6d228047 100644 --- a/Sources/SwiftDisc/Internal/TokenStorage.swift +++ b/Sources/SwiftDisc/Internal/TokenStorage.swift @@ -16,7 +16,8 @@ import Foundation /// ``` public struct RedactedToken: Sendable, CustomStringConvertible, CustomDebugStringConvertible { /// The raw token string. Avoid logging this directly. - public let rawValue: String + /// - Warning: Internal access to prevent accidental leakage outside the module. + let rawValue: String /// Creates a new redacted token wrapper. /// - Parameter rawValue: The raw bot token (no `Bot ` prefix). diff --git a/Sources/SwiftDisc/Models/Interaction.swift b/Sources/SwiftDisc/Models/Interaction.swift index 4156f006..5b8aafc6 100644 --- a/Sources/SwiftDisc/Models/Interaction.swift +++ b/Sources/SwiftDisc/Models/Interaction.swift @@ -56,8 +56,11 @@ public struct Interaction: Codable, Hashable, Sendable { /// The interaction token used for follow-up responses. public let token: String - /// The version of the interaction. - public let version: Int? + /// The raw version stored from the API (may be absent in older payloads). + private let _version: Int? + + /// The version of the interaction (defaults to 1 when absent from the payload). + public var version: Int { _version ?? 1 } /// The message the interaction was sent for (component interactions only). public let message: Box? @@ -78,6 +81,50 @@ public struct Interaction: Codable, Hashable, Sendable { public let context: Int? + enum CodingKeys: String, CodingKey { + case id, application_id, type, data, guild_id, channel, channel_id + case member, user, token, _version = "version", message, app_permissions + case locale, guild_locale, authorizing_integration_owners, context + } + + public init( + id: InteractionID, + application_id: ApplicationID, + type: InteractionType, + data: ApplicationCommandData? = nil, + guild_id: GuildID? = nil, + channel: ResolvedChannel? = nil, + channel_id: ChannelID? = nil, + member: GuildMember? = nil, + user: User? = nil, + token: String, + version: Int? = nil, + message: Box? = nil, + app_permissions: String? = nil, + locale: String? = nil, + guild_locale: String? = nil, + authorizing_integration_owners: [String: String]? = nil, + context: Int? = nil + ) { + self.id = id + self.application_id = application_id + self.type = type + self.data = data + self.guild_id = guild_id + self.channel = channel + self.channel_id = channel_id + self.member = member + self.user = user + self.token = token + self._version = version + self.message = message + self.app_permissions = app_permissions + self.locale = locale + self.guild_locale = guild_locale + self.authorizing_integration_owners = authorizing_integration_owners + self.context = context + } + // MARK: - Nested Types /// Represents a resolved channel in an interaction. diff --git a/Sources/SwiftDisc/Models/Invite.swift b/Sources/SwiftDisc/Models/Invite.swift index fabc48aa..cf275ffa 100644 --- a/Sources/SwiftDisc/Models/Invite.swift +++ b/Sources/SwiftDisc/Models/Invite.swift @@ -1,5 +1,23 @@ import Foundation +/// The type of a Discord invite. +public enum InviteType: Int, Codable, Sendable { + /// Guild invite. + case guild = 0 + /// Group DM invite. + case groupDm = 1 + /// Friend invite. + case friend = 2 + /// Unknown invite type (forward compatibility). + case unknown = 999 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = InviteType(rawValue: rawValue) ?? .unknown + } +} + /// Represents a Discord invite. /// /// Invites are used to invite users to join a guild or group DM. @@ -30,7 +48,7 @@ public struct Invite: Codable, Hashable, Sendable { public let name: String? /// The channel type. - public let type: Int? + public let type: ChannelType? } /// Partial role returned on community invite objects. @@ -60,8 +78,8 @@ public struct Invite: Codable, Hashable, Sendable { public let unicode_emoji: String? } - /// The type of invite (0 = GUILD, 1 = GROUP_DM, 2 = FRIEND). - public let type: Int? + /// The type of invite. + public let type: InviteType? /// The invite code. public let code: String diff --git a/Sources/SwiftDisc/Models/Monetization.swift b/Sources/SwiftDisc/Models/Monetization.swift index 49173973..b8c2bdc7 100644 --- a/Sources/SwiftDisc/Models/Monetization.swift +++ b/Sources/SwiftDisc/Models/Monetization.swift @@ -11,6 +11,34 @@ public struct SKU: Codable, Hashable, Sendable { public let access_type: Int? } +/// The type of a Discord entitlement. +public enum EntitlementType: Int, Codable, Sendable { + /// Purchase. + case purchase = 1 + /// Premium subscription. + case premiumSubscription = 2 + /// Developer gift. + case developerGift = 3 + /// Test mode purchase. + case testModePurchase = 4 + /// Free purchase. + case freePurchase = 5 + /// User gift. + case userGift = 6 + /// Premium purchase. + case premiumPurchase = 7 + /// Application subscription. + case applicationSubscription = 8 + /// Unknown entitlement type (forward compatibility). + case unknown = 999 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = EntitlementType(rawValue: rawValue) ?? .unknown + } +} + /// Represents an entitlement (purchased premium feature) for a user or guild. public struct Entitlement: Codable, Hashable, Sendable { public let id: EntitlementID @@ -24,6 +52,6 @@ public struct Entitlement: Codable, Hashable, Sendable { public let ends_at: String? public let consumed: Bool? public let deleted: Bool? - public let type: Int? + public let type: EntitlementType? public let subscription_id: String? } diff --git a/Sources/SwiftDisc/Models/Sticker.swift b/Sources/SwiftDisc/Models/Sticker.swift index 909b134b..1f943b6f 100644 --- a/Sources/SwiftDisc/Models/Sticker.swift +++ b/Sources/SwiftDisc/Models/Sticker.swift @@ -1,5 +1,43 @@ import Foundation +/// The type of a Discord sticker. +public enum StickerType: Int, Codable, Sendable { + /// Official sticker in a pack. + case standard = 1 + /// Custom sticker uploaded to a guild. + case guild = 2 + /// Nitro sticker. + case nitro = 3 + /// Unknown sticker type (forward compatibility). + case unknown = 999 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = StickerType(rawValue: rawValue) ?? .unknown + } +} + +/// The format type of a Discord sticker. +public enum StickerFormatType: Int, Codable, Sendable { + /// PNG format. + case png = 1 + /// APNG format. + case apng = 2 + /// Lottie format. + case lottie = 3 + /// GIF format. + case gif = 4 + /// Unknown sticker format type (forward compatibility). + case unknown = 999 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = StickerFormatType(rawValue: rawValue) ?? .unknown + } +} + /// Represents a Discord sticker. /// /// Stickers are custom images that can be added to messages. @@ -27,10 +65,10 @@ public struct Sticker: Codable, Hashable, Sendable { public let tags: String? /// The sticker type. - public let type: Int? + public let type: StickerType? /// The sticker format type. - public let format_type: Int? + public let format_type: StickerFormatType? /// Whether the sticker is available. public let available: Bool? @@ -50,7 +88,7 @@ public struct StickerItem: Codable, Hashable, Sendable { public let name: String /// The sticker format type. - public let format_type: Int + public let format_type: StickerFormatType } /// Represents a Discord sticker pack. diff --git a/Sources/SwiftDisc/Models/User.swift b/Sources/SwiftDisc/Models/User.swift index 56a948fd..4090dc34 100644 --- a/Sources/SwiftDisc/Models/User.swift +++ b/Sources/SwiftDisc/Models/User.swift @@ -57,8 +57,8 @@ public struct User: Codable, Hashable, Sendable { /// The unique ID of the user. public let id: UserID - /// The username of the user (2-32 characters). - public let username: String + /// The username of the user (2-32 characters, may be null for deleted users). + public let username: String? /// The user's discriminator (deprecated, now usually "0000"). public let discriminator: String? @@ -114,7 +114,7 @@ public struct User: Codable, Hashable, Sendable { public init( id: UserID, - username: String, + username: String?, discriminator: String? = nil, globalName: String? = nil, avatar: String? = nil, diff --git a/Sources/SwiftDisc/Models/Webhook.swift b/Sources/SwiftDisc/Models/Webhook.swift index fcb2dbd7..17424530 100644 --- a/Sources/SwiftDisc/Models/Webhook.swift +++ b/Sources/SwiftDisc/Models/Webhook.swift @@ -1,5 +1,23 @@ import Foundation +/// The type of a Discord webhook. +public enum WebhookType: Int, Codable, Sendable { + /// Incoming webhook. + case incoming = 1 + /// Channel follower webhook. + case channelFollower = 2 + /// Application webhook. + case application = 3 + /// Unknown webhook type (forward compatibility). + case unknown = 999 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = WebhookType(rawValue: rawValue) ?? .unknown + } +} + /// Represents a Discord webhook. /// /// Webhooks are a way to send messages to Discord channels without using a bot account. @@ -17,7 +35,7 @@ public struct Webhook: Codable, Hashable, Sendable { public let id: WebhookID /// The webhook type. - public let type: Int + public let type: WebhookType /// The channel ID the webhook is for. public let channel_id: ChannelID? diff --git a/Sources/SwiftDisc/REST/HTTPClient.swift b/Sources/SwiftDisc/REST/HTTPClient.swift index ef49987f..883630d1 100644 --- a/Sources/SwiftDisc/REST/HTTPClient.swift +++ b/Sources/SwiftDisc/REST/HTTPClient.swift @@ -39,10 +39,13 @@ private func parseRetryAfter(data: Data) -> TimeInterval { return 1.0 } -/// A simple async semaphore for limiting concurrent operations +/// A simple async semaphore for limiting concurrent operations. +/// Handles cancellation by removing cancelled waiters from the queue. private actor AsyncSemaphore { private var value: Int - private var waiters: [CheckedContinuation] = [] + private var nextId: Int = 0 + private var waiters: [Int: CheckedContinuation] = [:] + private var waiterOrder: [Int] = [] init(value: Int) { self.value = value @@ -53,18 +56,32 @@ private actor AsyncSemaphore { value -= 1 return } - await withCheckedContinuation { continuation in - waiters.append(continuation) + let id = nextId + nextId += 1 + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + waiters[id] = continuation + waiterOrder.append(id) + } + } onCancel: { + Task { await self.removeCancelledWaiter(id) } } } + private func removeCancelledWaiter(_ id: Int) { + waiters.removeValue(forKey: id) + waiterOrder.removeAll { $0 == id } + } + func signal() { - if let waiter = waiters.first { - waiters.removeFirst() - waiter.resume() - } else { - value += 1 + while let id = waiterOrder.first { + waiterOrder.removeFirst() + if let waiter = waiters.removeValue(forKey: id) { + waiter.resume() + return + } } + value += 1 } } @@ -482,7 +499,7 @@ final class HTTPClient: @unchecked Sendable { return body } - private func makeRouteKey(method: String, path: String) -> String { + func makeRouteKey(method: String, path: String) -> String { let components = path.split(separator: "/").map { String($0) } var majorParam: String? var majorParamIndex: Int? @@ -536,7 +553,10 @@ final class HTTPClient: @unchecked Sendable { do { let (data, http) = try await request() - let headerStrings = Dictionary(uniqueKeysWithValues: http.allHeaderFields.map { (String(describing: $0.key), String(describing: $0.value)) }) + // Use reduce to safely handle duplicate header keys (case-insensitive duplicates) + let headerStrings: [String: String] = http.allHeaderFields.reduce(into: [:]) { result, pair in + result[String(describing: pair.key)] = String(describing: pair.value) + } await rateLimiter.updateFromHeaders(routeKey: routeKey, headers: headerStrings) if let limit = headerStrings["X-RateLimit-Limit"], let limitInt = Int(limit) { @@ -633,6 +653,18 @@ final class HTTPClient: @unchecked Sendable { func getRaw(path: String, query: [String: String]? = nil, headers: [String: String]? = nil, reason: String? = nil) async throws(DiscordError) -> Data { throw DiscordError.unavailable } + + func putMultipart(path: String, jsonBody: B?, files: [FileAttachment], reason: String? = nil) async throws(DiscordError) { + throw DiscordError.unavailable + } + + func postStickerMultipart(path: String, name: String, description: String?, tags: String, file: FileAttachment, reason: String? = nil) async throws(DiscordError) -> T { + throw DiscordError.unavailable + } + + func putFile(path: String, data: Data, filename: String, reason: String? = nil) async throws(DiscordError) { + throw DiscordError.unavailable + } } #endif diff --git a/Sources/SwiftDisc/REST/RateLimiter.swift b/Sources/SwiftDisc/REST/RateLimiter.swift index a377b283..a1baa83a 100644 --- a/Sources/SwiftDisc/REST/RateLimiter.swift +++ b/Sources/SwiftDisc/REST/RateLimiter.swift @@ -35,6 +35,7 @@ actor RateLimiter { try await backoff(after: delay) } } + // Append after backoff so waited requests count against the 50/s budget correctly globalRequestTimestamps.append(Date()) // Respect reactive global rate limit if Discord returned a 429 global. @@ -66,8 +67,11 @@ actor RateLimiter { } func updateFromHeaders(routeKey: String, headers: [String: String]) { - // Convert headers to lowercase dictionary for efficient lookup - let lowercasedHeaders = Dictionary(uniqueKeysWithValues: headers.map { ($0.key.lowercased(), $0.value) }) + // Convert headers to lowercase dictionary for efficient lookup. + // Use reduce to safely handle duplicate keys (case-insensitive duplicates). + let lowercasedHeaders: [String: String] = headers.reduce(into: [:]) { result, pair in + result[pair.key.lowercased()] = pair.value + } func header(_ key: String) -> String? { lowercasedHeaders[key.lowercased()] @@ -155,6 +159,7 @@ actor RateLimiter { func clearBucket(routeKey: String) { let bucketKey = routeKeyToBucket[routeKey] ?? routeKey bucketStates.removeValue(forKey: bucketKey) + routeKeyToBucket.removeValue(forKey: routeKey) } func clearAllBuckets() { diff --git a/Tests/SwiftDiscTests/CacheTests.swift b/Tests/SwiftDiscTests/CacheTests.swift index 871c0a75..48041cd8 100644 --- a/Tests/SwiftDiscTests/CacheTests.swift +++ b/Tests/SwiftDiscTests/CacheTests.swift @@ -17,4 +17,119 @@ final class CacheTests: XCTestCase { XCTAssertEqual(recent.count, 1) XCTAssertEqual(recent.first?.id, "m2") } + + func testUpsertAndGetUser() async throws { + let cache = Cache() + let user = try TestFixtures.makeUser(id: "u100", username: "alice") + await cache.upsert(user: user) + let retrieved = await cache.getUser(id: UserID("u100")) + XCTAssertEqual(retrieved?.username, "alice") + XCTAssertEqual(retrieved?.id.rawValue, "u100") + } + + func testUpsertAndGetChannel() async throws { + let cache = Cache() + let channel = TestFixtures.makeChannel(id: "ch1", name: "test-channel") + await cache.upsert(channel: channel) + let retrieved = await cache.getChannel(id: ChannelID("ch1")) + XCTAssertEqual(retrieved?.name, "test-channel") + XCTAssertEqual(retrieved?.type, .text) + } + + func testUpsertAndGetGuild() async throws { + let cache = Cache() + let guild = try TestFixtures.makeGuild(id: "g100", name: "Test Server") + await cache.upsert(guild: guild) + let retrieved = await cache.getGuild(id: GuildID("g100")) + XCTAssertEqual(retrieved?.name, "Test Server") + XCTAssertEqual(retrieved?.id.rawValue, "g100") + } + + func testUpsertAndGetRole() async { + let cache = Cache() + let role = TestFixtures.makeRole(id: "r10", name: "Moderator") + await cache.upsert(role: role, guildId: GuildID("g1")) + let retrieved = await cache.getRole(id: RoleID("r10"), guildId: GuildID("g1")) + XCTAssertEqual(retrieved?.name, "Moderator") + } + + func testAddAndGetMessages() async throws { + let cache = Cache() + let user = try TestFixtures.makeUser() + let msg1 = try TestFixtures.makeMessage(id: "m1", channelId: "c1", content: "first", author: user) + let msg2 = try TestFixtures.makeMessage(id: "m2", channelId: "c1", content: "second", author: user) + + await cache.add(message: msg1) + await cache.add(message: msg2) + + let messages = await cache.getMessages(channelId: ChannelID("c1")) + XCTAssertEqual(messages.count, 2) + XCTAssertEqual(messages[0].content, "first") + XCTAssertEqual(messages[1].content, "second") + } + + func testRemoveUser() async throws { + let cache = Cache() + let user = try TestFixtures.makeUser(id: "u_remove") + await cache.upsert(user: user) + var retrieved = await cache.getUser(id: UserID("u_remove")) + XCTAssertNotNil(retrieved) + await cache.removeUser(id: UserID("u_remove")) + retrieved = await cache.getUser(id: UserID("u_remove")) + XCTAssertNil(retrieved) + } + + func testClearCache() async throws { + let cache = Cache() + let user = try TestFixtures.makeUser(id: "u1") + let channel = TestFixtures.makeChannel(id: "ch1") + let guild = try TestFixtures.makeGuild(id: "g1") + await cache.upsert(user: user) + await cache.upsert(channel: channel) + await cache.upsert(guild: guild) + + await cache.clear() + + let u = await cache.getUser(id: UserID("u1")) + XCTAssertNil(u) + let c = await cache.getChannel(id: ChannelID("ch1")) + XCTAssertNil(c) + let g = await cache.getGuild(id: GuildID("g1")) + XCTAssertNil(g) + } + + func testRemoveMessagesForChannel() async throws { + let cache = Cache() + let user = try TestFixtures.makeUser() + let msg1 = try TestFixtures.makeMessage(id: "m1", channelId: "c_rm", content: "a", author: user) + let msg2 = try TestFixtures.makeMessage(id: "m2", channelId: "c_rm", content: "b", author: user) + let msg3 = try TestFixtures.makeMessage(id: "m3", channelId: "c_other", content: "c", author: user) + + await cache.add(message: msg1) + await cache.add(message: msg2) + await cache.add(message: msg3) + + await cache.removeMessagesForChannel(channelId: ChannelID("c_rm")) + + let remaining = await cache.getMessages(channelId: ChannelID("c_rm")) + XCTAssertTrue(remaining.isEmpty) + let otherMessages = await cache.getMessages(channelId: ChannelID("c_other")) + XCTAssertEqual(otherMessages.count, 1) + } + + func testEnsureChannelStub() async throws { + let cache = Cache() + await cache.ensureChannelStub(id: ChannelID("stub1"), type: .text) + let channel = await cache.getChannel(id: ChannelID("stub1")) + XCTAssertNotNil(channel) + XCTAssertEqual(channel?.id.rawValue, "stub1") + XCTAssertEqual(channel?.type, .text) + + let existing = TestFixtures.makeChannel(id: "stub1", name: "real-name") + await cache.upsert(channel: existing) + await cache.ensureChannelStub(id: ChannelID("stub1"), type: .dm) + let after = await cache.getChannel(id: ChannelID("stub1")) + XCTAssertEqual(after?.name, "real-name") + XCTAssertEqual(after?.type, .text) + } } diff --git a/Tests/SwiftDiscTests/HTTPClientTests.swift b/Tests/SwiftDiscTests/HTTPClientTests.swift new file mode 100644 index 00000000..de8fec35 --- /dev/null +++ b/Tests/SwiftDiscTests/HTTPClientTests.swift @@ -0,0 +1,108 @@ +import XCTest +@testable import SwiftDisc + +final class HTTPClientTests: XCTestCase { + private actor EventCapture { + var event: RateLimitEvent? + func set(_ e: RateLimitEvent) { event = e } + } + + private struct TestResponse: Codable, Sendable { + let id: String + let value: String + } + + func testBasicGETRequest() async throws { + let transport = MockHTTPTransport() + let config = DiscordConfiguration() + let client = HTTPClient(token: "test_token", configuration: config, transport: transport) + + let responseData = try JSONEncoder().encode(TestResponse(id: "42", value: "hello")) + let path = "/api/v10/test/endpoint" + await transport.addResponse(for: path, data: responseData) + + let result: TestResponse = try await client.get(path: "test/endpoint") + XCTAssertEqual(result.id, "42") + XCTAssertEqual(result.value, "hello") + } + + func testRateLimitHeaderParsing() async throws { + let capture = EventCapture() + let config = DiscordConfiguration(onRateLimit: { event in + Task { await capture.set(event) } + }) + let transport = MockHTTPTransport() + let client = HTTPClient(token: "test_token", configuration: config, transport: transport) + + let responseData = try JSONEncoder().encode(["ok": true]) + let path = "/api/v10/channels/123/messages" + await transport.addResponse( + for: path, + data: responseData, + headers: [ + "X-RateLimit-Remaining": "4", + "X-RateLimit-Reset-After": "0.5", + "X-RateLimit-Limit": "5" + ] + ) + + let result: [String: Bool] = try await client.get(path: "channels/123/messages") + XCTAssertEqual(result["ok"], true) + + let captured = await capture.event + let event = try XCTUnwrap(captured) + XCTAssertEqual(event.remaining, 4) + XCTAssertEqual(event.limit, 5) + XCTAssertFalse(event.isGlobal) + } + + func test429RetryWithBackoff() async throws { + let transport = MockHTTPTransport() + let config = DiscordConfiguration() + let client = HTTPClient(token: "test_token", configuration: config, transport: transport) + + let successData = try JSONEncoder().encode(TestResponse(id: "1", value: "ok")) + let path = "/api/v10/guilds/111/channels" + + // First call returns 429 with minimal retry-after + await transport.addResponse( + for: path, + data: Data("\"rate limited\"".utf8), + statusCode: 429, + headers: ["Retry-After": "0.001"] + ) + // Second call (retry) returns success + await transport.addResponse( + for: path, + data: successData, + statusCode: 200 + ) + + let result: TestResponse = try await client.get(path: "guilds/111/channels") + XCTAssertEqual(result.id, "1") + XCTAssertEqual(result.value, "ok") + } + + func testMakeRouteKeySimplePath() throws { + let client = HTTPClient(token: "t", configuration: DiscordConfiguration()) + + let key = client.makeRouteKey(method: "GET", path: "channels/123/messages") + XCTAssertEqual(key, "GET:channels/123/messages|major=123") + } + + func testMakeRouteKeyWithSnowflakes() throws { + let client = HTTPClient(token: "t", configuration: DiscordConfiguration()) + + let noMajor = client.makeRouteKey(method: "POST", path: "some/endpoint") + XCTAssertEqual(noMajor, "POST:some/endpoint|major=global") + + let channelId = client.makeRouteKey(method: "GET", path: "channels/987654321098765432/messages/12345") + XCTAssertEqual(channelId, "GET:channels/987654321098765432/messages/:id|major=987654321098765432") + + let guildId = client.makeRouteKey(method: "GET", path: "guilds/111111111111111111/channels") + XCTAssertEqual(guildId, "GET:guilds/111111111111111111/channels|major=111111111111111111") + + let webhook = client.makeRouteKey(method: "POST", path: "webhooks/555555555555555555/abcdef123") + XCTAssertEqual(webhook, "POST:webhooks/555555555555555555/abcdef123|major=555555555555555555") + } +} diff --git a/Tests/SwiftDiscTests/InternalTests.swift b/Tests/SwiftDiscTests/InternalTests.swift index 72c42354..6c8a050f 100644 --- a/Tests/SwiftDiscTests/InternalTests.swift +++ b/Tests/SwiftDiscTests/InternalTests.swift @@ -79,21 +79,21 @@ final class InternalTests: XCTestCase { } func testRetryPolicyBackoffDelay() { - let policy = RetryPolicy(maxAttempts: 4, baseDelay: 0.5, maxDelay: 4.0) + let policy = RetryPolicy(maxAttempts: 4, baseDelay: 0.5, maxDelay: 4.0, jitter: 0) XCTAssertEqual(policy.backoffDelay(forAttempt: 1), 0.5) XCTAssertEqual(policy.backoffDelay(forAttempt: 2), 1.0) XCTAssertEqual(policy.backoffDelay(forAttempt: 3), 2.0) XCTAssertEqual(policy.backoffDelay(forAttempt: 4), 4.0) - XCTAssertEqual(policy.backoffDelay(forAttempt: 5), 4.0) // Capped at maxDelay + XCTAssertEqual(policy.backoffDelay(forAttempt: 5), 4.0) } func testRetryPolicyBackoffDelayCappedAtMax() { - let policy = RetryPolicy(maxAttempts: 10, baseDelay: 0.5, maxDelay: 2.0) + let policy = RetryPolicy(maxAttempts: 10, baseDelay: 0.5, maxDelay: 2.0, jitter: 0) XCTAssertEqual(policy.backoffDelay(forAttempt: 1), 0.5) XCTAssertEqual(policy.backoffDelay(forAttempt: 2), 1.0) XCTAssertEqual(policy.backoffDelay(forAttempt: 3), 2.0) - XCTAssertEqual(policy.backoffDelay(forAttempt: 4), 2.0) // Capped - XCTAssertEqual(policy.backoffDelay(forAttempt: 10), 2.0) // Still capped + XCTAssertEqual(policy.backoffDelay(forAttempt: 4), 2.0) + XCTAssertEqual(policy.backoffDelay(forAttempt: 10), 2.0) } func testRetryPolicyPreconditions() { diff --git a/Tests/SwiftDiscTests/MockTransport.swift b/Tests/SwiftDiscTests/MockTransport.swift new file mode 100644 index 00000000..ab1b0ee7 --- /dev/null +++ b/Tests/SwiftDiscTests/MockTransport.swift @@ -0,0 +1,119 @@ +import Foundation +@testable import SwiftDisc + +/// A mock HTTP transport that returns pre-configured responses. +actor MockHTTPTransport: HTTPTransport { + private var responseQueues: [String: [(data: Data, statusCode: Int, headers: [String: String])]] = [:] + private var requestedPaths: [String] = [] + + func addResponse(for path: String, data: Data, statusCode: Int = 200, headers: [String: String] = [:]) { + var queue = responseQueues[path] ?? [] + queue.append((data, statusCode, headers)) + responseQueues[path] = queue + } + + func request(method: String, url: URL, body: Data?, headers: [String: String]?) async throws -> HTTPResponse { + let path = url.path + requestedPaths.append("\(method):\(path)") + guard var queue = responseQueues[path], !queue.isEmpty else { + throw DiscordError.http(404, "No mock response for \(path)") + } + let response = queue.removeFirst() + if queue.isEmpty { + responseQueues.removeValue(forKey: path) + } else { + responseQueues[path] = queue + } + return HTTPResponse(data: response.data, statusCode: response.statusCode, headers: response.headers) + } + + func getRequestedPaths() -> [String] { requestedPaths } + + func reset() { + responseQueues.removeAll() + requestedPaths.removeAll() + } +} + +/// A mock WebSocket transport that simulates gateway events. +final class MockWebSocketTransport: @unchecked Sendable, WebSocketTransport { + private let lock = NSLock() + private var _messages: [WebSocketMessage] = [] + private var _sentMessages: [String] = [] + private var _closeCode: Int? = nil + private var _shouldThrowOnReceive = false + + var closeCode: Int? { lock.withLock { _closeCode } } + + func addMessage(_ message: WebSocketMessage) { lock.withLock { _messages.append(message) } } + func addString(_ text: String) { lock.withLock { _messages.append(.string(text)) } } + + func receive() async throws -> WebSocketMessage { + try Task.checkCancellation() + if lock.withLock({ _shouldThrowOnReceive }) { + throw DiscordError.gateway("Simulated receive error") + } + if let msg = lock.withLock({ _messages.isEmpty ? nil : _messages.removeFirst() }) { + return msg + } + throw DiscordError.gateway("No mock messages") + } + + func send(_ message: WebSocketMessage) async throws { + if case .string(let text) = message { lock.withLock { _sentMessages.append(text) } } + } + + func sendPing() async throws {} + func close() async { lock.withLock { _closeCode = 1000 } } + func forceClose() async { lock.withLock { _closeCode = 1006 } } + + func getSentMessages() -> [String] { lock.withLock { _sentMessages } } + func setThrowOnReceive(_ shouldThrow: Bool) { lock.withLock { _shouldThrowOnReceive = shouldThrow } } +} + +/// Minimal VoiceState struct for API compliance (voice not yet planned). +public struct VoiceState: Codable, Sendable, Hashable { + public let guild_id: GuildID? + public let channel_id: ChannelID? + public let user_id: UserID + public let member: GuildMember? + public let session_id: String + public let deaf: Bool + public let mute: Bool + public let self_deaf: Bool + public let self_mute: Bool + public let self_stream: Bool? + public let self_video: Bool? + public let suppress: Bool + public let request_to_speak_timestamp: String? + + public init( + guild_id: GuildID? = nil, + channel_id: ChannelID? = nil, + user_id: UserID, + member: GuildMember? = nil, + session_id: String, + deaf: Bool = false, + mute: Bool = false, + self_deaf: Bool = false, + self_mute: Bool = false, + self_stream: Bool? = nil, + self_video: Bool? = nil, + suppress: Bool = false, + request_to_speak_timestamp: String? = nil + ) { + self.guild_id = guild_id + self.channel_id = channel_id + self.user_id = user_id + self.member = member + self.session_id = session_id + self.deaf = deaf + self.mute = mute + self.self_deaf = self_deaf + self.self_mute = self_mute + self.self_stream = self_stream + self.self_video = self_video + self.suppress = suppress + self.request_to_speak_timestamp = request_to_speak_timestamp + } +} diff --git a/Tests/SwiftDiscTests/RateLimiterTests.swift b/Tests/SwiftDiscTests/RateLimiterTests.swift new file mode 100644 index 00000000..cfdb0766 --- /dev/null +++ b/Tests/SwiftDiscTests/RateLimiterTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import SwiftDisc + +final class RateLimiterTests: XCTestCase { + func testWaitTurnAllowsRequestsUnderLimit() async throws { + let limiter = RateLimiter() + for _ in 0..<50 { + try await limiter.waitTurn(routeKey: "test/route") + } + let state = await limiter.getBucketState(routeKey: "test/route") + XCTAssertNil(state) + } + + func testWaitTurnBlocksWhenOverLimit() async throws { + let limiter = RateLimiter() + for _ in 0..<50 { + try await limiter.waitTurn(routeKey: "test/route") + } + let start = Date() + try await limiter.waitTurn(routeKey: "test/route") + let elapsed = Date().timeIntervalSince(start) + XCTAssertGreaterThan(elapsed, 0.5) + } + + func testUpdateFromHeadersTracksRemainingAndLimit() async throws { + let limiter = RateLimiter() + await limiter.updateFromHeaders(routeKey: "channels/123", headers: [ + "X-RateLimit-Remaining": "9", + "X-RateLimit-Limit": "10", + "X-RateLimit-Reset-After": "1.0" + ]) + let state = await limiter.getBucketState(routeKey: "channels/123") + XCTAssertEqual(state?.remaining, 9) + XCTAssertEqual(state?.limit, 10) + } + + func testUpdateFromHeadersTracksReset() async throws { + let limiter = RateLimiter() + await limiter.updateFromHeaders(routeKey: "test/route", headers: [ + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset-After": "0.5" + ]) + let state = await limiter.getBucketState(routeKey: "test/route") + XCTAssertEqual(state?.remaining, 0) + let resetAt = try XCTUnwrap(state?.resetAt) + XCTAssertLessThan(resetAt.timeIntervalSinceNow, 0.6) + XCTAssertGreaterThan(resetAt.timeIntervalSinceNow, 0) + } + + func testBucketKeyResolution() async throws { + let limiter = RateLimiter() + await limiter.updateFromHeaders(routeKey: "channels/123", headers: [ + "X-RateLimit-Bucket": "abc123", + "X-RateLimit-Remaining": "4", + "X-RateLimit-Reset-After": "1.0" + ]) + let state = await limiter.getBucketState(routeKey: "channels/123") + XCTAssertEqual(state?.remaining, 4) + let allStates = await limiter.getAllBucketStates() + XCTAssertNotNil(allStates["abc123"]) + } + + func testClearBucket() async throws { + let limiter = RateLimiter() + await limiter.updateFromHeaders(routeKey: "test/route", headers: [ + "X-RateLimit-Remaining": "5", + "X-RateLimit-Reset-After": "2.0" + ]) + var state = await limiter.getBucketState(routeKey: "test/route") + XCTAssertNotNil(state) + await limiter.clearBucket(routeKey: "test/route") + state = await limiter.getBucketState(routeKey: "test/route") + XCTAssertNil(state) + } + + func testClearAllBuckets() async throws { + let limiter = RateLimiter() + await limiter.updateFromHeaders(routeKey: "route/a", headers: [ + "X-RateLimit-Remaining": "1", + "X-RateLimit-Reset-After": "1.0" + ]) + await limiter.updateFromHeaders(routeKey: "route/b", headers: [ + "X-RateLimit-Remaining": "2", + "X-RateLimit-Reset-After": "2.0" + ]) + var allStates = await limiter.getAllBucketStates() + XCTAssertEqual(allStates.count, 2) + await limiter.clearAllBuckets() + allStates = await limiter.getAllBucketStates() + XCTAssertTrue(allStates.isEmpty) + } +} diff --git a/Tests/SwiftDiscTests/SlashCommandRouterTests.swift b/Tests/SwiftDiscTests/SlashCommandRouterTests.swift index 29bfa56d..0d2e2cc5 100644 --- a/Tests/SwiftDiscTests/SlashCommandRouterTests.swift +++ b/Tests/SwiftDiscTests/SlashCommandRouterTests.swift @@ -7,7 +7,7 @@ final class SlashCommandRouterTests: XCTestCase { let optUser = Interaction.ApplicationCommandData.Option(name: "user", type: 3, value: .string("123"), options: nil, focused: nil) let sub = Interaction.ApplicationCommandData.Option(name: "ban", type: 1, value: nil, options: [optUser], focused: nil) let data = Interaction.ApplicationCommandData(id: nil, name: "admin", type: 1, resolved: nil, options: [sub], custom_id: nil, component_type: nil, values: nil, target_id: nil, components: nil, attachments: nil) - let interaction = Interaction(id: "1", application_id: "app", type: .applicationCommand, data: data, guild_id: "guild", channel: nil, channel_id: "chan", member: nil, user: nil, token: "tok", version: nil, message: nil, app_permissions: nil, locale: nil, guild_locale: nil, authorizing_integration_owners: nil, context: nil) + let interaction = Interaction(id: "1", application_id: "app", type: .applicationCommand, data: data, guild_id: "guild", channel: nil, channel_id: "chan", member: nil, user: nil, token: "tok", version: 1, message: nil, app_permissions: nil, locale: nil, guild_locale: nil, authorizing_integration_owners: nil, context: nil) let client = DiscordClient(token: "x") let router = SlashCommandRouter() diff --git a/Tests/SwiftDiscTests/TestFixtures.swift b/Tests/SwiftDiscTests/TestFixtures.swift index 2ce2a7b9..b41c03d1 100644 --- a/Tests/SwiftDiscTests/TestFixtures.swift +++ b/Tests/SwiftDiscTests/TestFixtures.swift @@ -2,7 +2,6 @@ import Foundation @testable import SwiftDisc enum TestFixtures { - /// Creates a minimal decodable `User` fixture for tests. static func makeUser(id: String = "u1", username: String = "tester") throws -> User { let payload: [String: Any] = [ "id": id, @@ -12,25 +11,21 @@ enum TestFixtures { return try JSONDecoder().decode(User.self, from: data) } - /// Creates a minimal decodable `Message` fixture with author and content fields. static func makeMessage(id: String = "m1", channelId: String = "c1", content: String = "hello", author: User? = nil) throws -> Message { let resolvedAuthor = try author ?? makeUser() let encoder = JSONEncoder() let authorData = try encoder.encode(resolvedAuthor) let authorObject = try JSONSerialization.jsonObject(with: authorData) - let payload: [String: Any] = [ "id": id, "channel_id": channelId, "author": authorObject, "content": content ] - let data = try JSONSerialization.data(withJSONObject: payload) return try JSONDecoder().decode(Message.self, from: data) } - /// Creates a minimal decodable `Guild` fixture with required fields. static func makeGuild(id: String = "g1", name: String = "Test Guild") throws -> Guild { let payload: [String: Any] = [ "id": id, @@ -40,23 +35,100 @@ enum TestFixtures { return try JSONDecoder().decode(Guild.self, from: data) } - /// Creates a minimal `Role` fixture. static func makeRole(id: String = "r1", name: String = "Test Role") -> Role { Role(id: RoleID(id), name: name, color: nil, colors: nil, hoist: nil, position: nil, permissions: nil, managed: nil, mentionable: nil, icon: nil, unicode_emoji: nil) } - /// Creates a minimal `Emoji` fixture. static func makeEmoji(id: String = "e1", name: String = "testemoji") -> Emoji { Emoji(id: EmojiID(id), name: name, roles: nil, user: nil, require_colons: nil, managed: nil, animated: nil, available: nil) } - /// Creates a minimal component `Interaction` fixture for collector and router tests. + static func makeChannel(id: String = "c1", type: ChannelType = .text, name: String = "general") -> Channel { + Channel( + id: ChannelID(id), + type: type, + name: name, + permission_overwrites: [] + ) + } + + static func makeGuildMember(userId: String = "u1", nick: String? = nil, roles: [RoleID] = [RoleID("r1")]) throws -> GuildMember { + GuildMember( + user: try makeUser(id: userId), + nick: nick, + avatar: nil, + roles: roles, + joined_at: "2024-01-01T00:00:00.000000+00:00", + deaf: false, + mute: false, + permissions: "1024", + banner: nil, + avatar_decoration_data: nil, + collectibles: nil, + flags: nil, + communication_disabled_until: nil, + pending: nil + ) + } + + static func makeEmbed() -> Embed { + Embed( + title: "Test Title", + description: "Test description text", + url: nil, + color: 0x00FF00, + footer: Embed.Footer(text: "Footer text", icon_url: "https://example.com/icon.png", proxy_icon_url: nil), + author: Embed.Author(name: "Author Name", url: "https://example.com", icon_url: "https://example.com/avatar.png"), + fields: [ + Embed.Field(name: "Field 1", value: "Value 1", inline: true), + Embed.Field(name: "Field 2", value: "Value 2", inline: false) + ], + thumbnail: nil, + image: nil, + video: nil, + provider: nil, + timestamp: "2024-01-01T12:00:00.000Z" + ) + } + + static func makeThread(id: String = "t1", name: String = "Test Thread", guildId: String = "g1", parentId: String = "c1") -> Channel { + Channel( + id: ChannelID(id), + type: .publicThread, + name: name, + parent_id: ChannelID(parentId), + thread_metadata: ThreadMetadata( + archived: false, + auto_archive_duration: 1440, + archive_timestamp: "2024-01-01T00:00:00.000000+00:00", + locked: false, + invitable: true, + create_timestamp: "2024-01-01T00:00:00.000000+00:00" + ) + ) + } + + static func makeMessageComponents() -> [MessageComponent] { + let button = MessageComponent.Button(style: 1, label: "Click Me", custom_id: "btn_click") + let row = MessageComponent.ActionRow(components: [.button(button)]) + return [.actionRow(row)] + } + + static func makePresenceUpdate() throws -> PresenceUpdate { + PresenceUpdate( + user: try makeUser(), + guild_id: GuildID("g1"), + status: "online", + activities: [], + client_status: PresenceUpdate.ClientStatus(desktop: nil, mobile: nil, web: "online") + ) + } + static func makeComponentInteraction(customId: String, guildId: String = "guild", channelId: String = "chan", id: String = "1", applicationId: String = "app", token: String = "tok") -> Interaction { let interactionId = InteractionID(id) let appId = ApplicationID(applicationId) let gid = GuildID(guildId) let cid = ChannelID(channelId) - let data = Interaction.ApplicationCommandData( id: nil, name: nil, @@ -70,7 +142,6 @@ enum TestFixtures { components: nil, attachments: nil ) - return Interaction( id: interactionId, application_id: appId, @@ -82,7 +153,7 @@ enum TestFixtures { member: nil, user: nil, token: token, - version: nil, + version: 1, message: nil, app_permissions: nil, locale: nil, @@ -91,4 +162,4 @@ enum TestFixtures { context: nil ) } -} \ No newline at end of file +}