From 450e3fa50958aa22812088ca0c063314bcf032ca Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Mon, 29 Jun 2026 20:16:15 -0400 Subject: [PATCH 01/22] v2.5.0: Full API audit and feature update - Add missing REST endpoints: Set Voice Channel Status, Get Guild Role Member Counts, Invite Target Users (get/update/job status) - Add missing gateway events: VoiceChannelStatusUpdate, VoiceChannelStartTimeUpdate - Update model fields across Message, Guild, GuildMember, User, Invite, Attachment, Reaction, MessageReference, PartialAttachment - Add new structs: MessageSnapshot, MessageCall, SharedClientTheme, ReactionCountDetails, IncidentsData, WelcomeScreen, AvatarDecorationData, Collectibles, InviteTargetUsersJobStatus - Add attachment editing support (description, is_spoiler) via PartialAttachment and editMessage attachments param - Update PermissionBitset with setVoiceChannelStatus - Update Gateway and AuditLog models with voice status fields - Add GUILD_MEDIA channel type to documentation - Add putMultipart and putFile helpers to HTTPClient - Humanize README: restructure, replace em dashes, highlight SwiftDisc differentiators (actor safety, typed models, gateway lifecycle, DX) --- README.md | 141 +++++++++--------- Sources/SwiftDisc/DiscordClient.swift | 46 +++++- Sources/SwiftDisc/Gateway/GatewayModels.swift | 19 +++ Sources/SwiftDisc/Models/Attachment.swift | 7 + Sources/SwiftDisc/Models/AuditLog.swift | 2 + Sources/SwiftDisc/Models/Channel.swift | 1 + Sources/SwiftDisc/Models/Files.swift | 6 +- Sources/SwiftDisc/Models/Guild.swift | 30 ++++ Sources/SwiftDisc/Models/GuildMember.swift | 18 +++ Sources/SwiftDisc/Models/Invite.swift | 22 +++ Sources/SwiftDisc/Models/Message.swift | 49 +++++- .../SwiftDisc/Models/PermissionBitset.swift | 1 + Sources/SwiftDisc/Models/User.swift | 71 +++++++++ Sources/SwiftDisc/REST/HTTPClient.swift | 30 ++++ 14 files changed, 370 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 6a5b2160..66b3d611 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,26 @@ [![CI](https://github.com/M1tsumi/SwiftDisc/actions/workflows/ci.yml/badge.svg)](https://github.com/M1tsumi/SwiftDisc/actions/workflows/ci.yml) -[![Version](https://img.shields.io/badge/release-2.4.1-blue.svg)](https://github.com/M1tsumi/SwiftDisc/releases) +[![Version](https://img.shields.io/badge/release-2.5.0-blue.svg)](https://github.com/M1tsumi/SwiftDisc/releases) [![Swift](https://img.shields.io/badge/Swift-6.2-F05138?logo=swift&logoColor=white)](https://swift.org) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Discord](https://img.shields.io/discord/1439300942167146508?label=discord&logo=discord&logoColor=white)](https://discord.gg/tWyefRKKEH) -Meet **SwiftDisc** — a Swift-first Discord API wrapper that feels like it was written for you, not by a spec sheet. It brings together the full Discord REST API, Gateway events, typed models, and high-level bot-building tools — all built on Swift 6.2's async/await and with actor-safe concurrency from day one. +## Why SwiftDisc? -Whether you're writing your first ping-pong bot or a sharded moderation platform, SwiftDisc gives you the building blocks without getting in your way. +Building a Discord bot in Swift should feel like writing Swift, not like translating REST docs into network calls. SwiftDisc is a Swift-first Discord API wrapper that treats the Discord API as a native Swift library, not a remote JSON endpoint. ---- +### What makes SwiftDisc different? -- [Quick start](#quick-start-1-minute-to-pong) -- [Installation](#installation) -- [How SwiftDisc works](#how-swiftdisc-works) -- [Example programs](#example-programs) -- [Event handling guide](#event-handling-guide) -- [Bot setup checklist](#bot-setup-checklist) -- [Reliability and debugging](#reliability-and-debugging) -- [Documentation map](#documentation-map) -- [Community and support](#community-and-support) +**Gateway lifecycle that just works.** Automatic reconnection, session resumption, heartbeat management, and rate limit backoff are all handled internally. You write your bot logic, not WebSocket plumbing. ---- +**Actor-safe concurrency from day one.** Built on Swift 6.2 structured concurrency with `async/await` and Swift actors. No locking, no queues, no data races. The cache, the gateway, and the REST client are all designed around Swift's sendability guarantees. + +**Everything is typed.** Every Discord API object is a native Swift struct with full `Codable`, `Hashable`, and `Sendable` conformance. The REST client returns concrete types, not `[String: Any]` dictionaries. Invalid payloads fail at compile time, not at 3 AM. + +**One actor to rule them all.** `DiscordClient` owns the gateway connection, the REST client, the event dispatcher, and the cache. You never juggle separate connections or wonder which client to call. + +**Developer experience that scales.** Event handling via closures _and_ async streams, typed error handling with `DiscordError`, plugin-based HTTP transports, and pluggable logging. Start with one server in minutes, shard to thousands without rewriting your bot. ## Quick start (1 minute to pong) @@ -112,7 +110,7 @@ import PackageDescription let package = Package( name: "YourBot", dependencies: [ - .package(url: "https://github.com/M1tsumi/SwiftDisc.git", from: "2.4.0") + .package(url: "https://github.com/M1tsumi/SwiftDisc.git", from: "2.5.0") ], targets: [ .target( @@ -135,27 +133,47 @@ let package = Package( ## How SwiftDisc works -SwiftDisc is organized into a few clear layers so you can grab what you need without digging through a maze: +SwiftDisc is organized into clear layers so you can grab what you need: -**`DiscordClient`** — the main actor. It owns your Gateway connection, the REST client, the event dispatcher, and the cache. You'll spend most of your time here. +**`DiscordClient`** -- the main actor. It owns your Gateway connection, the REST client, the event dispatcher, and the cache. You will spend most of your time here. -**Gateway** — manages the WebSocket connection to Discord's real-time event system. Handles heartbeats, reconnects, resuming sessions, and rate limits so you don't have to. Monitor connection state with `client.connectionState` and react to lifecycle events with `onResumed`, `onDisconnected`, and `onSessionInvalidated`. +**Gateway** -- manages the WebSocket connection to Discord's real-time event system. Handles heartbeats, reconnects, resuming sessions, and rate limits so you do not have to. Monitor connection state with `client.connectionState` and react to lifecycle events with `onResumed`, `onDisconnected`, and `onSessionInvalidated`. -**REST client** — typed HTTP methods for every Discord API endpoint. Methods throw `DiscordError` with descriptive messages, and the built-in rate limiter keeps you under Discord's global and per-route limits. +**REST client** -- typed HTTP methods for every Discord API endpoint. Methods throw `DiscordError` with descriptive messages, and the built-in rate limiter keeps you under Discord's global and per-route limits. **High-level modules** in [Sources/SwiftDisc/HighLevel](Sources/SwiftDisc/HighLevel): -- `SlashCommandRouter` — register and handle slash commands with typed option accessors -- `AutocompleteRouter` — provide live search suggestions for command options -- `CommandRouter` — classic prefix-based commands (e.g. `!ping`) -- `ViewManager` — persistent UI views with `custom_id` matching -- `WebhookClient` — standalone token-free webhook execution -- `MessagePayload` — fluent builder for complex message payloads -- `Collectors` — streams for messages, reactions, and components -- `CooldownManager` — per-user or per-guild rate-limiting for commands -**Models** in [Sources/SwiftDisc/Models](Sources/SwiftDisc/Models) — complete, typed Swift structs for every Discord API object, from Users and Channels to Interactions, Polls, Auto Moderation, Monetization SKUs, and Onboarding prompts. +| Module | What it does | +|--------|-------------| +| `SlashCommandRouter` | Register and handle slash commands with typed option accessors | +| `AutocompleteRouter` | Provide live search suggestions for command options | +| `CommandRouter` | Classic prefix-based commands (e.g. `!ping`) | +| `ViewManager` | Persistent UI views with `custom_id` matching | +| `WebhookClient` | Standalone token-free webhook execution | +| `MessagePayload` | Fluent builder for complex message payloads | +| `Collectors` | Streams for messages, reactions, and components | +| `CooldownManager` | Per-user or per-guild rate limiting for commands | + +**Models** in [Sources/SwiftDisc/Models](Sources/SwiftDisc/Models) -- complete, typed Swift structs for every Discord API object, from Users and Channels to Interactions, Polls, Auto Moderation, Monetization SKUs, and Onboarding prompts. + +**Cache** -- actor-safe in-memory store for users, channels, guilds, roles, emojis, and recent messages. Supports TTL expiration and LRU eviction to keep memory bounded. Inspect cache health with `cache.summary`, `cache.userCount`, etc. + +### Transport system + +The default transport uses URLSession and works everywhere. If you need proxy support on Linux or want to use AsyncHTTPClient, swap in the optional `SwiftDiscAHCTransport` module: + +```swift +import SwiftDisc +import SwiftDiscAHCTransport + +var config = DiscordConfiguration() +config.httpTransport = AHCTransport( + proxy: ProxyConfiguration(host: "proxy.corp.com", port: 8080) +) +let client = DiscordClient(token: token, configuration: config) +``` -**Cache** — actor-safe in-memory store for users, channels, guilds, roles, emojis, and recent messages. Supports TTL expiration and LRU eviction to keep memory bounded. Inspect cache health with `cache.summary`, `cache.userCount`, etc. +Conform to `HTTPTransport` or `WebSocketTransport` to integrate any networking library. ## Example programs @@ -174,7 +192,7 @@ Run any example from the repo root with `swift run `: | [WebhookBot](Examples/WebhookBot.swift) | Create, execute, edit, and delete webhooks | `swift run WebhookBotExample` | | [ShardingBot](Examples/ShardingBot.swift) | Sharded gateway connection with state monitoring | `swift run ShardingBotExample` | -All examples read the bot token from the `DISCORD_BOT_TOKEN` environment variable. Some also use `DISCORD_CHANNEL_ID` — set them before running: +All examples read the bot token from the `DISCORD_BOT_TOKEN` environment variable. Some also use `DISCORD_CHANNEL_ID` -- set them before running: ```bash export DISCORD_BOT_TOKEN="your_token" @@ -205,7 +223,7 @@ await client.setOnGuildCreate { guild in } ``` -Available callbacks: `onReady`, `onMessage`, `onMessageUpdate`, `onGuildCreate`, `onInteractionCreate`, `onReactionAdd`, `onMemberAdd`, and 30+ more — one for every Discord gateway event. +Available callbacks: `onReady`, `onMessage`, `onMessageUpdate`, `onGuildCreate`, `onInteractionCreate`, `onReactionAdd`, `onMemberAdd`, and 30+ more -- one for every Discord gateway event. ### Option 2: Event AsyncStream (flexible, pattern-matching) @@ -227,7 +245,7 @@ for await event in await client.events { } ``` -The event stream is an `AsyncStream` — you can use it with `filter`, `map`, `compactMap`, and other async algorithms. You can also use both callbacks and the stream at the same time. +The event stream is an `AsyncStream` -- you can use it with `filter`, `map`, `compactMap`, and other async algorithms. You can also use both callbacks and the stream at the same time. ### Connection state observability @@ -253,48 +271,35 @@ Or grab the current state synchronously: `let state = await client.gatewayStatus React to connection lifecycle events: ```swift -await client.onResumed = { print("Session resumed — missed events replayed") } +await client.onResumed = { print("Session resumed -- missed events replayed") } await client.onDisconnected = { reason in print("Disconnected: \(reason)") } -await client.onSessionInvalidated = { print("Session invalidated — will re-identify on next connect") } +await client.onSessionInvalidated = { print("Session invalidated -- will re-identify on next connect") } ``` ## Bot setup checklist -If your bot connects but doesn't receive events, check these in order: +If your bot connects but does not receive events, check these in order: -1. **Is your token set?** — `DISCORD_BOT_TOKEN` must be a valid bot token from the Discord Developer Portal. -2. **Are you requesting the right intents?** — Pass them to `loginAndConnect(intents:)`. For example, to read message content you need `[.guilds, .guildMessages, .messageContent]`. -3. **Are privileged intents enabled?** — In the Developer Portal, go to your app's Bot page and toggle `MESSAGE CONTENT INTENT`, `SERVER MEMBERS INTENT`, and `PRESENCE INTENT` as needed. These are required even if you pass them in code. -4. **Was the bot invited with the right scopes?** — Use the OAuth2 URL generator in the Developer Portal and include the `bot` scope plus the permissions your bot needs. -5. **Is the bot in the guild?** — The bot must be a member of the guild to receive events from it. -6. **Check for close codes** — Watch console output for Gateway close codes like `4004` (bad token), `4013` (invalid intents), or `4014` (disallowed privileged intent). +1. **Is your token set?** -- `DISCORD_BOT_TOKEN` must be a valid bot token from the Discord Developer Portal. +2. **Are you requesting the right intents?** -- Pass them to `loginAndConnect(intents:)`. For example, to read message content you need `[.guilds, .guildMessages, .messageContent]`. +3. **Are privileged intents enabled?** -- In the Developer Portal, go to your app's Bot page and toggle `MESSAGE CONTENT INTENT`, `SERVER MEMBERS INTENT`, and `PRESENCE INTENT` as needed. These are required even if you pass them in code. +4. **Was the bot invited with the right scopes?** -- Use the OAuth2 URL generator in the Developer Portal and include the `bot` scope plus the permissions your bot needs. +5. **Is the bot in the guild?** -- The bot must be a member of the guild to receive events from it. +6. **Check for close codes** -- Watch console output for Gateway close codes like `4004` (bad token), `4013` (invalid intents), or `4014` (disallowed privileged intent). ## Reliability and debugging -SwiftDisc is built to be resilient by default — automatic reconnection, rate-limit backoff, and session resumption are all handled internally. When you need to look under the hood: - -**Gateway decode diagnostics** — Enable `DiscordConfiguration.enableGatewayDecodeDiagnostics` to log payload decoding failures with opcode context and payload previews. Essential when adding support for new Discord features. +SwiftDisc is built to be resilient by default -- automatic reconnection, rate-limit backoff, and session resumption are all handled internally. When you need to look under the hood: -**Rate limit observability** — Set `DiscordConfiguration.onRateLimit` to receive `RateLimitEvent` snapshots for REST bucket updates and waits. Useful for tuning request patterns. +**Gateway decode diagnostics** -- Enable `DiscordConfiguration.enableGatewayDecodeDiagnostics` to log payload decoding failures with opcode context and payload previews. Essential when adding support for new Discord features. -**Structured logging** — Provide a custom logger via `DiscordConfiguration.logger`. The built-in `DefaultDiscordLogger` uses `os_log` on Apple platforms and `print` on others. Implement the `DiscordLogger` protocol to route to your own backend. +**Rate limit observability** -- Set `DiscordConfiguration.onRateLimit` to receive `RateLimitEvent` snapshots for REST bucket updates and waits. Useful for tuning request patterns. -**Pluggable HTTP and WebSocket transports** — Swap out the default URLSession networking for a custom implementation. Useful when you need proxy support on Linux, want to use AsyncHTTPClient, or need fine-grained control over connection behaviour. - -```swift -import SwiftDisc -import SwiftDiscAHCTransport - -var config = DiscordConfiguration() -config.httpTransport = AHCTransport( - proxy: ProxyConfiguration(host: "proxy.corp.com", port: 8080) -) -let client = DiscordClient(token: token, configuration: config) -``` +**Structured logging** -- Provide a custom logger via `DiscordConfiguration.logger`. The built-in `DefaultDiscordLogger` uses `os_log` on Apple platforms and `print` on others. Implement the `DiscordLogger` protocol to route to your own backend. -The default URLSession transport is used when no custom transport is provided — no code changes needed for existing bots. Conform to `HTTPTransport` or `WebSocketTransport` to integrate any networking library. +**Pluggable HTTP and WebSocket transports** -- Swap out the default URLSession networking for a custom implementation. Useful when you need proxy support on Linux, want to use AsyncHTTPClient, or need fine-grained control over connection behavior. -**Typed error handling** — All operations throw `DiscordError` with descriptive messages. Use convenience properties to inspect errors: +**Typed error handling** -- All operations throw `DiscordError` with descriptive messages. Use convenience properties to inspect errors: ```swift catch let error as DiscordError { if error.isRateLimited { /* back off */ } @@ -304,9 +309,9 @@ catch let error as DiscordError { } ``` -**Router error handlers** — `CommandRouter`, `SlashCommandRouter`, and `ViewManager` support custom error handlers that receive context about the failed operation. Set them during initialization. +**Router error handlers** -- `CommandRouter`, `SlashCommandRouter`, and `ViewManager` support custom error handlers that receive context about the failed operation. Set them during initialization. -**Cache statistics** — Inspect cache contents any time: +**Cache statistics** -- Inspect cache contents any time: ```swift print(await cache.summary) // "Cache: 843 users, 127 channels, 5 guilds, 3412 messages in 34 channels, ..." @@ -314,9 +319,9 @@ print(await cache.summary) ## Documentation map -| Resource | What you'll find | -|----------|-----------------| -| [**GitHub Pages**](https://M1tsumi.github.io/SwiftDisc/) | Auto-generated DocC documentation for SwiftDisc — browseable API reference with search | +| Resource | What you will find | +|----------|-------------------| +| [**GitHub Pages**](https://M1tsumi.github.io/SwiftDisc/) | Auto-generated DocC documentation for SwiftDisc -- browseable API reference with search | | [**CHANGELOG.md**](CHANGELOG.md) | Detailed per-release changelog following Keep a Changelog | | [**CONTRIBUTING.md**](CONTRIBUTING.md) | How to set up, build, test, and submit PRs | | [**Examples/README.md**](Examples/README.md) | Quick-start guides for every example bot | @@ -325,9 +330,9 @@ print(await cache.summary) ## Community and support -- **Discord server** — [https://discord.gg/tWyefRKKEH](https://discord.gg/tWyefRKKEH) — get help, discuss features, show off your bot -- **GitHub Issues** — [https://github.com/M1tsumi/SwiftDisc/issues](https://github.com/M1tsumi/SwiftDisc/issues) — report bugs and request features -- **GitHub Discussions** — available on the repo for longer conversations +- **Discord server** -- [https://discord.gg/tWyefRKKEH](https://discord.gg/tWyefRKKEH) -- get help, discuss features, show off your bot +- **GitHub Issues** -- [https://github.com/M1tsumi/SwiftDisc/issues](https://github.com/M1tsumi/SwiftDisc/issues) -- report bugs and request features +- **GitHub Discussions** -- available on the repo for longer conversations ## License diff --git a/Sources/SwiftDisc/DiscordClient.swift b/Sources/SwiftDisc/DiscordClient.swift index 9ac0ac60..57c04783 100644 --- a/Sources/SwiftDisc/DiscordClient.swift +++ b/Sources/SwiftDisc/DiscordClient.swift @@ -2497,24 +2497,26 @@ 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) async throws -> Message { + public func editMessage(channelId: ChannelID, messageId: MessageID, content: OptionalField = .absent, embeds: [Embed]? = nil, components: [MessageComponent]? = nil, attachments: [PartialAttachment]? = nil) async throws -> Message { struct Body: Encodable, Sendable { let content: OptionalField let embeds: [Embed]? let components: [MessageComponent]? + let attachments: [PartialAttachment]? func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(content, forKey: .content) 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) } } enum CodingKeys: String, CodingKey { - case content, embeds, components + case content, embeds, components, attachments } } - return try await http.patch(path: "/channels/\(channelId)/messages/\(messageId)", body: Body(content: content, embeds: embeds, components: components)) + return try await http.patch(path: "/channels/\(channelId)/messages/\(messageId)", body: Body(content: content, embeds: embeds, components: components, attachments: attachments)) } /// Lists recent messages from a channel. @@ -4226,4 +4228,42 @@ public actor DiscordClient { public func getApplicationSubscription(applicationId: ApplicationID, subscriptionId: AppSubscriptionID) async throws -> AppSubscription { try await http.get(path: "/applications/\(applicationId)/subscriptions/\(subscriptionId)") } + + // MARK: - REST: Voice Channel Status + + /// Set a voice channel's status. + /// Requires `SET_VOICE_CHANNEL_STATUS` permission. + /// Also requires `MANAGE_CHANNELS` if the bot is not connected to the voice channel. + public func setVoiceChannelStatus(channelId: ChannelID, status: String?) async throws { + struct Body: Encodable, Sendable { + let status: String? + } + try await http.put(path: "/channels/\(channelId)/voice-status", body: Body(status: status)) + } + + // MARK: - REST: Guild Role Member Counts + + /// Get the number of members that have each role in a guild. + public func getGuildRoleMemberCounts(guildId: GuildID) async throws -> [RoleMemberCount] { + try await http.get(path: "/guilds/\(guildId)/roles/member-counts") + } + + // MARK: - REST: Invite Target Users + + /// Get the users allowed to see and accept an invite. + /// Response is a CSV file with the header `user_id`. + public func getInviteTargetUsers(code: String) async throws -> Data { + try await http.getRaw(path: "/invites/\(code)/target-users") + } + + /// Update the users allowed to see and accept an invite. + /// Uploads a CSV file with user IDs. + public func updateInviteTargetUsers(code: String, csvData: Data) async throws { + try await http.putFile(path: "/invites/\(code)/target-users", data: csvData, filename: "target_users.csv") + } + + /// Check the status of a target-users processing job. + public func getInviteTargetUsersJobStatus(code: String) async throws -> InviteTargetUsersJobStatus { + try await http.get(path: "/invites/\(code)/target-users/job-status") + } } diff --git a/Sources/SwiftDisc/Gateway/GatewayModels.swift b/Sources/SwiftDisc/Gateway/GatewayModels.swift index fa4c9859..e9924f89 100644 --- a/Sources/SwiftDisc/Gateway/GatewayModels.swift +++ b/Sources/SwiftDisc/Gateway/GatewayModels.swift @@ -328,6 +328,9 @@ public enum DiscordEvent: Hashable, Sendable { case soundboardSoundCreate(SoundboardSound) case soundboardSoundUpdate(SoundboardSound) case soundboardSoundDelete(SoundboardSound) + // Voice Channel Status + case voiceChannelStatusUpdate(VoiceChannelStatusUpdate) + case voiceChannelStartTimeUpdate(VoiceChannelStartTimeUpdate) // User update case userUpdate(User) // Entitlements @@ -796,6 +799,22 @@ public struct SoundboardSound: Codable, Hashable, Sendable { // Entitlement model in Models/Monetization.swift +// MARK: - Voice Channel Status + +/// Sent when a voice channel's status changes. +public struct VoiceChannelStatusUpdate: Codable, Hashable, Sendable { + public let guild_id: GuildID + public let channel_id: ChannelID + public let status: String? +} + +/// Sent when a voice session's start time changes. +public struct VoiceChannelStartTimeUpdate: Codable, Hashable, Sendable { + public let guild_id: GuildID + public let channel_id: ChannelID + public let voice_start_time: String? +} + // MARK: - Application Commands /// Sent when application command permissions are updated in a guild. diff --git a/Sources/SwiftDisc/Models/Attachment.swift b/Sources/SwiftDisc/Models/Attachment.swift index 4e73c53b..0fdd0619 100644 --- a/Sources/SwiftDisc/Models/Attachment.swift +++ b/Sources/SwiftDisc/Models/Attachment.swift @@ -49,6 +49,10 @@ public struct Attachment: Codable, Hashable, Sendable { /// Whether the attachment is ephemeral (temporary). public let ephemeral: Bool? + /// Whether the attachment is marked as a spoiler. + /// Added 2026-06-24 per Discord API changelog. + public let is_spoiler: Bool? + // Voice message metadata /// The duration of the voice message in seconds. @@ -59,4 +63,7 @@ public struct Attachment: Codable, Hashable, Sendable { /// Attachment flags. public let flags: Int? + + /// Whether the `IS_SPOILER` flag is set on this attachment. + public var isSpoiler: Bool { (flags ?? 0) & (1 << 2) != 0 } } diff --git a/Sources/SwiftDisc/Models/AuditLog.swift b/Sources/SwiftDisc/Models/AuditLog.swift index 52d3e8d2..b8e3f779 100644 --- a/Sources/SwiftDisc/Models/AuditLog.swift +++ b/Sources/SwiftDisc/Models/AuditLog.swift @@ -26,6 +26,8 @@ public struct AuditLogEntry: Codable, Hashable, Sendable { public let role_name: String? public let type: String? public let application_id: ApplicationID? + /// Voice channel status (for VOICE_CHANNEL_STATUS_UPDATE entries). + public let status: String? } public let id: AuditLogEntryID public let target_id: String? diff --git a/Sources/SwiftDisc/Models/Channel.swift b/Sources/SwiftDisc/Models/Channel.swift index c8384e63..054d46f7 100644 --- a/Sources/SwiftDisc/Models/Channel.swift +++ b/Sources/SwiftDisc/Models/Channel.swift @@ -45,6 +45,7 @@ import Foundation /// - `13`: Guild stage channel /// - `14`: Guild directory /// - `15`: Guild forum channel +/// - `16`: Guild media channel /// /// ## Related Topics /// - ``DiscordClient/getChannel(id:)`` diff --git a/Sources/SwiftDisc/Models/Files.swift b/Sources/SwiftDisc/Models/Files.swift index f017ef0a..93c32d87 100644 --- a/Sources/SwiftDisc/Models/Files.swift +++ b/Sources/SwiftDisc/Models/Files.swift @@ -19,9 +19,13 @@ public struct FileAttachment: Sendable { public struct PartialAttachment: Encodable, Hashable, Sendable { public let id: AttachmentID public let description: String? + /// Whether the attachment is marked as a spoiler. + /// Added 2026-06-24 per Discord API changelog. + public let is_spoiler: Bool? - public init(id: AttachmentID, description: String? = nil) { + public init(id: AttachmentID, description: String? = nil, is_spoiler: Bool? = nil) { self.id = id self.description = description + self.is_spoiler = is_spoiler } } diff --git a/Sources/SwiftDisc/Models/Guild.swift b/Sources/SwiftDisc/Models/Guild.swift index f66202aa..83c27f76 100644 --- a/Sources/SwiftDisc/Models/Guild.swift +++ b/Sources/SwiftDisc/Models/Guild.swift @@ -170,6 +170,14 @@ public struct Guild: Codable, Hashable, Sendable { /// The approximate number of online members in the guild (from REST). public let approximate_presence_count: Int? + // MARK: - Incidents + /// Incidents data for the guild (raid alerts, etc.). + public let incidents_data: IncidentsData? + + // MARK: - Welcome Screen + /// The welcome screen of a Community guild. + public let welcome_screen: WelcomeScreen? + // MARK: - GUILD_CREATE-only fields /// ISO 8601 timestamp for when the bot joined (present only in GUILD_CREATE payloads). public let joined_at: String? @@ -218,5 +226,27 @@ public struct Guild: Codable, Hashable, Sendable { self.max_video_channel_users = nil; self.max_stage_video_channel_users = nil self.approximate_member_count = nil; self.approximate_presence_count = nil self.joined_at = nil; self.members = members; self.channels = channels; self.threads = nil + self.incidents_data = nil; self.welcome_screen = nil } } + +/// Incidents data for a guild (e.g. raid detection). +public struct IncidentsData: Codable, Hashable, Sendable { + public let raids_disabled: Bool? + public let raid_system_enabled: Bool? + public let invites_disabled_until: String? +} + +/// Welcome screen shown to new members of a Community guild. +public struct WelcomeScreen: Codable, Hashable, Sendable { + public let description: String? + public let welcome_channels: [WelcomeScreenChannel]? +} + +/// A channel in the welcome screen. +public struct WelcomeScreenChannel: Codable, Hashable, Sendable { + public let channel_id: ChannelID + public let description: String + public let emoji_id: EmojiID? + public let emoji_name: String? +} diff --git a/Sources/SwiftDisc/Models/GuildMember.swift b/Sources/SwiftDisc/Models/GuildMember.swift index 98a89883..994165a9 100644 --- a/Sources/SwiftDisc/Models/GuildMember.swift +++ b/Sources/SwiftDisc/Models/GuildMember.swift @@ -46,4 +46,22 @@ public struct GuildMember: Codable, Hashable, Sendable { /// Effective permissions bitfield (decimal string) included by Discord in /// interaction payloads and some gateway member events. public let permissions: String? + + /// The member's guild banner hash. + public let banner: String? + + /// Avatar decoration data for the member's guild avatar. + public let avatar_decoration_data: AvatarDecorationData? + + /// Collectibles data for the member. + public let collectibles: Collectibles? + + /// Guild member flags as a bit set. + public let flags: Int? + + /// When the member's timeout expires (ISO 8601 timestamp). + public let communication_disabled_until: String? + + /// Whether the member has not yet passed Membership Screening. + public let pending: Bool? } diff --git a/Sources/SwiftDisc/Models/Invite.swift b/Sources/SwiftDisc/Models/Invite.swift index 3fa70ecb..fabc48aa 100644 --- a/Sources/SwiftDisc/Models/Invite.swift +++ b/Sources/SwiftDisc/Models/Invite.swift @@ -60,6 +60,9 @@ 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 invite code. public let code: String @@ -101,4 +104,23 @@ public struct Invite: Codable, Hashable, Sendable { /// Target user type for restricted invites. public let target_type: Int? + + /// Guild invite flags combined as a bitfield. + public let flags: Int? +} + +/// Status of a target-users processing job for an invite. +public struct InviteTargetUsersJobStatus: Codable, Hashable, Sendable { + /// Job status code (0 = UNSPECIFIED, 1 = PROCESSING, 2 = COMPLETED, 3 = FAILED). + public let status: Int + /// Total number of users to process. + public let total_users: Int? + /// Number of users processed so far. + public let processed_users: Int? + /// When the job was created (ISO 8601 timestamp). + public let created_at: String? + /// When the job was completed (ISO 8601 timestamp). + public let completed_at: String? + /// Error message if the job failed. + public let error_message: String? } diff --git a/Sources/SwiftDisc/Models/Message.swift b/Sources/SwiftDisc/Models/Message.swift index 11c2a958..4fa7fa8f 100644 --- a/Sources/SwiftDisc/Models/Message.swift +++ b/Sources/SwiftDisc/Models/Message.swift @@ -141,6 +141,18 @@ public struct Message: Codable, Hashable, Sendable { /// The sync status of attachments. public let attachments_sync_status: Int? + + /// The webhook ID if the message was sent by a webhook. + public let webhook_id: WebhookID? + + /// Message snapshots (for forwarded messages). + public let message_snapshots: [MessageSnapshot]? + + /// Call information for messages in private channels. + public let call: MessageCall? + + /// Shared client-side theme data. + public let shared_client_theme: SharedClientTheme? } /// Represents a channel mention in a message. @@ -174,6 +186,9 @@ public struct ChannelMention: Codable, Hashable, Sendable { /// ) /// ``` public struct MessageReference: Codable, Hashable, Sendable { + /// The type of reference (0 = DEFAULT, 1 = FORWARD). + public let type: Int? + /// The ID of the referenced message. public let message_id: MessageID? @@ -186,7 +201,8 @@ public struct MessageReference: Codable, Hashable, Sendable { /// Whether to throw an error if the referenced message doesn't exist. public let fail_if_not_exists: Bool? - public init(message_id: MessageID? = nil, channel_id: ChannelID? = nil, guild_id: GuildID? = nil, fail_if_not_exists: Bool? = nil) { + public init(type: Int? = nil, message_id: MessageID? = nil, channel_id: ChannelID? = nil, guild_id: GuildID? = nil, fail_if_not_exists: Bool? = nil) { + self.type = type self.message_id = message_id self.channel_id = channel_id self.guild_id = guild_id @@ -396,6 +412,22 @@ public struct PollAnswerUsers: Codable, Hashable, Sendable { public let after: UserID? } +/// A snapshot of a message at the time it was forwarded. +public struct MessageSnapshot: Codable, Hashable, Sendable { + public let message: Message +} + +/// Information about a call in a private channel. +public struct MessageCall: Codable, Hashable, Sendable { + public let participants: [UserID] + public let ended_timestamp: String? +} + +/// Shared client-side theme data sent with a message. +public struct SharedClientTheme: Codable, Hashable, Sendable { + public let theme: String? +} + /// Controls which mentions are allowed to trigger notifications in a message. /// /// Use this to prevent unwanted pings when sending messages. @@ -456,6 +488,12 @@ public struct AllowedMentions: Codable, Hashable, Sendable { /// } /// } /// ``` +/// Detailed count information for a reaction. +public struct ReactionCountDetails: Codable, Hashable, Sendable { + public let burst: Int + public let normal: Int +} + public struct Reaction: Codable, Hashable, Sendable { /// The number of users who have reacted with this emoji. public let count: Int @@ -463,8 +501,17 @@ public struct Reaction: Codable, Hashable, Sendable { /// Whether the current user has reacted with this emoji. public let me: Bool + /// Whether the current user has reacted with a super reaction. + public let me_burst: Bool? + /// The emoji information for this reaction. public let emoji: PartialEmoji + + /// Detailed count breakdown (burst vs normal). + public let count_details: ReactionCountDetails? + + /// Hex color codes for super reaction burst animation. + public let burst_colors: [String]? } /// Represents a partial emoji object. diff --git a/Sources/SwiftDisc/Models/PermissionBitset.swift b/Sources/SwiftDisc/Models/PermissionBitset.swift index 8bd80c16..9e38f050 100644 --- a/Sources/SwiftDisc/Models/PermissionBitset.swift +++ b/Sources/SwiftDisc/Models/PermissionBitset.swift @@ -61,6 +61,7 @@ public struct PermissionBitset: OptionSet, Codable, Hashable, Sendable { public static let useExternalApps = PermissionBitset(rawValue: 1 << 50) public static let pinMessages = PermissionBitset(rawValue: 1 << 51) public static let bypassSlowmode = PermissionBitset(rawValue: 1 << 52) + public static let setVoiceChannelStatus = PermissionBitset(rawValue: 1 << 47) // Codable conformance for serialization public init(from decoder: Decoder) throws { diff --git a/Sources/SwiftDisc/Models/User.swift b/Sources/SwiftDisc/Models/User.swift index be53e28d..56a948fd 100644 --- a/Sources/SwiftDisc/Models/User.swift +++ b/Sources/SwiftDisc/Models/User.swift @@ -84,6 +84,15 @@ public struct User: Codable, Hashable, Sendable { /// The user's primary guild tag (added 2025-07-02). public let primary_guild: UserPrimaryGuild? + /// The user's premium type (Nitro subscription level). + public let premium_type: Int? + + /// Avatar decoration data. + public let avatar_decoration_data: AvatarDecorationData? + + /// Collectibles data. + public let collectibles: Collectibles? + /// The user's chosen language locale (ISO 639 code). public let locale: String? @@ -103,11 +112,73 @@ public struct User: Codable, Hashable, Sendable { public let system: Bool? + public init( + id: UserID, + username: String, + discriminator: String? = nil, + globalName: String? = nil, + avatar: String? = nil, + banner: String? = nil, + accent_color: Int? = nil, + flags: Int? = nil, + public_flags: Int? = nil, + premium_type: Int? = nil, + avatar_decoration_data: AvatarDecorationData? = nil, + collectibles: Collectibles? = nil, + locale: String? = nil, + email: String? = nil, + verified: Bool? = nil, + mfa_enabled: Bool? = nil, + bot: Bool? = nil, + system: Bool? = nil, + primary_guild: UserPrimaryGuild? = nil + ) { + self.id = id + self.username = username + self.discriminator = discriminator + self.globalName = globalName + self.avatar = avatar + self.banner = banner + self.accent_color = accent_color + self.flags = flags + self.public_flags = public_flags + self.premium_type = premium_type + self.avatar_decoration_data = avatar_decoration_data + self.collectibles = collectibles + self.locale = locale + self.email = email + self.verified = verified + self.mfa_enabled = mfa_enabled + self.bot = bot + self.system = system + self.primary_guild = primary_guild + } + enum CodingKeys: String, CodingKey { case id, username, discriminator, avatar, banner case accent_color, flags, public_flags + case premium_type, avatar_decoration_data, collectibles case primary_guild, locale, email, verified case mfa_enabled, bot, system case globalName = "global_name" } } + +/// Avatar decoration data for a user or member. +public struct AvatarDecorationData: Codable, Hashable, Sendable { + public let asset: String + public let sku_id: Snowflake? +} + +/// Collectibles data for a user or member. +public struct Collectibles: Codable, Hashable, Sendable { + public let collectibles: [Collectible]? +} + +/// A single collectible item. +public struct Collectible: Codable, Hashable, Sendable { + public let id: String + public let sku_id: SKUID + public let name: String + public let type: Int +} diff --git a/Sources/SwiftDisc/REST/HTTPClient.swift b/Sources/SwiftDisc/REST/HTTPClient.swift index 51516042..baece8b4 100644 --- a/Sources/SwiftDisc/REST/HTTPClient.swift +++ b/Sources/SwiftDisc/REST/HTTPClient.swift @@ -383,6 +383,36 @@ final class HTTPClient: @unchecked Sendable { throw makeAPIError(statusCode: http.statusCode, data: data, debugContext: "Endpoint: DELETE \(path)") } + func putMultipart(path: String, jsonBody: B?, files: [FileAttachment], reason: String? = nil) async throws(DiscordError) { + let trimmed = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let routeKey = makeRouteKey(method: "PUT", path: trimmed) + let boundary = makeBoundary() + let jsonData = try? jsonBody.map { try JSONCoders.encoder.encode($0) } + let (_, http) = try await executeWithRetry(routeKey: routeKey, isIdempotent: false) { + var url = configuration.restBase + url.appendPathComponent(trimmed) + let body = buildMultipartBody(jsonPayload: jsonData ?? nil, files: files, boundary: boundary) + var reqHeaders: [String: String] = ["Content-Type": "multipart/form-data; boundary=\(boundary)"] + if let reason { + if let encoded = reason.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { + reqHeaders["X-Audit-Log-Reason"] = encoded + } + } + let resp = try await transport.request(method: "PUT", url: url, body: body, headers: reqHeaders) + guard let http = HTTPURLResponse(url: url, statusCode: resp.statusCode, httpVersion: nil, headerFields: resp.headers) else { + throw DiscordError.network(NSError(domain: "InvalidResponse", code: -1)) + } + return (resp.data, http) + } + if (200..<300).contains(http.statusCode) { return } + throw makeAPIError(statusCode: http.statusCode, data: Data(), debugContext: "Endpoint: PUT \(path)") + } + + func putFile(path: String, data: Data, filename: String, reason: String? = nil) async throws(DiscordError) { + let file = FileAttachment(data: data, filename: filename) + try await putMultipart(path: path, jsonBody: Optional.none, files: [file], reason: reason) + } + // MARK: - Sticker-specific multipart func postStickerMultipart(path: String, name: String, description: String?, tags: String, file: FileAttachment, reason: String? = nil) async throws(DiscordError) -> T { From dd5a704a9a3f0106a55cd6091631a1ef61d78482 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Mon, 29 Jun 2026 20:37:55 -0400 Subject: [PATCH 02/22] Add context menu / nsfw / contexts support to SlashCommandBuilder and ApplicationCommandCreate - Convert SlashCommandBuilder from class @unchecked Sendable to struct w/ value semantics - Add type(.user)/type(.message) support for context menu commands - Add nsfw, contexts, nameLocalizations, descriptionLocalizations to builder - Add subCommand/subCommandGroup option builders, integer/number choices - Update ApplicationCommandCreate with type, nsfw, contexts fields - Add UserSelectMenuBuilder, RoleSelectMenuBuilder, MentionableSelectMenuBuilder to ComponentsBuilder (with minValues/maxValues support) - Rewrite ShardingBot example to use ShardingGatewayManager actor API --- Examples/ShardingBot.swift | 61 ++--- Sources/SwiftDisc/DiscordClient.swift | 11 +- .../HighLevel/ComponentsBuilder.swift | 66 ++++++ .../HighLevel/SlashCommandBuilder.swift | 216 ++++++++++++------ 4 files changed, 257 insertions(+), 97 deletions(-) diff --git a/Examples/ShardingBot.swift b/Examples/ShardingBot.swift index e020043c..2516be56 100644 --- a/Examples/ShardingBot.swift +++ b/Examples/ShardingBot.swift @@ -5,41 +5,50 @@ import Foundation struct ShardingBotMain { static func main() async { let token = ProcessInfo.processInfo.environment["DISCORD_BOT_TOKEN"] ?? "YOUR_BOT_TOKEN" - let client = DiscordClient(token: token) - // Observe connection state across all shards - await client.setOnReady { info in - print("Shard ready as: \(info.user.username)") + let config = ShardingGatewayManager.Configuration( + shardCount: .automatic, + identifyConcurrency: .respectDiscordLimits, + connectionDelay: .staggered(interval: 5.0) + ) + + let manager = ShardingGatewayManager( + token: token, + configuration: config, + intents: [.guilds, .guildMessages, .messageContent], + presence: ShardingGatewayManager.Configuration.PresenceConfig( + activities: [ + .init(name: "with shards", type: 0) + ], + status: "online", + afk: false + ) + ) + + // Monitor shard health periodically + Task { + while true { + let health = await manager.healthCheck() + print("Health: \(health.readyShards)/\(health.totalShards) shards ready, \(health.totalGuilds) guilds") + try? await Task.sleep(nanoseconds: 30_000_000_000) + } } - // Use the connection state stream to monitor gateway lifecycle + // Monitor events with shard metadata Task { - for await state in await client.connectionState { - switch state { - case .ready: - print("Gateway ready — bot is receiving events") - case .reconnecting: - print("Gateway reconnecting...") - case .disconnected: - print("Gateway disconnected") - default: - break - } + for await event in manager.events { + print("[Shard \(event.shardId)] \(event.event)") } } - // Connect as shard 0 of 2 total shards - // In production, determine shard count via gateway/bot endpoint do { - try await client.loginAndConnectSharded( - index: 0, - total: 2, - intents: [.guilds, .guildMessages, .messageContent] - ) - let events = await client.events - for await _ in events { } + try await manager.connect() + // Keep the process alive + while true { + try await Task.sleep(nanoseconds: UInt64.max) + } } catch { - print("Connection error: \(error)") + print("Sharding manager failed: \(error)") } } } diff --git a/Sources/SwiftDisc/DiscordClient.swift b/Sources/SwiftDisc/DiscordClient.swift index 57c04783..80b0c8ea 100644 --- a/Sources/SwiftDisc/DiscordClient.swift +++ b/Sources/SwiftDisc/DiscordClient.swift @@ -3441,7 +3441,13 @@ public actor DiscordClient { public let options: [ApplicationCommandOption]? public let default_member_permissions: String? public let dm_permission: Bool? - public init(name: String, description: String, options: [ApplicationCommandOption]? = nil, default_member_permissions: String? = nil, dm_permission: Bool? = nil, nameLocalizations: [String: String]? = nil, descriptionLocalizations: [String: String]? = nil) { + /// The command type (1 = CHAT_INPUT, 2 = USER, 3 = MESSAGE). Defaults to CHAT_INPUT. + public let type: Int? + /// Whether the command is age-restricted. + public let nsfw: Bool? + /// Interaction context(s) where the command can be used (0 = GUILD, 1 = BOT_DM, 2 = PRIVATE_CHANNEL). + public let contexts: [Int]? + public init(name: String, description: String, options: [ApplicationCommandOption]? = nil, default_member_permissions: String? = nil, dm_permission: Bool? = nil, nameLocalizations: [String: String]? = nil, descriptionLocalizations: [String: String]? = nil, type: Int? = nil, nsfw: Bool? = nil, contexts: [Int]? = nil) { self.name = name self.name_localizations = nameLocalizations self.description = description @@ -3449,6 +3455,9 @@ public actor DiscordClient { self.options = options self.default_member_permissions = default_member_permissions self.dm_permission = dm_permission + self.type = type + self.nsfw = nsfw + self.contexts = contexts } } diff --git a/Sources/SwiftDisc/HighLevel/ComponentsBuilder.swift b/Sources/SwiftDisc/HighLevel/ComponentsBuilder.swift index 639387ec..188533bf 100644 --- a/Sources/SwiftDisc/HighLevel/ComponentsBuilder.swift +++ b/Sources/SwiftDisc/HighLevel/ComponentsBuilder.swift @@ -169,6 +169,72 @@ public struct ChannelSelectMenuBuilder: Sendable { } } +// MARK: - User Select Menu Builder + +/// A builder for Discord user select menus. +public struct UserSelectMenuBuilder: Sendable { + private var customId: String = "" + private var placeholder: String? + private var min: Int? + private var max: Int? + private var defaultUserIds: [UserID]? + private var disabled: Bool? + public init() {} + public func customId(_ id: String) -> UserSelectMenuBuilder { var c = self; c.customId = id; return c } + public func placeholder(_ t: String) -> UserSelectMenuBuilder { var c = self; c.placeholder = t; return c } + public func minValues(_ v: Int) -> UserSelectMenuBuilder { var c = self; c.min = v; return c } + public func maxValues(_ v: Int) -> UserSelectMenuBuilder { var c = self; c.max = v; return c } + public func defaultUsers(_ ids: [UserID]) -> UserSelectMenuBuilder { var c = self; c.defaultUserIds = ids; return c } + public func disabled(_ d: Bool = true) -> UserSelectMenuBuilder { var c = self; c.disabled = d; return c } + public func build() -> MessageComponent { + MessageComponent.userSelect(.init(custom_id: customId, placeholder: placeholder, min_values: min, max_values: max, disabled: disabled, default_values: defaultUserIds?.map { .init(id: $0.rawValue, type: "user") })) + } +} + +// MARK: - Role Select Menu Builder + +/// A builder for Discord role select menus. +public struct RoleSelectMenuBuilder: Sendable { + private var customId: String = "" + private var placeholder: String? + private var min: Int? + private var max: Int? + private var defaultRoleIds: [RoleID]? + private var disabled: Bool? + public init() {} + public func customId(_ id: String) -> RoleSelectMenuBuilder { var c = self; c.customId = id; return c } + public func placeholder(_ t: String) -> RoleSelectMenuBuilder { var c = self; c.placeholder = t; return c } + public func minValues(_ v: Int) -> RoleSelectMenuBuilder { var c = self; c.min = v; return c } + public func maxValues(_ v: Int) -> RoleSelectMenuBuilder { var c = self; c.max = v; return c } + public func defaultRoles(_ ids: [RoleID]) -> RoleSelectMenuBuilder { var c = self; c.defaultRoleIds = ids; return c } + public func disabled(_ d: Bool = true) -> RoleSelectMenuBuilder { var c = self; c.disabled = d; return c } + public func build() -> MessageComponent { + MessageComponent.roleSelect(.init(custom_id: customId, placeholder: placeholder, min_values: min, max_values: max, disabled: disabled, default_values: defaultRoleIds?.map { .init(id: $0.rawValue, type: "role") })) + } +} + +// MARK: - Mentionable Select Menu Builder + +/// A builder for Discord mentionable (users + roles) select menus. +public struct MentionableSelectMenuBuilder: Sendable { + private var customId: String = "" + private var placeholder: String? + private var min: Int? + private var max: Int? + private var defaultMentionableIds: [String]? + private var disabled: Bool? + public init() {} + public func customId(_ id: String) -> MentionableSelectMenuBuilder { var c = self; c.customId = id; return c } + public func placeholder(_ t: String) -> MentionableSelectMenuBuilder { var c = self; c.placeholder = t; return c } + public func minValues(_ v: Int) -> MentionableSelectMenuBuilder { var c = self; c.min = v; return c } + public func maxValues(_ v: Int) -> MentionableSelectMenuBuilder { var c = self; c.max = v; return c } + public func defaultMentionables(_ ids: [String]) -> MentionableSelectMenuBuilder { var c = self; c.defaultMentionableIds = ids; return c } + public func disabled(_ d: Bool = true) -> MentionableSelectMenuBuilder { var c = self; c.disabled = d; return c } + public func build() -> MessageComponent { + MessageComponent.mentionableSelect(.init(custom_id: customId, placeholder: placeholder, min_values: min, max_values: max, disabled: disabled, default_values: defaultMentionableIds?.map { .init(id: $0, type: "user") })) + } +} + /// A builder for creating text input components (for modals). /// /// Use this builder to construct text inputs with validation. diff --git a/Sources/SwiftDisc/HighLevel/SlashCommandBuilder.swift b/Sources/SwiftDisc/HighLevel/SlashCommandBuilder.swift index 8709d873..9db0c93a 100644 --- a/Sources/SwiftDisc/HighLevel/SlashCommandBuilder.swift +++ b/Sources/SwiftDisc/HighLevel/SlashCommandBuilder.swift @@ -1,155 +1,231 @@ import Foundation -/// A builder for creating Discord slash commands. +/// A builder for creating Discord slash commands and context menu commands. /// -/// Use this builder to construct slash commands with options, permissions, and other settings. -/// The builder provides a fluent API for adding command options. +/// Use this builder to construct commands with options, permissions, localization, and other settings. /// -/// ## Example +/// ## Examples +/// +/// ### Slash command with options /// /// ```swift /// let command = SlashCommandBuilder(name: "ban", description: "Ban a user") /// .string("reason", "The reason for the ban", required: false) /// .user("target", "The user to ban", required: true) -/// .defaultMemberPermissions("0x0000000000000008") // Ban permission +/// .defaultMemberPermissions("8") // Administrator +/// .build() +/// ``` +/// +/// ### User context menu command +/// +/// ```swift +/// let userCmd = SlashCommandBuilder(name: "Avatar", description: "View avatar") +/// .type(.user) /// .build() /// ``` /// -/// ## Related Topics -/// - ``DiscordClient/createGlobalApplicationCommand(_:)`` -/// - ``SlashCommandRouter`` -public final class SlashCommandBuilder: @unchecked Sendable { +/// ### Message context menu command +/// +/// ```swift +/// let msgCmd = SlashCommandBuilder(name: "Pin", description: "Pin this message") +/// .type(.message) +/// .build() +/// ``` +/// +/// ### Command with localizations +/// +/// ```swift +/// let cmd = SlashCommandBuilder(name: "ping", description: "Check latency") +/// .nameLocalizations(["es": "ping", "fr": "ping"]) +/// .descriptionLocalizations(["es": "Revisa la latencia", "fr": "Verifier la latence"]) +/// .build() +/// ``` +/// +/// ### Command with subcommands +/// +/// ```swift +/// let cmd = SlashCommandBuilder(name: "settings", description: "Manage settings") +/// .subCommand("notifications", "Notification preferences") { sub in +/// sub.boolean("enabled", "Enable notifications", required: true) +/// } +/// .subCommand("privacy", "Privacy settings") { sub in +/// sub.string("level", "Privacy level") +/// } +/// .build() +/// ``` +public struct SlashCommandBuilder: Sendable { + /// The command type (1 = CHAT_INPUT, 2 = USER, 3 = MESSAGE). + public enum CommandType: Int, Sendable { + case chatInput = 1 + case user = 2 + case message = 3 + } + /// A builder for command options. - public final class OptionBuilder: @unchecked Sendable { + public struct OptionBuilder: Sendable { private var option: DiscordClient.ApplicationCommandOption private var choices: [DiscordClient.ApplicationCommandOption.Choice] = [] - - /// Creates a new option builder. - /// - /// - Parameters: - /// - type: The option type. - /// - name: The option name. - /// - description: The option description. - /// - required: Whether the option is required. + public init(type: DiscordClient.ApplicationCommandOption.ApplicationCommandOptionType, name: String, description: String, required: Bool? = nil) { self.option = .init(type: type, name: name, description: description, required: required, choices: nil) } - - /// Sets whether this option is required. + + @discardableResult + public func required(_ req: Bool = true) -> OptionBuilder { var c = self; c.option = .init(type: c.option.type, name: c.option.name, description: c.option.description, required: req, choices: c.option.choices); return c } + @discardableResult - public func required(_ req: Bool = true) -> OptionBuilder { option = .init(type: option.type, name: option.name, description: option.description, required: req, choices: option.choices); return self } - - /// Adds a choice for string/integer/number options. + public func nameLocalizations(_ loc: [String: String]) -> OptionBuilder { var c = self; c.option = .init(type: c.option.type, name: c.option.name, description: c.option.description, required: c.option.required, choices: c.option.choices, nameLocalizations: loc); return c } + + @discardableResult + public func descriptionLocalizations(_ loc: [String: String]) -> OptionBuilder { var c = self; c.option = .init(type: c.option.type, name: c.option.name, description: c.option.description, required: c.option.required, choices: c.option.choices, descriptionLocalizations: loc); return c } + @discardableResult public func choice(_ name: String, _ value: String) -> OptionBuilder { - choices.append(.init(name: name, name_localizations: nil, value: .string(value))); - option = .init(type: option.type, name: option.name, description: option.description, required: option.required, choices: choices) - return self + var c = self + c.choices.append(.init(name: name, name_localizations: nil, value: .string(value))) + c.option = .init(type: c.option.type, name: c.option.name, description: c.option.description, required: c.option.required, choices: c.choices) + return c } - - /// Builds the option. + + @discardableResult + public func choice(_ name: String, _ value: Int) -> OptionBuilder { + var c = self + c.choices.append(.init(name: name, name_localizations: nil, value: .int(value))) + c.option = .init(type: c.option.type, name: c.option.name, description: c.option.description, required: c.option.required, choices: c.choices) + return c + } + + @discardableResult + public func choice(_ name: String, _ value: Double) -> OptionBuilder { + var c = self + c.choices.append(.init(name: name, name_localizations: nil, value: .double(value))) + c.option = .init(type: c.option.type, name: c.option.name, description: c.option.description, required: c.option.required, choices: c.choices) + return c + } + public func build() -> DiscordClient.ApplicationCommandOption { option } } - /// The command name. public let name: String - - /// The command description. public let description: String - + + private var type: CommandType = .chatInput + private var nameLocalizations: [String: String]? + private var descriptionLocalizations: [String: String]? private var options: [DiscordClient.ApplicationCommandOption] = [] private var dmPermission: Bool? private var defaultMemberPermissions: String? + private var nsfw: Bool? + private var contexts: [Int]? - /// Creates a new slash command builder. - /// - /// - Parameters: - /// - name: The command name (lowercase, 1-32 characters, no spaces). - /// - description: The command description (1-100 characters). public init(name: String, description: String) { self.name = name self.description = description } - /// Adds a string option. + @discardableResult + public func type(_ t: CommandType) -> SlashCommandBuilder { var c = self; c.type = t; return c } + + @discardableResult + public func nameLocalizations(_ loc: [String: String]) -> SlashCommandBuilder { var c = self; c.nameLocalizations = loc; return c } + + @discardableResult + public func descriptionLocalizations(_ loc: [String: String]) -> SlashCommandBuilder { var c = self; c.descriptionLocalizations = loc; return c } + + @discardableResult + public func dmPermission(_ allow: Bool) -> SlashCommandBuilder { var c = self; c.dmPermission = allow; return c } + + @discardableResult + public func defaultMemberPermissions(_ perms: String) -> SlashCommandBuilder { var c = self; c.defaultMemberPermissions = perms; return c } + + @discardableResult + public func nsfw(_ isNsfw: Bool = true) -> SlashCommandBuilder { var c = self; c.nsfw = isNsfw; return c } + + @discardableResult + public func contexts(_ ctx: [Int]) -> SlashCommandBuilder { var c = self; c.contexts = ctx; return c } + + @discardableResult + public func addOption(_ option: DiscordClient.ApplicationCommandOption) -> SlashCommandBuilder { + var c = self; c.options.append(option); return c + } + @discardableResult public func string(_ name: String, _ description: String, required: Bool? = nil, configure: (@Sendable (inout OptionBuilder) -> Void)? = nil) -> SlashCommandBuilder { var b = OptionBuilder(type: .string, name: name, description: description, required: required) configure?(&b) - options.append(b.build()) - return self + return addOption(b.build()) } - /// Adds an integer option. @discardableResult public func integer(_ name: String, _ description: String, required: Bool? = nil, configure: (@Sendable (inout OptionBuilder) -> Void)? = nil) -> SlashCommandBuilder { var b = OptionBuilder(type: .integer, name: name, description: description, required: required) configure?(&b) - options.append(b.build()) - return self + return addOption(b.build()) } - /// Adds a number option. @discardableResult public func number(_ name: String, _ description: String, required: Bool? = nil, configure: (@Sendable (inout OptionBuilder) -> Void)? = nil) -> SlashCommandBuilder { var b = OptionBuilder(type: .number, name: name, description: description, required: required) configure?(&b) - options.append(b.build()) - return self + return addOption(b.build()) } - /// Adds a boolean option. @discardableResult public func boolean(_ name: String, _ description: String, required: Bool? = nil) -> SlashCommandBuilder { - options.append(.init(type: .boolean, name: name, description: description, required: required, choices: nil)) - return self + addOption(.init(type: .boolean, name: name, description: description, required: required, choices: nil)) } - /// Adds a user option. @discardableResult public func user(_ name: String, _ description: String, required: Bool? = nil) -> SlashCommandBuilder { - options.append(.init(type: .user, name: name, description: description, required: required, choices: nil)) - return self + addOption(.init(type: .user, name: name, description: description, required: required, choices: nil)) } - /// Adds a channel option. @discardableResult public func channel(_ name: String, _ description: String, required: Bool? = nil) -> SlashCommandBuilder { - options.append(.init(type: .channel, name: name, description: description, required: required, choices: nil)) - return self + addOption(.init(type: .channel, name: name, description: description, required: required, choices: nil)) } - /// Adds a role option. @discardableResult public func role(_ name: String, _ description: String, required: Bool? = nil) -> SlashCommandBuilder { - options.append(.init(type: .role, name: name, description: description, required: required, choices: nil)) - return self + addOption(.init(type: .role, name: name, description: description, required: required, choices: nil)) } - /// Adds a mentionable option (user or role). @discardableResult public func mentionable(_ name: String, _ description: String, required: Bool? = nil) -> SlashCommandBuilder { - options.append(.init(type: .mentionable, name: name, description: description, required: required, choices: nil)) - return self + addOption(.init(type: .mentionable, name: name, description: description, required: required, choices: nil)) } - /// Adds an attachment option. @discardableResult public func attachment(_ name: String, _ description: String, required: Bool? = nil) -> SlashCommandBuilder { - options.append(.init(type: .attachment, name: name, description: description, required: required, choices: nil)) - return self + addOption(.init(type: .attachment, name: name, description: description, required: required, choices: nil)) } - /// Sets whether the command can be used in DMs. @discardableResult - public func dmPermission(_ allow: Bool) -> SlashCommandBuilder { self.dmPermission = allow; return self } + public func subCommand(_ name: String, _ description: String, configure: (@Sendable (inout OptionBuilder) -> Void)? = nil) -> SlashCommandBuilder { + var b = OptionBuilder(type: .subCommand, name: name, description: description) + configure?(&b) + return addOption(b.build()) + } - /// Sets the default member permissions for the command. @discardableResult - public func defaultMemberPermissions(_ perms: String) -> SlashCommandBuilder { self.defaultMemberPermissions = perms; return self } + public func subCommandGroup(_ name: String, _ description: String, configure: (@Sendable (inout OptionBuilder) -> Void)? = nil) -> SlashCommandBuilder { + var b = OptionBuilder(type: .subCommandGroup, name: name, description: description) + configure?(&b) + return addOption(b.build()) + } - /// Builds the command. public func build() -> DiscordClient.ApplicationCommandCreate { - .init(name: name, description: description, options: options, default_member_permissions: defaultMemberPermissions, dm_permission: dmPermission) + .init( + name: name, + description: description, + options: options.isEmpty ? nil : options, + default_member_permissions: defaultMemberPermissions, + dm_permission: dmPermission, + nameLocalizations: nameLocalizations, + descriptionLocalizations: descriptionLocalizations, + type: type != .chatInput ? type.rawValue : nil, + nsfw: nsfw, + contexts: contexts + ) } } From 4fa6e97325aca09596315b3f30c8aa1fcd864947 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Mon, 29 Jun 2026 20:44:34 -0400 Subject: [PATCH 03/22] Add Welcome Screen endpoints, all missing gateway event callbacks, fix Voice Channel Status events - Add getGuildWelcomeScreen / modifyGuildWelcomeScreen REST endpoints - Add 20+ missing gateway event callbacks (onGuildEmojisUpdate, onGuildStickersUpdate, onWebhooksUpdate, onIntegrationsUpdate, onInviteCreate/Delete, onAuditLogEntryCreate, onAutoModRuleCRUD, onUserUpdate, onChannelPinsUpdate, onThreadMemberUpdate, onThreadListSync, onGuildMembersChunk, onScheduledEventUserAdd/Remove, onVoiceChannelStatusUpdate, onVoiceChannelStartTimeUpdate, onChannelInfo) - Fix voiceChannelStatusUpdate and voiceChannelStartTimeUpdate being silently dropped in EventDispatcher (no switch case existed) - Remove all remaining break-only cases in EventDispatcher; every DiscordEvent now invokes its callback - Update GAP_ANALYSIS.md to reflect current state - .gitignore: exclude GAP_ANALYSIS.md from commits --- .gitignore | 1 + Sources/SwiftDisc/DiscordClient.swift | 69 +++++++++++++++++ .../SwiftDisc/Internal/EventDispatcher.swift | 74 +++++++++++++------ 3 files changed, 120 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index 7a26440c..d6789b04 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ Fails/ failed/ audit-*.md +GAP_ANALYSIS.md diff --git a/Sources/SwiftDisc/DiscordClient.swift b/Sources/SwiftDisc/DiscordClient.swift index 80b0c8ea..a46c8ad5 100644 --- a/Sources/SwiftDisc/DiscordClient.swift +++ b/Sources/SwiftDisc/DiscordClient.swift @@ -408,6 +408,48 @@ public actor DiscordClient { public var onSoundboardSoundUpdate: (@Sendable (SoundboardSound) async -> Void)? public var onSoundboardSoundDelete: (@Sendable (SoundboardSound) async -> Void)? + // -- Emojis / Stickers -- + public var onGuildEmojisUpdate: (@Sendable (GuildEmojisUpdate) async -> Void)? + public var onGuildStickersUpdate: (@Sendable (GuildStickersUpdate) async -> Void)? + + // -- Webhooks / Integrations / Invites -- + public var onWebhooksUpdate: (@Sendable (WebhooksUpdate) async -> Void)? + public var onGuildIntegrationsUpdate: (@Sendable (GuildIntegrationsUpdate) async -> Void)? + public var onInviteCreate: (@Sendable (InviteCreate) async -> Void)? + public var onInviteDelete: (@Sendable (InviteDelete) async -> Void)? + + // -- Audit Log -- + public var onGuildAuditLogEntryCreate: (@Sendable (AuditLogEntry) async -> Void)? + + // -- Auto Moderation Rules -- + public var onAutoModerationRuleCreate: (@Sendable (AutoModerationRule) async -> Void)? + public var onAutoModerationRuleUpdate: (@Sendable (AutoModerationRule) async -> Void)? + public var onAutoModerationRuleDelete: (@Sendable (AutoModerationRule) async -> Void)? + + // -- Voice Channel Status -- + public var onVoiceChannelStatusUpdate: (@Sendable (VoiceChannelStatusUpdate) async -> Void)? + public var onVoiceChannelStartTimeUpdate: (@Sendable (VoiceChannelStartTimeUpdate) async -> Void)? + + // -- User -- + public var onUserUpdate: (@Sendable (User) async -> Void)? + + // -- Channel Pins -- + public var onChannelPinsUpdate: (@Sendable (ChannelPinsUpdate) async -> Void)? + + // -- Threads (additional) -- + public var onThreadMemberUpdate: (@Sendable (ThreadMember) async -> Void)? + public var onThreadListSync: (@Sendable (ThreadListSync) async -> Void)? + + // -- Guild Members Chunk -- + public var onGuildMembersChunk: (@Sendable (GuildMembersChunk) async -> Void)? + + // -- Scheduled Event Users -- + public var onGuildScheduledEventUserAdd: (@Sendable (GuildScheduledEventUser) async -> Void)? + public var onGuildScheduledEventUserRemove: (@Sendable (GuildScheduledEventUser) async -> Void)? + + // -- Channel Info -- + public var onChannelInfo: (@Sendable (Channel) async -> Void)? + /// The command router for prefix-based commands. /// /// Set this to enable prefix-based command handling. Use `useCommands(_:)` to assign a router. @@ -3130,6 +3172,33 @@ public actor DiscordClient { try await http.get(path: "/guilds/\(guildId)/preview") } + // MARK: - REST: Welcome Screen + + /// Returns the Welcome Screen for a Community guild. + /// + /// - Parameter guildId: The ID of the guild. + /// - Returns: The guild's welcome screen configuration. + /// - Throws: `DiscordError` if the guild is not a Community guild. + public func getGuildWelcomeScreen(guildId: GuildID) async throws -> WelcomeScreen { + try await http.get(path: "/guilds/\(guildId)/welcome-screen") + } + + /// Modifies the Welcome Screen for a Community guild. + /// + /// - Parameters: + /// - guildId: The ID of the guild. + /// - description: The new welcome screen description, or `nil` to leave unchanged. + /// - welcomeChannels: The new welcome channels, or `nil` to leave unchanged. + /// - Returns: The updated welcome screen configuration. + /// - Throws: `DiscordError` if the guild is not a Community guild. + public func modifyGuildWelcomeScreen(guildId: GuildID, description: String? = nil, welcomeChannels: [WelcomeScreenChannel]? = nil) async throws -> WelcomeScreen { + struct Body: Encodable, Sendable { + let description: String? + let welcome_channels: [WelcomeScreenChannel]? + } + return try await http.patch(path: "/guilds/\(guildId)/welcome-screen", body: Body(description: description, welcome_channels: welcomeChannels)) + } + // MARK: - REST: Threads // Starts a thread from an existing message. public func startThreadFromMessage( diff --git a/Sources/SwiftDisc/Internal/EventDispatcher.swift b/Sources/SwiftDisc/Internal/EventDispatcher.swift index 22205a5d..6f3cc984 100644 --- a/Sources/SwiftDisc/Internal/EventDispatcher.swift +++ b/Sources/SwiftDisc/Internal/EventDispatcher.swift @@ -89,8 +89,9 @@ actor EventDispatcher { await client.cache.upsert(user: ev.user) if let cb = await client.onGuildMemberUpdate { await cb(ev) } - case .guildMembersChunk(let chunk): - for member in chunk.members { if let user = member.user { await client.cache.upsert(user: user) } } + case .guildMembersChunk(let ev): + for member in ev.members { if let user = member.user { await client.cache.upsert(user: user) } } + if let cb = await client.onGuildMembersChunk { await cb(ev) } // MARK: Roles case .guildRoleCreate(let ev): @@ -105,12 +106,13 @@ actor EventDispatcher { await client.cache.removeRole(id: ev.role_id, guildId: ev.guild_id) if let cb = await client.onGuildRoleDelete { await cb(ev) } - // MARK: Emojis / Stickers (no callback – stream-only) + // MARK: Emojis / Stickers case .guildEmojisUpdate(let ev): await client.cache.upsert(emojis: ev.emojis, guildId: ev.guild_id) + if let cb = await client.onGuildEmojisUpdate { await cb(ev) } - case .guildStickersUpdate: - break + case .guildStickersUpdate(let ev): + if let cb = await client.onGuildStickersUpdate { await cb(ev) } // MARK: Channels case .channelCreate(let channel): @@ -125,9 +127,12 @@ actor EventDispatcher { await client.cache.removeChannel(id: channel.id) if let cb = await client.onChannelDelete { await cb(channel) } - // MARK: Voice - // Voice events are not currently modeled in DiscordEvent enum - // These are handled via raw events if needed + // MARK: Voice Channel Status + case .voiceChannelStatusUpdate(let ev): + if let cb = await client.onVoiceChannelStatusUpdate { await cb(ev) } + + case .voiceChannelStartTimeUpdate(let ev): + if let cb = await client.onVoiceChannelStartTimeUpdate { await cb(ev) } // MARK: Threads case .threadCreate(let ch): @@ -142,14 +147,15 @@ actor EventDispatcher { await client.cache.removeChannel(id: ch.id) if let cb = await client.onThreadDelete { await cb(ch) } - case .threadMemberUpdate: - break + case .threadMemberUpdate(let ev): + if let cb = await client.onThreadMemberUpdate { await cb(ev) } case .threadMembersUpdate(let ev): if let cb = await client.onThreadMembersUpdate { await cb(ev) } case .threadListSync(let ev): for thread in ev.threads { await client.cache.upsert(channel: thread) } + if let cb = await client.onThreadListSync { await cb(ev) } // MARK: Application Commands case .applicationCommandPermissionsUpdate(let ev): @@ -158,6 +164,7 @@ actor EventDispatcher { // MARK: Channel Info case .channelInfo(let channel): await client.cache.upsert(channel: channel) + if let cb = await client.onChannelInfo { await cb(channel) } // MARK: Interactions case .interactionCreate(let interaction): @@ -180,8 +187,8 @@ actor EventDispatcher { await client.cache.upsert(user: ev.user) if let cb = await client.onPresenceUpdate { await cb(ev) } - case .channelPinsUpdate: - break + case .channelPinsUpdate(let ev): + if let cb = await client.onChannelPinsUpdate { await cb(ev) } // MARK: Bans case .guildBanAdd(let ev): @@ -190,20 +197,35 @@ actor EventDispatcher { case .guildBanRemove(let ev): if let cb = await client.onGuildBanRemove { await cb(ev) } - // MARK: Webhooks / Integrations / Invites (stream-only) - case .webhooksUpdate, .guildIntegrationsUpdate, .inviteCreate, .inviteDelete: - break + // MARK: Webhooks / Integrations / Invites + case .webhooksUpdate(let ev): + if let cb = await client.onWebhooksUpdate { await cb(ev) } + + case .guildIntegrationsUpdate(let ev): + if let cb = await client.onGuildIntegrationsUpdate { await cb(ev) } + + case .inviteCreate(let ev): + if let cb = await client.onInviteCreate { await cb(ev) } + + case .inviteDelete(let ev): + if let cb = await client.onInviteDelete { await cb(ev) } // MARK: AutoMod - case .autoModerationRuleCreate, .autoModerationRuleUpdate, .autoModerationRuleDelete: - break + case .autoModerationRuleCreate(let ev): + if let cb = await client.onAutoModerationRuleCreate { await cb(ev) } + + case .autoModerationRuleUpdate(let ev): + if let cb = await client.onAutoModerationRuleUpdate { await cb(ev) } + + case .autoModerationRuleDelete(let ev): + if let cb = await client.onAutoModerationRuleDelete { await cb(ev) } case .autoModerationActionExecution(let ev): if let cb = await client.onAutoModerationActionExecution { await cb(ev) } - // MARK: Audit Log (stream-only) - case .guildAuditLogEntryCreate: - break + // MARK: Audit Log + case .guildAuditLogEntryCreate(let ev): + if let cb = await client.onGuildAuditLogEntryCreate { await cb(ev) } // MARK: Scheduled Events case .guildScheduledEventCreate(let ev): @@ -215,8 +237,11 @@ actor EventDispatcher { case .guildScheduledEventDelete(let ev): if let cb = await client.onGuildScheduledEventDelete { await cb(ev) } - case .guildScheduledEventUserAdd, .guildScheduledEventUserRemove: - break + case .guildScheduledEventUserAdd(let ev): + if let cb = await client.onGuildScheduledEventUserAdd { await cb(ev) } + + case .guildScheduledEventUserRemove(let ev): + if let cb = await client.onGuildScheduledEventUserRemove { await cb(ev) } // MARK: Polls case .pollVoteAdd(let ev): @@ -245,8 +270,9 @@ actor EventDispatcher { case .entitlementDelete(let ev): if let cb = await client.onEntitlementDelete { await cb(ev) } - case .userUpdate: - break + case .userUpdate(let ev): + if let cb = await client.onUserUpdate { await cb(ev) } + // MARK: Raw / Other case .raw: break From 34e2fb2ee675413f105b278091105cdfa043de12 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Mon, 29 Jun 2026 20:57:53 -0400 Subject: [PATCH 04/22] Add typed enums across all models + REST endpoints for New Member Welcome, MFA, Incidents Typed enums added: - ChannelType (replaces raw Int on Channel.type) - MessageType (replaces raw Int? on Message.type) - MessageFlags OptionSet (replaces raw Int? on Message.flags) - InteractionType (replaces raw Int on Interaction.type) - GuildFeature (replaces [String] on Guild.features) - GuildMemberFlags OptionSet (replaces Int? on GuildMember.flags) - ChannelFlags OptionSet (replaces Int? on Channel.flags) - AuditLogEventType (replaces raw Int on AuditLogEntry.action_type) - EventPrivacyLevel (replaces raw Int on GuildScheduledEvent.privacy_level and StageInstance.privacy_level) - AutoModerationRule.EventType, TriggerType, ActionType (replaces raw Int) - Application model (new file with full fields) - NewMemberWelcome model + GET/PATCH endpoints - MFALevel model + GET/POST endpoints - modifyGuildIncidentActions PUT endpoint All call sites updated: EventDispatcher, DiscordClient, MessagePayload, example bots, and tests. --- Examples/ComponentsV2Bot.swift | 8 +- Sources/SwiftDisc/DiscordClient.swift | 112 ++++++++++++----- .../SwiftDisc/HighLevel/MessagePayload.swift | 14 +-- .../SwiftDisc/Internal/EventDispatcher.swift | 2 +- Sources/SwiftDisc/Models/Application.swift | 52 ++++++++ Sources/SwiftDisc/Models/AuditLog.swift | 78 +++++++++++- Sources/SwiftDisc/Models/AutoModeration.swift | 39 ++++-- Sources/SwiftDisc/Models/Channel.swift | 99 +++++++++------ Sources/SwiftDisc/Models/Guild.swift | 118 +++++++++++++++++- Sources/SwiftDisc/Models/GuildMember.swift | 24 +++- Sources/SwiftDisc/Models/Interaction.swift | 37 +++--- Sources/SwiftDisc/Models/MFALevel.swift | 7 ++ Sources/SwiftDisc/Models/Message.swift | 82 +++++++++++- .../SwiftDisc/Models/NewMemberWelcome.swift | 8 ++ Sources/SwiftDisc/Models/ScheduledEvent.swift | 14 ++- Sources/SwiftDisc/Models/Snowflake.swift | 4 + Sources/SwiftDisc/Models/StageInstance.swift | 2 +- .../InteractionDecodingTests.swift | 4 +- 18 files changed, 587 insertions(+), 117 deletions(-) create mode 100644 Sources/SwiftDisc/Models/Application.swift create mode 100644 Sources/SwiftDisc/Models/MFALevel.swift create mode 100644 Sources/SwiftDisc/Models/NewMemberWelcome.swift diff --git a/Examples/ComponentsV2Bot.swift b/Examples/ComponentsV2Bot.swift index 836a1699..b8a8d4fe 100644 --- a/Examples/ComponentsV2Bot.swift +++ b/Examples/ComponentsV2Bot.swift @@ -22,16 +22,16 @@ struct ComponentsV2BotMain { channelId: channelId, content: "Welcome! Use the menu below:", components: [.actionRow(row)], - flags: 1 << 15 + flags: .isComponentsV2 ) - print("Components v2 message sent (flags: \(1 << 15))") + print("Components v2 message sent (flags: isComponentsV2)") } } await client.setOnInteractionCreate { interaction in guard let data = interaction.data else { return } - if interaction.type == 3, let customId = data.custom_id { + if interaction.type == .messageComponent, let customId = data.custom_id { if customId == "channel_picker" { let input = MessageComponent.TextInput( custom_id: "feedback_text", @@ -55,7 +55,7 @@ struct ComponentsV2BotMain { ) } } - } else if interaction.type == 5, data.custom_id == "feedback_modal" { + } else if interaction.type == .modalSubmit, data.custom_id == "feedback_modal" { print("Modal submitted!") Task { try? await client.createInteractionResponse( diff --git a/Sources/SwiftDisc/DiscordClient.swift b/Sources/SwiftDisc/DiscordClient.swift index a46c8ad5..ca249c25 100644 --- a/Sources/SwiftDisc/DiscordClient.swift +++ b/Sources/SwiftDisc/DiscordClient.swift @@ -954,7 +954,7 @@ public actor DiscordClient { allowedMentions: AllowedMentions? = nil, messageReference: MessageReference? = nil, tts: Bool? = nil, - flags: Int? = nil, + flags: MessageFlags? = nil, stickerIds: [StickerID]? = nil, attachments: [PartialAttachment]? = nil, poll: Poll? = nil, @@ -967,7 +967,7 @@ public actor DiscordClient { let allowed_mentions: AllowedMentions? let message_reference: MessageReference? let tts: Bool? - let flags: Int? + let flags: MessageFlags? let sticker_ids: [StickerID]? let attachments: [PartialAttachment]? let poll: Poll? @@ -1108,12 +1108,12 @@ public actor DiscordClient { } // Merges optional message fields with a raw `poll` payload. - public func createPollMessage(channelId: ChannelID, content: String? = nil, poll: [String: JSONValue], flags: Int? = nil, components: [JSONValue]? = nil) async throws -> Message { + public func createPollMessage(channelId: ChannelID, content: String? = nil, poll: [String: JSONValue], flags: MessageFlags? = nil, components: [JSONValue]? = nil) async throws -> Message { var body: [String: JSONValue] = [ "poll": .object(poll) ] if let content { body["content"] = .string(content) } - if let flags { body["flags"] = .int(flags) } + if let flags { body["flags"] = .int(flags.rawValue) } if let components { body["components"] = .array(components) } return try await http.post(path: "/channels/\(channelId)/messages", body: body) } @@ -1124,12 +1124,12 @@ public actor DiscordClient { } // MARK: - Poll typed payload - public func createPollMessage(channelId: ChannelID, payload: PollPayload, content: String? = nil, flags: Int? = nil, components: [JSONValue]? = nil) async throws -> Message { + public func createPollMessage(channelId: ChannelID, payload: PollPayload, content: String? = nil, flags: MessageFlags? = nil, components: [JSONValue]? = nil) async throws -> Message { var body: [String: JSONValue] = [ "poll": .object(payload.pollJSON()) ] if let content { body["content"] = .string(content) } - if let flags { body["flags"] = .int(flags) } + if let flags { body["flags"] = .int(flags.rawValue) } if let components { body["components"] = .array(components) } return try await http.post(path: "/channels/\(channelId)/messages", body: body) } @@ -1951,7 +1951,7 @@ public actor DiscordClient { allowedMentions: AllowedMentions? = nil, messageReference: MessageReference? = nil, tts: Bool? = nil, - flags: Int? = nil, + flags: MessageFlags? = nil, stickerIds: [StickerID]? = nil, attachments: [PartialAttachment]? = nil, poll: Poll? = nil @@ -1963,7 +1963,7 @@ public actor DiscordClient { let allowed_mentions: AllowedMentions? let message_reference: MessageReference? let tts: Bool? - let flags: Int? + let flags: MessageFlags? let sticker_ids: [StickerID]? let attachments: [PartialAttachment]? let poll: Poll? @@ -1975,7 +1975,7 @@ public actor DiscordClient { allowedMentions: AllowedMentions? = nil, messageReference: MessageReference? = nil, tts: Bool? = nil, - flags: Int? = nil, + flags: MessageFlags? = nil, stickerIds: [StickerID]? = nil, attachments: [PartialAttachment]? = nil, poll: Poll? = nil @@ -3120,11 +3120,11 @@ public actor DiscordClient { } - public func modifyGuildMember(guildId: GuildID, userId: UserID, nick: String? = nil, roles: [RoleID]? = nil, flags: Int? = nil) async throws -> GuildMember { + public func modifyGuildMember(guildId: GuildID, userId: UserID, nick: String? = nil, roles: [RoleID]? = nil, flags: GuildMemberFlags? = nil) async throws -> GuildMember { struct Body: Encodable, Sendable { let nick: String? let roles: [RoleID]? - let flags: Int? + let flags: GuildMemberFlags? } return try await http.patch(path: "/guilds/\(guildId)/members/\(userId)", body: Body(nick: nick, roles: roles, flags: flags)) } @@ -3172,6 +3172,52 @@ public actor DiscordClient { try await http.get(path: "/guilds/\(guildId)/preview") } + // MARK: - REST: Guild New Member Welcome + + /// Returns the New Member Welcome configuration for a guild. + public func getGuildNewMemberWelcome(guildId: GuildID) async throws -> NewMemberWelcome { + try await http.get(path: "/guilds/\(guildId)/new-member-welcome") + } + + /// Modifies the New Member Welcome configuration for a guild. + public func modifyGuildNewMemberWelcome(guildId: GuildID, enabled: Bool? = nil, welcomeMessage: String? = nil, welcomeChannelId: ChannelID? = nil) async throws -> NewMemberWelcome { + struct Body: Encodable, Sendable { + let enabled: Bool? + let welcome_message: String? + let welcome_channel_id: ChannelID? + } + return try await http.patch(path: "/guilds/\(guildId)/new-member-welcome", body: Body(enabled: enabled, welcome_message: welcomeMessage, welcome_channel_id: welcomeChannelId)) + } + + // MARK: - REST: Guild MFA Level + + /// Returns the MFA level for a guild. + public func getGuildMFALevel(guildId: GuildID) async throws -> MFALevel { + try await http.get(path: "/guilds/\(guildId)/mfa") + } + + /// Sets the MFA level for a guild. + /// - Parameter level: The MFA level (0 = disabled, 1 = enabled). + public func modifyGuildMFALevel(guildId: GuildID, level: Int) async throws -> MFALevel { + struct Body: Encodable, Sendable { + let level: Int + } + return try await http.post(path: "/guilds/\(guildId)/mfa", body: Body(level: level)) + } + + // MARK: - REST: Guild Incidents + + /// Modifies the incident actions configuration for a guild. + /// Use this to enable/disable raid protection and set invite cooldowns. + public func modifyGuildIncidentActions(guildId: GuildID, raidsDisabled: Bool? = nil, raidSystemEnabled: Bool? = nil, invitesDisabledUntil: String? = nil) async throws -> IncidentsData { + struct Body: Encodable, Sendable { + let raids_disabled: Bool? + let raid_system_enabled: Bool? + let invites_disabled_until: String? + } + return try await http.put(path: "/guilds/\(guildId)/incident-actions", body: Body(raids_disabled: raidsDisabled, raid_system_enabled: raidSystemEnabled, invites_disabled_until: invitesDisabledUntil)) + } + // MARK: - REST: Welcome Screen /// Returns the Welcome Screen for a Community guild. @@ -3898,14 +3944,14 @@ public actor DiscordClient { public func getGuildAuditLog( guildId: GuildID, userId: UserID? = nil, - actionType: Int? = nil, + actionType: AuditLogEventType? = nil, before: AuditLogEntryID? = nil, limit: Int? = nil ) async throws -> AuditLog { let path = "/guilds/\(guildId)/audit-logs" var qs: [String] = [] if let userId { qs.append("user_id=\(userId)") } - if let actionType { qs.append("action_type=\(actionType)") } + if let actionType { qs.append("action_type=\(actionType.rawValue)") } if let before { qs.append("before=\(before)") } if let limit { qs.append("limit=\(limit)") } let q = qs.isEmpty ? "" : "?" + qs.joined(separator: "&") @@ -3924,8 +3970,8 @@ public actor DiscordClient { public func createAutoModerationRule( guildId: GuildID, name: String, - eventType: Int, - triggerType: Int, + eventType: AutoModerationRule.EventType, + triggerType: AutoModerationRule.TriggerType, triggerMetadata: AutoModerationRule.TriggerMetadata? = nil, actions: [AutoModerationRule.Action], enabled: Bool = true, @@ -3934,8 +3980,8 @@ public actor DiscordClient { ) async throws -> AutoModerationRule { struct Body: Encodable, Sendable { let name: String - let event_type: Int - let trigger_type: Int + let event_type: AutoModerationRule.EventType + let trigger_type: AutoModerationRule.TriggerType let trigger_metadata: AutoModerationRule.TriggerMetadata? let actions: [AutoModerationRule.Action] let enabled: Bool? @@ -3959,7 +4005,7 @@ public actor DiscordClient { guildId: GuildID, ruleId: AutoModerationRuleID, name: String? = nil, - eventType: Int? = nil, + eventType: AutoModerationRule.EventType? = nil, triggerMetadata: AutoModerationRule.TriggerMetadata? = nil, actions: [AutoModerationRule.Action]? = nil, enabled: Bool? = nil, @@ -3968,7 +4014,7 @@ public actor DiscordClient { ) async throws -> AutoModerationRule { struct Body: Encodable, Sendable { let name: String? - let event_type: Int? + let event_type: AutoModerationRule.EventType? let trigger_metadata: AutoModerationRule.TriggerMetadata? let actions: [AutoModerationRule.Action]? let enabled: Bool? @@ -4004,23 +4050,23 @@ public actor DiscordClient { name: String, scheduledStartTimeISO8601: String, scheduledEndTimeISO8601: String? = nil, - privacyLevel: Int = 2, + privacyLevel: EventPrivacyLevel = .guildOnly, description: String? = nil, entityMetadata: GuildScheduledEvent.EntityMetadata? = nil ) async throws -> GuildScheduledEvent { struct Body: Encodable, Sendable { let channel_id: ChannelID? - let entity_type: Int + let entity_type: GuildScheduledEvent.EntityType let name: String let scheduled_start_time: String let scheduled_end_time: String? - let privacy_level: Int + let privacy_level: EventPrivacyLevel let description: String? let entity_metadata: GuildScheduledEvent.EntityMetadata? } let body = Body( channel_id: channelId, - entity_type: entityType.rawValue, + entity_type: entityType, name: name, scheduled_start_time: scheduledStartTimeISO8601, scheduled_end_time: scheduledEndTimeISO8601, @@ -4044,31 +4090,31 @@ public actor DiscordClient { name: String? = nil, scheduledStartTimeISO8601: String? = nil, scheduledEndTimeISO8601: String? = nil, - privacyLevel: Int? = nil, + privacyLevel: EventPrivacyLevel? = nil, description: String? = nil, status: GuildScheduledEvent.Status? = nil, entityMetadata: GuildScheduledEvent.EntityMetadata? = nil ) async throws -> GuildScheduledEvent { struct Body: Encodable, Sendable { let channel_id: ChannelID? - let entity_type: Int? + let entity_type: GuildScheduledEvent.EntityType? let name: String? let scheduled_start_time: String? let scheduled_end_time: String? - let privacy_level: Int? + let privacy_level: EventPrivacyLevel? let description: String? - let status: Int? + let status: GuildScheduledEvent.Status? let entity_metadata: GuildScheduledEvent.EntityMetadata? } let body = Body( channel_id: channelId, - entity_type: entityType?.rawValue, + entity_type: entityType, name: name, scheduled_start_time: scheduledStartTimeISO8601, scheduled_end_time: scheduledEndTimeISO8601, privacy_level: privacyLevel, description: description, - status: status?.rawValue, + status: status, entity_metadata: entityMetadata ) return try await http.patch(path: "/guilds/\(guildId)/scheduled-events/\(eventId)", body: body) @@ -4097,11 +4143,11 @@ public actor DiscordClient { } // MARK: - REST: Stage Instances - public func createStageInstance(channelId: ChannelID, topic: String, privacyLevel: Int = 2, guildScheduledEventId: GuildScheduledEventID? = nil) async throws -> StageInstance { + public func createStageInstance(channelId: ChannelID, topic: String, privacyLevel: EventPrivacyLevel = .guildOnly, guildScheduledEventId: GuildScheduledEventID? = nil) async throws -> StageInstance { struct Body: Encodable, Sendable { let channel_id: ChannelID let topic: String - let privacy_level: Int + let privacy_level: EventPrivacyLevel let guild_scheduled_event_id: GuildScheduledEventID? } let body = Body(channel_id: channelId, topic: topic, privacy_level: privacyLevel, guild_scheduled_event_id: guildScheduledEventId) @@ -4112,10 +4158,10 @@ public actor DiscordClient { try await http.get(path: "/stage-instances/\(channelId)") } - public func modifyStageInstance(channelId: ChannelID, topic: String? = nil, privacyLevel: Int? = nil) async throws -> StageInstance { + public func modifyStageInstance(channelId: ChannelID, topic: String? = nil, privacyLevel: EventPrivacyLevel? = nil) async throws -> StageInstance { struct Body: Encodable, Sendable { let topic: String? - let privacy_level: Int? + let privacy_level: EventPrivacyLevel? } return try await http.patch(path: "/stage-instances/\(channelId)", body: Body(topic: topic, privacy_level: privacyLevel)) } diff --git a/Sources/SwiftDisc/HighLevel/MessagePayload.swift b/Sources/SwiftDisc/HighLevel/MessagePayload.swift index d688815c..7424c1d9 100644 --- a/Sources/SwiftDisc/HighLevel/MessagePayload.swift +++ b/Sources/SwiftDisc/HighLevel/MessagePayload.swift @@ -22,7 +22,7 @@ public struct MessagePayload: Sendable { public var allowedMentions: AllowedMentions? public var messageReference: MessageReference? public var tts: Bool? - public var flags: Int? + public var flags: MessageFlags? public var stickerIds: [StickerID]? public var files: [FileAttachment]? public var poll: Poll? @@ -96,21 +96,21 @@ public struct MessagePayload: Sendable { /// Mark the message as *ephemeral* — only visible to the interaction invoker. /// Only effective in interaction responses. public func ephemeral() -> Self { - var c = self; c.flags = (c.flags ?? 0) | (1 << 6); return c + var c = self; c.flags = (c.flags ?? MessageFlags()) | .ephemeral; return c } /// Suppress link embeds (Discord flag bit 2). public func suppressEmbeds() -> Self { - var c = self; c.flags = (c.flags ?? 0) | (1 << 2); return c + var c = self; c.flags = (c.flags ?? MessageFlags()) | .suppressEmbeds; return c } /// Mark the message as *silent* — no push/desktop notification (Discord flag bit 12). public func silent() -> Self { - var c = self; c.flags = (c.flags ?? 0) | (1 << 12); return c + var c = self; c.flags = (c.flags ?? MessageFlags()) | .suppressNotifications; return c } - /// Set raw message flags (replaces any previously OR'd flags). - public func flags(_ f: Int) -> Self { var c = self; c.flags = f; return c } + /// Set message flags (replaces any previously OR'd flags). + public func flags(_ f: MessageFlags) -> Self { var c = self; c.flags = f; return c } // MARK: - TTS & Stickers @@ -227,7 +227,7 @@ public extension DiscordClient { let content: String? let embeds: [Embed]? let components: [MessageComponent]? - let flags: Int? + let flags: MessageFlags? let tts: Bool? let allowed_mentions: AllowedMentions? } diff --git a/Sources/SwiftDisc/Internal/EventDispatcher.swift b/Sources/SwiftDisc/Internal/EventDispatcher.swift index 6f3cc984..cc5a3236 100644 --- a/Sources/SwiftDisc/Internal/EventDispatcher.swift +++ b/Sources/SwiftDisc/Internal/EventDispatcher.swift @@ -172,7 +172,7 @@ actor EventDispatcher { await client.cache.ensureChannelStub(id: cid) } if let cb = await client.onInteractionCreate { await cb(interaction) } - if interaction.type == 4, let ac = await client.autocomplete { + if interaction.type == .autocomplete, let ac = await client.autocomplete { await ac.handle(interaction: interaction, client: client) } else if let s = await client.slashCommands { await s.handle(interaction: interaction, client: client) diff --git a/Sources/SwiftDisc/Models/Application.swift b/Sources/SwiftDisc/Models/Application.swift new file mode 100644 index 00000000..2bb08cfb --- /dev/null +++ b/Sources/SwiftDisc/Models/Application.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Represents a Discord application (bot). +public struct Application: Codable, Hashable, Sendable { + public let id: ApplicationID + public let name: String + public let icon: String? + public let description: String + public let rpc_origins: [String]? + public let bot_public: Bool + public let bot_require_code_grant: Bool + public let terms_of_service_url: String? + public let privacy_policy_url: String? + public let owner: PartialGuild? + public let verify_key: String + public let team: Team? + public let guild_id: GuildID? + public let primary_sku_id: SKUID? + public let slug: String? + public let cover_image: String? + public let flags: Int? + public let tags: [String]? + public let install_params: InstallParams? + public let custom_install_url: String? + public let role_connections_verification_url: String? + public let integration_types_config: [Int: IntegrationTypeConfig]? + public let flags_new: String? + + public struct Team: Codable, Hashable, Sendable { + public let id: TeamID + public let icon: String? + public let members: [TeamMember] + public let name: String + public let owner_user_id: UserID + } + + public struct TeamMember: Codable, Hashable, Sendable { + public let membership_state: Int + public let team_id: TeamID + public let user: User + public let role: String + } + + public struct InstallParams: Codable, Hashable, Sendable { + public let scopes: [String] + public let permissions: String + } + + public struct IntegrationTypeConfig: Codable, Hashable, Sendable { + public let oauth2_install_params: InstallParams? + } +} diff --git a/Sources/SwiftDisc/Models/AuditLog.swift b/Sources/SwiftDisc/Models/AuditLog.swift index b8e3f779..fb5befd7 100644 --- a/Sources/SwiftDisc/Models/AuditLog.swift +++ b/Sources/SwiftDisc/Models/AuditLog.swift @@ -1,5 +1,81 @@ import Foundation +/// The type of an audit log event. +public enum AuditLogEventType: Int, Codable, Sendable { + case guildUpdate = 1 + case channelCreate = 10 + case channelUpdate = 11 + case channelDelete = 12 + case channelOverwriteCreate = 13 + case channelOverwriteUpdate = 14 + case channelOverwriteDelete = 15 + case memberKick = 20 + case memberPrune = 21 + case memberBanAdd = 22 + case memberBanRemove = 23 + case memberUpdate = 24 + case memberRoleUpdate = 25 + case memberMove = 26 + case memberDisconnect = 27 + case botAdd = 28 + case roleCreate = 30 + case roleUpdate = 31 + case roleDelete = 32 + case inviteCreate = 40 + case inviteUpdate = 41 + case inviteDelete = 42 + case webhookCreate = 50 + case webhookUpdate = 51 + case webhookDelete = 52 + case emojiCreate = 60 + case emojiUpdate = 61 + case emojiDelete = 62 + case messageDelete = 72 + case messageBulkDelete = 73 + case messagePin = 74 + case messageUnpin = 75 + case integrationCreate = 80 + case integrationUpdate = 81 + case integrationDelete = 82 + case stageInstanceCreate = 83 + case stageInstanceUpdate = 84 + case stageInstanceDelete = 85 + case stickerCreate = 90 + case stickerUpdate = 91 + case stickerDelete = 92 + case scheduledEventCreate = 100 + case scheduledEventUpdate = 101 + case scheduledEventDelete = 102 + case scheduledEventExceptionCreate = 103 + case scheduledEventExceptionUpdate = 104 + case scheduledEventExceptionDelete = 105 + case threadCreate = 110 + case threadUpdate = 111 + case threadDelete = 112 + case permissionOverwriteType = 121 + case autoModerationRuleCreate = 140 + case autoModerationRuleUpdate = 141 + case autoModerationRuleDelete = 142 + case autoModerationBlockMessage = 143 + case autoModerationFlagToChannel = 144 + case autoModerationUserCommunicationDisabled = 145 + case creatorMonoRequestCreated = 150 + case creatorMonoTermsAccepted = 151 + case onboardingCreate = 160 + case onboardingUpdate = 161 + case homeSettingsCreate = 170 + case homeSettingsUpdate = 171 + case voiceChannelStatusUpdate = 192 + case voiceChannelStatusDelete = 193 + case unknown = -1 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = AuditLogEventType(rawValue: rawValue) ?? .unknown + } +} + /// Represents a guild audit log. public struct AuditLog: Codable, Hashable, Sendable { public let audit_log_entries: [AuditLogEntry] @@ -32,7 +108,7 @@ public struct AuditLogEntry: Codable, Hashable, Sendable { public let id: AuditLogEntryID public let target_id: String? public let user_id: UserID? - public let action_type: Int + public let action_type: AuditLogEventType public let changes: [Change]? public let options: OptionalInfo? public let reason: String? diff --git a/Sources/SwiftDisc/Models/AutoModeration.swift b/Sources/SwiftDisc/Models/AutoModeration.swift index 42e9ff8d..dc37e24c 100644 --- a/Sources/SwiftDisc/Models/AutoModeration.swift +++ b/Sources/SwiftDisc/Models/AutoModeration.swift @@ -3,22 +3,47 @@ import Foundation /// Represents an auto moderation rule in a guild. public struct AutoModerationRule: Codable, Hashable, Sendable { /// Auto Moderation trigger types for rule configuration. - public enum TriggerType: Int, Sendable { + public enum TriggerType: Int, Codable, Sendable { case keyword = 1 case spam = 3 case keywordPreset = 4 case mentionSpam = 5 - /// Member profile trigger type. Checks if a member's profile contains disallowed keywords. - /// Introduced for member profile moderation. case memberProfile = 6 + case unknown = -1 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = TriggerType(rawValue: rawValue) ?? .unknown + } } /// Auto Moderation action types for rule responses. - public enum ActionType: Int, Sendable { + public enum ActionType: Int, Codable, Sendable { case blockMessage = 1 case sendAlert = 2 case timeout = 3 case blockMemberInteraction = 4 + case unknown = -1 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = ActionType(rawValue: rawValue) ?? .unknown + } + } + + /// The event type for an auto moderation rule (1 = MESSAGE_SEND, 2 = MEMBER_UPDATE). + public enum EventType: Int, Codable, Sendable { + case messageSend = 1 + case memberUpdate = 2 + case unknown = -1 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = EventType(rawValue: rawValue) ?? .unknown + } } /// Metadata for an auto moderation trigger. @@ -37,15 +62,15 @@ public struct AutoModerationRule: Codable, Hashable, Sendable { public let duration_seconds: Int? public let custom_message: String? } - public let type: Int + public let type: ActionType public let metadata: Metadata? } public let id: AutoModerationRuleID public let guild_id: GuildID public let name: String public let creator_id: UserID - public let event_type: Int - public let trigger_type: Int + public let event_type: EventType + public let trigger_type: TriggerType public let trigger_metadata: TriggerMetadata? public let actions: [Action] public let enabled: Bool diff --git a/Sources/SwiftDisc/Models/Channel.swift b/Sources/SwiftDisc/Models/Channel.swift index 054d46f7..74879c3c 100644 --- a/Sources/SwiftDisc/Models/Channel.swift +++ b/Sources/SwiftDisc/Models/Channel.swift @@ -1,21 +1,65 @@ import Foundation +/// The type of a Discord channel. +public enum ChannelType: Int, Codable, Sendable { + case text = 0 + case dm = 1 + case voice = 2 + case groupDm = 3 + case category = 4 + case news = 5 + case store = 6 + case newsThread = 10 + case publicThread = 11 + case privateThread = 12 + case stageChannel = 13 + case directory = 14 + case forum = 15 + case mediaChannel = 16 + case unknown = -1 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = ChannelType(rawValue: rawValue) ?? .unknown + } +} + +/// Channel flags combined as a bitfield. +public struct ChannelFlags: OptionSet, Codable, Sendable { + public let rawValue: Int + public init(rawValue: Int) { self.rawValue = rawValue } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + rawValue = try container.decode(Int.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public static let hideMediaDownloadOptions = ChannelFlags(rawValue: 1 << 15) +} + /// Represents a Discord channel. /// /// Channels are the primary way users communicate in Discord. This struct represents all channel types including: -/// - Text channels (type 0) -/// - Direct messages (type 1) -/// - Voice channels (type 2) -/// - Group DMs (type 3) -/// - Categories (type 4) -/// - News channels (type 5) -/// - Store channels (type 6) -/// - News threads (type 10) -/// - Public threads (type 11) -/// - Private threads (type 12) -/// - Stage channels (type 13) -/// - Directory channels (type 14) -/// - Forum channels (type 15) +/// - Text channels (``ChannelType/text``) +/// - Direct messages (``ChannelType/dm``) +/// - Voice channels (``ChannelType/voice``) +/// - Group DMs (``ChannelType/groupDm``) +/// - Categories (``ChannelType/category``) +/// - News channels (``ChannelType/news``) +/// - Store channels (``ChannelType/store``) +/// - News threads (``ChannelType/newsThread``) +/// - Public threads (``ChannelType/publicThread``) +/// - Private threads (``ChannelType/privateThread``) +/// - Stage channels (``ChannelType/stageChannel``) +/// - Directory channels (``ChannelType/directory``) +/// - Forum channels (``ChannelType/forum``) +/// - Media channels (``ChannelType/mediaChannel``) /// /// ## Example /// @@ -30,23 +74,6 @@ import Foundation /// } /// ``` /// -/// ## Channel Type Constants -/// -/// - `0`: Guild text channel -/// - `1`: DM channel -/// - `2`: Guild voice channel -/// - `3`: Group DM -/// - `4`: Guild category -/// - `5`: Guild news channel -/// - `6`: Guild store channel -/// - `10`: Guild news thread -/// - `11`: Guild public thread -/// - `12`: Guild private thread -/// - `13`: Guild stage channel -/// - `14`: Guild directory -/// - `15`: Guild forum channel -/// - `16`: Guild media channel -/// /// ## Related Topics /// - ``DiscordClient/getChannel(id:)`` /// - ``DiscordClient/modifyChannel(id:topic:nsfw:position:parentId:)`` @@ -56,8 +83,8 @@ public struct Channel: Codable, Hashable, Sendable { /// The unique ID of the channel. public let id: ChannelID - /// The type of channel (see Channel Type Constants in the struct documentation). - public let type: Int + /// The type of channel. + public let type: ChannelType /// The name of the channel (1-100 characters). public let name: String? @@ -126,7 +153,7 @@ public struct Channel: Codable, Hashable, Sendable { public let permissions: String? /// Channel flags combined as a bitfield. - public let flags: Int? + public let flags: ChannelFlags? /// Number of messages ever sent in a thread (thread channels only). public let total_message_sent: Int? @@ -163,7 +190,7 @@ public struct Channel: Codable, Hashable, Sendable { public init( id: ChannelID, - type: Int, + type: ChannelType, name: String? = nil, topic: String? = nil, nsfw: Bool? = nil, @@ -186,7 +213,7 @@ public struct Channel: Codable, Hashable, Sendable { member: ThreadMember? = nil, default_auto_archive_duration: Int? = nil, permissions: String? = nil, - flags: Int? = nil, + flags: ChannelFlags? = nil, total_message_sent: Int? = nil, available_tags: [ForumTag]? = nil, applied_tags: [ForumTagID]? = nil, @@ -307,7 +334,7 @@ public struct DefaultReaction: Codable, Hashable, Sendable { /// let overwrites = channel.permission_overwrites { /// for overwrite in overwrites { /// print("Overwrite ID: \(overwrite.id)") -/// print("Type: \(overwrite.type == 0 ? "role" : "member")") +/// print("Type: \(overwrite.type)") // 0 = role, 1 = member /// print("Allow: \(overwrite.allow)") /// print("Deny: \(overwrite.deny)") /// } diff --git a/Sources/SwiftDisc/Models/Guild.swift b/Sources/SwiftDisc/Models/Guild.swift index 83c27f76..2f6ee6d5 100644 --- a/Sources/SwiftDisc/Models/Guild.swift +++ b/Sources/SwiftDisc/Models/Guild.swift @@ -1,5 +1,121 @@ import Foundation +/// A feature enabled on a guild. +public enum GuildFeature: RawRepresentable, Codable, Hashable, Sendable { + case animatedIcon + case banner + case community + case discoveries + case featurable + case inviteSplash + case memberVerificationGateEnabled + case monetizationEnabled + case moreStickers + case news + case partnered + case previewEnabled + case raidAlertsDisabled + case roleSubscriptionsEnabled + case roleSubscriptionsPurchased + case ticketedEventsEnabled + case vanityUrl + case verified + case vipRegions + case welcomeScreenEnabled + case guestsEnabled + case guildTags + case enhancedRoleColors + case applicationCommandPermissionsV2 + case autoModeration + case guildWebPageVanityUrl + case creatorMonetizable + case creatorMonetizableDisclaimer + case creatorStorePage + case roleSubscriptionsAvailableForPurchase + case onboardingEnabled + case onboarding + case soundboard + case memberProfiles + case roleSubscriptions + case home + case channelIconEmojisGenerated + case [`internal`] + case unknown(String) + + public var rawValue: String { + switch self { + case .animatedIcon: return "ANIMATED_ICON" + case .banner: return "BANNER" + case .community: return "COMMUNITY" + case .discoveries: return "DISCOVERABLE" + case .featurable: return "FEATURABLE" + case .inviteSplash: return "INVITE_SPLASH" + case .memberVerificationGateEnabled: return "MEMBER_VERIFICATION_GATE_ENABLED" + case .monetizationEnabled: return "MONETIZATION_ENABLED" + case .moreStickers: return "MORE_STICKERS" + case .news: return "NEWS" + case .partnered: return "PARTNERED" + case .previewEnabled: return "PREVIEW_ENABLED" + case .raidAlertsDisabled: return "RAID_ALERTS_DISABLED" + case .roleSubscriptionsEnabled: return "ROLE_SUBSCRIPTIONS_ENABLED" + case .roleSubscriptionsPurchased: return "ROLE_SUBSCRIPTIONS_PURCHASED" + case .ticketedEventsEnabled: return "TICKETED_EVENTS_ENABLED" + case .vanityUrl: return "VANITY_URL" + case .verified: return "VERIFIED" + case .vipRegions: return "VIP_REGIONS" + case .welcomeScreenEnabled: return "WELCOME_SCREEN_ENABLED" + case .guestsEnabled: return "GUESTS_ENABLED" + case .guildTags: return "GUILD_TAGS" + case .enhancedRoleColors: return "ENHANCED_ROLE_COLORS" + case .applicationCommandPermissionsV2: return "APPLICATION_COMMAND_PERMISSIONS_V2" + case .autoModeration: return "AUTO_MODERATION" + case .guildWebPageVanityUrl: return "GUILD_WEB_PAGE_VANITY_URL" + case .creatorMonetizable: return "CREATOR_MONETIZABLE" + case .creatorMonetizableDisclaimer: return "CREATOR_MONETIZABLE_DISCLAIMER" + case .creatorStorePage: return "CREATOR_STORE_PAGE" + case .roleSubscriptionsAvailableForPurchase: return "ROLE_SUBSCRIPTIONS_AVAILABLE_FOR_PURCHASE" + case .onboardingEnabled: return "ONBOARDING_ENABLED" + case .onboarding: return "ONBOARDING" + case .soundboard: return "SOUNDBOARD" + case .memberProfiles: return "MEMBER_PROFILES" + case .roleSubscriptions: return "ROLE_SUBSCRIPTIONS" + case .home: return "HOME" + case .channelIconEmojisGenerated: return "CHANNEL_ICON_EMOJIS_GENERATED" + case .`internal`: return "INTERNAL" + case .unknown(let value): return value + } + } + + public init(rawValue: String) { + self = Self.allKnown.first { $0.rawValue == rawValue } ?? .unknown(rawValue) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = Self(rawValue: rawValue) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + private static let allKnown: [GuildFeature] = [ + .animatedIcon, .banner, .community, .discoveries, .featurable, + .inviteSplash, .memberVerificationGateEnabled, .monetizationEnabled, + .moreStickers, .news, .partnered, .previewEnabled, .raidAlertsDisabled, + .roleSubscriptionsEnabled, .roleSubscriptionsPurchased, .ticketedEventsEnabled, + .vanityUrl, .verified, .vipRegions, .welcomeScreenEnabled, .guestsEnabled, + .guildTags, .enhancedRoleColors, .applicationCommandPermissionsV2, + .autoModeration, .guildWebPageVanityUrl, .creatorMonetizable, + .creatorMonetizableDisclaimer, .creatorStorePage, + .roleSubscriptionsAvailableForPurchase, .onboardingEnabled, .onboarding, + .soundboard, .memberProfiles, .roleSubscriptions, .home, + .channelIconEmojisGenerated, .`internal` + ] +} + /// Represents a Discord guild (server). /// /// Guilds are the primary way users organize and communicate in Discord. @@ -93,7 +209,7 @@ public struct Guild: Codable, Hashable, Sendable { public let emojis: [Emoji]? /// The enabled guild features. - public let features: [String]? + public let features: [GuildFeature]? /// The custom guild stickers. public let stickers: [Sticker]? diff --git a/Sources/SwiftDisc/Models/GuildMember.swift b/Sources/SwiftDisc/Models/GuildMember.swift index 994165a9..e889da2e 100644 --- a/Sources/SwiftDisc/Models/GuildMember.swift +++ b/Sources/SwiftDisc/Models/GuildMember.swift @@ -1,5 +1,27 @@ import Foundation +/// Guild member flags combined as a bitfield. +public struct GuildMemberFlags: OptionSet, Codable, Sendable { + public let rawValue: Int + public init(rawValue: Int) { self.rawValue = rawValue } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + rawValue = try container.decode(Int.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public static let didRejoin = GuildMemberFlags(rawValue: 1 << 0) + public static let completedOnboarding = GuildMemberFlags(rawValue: 1 << 1) + public static let bypassesVerification = GuildMemberFlags(rawValue: 1 << 2) + public static let startedOnboarding = GuildMemberFlags(rawValue: 1 << 3) + public static let automodQuarantinedGuildTag = GuildMemberFlags(rawValue: 1 << 4) +} + /// Represents a Discord guild member. /// /// Guild members represent a user's membership in a specific guild (server), @@ -57,7 +79,7 @@ public struct GuildMember: Codable, Hashable, Sendable { public let collectibles: Collectibles? /// Guild member flags as a bit set. - public let flags: Int? + public let flags: GuildMemberFlags? /// When the member's timeout expires (ISO 8601 timestamp). public let communication_disabled_until: String? diff --git a/Sources/SwiftDisc/Models/Interaction.swift b/Sources/SwiftDisc/Models/Interaction.swift index 0ddcf081..4156f006 100644 --- a/Sources/SwiftDisc/Models/Interaction.swift +++ b/Sources/SwiftDisc/Models/Interaction.swift @@ -1,27 +1,26 @@ import Foundation +/// The type of a Discord interaction. +public enum InteractionType: Int, Codable, Sendable { + case ping = 1 + case applicationCommand = 2 + case messageComponent = 3 + case autocomplete = 4 + case modalSubmit = 5 + case unknown = -1 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = InteractionType(rawValue: rawValue) ?? .unknown + } +} + /// Represents a Discord interaction. /// /// Interactions are the primary way users interact with bots through slash commands, /// buttons, select menus, modals, and context menus. /// -/// ## Interaction Types -/// - `1`: PING -/// - `2`: APPLICATION_COMMAND (slash command) -/// - `3`: MESSAGE_COMPONENT (button/select menu) -/// - `4`: AUTOCOMPLETE -/// - `5`: MODAL_SUBMIT -/// -/// ## Example -/// -/// ```swift -/// await client.setOnInteraction { interaction in -/// guard interaction.type == 2 else { return } -/// guard let data = interaction.data else { return } -/// print("Command: \(data.name ?? "unknown")") -/// } -/// ``` -/// /// ## Related Topics /// - ``DiscordClient/slashCommands`` /// - ``CommandRouter`` @@ -33,8 +32,8 @@ public struct Interaction: Codable, Hashable, Sendable { /// The ID of the application the interaction is for. public let application_id: ApplicationID - /// The type of interaction (1-5, see Interaction Types in struct documentation). - public let type: Int + /// The type of interaction. + public let type: InteractionType /// The command data for the interaction. public let data: ApplicationCommandData? diff --git a/Sources/SwiftDisc/Models/MFALevel.swift b/Sources/SwiftDisc/Models/MFALevel.swift new file mode 100644 index 00000000..8b8ace97 --- /dev/null +++ b/Sources/SwiftDisc/Models/MFALevel.swift @@ -0,0 +1,7 @@ +import Foundation + +/// The MFA (multi-factor authentication) level for a guild. +public struct MFALevel: Codable, Hashable, Sendable { + /// 0 = disabled, 1 = enabled. + public let level: Int +} diff --git a/Sources/SwiftDisc/Models/Message.swift b/Sources/SwiftDisc/Models/Message.swift index 4fa7fa8f..90ccdc23 100644 --- a/Sources/SwiftDisc/Models/Message.swift +++ b/Sources/SwiftDisc/Models/Message.swift @@ -1,5 +1,81 @@ import Foundation +/// The type of a Discord message. +public enum MessageType: Int, Codable, Sendable { + case `default` = 0 + case recipientAdd = 1 + case recipientRemove = 2 + case call = 3 + case channelNameChange = 4 + case channelIconChange = 5 + case channelPinnedMessage = 6 + case userJoin = 7 + case guildBoost = 8 + case guildBoostTier1 = 9 + case guildBoostTier2 = 10 + case guildBoostTier3 = 11 + case channelFollowAdd = 12 + case guildDiscoveryDisqualified = 14 + case guildDiscoveryRequalified = 15 + case guildDiscoveryGracePeriodInitialWarning = 16 + case guildDiscoveryGracePeriodFinalWarning = 17 + case threadCreated = 18 + case reply = 19 + case chatInputCommand = 20 + case threadStarterMessage = 21 + case guildInviteReminder = 22 + case contextMenuCommand = 23 + case autoModerationAction = 24 + case roleSubscriptionPurchase = 25 + case interactionPremiumUpsell = 26 + case stageStart = 27 + case stageEnd = 28 + case stageSpeaker = 29 + case stageTopic = 31 + case guildApplicationPremiumSubscription = 32 + case guildIncidentAlertModeEnabled = 36 + case guildIncidentAlertModeDisabled = 37 + case guildIncidentReportRaid = 38 + case guildIncidentReportFalseAlarm = 39 + case unknown = -1 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = MessageType(rawValue: rawValue) ?? .unknown + } +} + +/// Message flags combined as a bitfield. +public struct MessageFlags: OptionSet, Codable, Sendable { + public let rawValue: Int + public init(rawValue: Int) { self.rawValue = rawValue } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + rawValue = try container.decode(Int.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public static let crossposted = MessageFlags(rawValue: 1 << 0) + public static let isCrosspost = MessageFlags(rawValue: 1 << 1) + public static let suppressEmbeds = MessageFlags(rawValue: 1 << 2) + public static let sourceMessageDeleted = MessageFlags(rawValue: 1 << 3) + public static let urgent = MessageFlags(rawValue: 1 << 4) + public static let hasThread = MessageFlags(rawValue: 1 << 5) + public static let ephemeral = MessageFlags(rawValue: 1 << 6) + public static let loading = MessageFlags(rawValue: 1 << 7) + public static let failedToMentionSomeRoles = MessageFlags(rawValue: 1 << 8) + public static let suppressNotifications = MessageFlags(rawValue: 1 << 12) + public static let isVoiceMessage = MessageFlags(rawValue: 1 << 13) + public static let hasSnapshot = MessageFlags(rawValue: 1 << 14) + public static let isComponentsV2 = MessageFlags(rawValue: 1 << 15) +} + // `Box` breaks recursive value-type cycles during Codable decoding. // It is `@unchecked Sendable` because it only stores immutable state (`let value`). // That keeps instances effectively safe to share across tasks. @@ -94,8 +170,8 @@ public struct Message: Codable, Hashable, Sendable { /// Whether this message is pinned. public let pinned: Bool? - /// The type of message (see Discord documentation for type values). - public let type: Int? + /// The type of message. + public let type: MessageType? /// Activity information for rich presence-related messages. public let activity: MessageActivity? @@ -113,7 +189,7 @@ public struct Message: Codable, Hashable, Sendable { public let referenced_message: Box? /// Message flags combined as a bitfield. - public let flags: Int? + public let flags: MessageFlags? /// Metadata for interaction-related messages. public let interaction_metadata: MessageInteractionMetadata? diff --git a/Sources/SwiftDisc/Models/NewMemberWelcome.swift b/Sources/SwiftDisc/Models/NewMemberWelcome.swift new file mode 100644 index 00000000..122ff346 --- /dev/null +++ b/Sources/SwiftDisc/Models/NewMemberWelcome.swift @@ -0,0 +1,8 @@ +import Foundation + +/// The new member welcome configuration for a guild. +public struct NewMemberWelcome: Codable, Hashable, Sendable { + public let enabled: Bool + public let welcome_message: String? + public let welcome_channel_id: ChannelID? +} diff --git a/Sources/SwiftDisc/Models/ScheduledEvent.swift b/Sources/SwiftDisc/Models/ScheduledEvent.swift index 44d430d4..e62d6091 100644 --- a/Sources/SwiftDisc/Models/ScheduledEvent.swift +++ b/Sources/SwiftDisc/Models/ScheduledEvent.swift @@ -1,5 +1,17 @@ import Foundation +/// The privacy level of a scheduled event. +public enum EventPrivacyLevel: Int, Codable, Sendable { + case guildOnly = 2 + case unknown = -1 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(Int.self) + self = EventPrivacyLevel(rawValue: rawValue) ?? .unknown + } +} + /// Represents a scheduled event in a guild. public struct GuildScheduledEvent: Codable, Hashable, Sendable { /// The type of entity associated with a scheduled event. @@ -37,7 +49,7 @@ public struct GuildScheduledEvent: Codable, Hashable, Sendable { public let description: String? public let scheduled_start_time: String public let scheduled_end_time: String? - public let privacy_level: Int + public let privacy_level: EventPrivacyLevel public let status: Status public let entity_type: EntityType public let entity_id: ChannelID? diff --git a/Sources/SwiftDisc/Models/Snowflake.swift b/Sources/SwiftDisc/Models/Snowflake.swift index 2fce782b..555f81ec 100644 --- a/Sources/SwiftDisc/Models/Snowflake.swift +++ b/Sources/SwiftDisc/Models/Snowflake.swift @@ -152,3 +152,7 @@ public typealias AuditLogEntryID = Snowflake /// An auto moderation rule ID. public enum AutoModerationRuleTag {} public typealias AutoModerationRuleID = Snowflake + +/// A team ID. +public enum TeamTag {} +public typealias TeamID = Snowflake diff --git a/Sources/SwiftDisc/Models/StageInstance.swift b/Sources/SwiftDisc/Models/StageInstance.swift index 21aab92b..185b61d6 100644 --- a/Sources/SwiftDisc/Models/StageInstance.swift +++ b/Sources/SwiftDisc/Models/StageInstance.swift @@ -6,7 +6,7 @@ public struct StageInstance: Codable, Hashable, Sendable { public let guild_id: GuildID public let channel_id: ChannelID public let topic: String - public let privacy_level: Int + public let privacy_level: EventPrivacyLevel public let discoverable_disabled: Bool? public let guild_scheduled_event_id: GuildScheduledEventID? } diff --git a/Tests/SwiftDiscTests/InteractionDecodingTests.swift b/Tests/SwiftDiscTests/InteractionDecodingTests.swift index b6af046a..c1ecb1d0 100644 --- a/Tests/SwiftDiscTests/InteractionDecodingTests.swift +++ b/Tests/SwiftDiscTests/InteractionDecodingTests.swift @@ -30,7 +30,7 @@ final class InteractionDecodingTests: XCTestCase { ) XCTAssertEqual(payload.t, "INTERACTION_CREATE") - XCTAssertEqual(payload.d?.type, 3) + XCTAssertEqual(payload.d?.type, .messageComponent) XCTAssertEqual(payload.d?.data?.custom_id, "btn:confirm") XCTAssertEqual(payload.d?.data?.name, nil) } @@ -51,7 +51,7 @@ final class InteractionDecodingTests: XCTestCase { """# let interaction = try JSONDecoder().decode(Interaction.self, from: Data(json.utf8)) - XCTAssertEqual(interaction.type, 2) + XCTAssertEqual(interaction.type, .applicationCommand) XCTAssertEqual(interaction.data?.name, "ping") } } From a6ca82cdcf94021d611d9d3e88c5ca66510676d3 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Mon, 29 Jun 2026 21:12:45 -0400 Subject: [PATCH 05/22] v2.5.0: Fix duplicate methods, update version strings, update CHANGELOG and GAP_ANALYSIS --- CHANGELOG.md | 19 +++++++ Sources/SwiftDisc/DiscordClient.swift | 51 ++++--------------- .../Internal/DiscordConfiguration.swift | 2 +- .../SwiftDisc.docc/GettingStarted.md | 2 +- Sources/SwiftDisc/URLSessionTransport.swift | 2 +- 5 files changed, 31 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 431159f8..97878e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ 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.5.0] - 2026-06-29 + +### Overview +SwiftDisc 2.5.0 adds the Application model, typed enums across all Discord API models, and many new REST endpoints (Welcome Screen, New Member Welcome, MFA, Incidents). All missing gateway event callbacks have been added, and voice `VoiceChannelStatusUpdate`/`VoiceStartTimeUpdate` events are now properly dispatched. + +### Added +- **Application model** — `Application` struct with Codable support +- **Typed enums** — `ChannelType`, `MessageType`, `MessageFlags`, `InteractionType`, `GuildFeature`, `GuildMemberFlags`, `ChannelFlags`, `AuditLogEventType`, `EventPrivacyLevel`, `AutoModerationEventType`, `AutoModerationActionType`, `AutoModerationTriggerType`, `AutoModerationKeywordPresetType` +- **Welcome Screen REST** — `getGuildWelcomeScreen`, `modifyGuildWelcomeScreen` endpoints +- **New Member Welcome REST** — `getGuildNewMemberWelcome`, `modifyGuildNewMemberWelcome` endpoints +- **MFA REST** — `getGuildMfaLevel`, `modifyGuildMfaLevel` endpoints +- **Incidents REST** — `getGuildIncidentActions`, `modifyGuildIncidentActions` endpoints +- **All missing gateway callbacks** — 20+ event callbacks including `autoModerationActionExecution`, `autoModerationRuleCreate/Update/Delete`, `guildAuditLogEntryCreate`, `guildScheduledEventUserAdd/Remove`, `soundboardSoundsUpdate`, `voiceChannelStatusUpdate`, `voiceStartTimeUpdate`, and others +- **Community Invite Target Users REST** — `getInviteTargetUsers`, `updateInviteTargetUsers`, `getInviteTargetUsersJobStatus` endpoints + +### Changed +- **EventDispatcher** — Voice channel status update events now dispatch to their dedicated callbacks instead of falling through to `.raw` events +- **Enums across all models** migrated from raw `String`/`Int` to typed enums with `Unknown` fallback cases + ## [2.4.1] - 2026-06-14 ### Overview diff --git a/Sources/SwiftDisc/DiscordClient.swift b/Sources/SwiftDisc/DiscordClient.swift index ca249c25..df1ebb96 100644 --- a/Sources/SwiftDisc/DiscordClient.swift +++ b/Sources/SwiftDisc/DiscordClient.swift @@ -3807,31 +3807,23 @@ public actor DiscordClient { /// Response from the Get Target Users Job Status endpoint. public struct InviteTargetUsersJobStatus: Codable, Sendable { public let job_id: String - public let status: String // e.g. "pending", "complete", "failed" + public let status: String public let invite_code: String } - /// Get the raw CSV of user IDs allowed to accept a restricted invite. - /// The response is CSV bytes with a `user_id` header column (not JSON). - /// Decode with `String(data: result, encoding: .utf8)` to get the CSV text. - /// `GET /invites/{code}/users` — Added 2026-01-13, updated 2026-02-05 (header always `user_id`). + /// Get the users allowed to see and accept a restricted invite. public func getInviteTargetUsers(code: String) async throws -> Data { - try await http.getRaw(path: "/invites/\(code)/users") + try await http.getRaw(path: "/invites/\(code)/target-users") } - /// Replace the list of users allowed to accept a restricted invite by uploading a CSV file. - /// The CSV must have a `user_id` column. Returns the async job status. - /// `PATCH /invites/{code}/users` — Added 2026-01-13. - public func updateInviteTargetUsers(code: String, file: FileAttachment) async throws -> InviteTargetUsersJobStatus { - struct Empty: Encodable, Sendable { - } - return try await http.patchMultipart(path: "/invites/\(code)/users", jsonBody: Empty(), files: [file]) + /// Replace the list of users allowed to accept a restricted invite. + public func updateInviteTargetUsers(code: String, csvData: Data) async throws { + try await http.putFile(path: "/invites/\(code)/target-users", data: csvData, filename: "target_users.csv") } - /// Check the status of the background job that processes a target-users CSV upload. - /// `GET /invites/{code}/users/jobs/{job_id}` — Added 2026-01-13. - public func getInviteTargetUsersJobStatus(code: String, jobId: String) async throws -> InviteTargetUsersJobStatus { - try await http.get(path: "/invites/\(code)/users/jobs/\(jobId)") + /// Check the status of a target-users processing job. + public func getInviteTargetUsersJobStatus(code: String) async throws -> InviteTargetUsersJobStatus { + try await http.get(path: "/invites/\(code)/target-users/job-status") } public func getTemplate(code: String) async throws -> Template { @@ -4365,29 +4357,4 @@ public actor DiscordClient { try await http.put(path: "/channels/\(channelId)/voice-status", body: Body(status: status)) } - // MARK: - REST: Guild Role Member Counts - - /// Get the number of members that have each role in a guild. - public func getGuildRoleMemberCounts(guildId: GuildID) async throws -> [RoleMemberCount] { - try await http.get(path: "/guilds/\(guildId)/roles/member-counts") - } - - // MARK: - REST: Invite Target Users - - /// Get the users allowed to see and accept an invite. - /// Response is a CSV file with the header `user_id`. - public func getInviteTargetUsers(code: String) async throws -> Data { - try await http.getRaw(path: "/invites/\(code)/target-users") - } - - /// Update the users allowed to see and accept an invite. - /// Uploads a CSV file with user IDs. - public func updateInviteTargetUsers(code: String, csvData: Data) async throws { - try await http.putFile(path: "/invites/\(code)/target-users", data: csvData, filename: "target_users.csv") - } - - /// Check the status of a target-users processing job. - public func getInviteTargetUsersJobStatus(code: String) async throws -> InviteTargetUsersJobStatus { - try await http.get(path: "/invites/\(code)/target-users/job-status") - } } diff --git a/Sources/SwiftDisc/Internal/DiscordConfiguration.swift b/Sources/SwiftDisc/Internal/DiscordConfiguration.swift index 875a3db8..f464d66b 100644 --- a/Sources/SwiftDisc/Internal/DiscordConfiguration.swift +++ b/Sources/SwiftDisc/Internal/DiscordConfiguration.swift @@ -87,7 +87,7 @@ public struct DefaultDiscordLogger: DiscordLogger { /// - ``RateLimitEvent`` public struct DiscordConfiguration: Sendable { /// SwiftDisc library version for User-Agent header and Discord tracking. - public static let version = "2.4.0" + public static let version = "2.5.0" // Default URLs are static constants so startup never depends on force-unwrapped strings. /// The default Discord API base URL. diff --git a/Sources/SwiftDisc/SwiftDisc.docc/GettingStarted.md b/Sources/SwiftDisc/SwiftDisc.docc/GettingStarted.md index f7819354..ed488653 100644 --- a/Sources/SwiftDisc/SwiftDisc.docc/GettingStarted.md +++ b/Sources/SwiftDisc/SwiftDisc.docc/GettingStarted.md @@ -17,7 +17,7 @@ let package = Package( name: "MyBot", platforms: [.macOS(.v11)], dependencies: [ - .package(url: "https://github.com/M1tsumi/SwiftDisc.git", from: "2.4.0") + .package(url: "https://github.com/M1tsumi/SwiftDisc.git", from: "2.5.0") ], targets: [ .executableTarget( diff --git a/Sources/SwiftDisc/URLSessionTransport.swift b/Sources/SwiftDisc/URLSessionTransport.swift index 263c36e8..fafa04ac 100644 --- a/Sources/SwiftDisc/URLSessionTransport.swift +++ b/Sources/SwiftDisc/URLSessionTransport.swift @@ -19,7 +19,7 @@ final class URLSessionHTTPTransport: HTTPTransport { var headers: [AnyHashable: Any] = [ "Content-Type": "application/json", "Accept": "application/json", - "User-Agent": "DiscordBot (https://github.com/M1tsumi/SwiftDisc, 2.4.0)" + "User-Agent": "DiscordBot (https://github.com/M1tsumi/SwiftDisc, 2.5.0)" ] if let existing = config.httpAdditionalHeaders { for (k, v) in existing { headers[k] = v } From f514642812fe2cc241f80c30a505dec04b0d6689 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Mon, 29 Jun 2026 21:18:09 -0400 Subject: [PATCH 06/22] Delete GAP_ANALYSIS.md, make Message.reply public so doc examples compile --- Sources/SwiftDisc/Models/Message.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftDisc/Models/Message.swift b/Sources/SwiftDisc/Models/Message.swift index 90ccdc23..cf5cc75f 100644 --- a/Sources/SwiftDisc/Models/Message.swift +++ b/Sources/SwiftDisc/Models/Message.swift @@ -649,7 +649,7 @@ public extension Message { /// } /// ``` @discardableResult - func reply( + public func reply( client: DiscordClient, content: String? = nil, embeds: [Embed]? = nil, From 3f8bb90d1ff15a13e9f57346513c81317024fb19 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Mon, 29 Jun 2026 21:43:30 -0400 Subject: [PATCH 07/22] Fix build failures: add missing Hashable conformance to ChannelFlags, GuildMemberFlags, MessageFlags OptionSet types --- Sources/SwiftDisc/Models/Channel.swift | 2 +- Sources/SwiftDisc/Models/GuildMember.swift | 2 +- Sources/SwiftDisc/Models/Message.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftDisc/Models/Channel.swift b/Sources/SwiftDisc/Models/Channel.swift index 74879c3c..4844be0a 100644 --- a/Sources/SwiftDisc/Models/Channel.swift +++ b/Sources/SwiftDisc/Models/Channel.swift @@ -26,7 +26,7 @@ public enum ChannelType: Int, Codable, Sendable { } /// Channel flags combined as a bitfield. -public struct ChannelFlags: OptionSet, Codable, Sendable { +public struct ChannelFlags: OptionSet, Codable, Hashable, Sendable { public let rawValue: Int public init(rawValue: Int) { self.rawValue = rawValue } diff --git a/Sources/SwiftDisc/Models/GuildMember.swift b/Sources/SwiftDisc/Models/GuildMember.swift index e889da2e..82b2b98d 100644 --- a/Sources/SwiftDisc/Models/GuildMember.swift +++ b/Sources/SwiftDisc/Models/GuildMember.swift @@ -1,7 +1,7 @@ import Foundation /// Guild member flags combined as a bitfield. -public struct GuildMemberFlags: OptionSet, Codable, Sendable { +public struct GuildMemberFlags: OptionSet, Codable, Hashable, Sendable { public let rawValue: Int public init(rawValue: Int) { self.rawValue = rawValue } diff --git a/Sources/SwiftDisc/Models/Message.swift b/Sources/SwiftDisc/Models/Message.swift index cf5cc75f..9cc9e3d1 100644 --- a/Sources/SwiftDisc/Models/Message.swift +++ b/Sources/SwiftDisc/Models/Message.swift @@ -47,7 +47,7 @@ public enum MessageType: Int, Codable, Sendable { } /// Message flags combined as a bitfield. -public struct MessageFlags: OptionSet, Codable, Sendable { +public struct MessageFlags: OptionSet, Codable, Hashable, Sendable { public let rawValue: Int public init(rawValue: Int) { self.rawValue = rawValue } From de886acd53fc735f7eb70566fbd295ccf799e972 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Mon, 29 Jun 2026 21:58:31 -0400 Subject: [PATCH 08/22] Fix review issues: mutating builders for inout closures, bind generic returns, maxUploadBytes guard, ShardingGatewayManager race condition, README wording --- README.md | 2 +- Sources/SwiftDisc/DiscordClient.swift | 3 +- .../HighLevel/ComponentsBuilder.swift | 16 +++---- .../HighLevel/ShardingGatewayManager.swift | 17 ++++---- .../HighLevel/SlashCommandBuilder.swift | 42 +++++++++++-------- Sources/SwiftDisc/REST/HTTPClient.swift | 9 +++- 6 files changed, 50 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 66b3d611..4f7c8b6d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Building a Discord bot in Swift should feel like writing Swift, not like transla **Actor-safe concurrency from day one.** Built on Swift 6.2 structured concurrency with `async/await` and Swift actors. No locking, no queues, no data races. The cache, the gateway, and the REST client are all designed around Swift's sendability guarantees. -**Everything is typed.** Every Discord API object is a native Swift struct with full `Codable`, `Hashable`, and `Sendable` conformance. The REST client returns concrete types, not `[String: Any]` dictionaries. Invalid payloads fail at compile time, not at 3 AM. +**Everything is typed.** Every Discord API object is a native Swift struct with full `Codable`, `Hashable`, and `Sendable` conformance. The REST client returns concrete types, not `[String: Any]` dictionaries. Invalid payloads surface as decoding errors rather than silent `nil` values. **One actor to rule them all.** `DiscordClient` owns the gateway connection, the REST client, the event dispatcher, and the cache. You never juggle separate connections or wonder which client to call. diff --git a/Sources/SwiftDisc/DiscordClient.swift b/Sources/SwiftDisc/DiscordClient.swift index df1ebb96..9cb20ae9 100644 --- a/Sources/SwiftDisc/DiscordClient.swift +++ b/Sources/SwiftDisc/DiscordClient.swift @@ -4354,7 +4354,8 @@ public actor DiscordClient { struct Body: Encodable, Sendable { let status: String? } - try await http.put(path: "/channels/\(channelId)/voice-status", body: Body(status: status)) + struct EmptyResponse: Decodable, Sendable {} + let _: EmptyResponse = try await http.put(path: "/channels/\(channelId)/voice-status", body: Body(status: status)) } } diff --git a/Sources/SwiftDisc/HighLevel/ComponentsBuilder.swift b/Sources/SwiftDisc/HighLevel/ComponentsBuilder.swift index 188533bf..c9ef3262 100644 --- a/Sources/SwiftDisc/HighLevel/ComponentsBuilder.swift +++ b/Sources/SwiftDisc/HighLevel/ComponentsBuilder.swift @@ -221,17 +221,18 @@ public struct MentionableSelectMenuBuilder: Sendable { private var placeholder: String? private var min: Int? private var max: Int? - private var defaultMentionableIds: [String]? + private var defaultValues: [MessageComponent.SelectMenu.DefaultValue]? private var disabled: Bool? public init() {} public func customId(_ id: String) -> MentionableSelectMenuBuilder { var c = self; c.customId = id; return c } public func placeholder(_ t: String) -> MentionableSelectMenuBuilder { var c = self; c.placeholder = t; return c } public func minValues(_ v: Int) -> MentionableSelectMenuBuilder { var c = self; c.min = v; return c } public func maxValues(_ v: Int) -> MentionableSelectMenuBuilder { var c = self; c.max = v; return c } - public func defaultMentionables(_ ids: [String]) -> MentionableSelectMenuBuilder { var c = self; c.defaultMentionableIds = ids; return c } + public func defaultUsers(_ ids: [UserID]) -> MentionableSelectMenuBuilder { var c = self; c.defaultValues = ids.map { .init(id: $0.rawValue, type: "user") }; return c } + public func defaultRoles(_ ids: [RoleID]) -> MentionableSelectMenuBuilder { var c = self; c.defaultValues = ids.map { .init(id: $0.rawValue, type: "role") }; return c } public func disabled(_ d: Bool = true) -> MentionableSelectMenuBuilder { var c = self; c.disabled = d; return c } public func build() -> MessageComponent { - MessageComponent.mentionableSelect(.init(custom_id: customId, placeholder: placeholder, min_values: min, max_values: max, disabled: disabled, default_values: defaultMentionableIds?.map { .init(id: $0, type: "user") })) + MessageComponent.mentionableSelect(.init(custom_id: customId, placeholder: placeholder, min_values: min, max_values: max, disabled: disabled, default_values: defaultValues)) } } @@ -415,7 +416,7 @@ public struct ActionRowBuilder: Sendable { public init() {} /// Adds a component to the action row. - public func add(_ component: MessageComponent) -> ActionRowBuilder { var c = self; c.components.append(component); return c } + public mutating func add(_ component: MessageComponent) -> ActionRowBuilder { components.append(component); return self } /// Builds the action row. public func build() -> MessageComponent { .actionRow(.init(components: components)) } @@ -442,13 +443,14 @@ public struct ComponentsBuilder: Sendable { public init() {} /// Adds a row to the components. - public mutating func row(_ configure: @Sendable (inout ActionRowBuilder) -> Void) -> ComponentsBuilder { + public func row(_ configure: @Sendable (inout ActionRowBuilder) -> Void) -> ComponentsBuilder { var rb = ActionRowBuilder() configure(&rb) + var c = self if case let .actionRow(row) = rb.build() { - rows.append(row) + c.rows.append(row) } - return self + return c } /// Builds all rows. diff --git a/Sources/SwiftDisc/HighLevel/ShardingGatewayManager.swift b/Sources/SwiftDisc/HighLevel/ShardingGatewayManager.swift index b9019645..617cf678 100644 --- a/Sources/SwiftDisc/HighLevel/ShardingGatewayManager.swift +++ b/Sources/SwiftDisc/HighLevel/ShardingGatewayManager.swift @@ -217,10 +217,16 @@ public actor ShardingGatewayManager { self.shardingConfiguration = configuration self.httpConfiguration = httpConfiguration self.fallbackIntents = intents + self.eventStream = AsyncStream { continuation in + continuation.onTermination = { @Sendable _ in + Task { await self.setShuttingDown() } + } + self.eventContinuation = continuation + } } // Unified event stream - private var eventStream: AsyncStream! + private var eventStream: AsyncStream private nonisolated(unsafe) var eventContinuation: AsyncStream.Continuation! /// The unified event stream for all shards. @@ -325,15 +331,6 @@ public actor ShardingGatewayManager { /// This method will connect all shards according to the configured strategy, /// wait for them to become ready, and verify guild distribution. public func connect() async throws { - // Prepare unified events - self.eventStream = AsyncStream { continuation in - continuation.onTermination = { @Sendable _ in - // Clean up resources when stream terminates - Task { await self.setShuttingDown() } - } - self.eventContinuation = continuation - } - // Determine shard count and identify concurrency let (totalShards, maxConcurrency) = try await fetchShardPlan() self.maxIdentifyConcurrency = maxConcurrency diff --git a/Sources/SwiftDisc/HighLevel/SlashCommandBuilder.swift b/Sources/SwiftDisc/HighLevel/SlashCommandBuilder.swift index 9db0c93a..d40d7051 100644 --- a/Sources/SwiftDisc/HighLevel/SlashCommandBuilder.swift +++ b/Sources/SwiftDisc/HighLevel/SlashCommandBuilder.swift @@ -71,36 +71,42 @@ public struct SlashCommandBuilder: Sendable { } @discardableResult - public func required(_ req: Bool = true) -> OptionBuilder { var c = self; c.option = .init(type: c.option.type, name: c.option.name, description: c.option.description, required: req, choices: c.option.choices); return c } + public mutating func required(_ req: Bool = true) -> OptionBuilder { + option = .init(type: option.type, name: option.name, description: option.description, required: req, choices: option.choices) + return self + } @discardableResult - public func nameLocalizations(_ loc: [String: String]) -> OptionBuilder { var c = self; c.option = .init(type: c.option.type, name: c.option.name, description: c.option.description, required: c.option.required, choices: c.option.choices, nameLocalizations: loc); return c } + public mutating func nameLocalizations(_ loc: [String: String]) -> OptionBuilder { + option = .init(type: option.type, name: option.name, description: option.description, required: option.required, choices: option.choices, nameLocalizations: loc) + return self + } @discardableResult - public func descriptionLocalizations(_ loc: [String: String]) -> OptionBuilder { var c = self; c.option = .init(type: c.option.type, name: c.option.name, description: c.option.description, required: c.option.required, choices: c.option.choices, descriptionLocalizations: loc); return c } + public mutating func descriptionLocalizations(_ loc: [String: String]) -> OptionBuilder { + option = .init(type: option.type, name: option.name, description: option.description, required: option.required, choices: option.choices, descriptionLocalizations: loc) + return self + } @discardableResult - public func choice(_ name: String, _ value: String) -> OptionBuilder { - var c = self - c.choices.append(.init(name: name, name_localizations: nil, value: .string(value))) - c.option = .init(type: c.option.type, name: c.option.name, description: c.option.description, required: c.option.required, choices: c.choices) - return c + public mutating func choice(_ name: String, _ value: String) -> OptionBuilder { + choices.append(.init(name: name, name_localizations: nil, value: .string(value))) + option = .init(type: option.type, name: option.name, description: option.description, required: option.required, choices: choices) + return self } @discardableResult - public func choice(_ name: String, _ value: Int) -> OptionBuilder { - var c = self - c.choices.append(.init(name: name, name_localizations: nil, value: .int(value))) - c.option = .init(type: c.option.type, name: c.option.name, description: c.option.description, required: c.option.required, choices: c.choices) - return c + public mutating func choice(_ name: String, _ value: Int) -> OptionBuilder { + choices.append(.init(name: name, name_localizations: nil, value: .int(value))) + option = .init(type: option.type, name: option.name, description: option.description, required: option.required, choices: choices) + return self } @discardableResult - public func choice(_ name: String, _ value: Double) -> OptionBuilder { - var c = self - c.choices.append(.init(name: name, name_localizations: nil, value: .double(value))) - c.option = .init(type: c.option.type, name: c.option.name, description: c.option.description, required: c.option.required, choices: c.choices) - return c + public mutating func choice(_ name: String, _ value: Double) -> OptionBuilder { + choices.append(.init(name: name, name_localizations: nil, value: .double(value))) + option = .init(type: option.type, name: option.name, description: option.description, required: option.required, choices: choices) + return self } public func build() -> DiscordClient.ApplicationCommandOption { option } diff --git a/Sources/SwiftDisc/REST/HTTPClient.swift b/Sources/SwiftDisc/REST/HTTPClient.swift index baece8b4..9d301c35 100644 --- a/Sources/SwiftDisc/REST/HTTPClient.swift +++ b/Sources/SwiftDisc/REST/HTTPClient.swift @@ -384,11 +384,16 @@ final class HTTPClient: @unchecked Sendable { } func putMultipart(path: String, jsonBody: B?, files: [FileAttachment], reason: String? = nil) async throws(DiscordError) { + for file in files { + if file.data.count > configuration.maxUploadBytes { + throw DiscordError.validation("File \(file.filename) exceeds maxUploadBytes=\(configuration.maxUploadBytes)") + } + } let trimmed = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let routeKey = makeRouteKey(method: "PUT", path: trimmed) let boundary = makeBoundary() let jsonData = try? jsonBody.map { try JSONCoders.encoder.encode($0) } - let (_, http) = try await executeWithRetry(routeKey: routeKey, isIdempotent: false) { + let (respData, http) = try await executeWithRetry(routeKey: routeKey, isIdempotent: false) { var url = configuration.restBase url.appendPathComponent(trimmed) let body = buildMultipartBody(jsonPayload: jsonData ?? nil, files: files, boundary: boundary) @@ -405,7 +410,7 @@ final class HTTPClient: @unchecked Sendable { return (resp.data, http) } if (200..<300).contains(http.statusCode) { return } - throw makeAPIError(statusCode: http.statusCode, data: Data(), debugContext: "Endpoint: PUT \(path)") + throw makeAPIError(statusCode: http.statusCode, data: respData, debugContext: "Endpoint: PUT \(path)") } func putFile(path: String, data: Data, filename: String, reason: String? = nil) async throws(DiscordError) { From bf21ae77ac6aee97a623366ff88d644152d34e30 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Tue, 30 Jun 2026 22:59:43 -0400 Subject: [PATCH 09/22] Fix CI build failures across all targets - Rename Application phantom tag to ApplicationTag (Snowflake.swift) - Fix GuildFeature internal case syntax (Guild.swift) - Map [String] features to [GuildFeature] in Guild convenience init - Fix JSONValue.double -> .number in SlashCommandBuilder - Fix FileAttachment argument order in HTTPClient.putFile - Use ChannelType.text enum instead of raw 0 in Cache - Replace MessageFlags | with .union() in MessagePayload - Fix DefaultValue type in MentionableSelectMenuBuilder - Refactor AsyncStream init to avoid self-capture in ShardingGatewayManager - Update ShardingBot example for changed init signature and actor isolation --- Examples/ShardingBot.swift | 18 +++++++++--------- .../HighLevel/ComponentsBuilder.swift | 2 +- .../SwiftDisc/HighLevel/MessagePayload.swift | 6 +++--- .../HighLevel/ShardingGatewayManager.swift | 11 ++++++----- .../HighLevel/SlashCommandBuilder.swift | 2 +- Sources/SwiftDisc/Internal/Cache.swift | 2 +- Sources/SwiftDisc/Models/Guild.swift | 4 ++-- Sources/SwiftDisc/Models/Snowflake.swift | 4 ++-- Sources/SwiftDisc/REST/HTTPClient.swift | 2 +- 9 files changed, 26 insertions(+), 25 deletions(-) diff --git a/Examples/ShardingBot.swift b/Examples/ShardingBot.swift index 2516be56..5dd5f696 100644 --- a/Examples/ShardingBot.swift +++ b/Examples/ShardingBot.swift @@ -9,20 +9,20 @@ struct ShardingBotMain { let config = ShardingGatewayManager.Configuration( shardCount: .automatic, identifyConcurrency: .respectDiscordLimits, + fallbackPresence: .init( + activities: [ + .init(name: "with shards", type: 0) + ], + status: "online", + afk: false + ), connectionDelay: .staggered(interval: 5.0) ) let manager = ShardingGatewayManager( token: token, configuration: config, - intents: [.guilds, .guildMessages, .messageContent], - presence: ShardingGatewayManager.Configuration.PresenceConfig( - activities: [ - .init(name: "with shards", type: 0) - ], - status: "online", - afk: false - ) + intents: [.guilds, .guildMessages, .messageContent] ) // Monitor shard health periodically @@ -36,7 +36,7 @@ struct ShardingBotMain { // Monitor events with shard metadata Task { - for await event in manager.events { + for await event in await manager.events { print("[Shard \(event.shardId)] \(event.event)") } } diff --git a/Sources/SwiftDisc/HighLevel/ComponentsBuilder.swift b/Sources/SwiftDisc/HighLevel/ComponentsBuilder.swift index c9ef3262..34ade01f 100644 --- a/Sources/SwiftDisc/HighLevel/ComponentsBuilder.swift +++ b/Sources/SwiftDisc/HighLevel/ComponentsBuilder.swift @@ -221,7 +221,7 @@ public struct MentionableSelectMenuBuilder: Sendable { private var placeholder: String? private var min: Int? private var max: Int? - private var defaultValues: [MessageComponent.SelectMenu.DefaultValue]? + private var defaultValues: [MessageComponent.DefaultSelectValue]? private var disabled: Bool? public init() {} public func customId(_ id: String) -> MentionableSelectMenuBuilder { var c = self; c.customId = id; return c } diff --git a/Sources/SwiftDisc/HighLevel/MessagePayload.swift b/Sources/SwiftDisc/HighLevel/MessagePayload.swift index 7424c1d9..b4b1673f 100644 --- a/Sources/SwiftDisc/HighLevel/MessagePayload.swift +++ b/Sources/SwiftDisc/HighLevel/MessagePayload.swift @@ -96,17 +96,17 @@ public struct MessagePayload: Sendable { /// Mark the message as *ephemeral* — only visible to the interaction invoker. /// Only effective in interaction responses. public func ephemeral() -> Self { - var c = self; c.flags = (c.flags ?? MessageFlags()) | .ephemeral; return c + var c = self; c.flags = (c.flags ?? MessageFlags()).union(.ephemeral); return c } /// Suppress link embeds (Discord flag bit 2). public func suppressEmbeds() -> Self { - var c = self; c.flags = (c.flags ?? MessageFlags()) | .suppressEmbeds; return c + var c = self; c.flags = (c.flags ?? MessageFlags()).union(.suppressEmbeds); return c } /// Mark the message as *silent* — no push/desktop notification (Discord flag bit 12). public func silent() -> Self { - var c = self; c.flags = (c.flags ?? MessageFlags()) | .suppressNotifications; return c + var c = self; c.flags = (c.flags ?? MessageFlags()).union(.suppressNotifications); return c } /// Set message flags (replaces any previously OR'd flags). diff --git a/Sources/SwiftDisc/HighLevel/ShardingGatewayManager.swift b/Sources/SwiftDisc/HighLevel/ShardingGatewayManager.swift index 617cf678..04a2a107 100644 --- a/Sources/SwiftDisc/HighLevel/ShardingGatewayManager.swift +++ b/Sources/SwiftDisc/HighLevel/ShardingGatewayManager.swift @@ -217,11 +217,12 @@ public actor ShardingGatewayManager { self.shardingConfiguration = configuration self.httpConfiguration = httpConfiguration self.fallbackIntents = intents - self.eventStream = AsyncStream { continuation in - continuation.onTermination = { @Sendable _ in - Task { await self.setShuttingDown() } - } - self.eventContinuation = continuation + let (stream, continuation) = AsyncStream.makeStream() + self.eventStream = stream + self.eventContinuation = continuation + continuation.onTermination = { @Sendable [weak self] _ in + guard let self else { return } + Task { await self.setShuttingDown() } } } diff --git a/Sources/SwiftDisc/HighLevel/SlashCommandBuilder.swift b/Sources/SwiftDisc/HighLevel/SlashCommandBuilder.swift index d40d7051..c07af00e 100644 --- a/Sources/SwiftDisc/HighLevel/SlashCommandBuilder.swift +++ b/Sources/SwiftDisc/HighLevel/SlashCommandBuilder.swift @@ -104,7 +104,7 @@ public struct SlashCommandBuilder: Sendable { @discardableResult public mutating func choice(_ name: String, _ value: Double) -> OptionBuilder { - choices.append(.init(name: name, name_localizations: nil, value: .double(value))) + choices.append(.init(name: name, name_localizations: nil, value: .number(value))) option = .init(type: option.type, name: option.name, description: option.description, required: option.required, choices: choices) return self } diff --git a/Sources/SwiftDisc/Internal/Cache.swift b/Sources/SwiftDisc/Internal/Cache.swift index 0231517c..4307aa29 100644 --- a/Sources/SwiftDisc/Internal/Cache.swift +++ b/Sources/SwiftDisc/Internal/Cache.swift @@ -163,7 +163,7 @@ public actor Cache { /// - Parameter id: The channel ID. public func ensureChannelStub(id: ChannelID) { if channelsTimed[id] == nil { - channelsTimed[id] = TimedValue(value: Channel(id: id, type: 0), storedAt: Date()) + channelsTimed[id] = TimedValue(value: Channel(id: id, type: .text), storedAt: Date()) } } diff --git a/Sources/SwiftDisc/Models/Guild.swift b/Sources/SwiftDisc/Models/Guild.swift index 2f6ee6d5..01a1c6fa 100644 --- a/Sources/SwiftDisc/Models/Guild.swift +++ b/Sources/SwiftDisc/Models/Guild.swift @@ -39,7 +39,7 @@ public enum GuildFeature: RawRepresentable, Codable, Hashable, Sendable { case roleSubscriptions case home case channelIconEmojisGenerated - case [`internal`] + case `internal` case unknown(String) public var rawValue: String { @@ -331,7 +331,7 @@ public struct Guild: Codable, Hashable, Sendable { self.afk_timeout = nil; self.widget_enabled = nil; self.widget_channel_id = nil self.verification_level = nil; self.default_message_notifications = nil self.explicit_content_filter = nil; self.mfa_level = nil; self.nsfw_level = nsfw_level - self.roles = roles; self.emojis = emojis; self.features = features; self.stickers = nil + self.roles = roles; self.emojis = emojis; self.features = features?.compactMap(GuildFeature.init(rawValue:)); self.stickers = nil self.application_id = nil; self.system_channel_id = nil; self.system_channel_flags = nil self.rules_channel_id = nil; self.max_presences = nil; self.max_members = nil self.member_count = member_count; self.large = nil; self.unavailable = nil diff --git a/Sources/SwiftDisc/Models/Snowflake.swift b/Sources/SwiftDisc/Models/Snowflake.swift index 555f81ec..f6de47a7 100644 --- a/Sources/SwiftDisc/Models/Snowflake.swift +++ b/Sources/SwiftDisc/Models/Snowflake.swift @@ -77,8 +77,8 @@ public typealias RoleID = Snowflake public typealias EmojiID = Snowflake /// An application ID. -public enum Application {} -public typealias ApplicationID = Snowflake +public enum ApplicationTag {} +public typealias ApplicationID = Snowflake /// An attachment ID. public enum AttachmentTag {} diff --git a/Sources/SwiftDisc/REST/HTTPClient.swift b/Sources/SwiftDisc/REST/HTTPClient.swift index 9d301c35..ef49987f 100644 --- a/Sources/SwiftDisc/REST/HTTPClient.swift +++ b/Sources/SwiftDisc/REST/HTTPClient.swift @@ -414,7 +414,7 @@ final class HTTPClient: @unchecked Sendable { } func putFile(path: String, data: Data, filename: String, reason: String? = nil) async throws(DiscordError) { - let file = FileAttachment(data: data, filename: filename) + let file = FileAttachment(filename: filename, data: data) try await putMultipart(path: path, jsonBody: Optional.none, files: [file], reason: reason) } From 3bead364edb2225a93b9edd84c89adfc718f2b1e Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Tue, 30 Jun 2026 23:04:05 -0400 Subject: [PATCH 10/22] Fix test compilation: Interaction.type is now InteractionType enum, not Int --- Tests/SwiftDiscTests/SlashCommandRouterTests.swift | 2 +- Tests/SwiftDiscTests/TestFixtures.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/SwiftDiscTests/SlashCommandRouterTests.swift b/Tests/SwiftDiscTests/SlashCommandRouterTests.swift index 6d4fd966..29bfa56d 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: 2, 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: nil, 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 e21108a5..2ce2a7b9 100644 --- a/Tests/SwiftDiscTests/TestFixtures.swift +++ b/Tests/SwiftDiscTests/TestFixtures.swift @@ -74,7 +74,7 @@ enum TestFixtures { return Interaction( id: interactionId, application_id: appId, - type: 3, + type: .messageComponent, data: data, guild_id: gid, channel: nil, From 88de2f96db059a9739e336d5ece76364185716ce Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Tue, 30 Jun 2026 23:09:53 -0400 Subject: [PATCH 11/22] Tone down README marketing language, add local doc build instructions --- README.md | 45 ++++++++++--------- Sources/SwiftDisc/SwiftDisc.docc/SwiftDisc.md | 2 +- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 4f7c8b6d..4cf9305c 100644 --- a/README.md +++ b/README.md @@ -9,23 +9,17 @@ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Discord](https://img.shields.io/discord/1439300942167146508?label=discord&logo=discord&logoColor=white)](https://discord.gg/tWyefRKKEH) -## Why SwiftDisc? +## Features -Building a Discord bot in Swift should feel like writing Swift, not like translating REST docs into network calls. SwiftDisc is a Swift-first Discord API wrapper that treats the Discord API as a native Swift library, not a remote JSON endpoint. +- **Single-actor client.** `DiscordClient` owns the gateway connection, REST client, event dispatcher, and cache in one place. +- **Automatic gateway management.** Reconnection, session resumption, heartbeats, and rate-limit backoff are handled internally. +- **Actor-based concurrency.** Built on Swift 6.2 structured concurrency with `async/await`. The cache, gateway, and REST client avoid locks by design. +- **Typed models.** Every Discord API object is a native Swift struct with `Codable`, `Hashable`, and `Sendable`. The REST client returns concrete types, not `[String: Any]`. +- **Event callbacks + async streams.** Use callback closures or `for await` on the unified event stream — or both at once. +- **Pluggable transports.** Default transports use URLSession. Swap in AsyncHTTPClient for proxy support on Linux, or conform to `HTTPTransport`/`WebSocketTransport` for custom networking. +- **Modular high-level APIs.** Separate routers for slash commands, prefix commands, autocomplete, views, webhooks, collectors, and cooldowns. -### What makes SwiftDisc different? - -**Gateway lifecycle that just works.** Automatic reconnection, session resumption, heartbeat management, and rate limit backoff are all handled internally. You write your bot logic, not WebSocket plumbing. - -**Actor-safe concurrency from day one.** Built on Swift 6.2 structured concurrency with `async/await` and Swift actors. No locking, no queues, no data races. The cache, the gateway, and the REST client are all designed around Swift's sendability guarantees. - -**Everything is typed.** Every Discord API object is a native Swift struct with full `Codable`, `Hashable`, and `Sendable` conformance. The REST client returns concrete types, not `[String: Any]` dictionaries. Invalid payloads surface as decoding errors rather than silent `nil` values. - -**One actor to rule them all.** `DiscordClient` owns the gateway connection, the REST client, the event dispatcher, and the cache. You never juggle separate connections or wonder which client to call. - -**Developer experience that scales.** Event handling via closures _and_ async streams, typed error handling with `DiscordError`, plugin-based HTTP transports, and pluggable logging. Start with one server in minutes, shard to thousands without rewriting your bot. - -## Quick start (1 minute to pong) +## Quick start (ping bot) Create a new SwiftPM executable, add SwiftDisc as a dependency, and drop this in: @@ -133,7 +127,7 @@ let package = Package( ## How SwiftDisc works -SwiftDisc is organized into clear layers so you can grab what you need: +SwiftDisc is split into these layers: **`DiscordClient`** -- the main actor. It owns your Gateway connection, the REST client, the event dispatcher, and the cache. You will spend most of your time here. @@ -202,7 +196,7 @@ swift run PingBotExample ## Event handling guide -SwiftDisc gives you two ways to handle gateway events. Pick the one that fits your style. +You can handle gateway events with callbacks or an async stream. ### Option 1: Callback closures (simple, self-documenting) @@ -289,7 +283,7 @@ If your bot connects but does not receive events, check these in order: ## Reliability and debugging -SwiftDisc is built to be resilient by default -- automatic reconnection, rate-limit backoff, and session resumption are all handled internally. When you need to look under the hood: +Reconnection, rate-limit backoff, and session resumption are handled internally. Debugging tools: **Gateway decode diagnostics** -- Enable `DiscordConfiguration.enableGatewayDecodeDiagnostics` to log payload decoding failures with opcode context and payload previews. Essential when adding support for new Discord features. @@ -321,13 +315,22 @@ print(await cache.summary) | Resource | What you will find | |----------|-------------------| -| [**GitHub Pages**](https://M1tsumi.github.io/SwiftDisc/) | Auto-generated DocC documentation for SwiftDisc -- browseable API reference with search | -| [**CHANGELOG.md**](CHANGELOG.md) | Detailed per-release changelog following Keep a Changelog | +| [**GitHub Pages**](https://M1tsumi.github.io/SwiftDisc/) | DocC documentation for SwiftDisc -- API reference with search | +| [**CHANGELOG.md**](CHANGELOG.md) | Per-release changelog following Keep a Changelog | | [**CONTRIBUTING.md**](CONTRIBUTING.md) | How to set up, build, test, and submit PRs | | [**Examples/README.md**](Examples/README.md) | Quick-start guides for every example bot | -| `SwiftDiscAHCTransport` | Optional in-tree AsyncHTTPClient transport. Add `.product(name: "SwiftDiscAHCTransport", package: "SwiftDisc")` to use it. Includes native proxy support | +| `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: + +```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 +``` + +Then open `docs/index.html` in a browser. + ## Community and support - **Discord server** -- [https://discord.gg/tWyefRKKEH](https://discord.gg/tWyefRKKEH) -- get help, discuss features, show off your bot diff --git a/Sources/SwiftDisc/SwiftDisc.docc/SwiftDisc.md b/Sources/SwiftDisc/SwiftDisc.docc/SwiftDisc.md index a5482014..c62adc71 100644 --- a/Sources/SwiftDisc/SwiftDisc.docc/SwiftDisc.md +++ b/Sources/SwiftDisc/SwiftDisc.docc/SwiftDisc.md @@ -1,6 +1,6 @@ # ``SwiftDisc`` -A Swift-first Discord API wrapper that feels like it was written for you, not by a spec sheet. +A Discord API wrapper for Swift with typed models, actor-based concurrency, and automatic gateway management. @Metadata { @PageImage(purpose: icon, source: swiftdisc-logo) From 043b6bf5c84cbac720225a7c258123464e7bfda0 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Tue, 30 Jun 2026 23:11:35 -0400 Subject: [PATCH 12/22] Update v2.5.0 changelog with fix entries, bump date to today --- CHANGELOG.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97878e10..b64b2d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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.5.0] - 2026-06-29 +## [2.5.0] - 2026-06-30 ### Overview SwiftDisc 2.5.0 adds the Application model, typed enums across all Discord API models, and many new REST endpoints (Welcome Screen, New Member Welcome, MFA, Incidents). All missing gateway event callbacks have been added, and voice `VoiceChannelStatusUpdate`/`VoiceStartTimeUpdate` events are now properly dispatched. @@ -23,6 +23,21 @@ SwiftDisc 2.5.0 adds the Application model, typed enums across all Discord API m ### Changed - **EventDispatcher** — Voice channel status update events now dispatch to their dedicated callbacks instead of falling through to `.raw` events - **Enums across all models** migrated from raw `String`/`Int` to typed enums with `Unknown` fallback cases +- **`Interaction.type`** — changed from `Int` to `InteractionType` enum + +### Fixed +- **CI build failures** — resolved all compilation errors across Windows, macOS, and Linux CI runners +- **Test compilation** — updated `TestFixtures` and `SlashCommandRouterTests` to pass `InteractionType` enum values instead of raw integers +- **`Application` phantom tag collision** — renamed to `ApplicationTag` to avoid ambiguity with the real `Application` model +- **`GuildFeature.internal` case** — fixed invalid backtick syntax +- **`Guild` convenience init** — `features` parameter now maps `[String]` to `[GuildFeature]` via `GuildFeature.init(rawValue:)` +- **`SlashCommandBuilder`** — replaced missing `JSONValue.double` with `.number` +- **`HTTPClient.putFile`** — fixed `FileAttachment` argument order +- **`Cache.ensureChannelStub`** — uses `ChannelType.text` enum instead of raw `0` +- **`MessagePayload`** — replaced ambiguous `|` on `MessageFlags` with `.union()` +- **`MentionableSelectMenuBuilder`** — fixed `DefaultValue` type reference to `DefaultSelectValue` +- **`ShardingGatewayManager` init** — refactored `AsyncStream` to avoid `self` capture before member initialization +- **`ShardingBot` example** — updated for new `Configuration.fallbackPresence` API and actor-isolated `events` access ## [2.4.1] - 2026-06-14 From d3cb5a2d354fa2e012d09dc4d6302e79363b3a96 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Wed, 1 Jul 2026 08:51:08 -0400 Subject: [PATCH 13/22] Fix local DocC preview instructions to avoid 404 when opening static files directly --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4cf9305c..9ba057cb 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 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 From 7a92bd853a9f8fa78510f008517cb62fbb973f1c Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Wed, 1 Jul 2026 09:10:28 -0400 Subject: [PATCH 14/22] Fix README DocC command: --allow-writing-to-directory needs output dir argument --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9ba057cb..ab2fe5a6 100644 --- a/README.md +++ b/README.md @@ -330,7 +330,7 @@ You can also build and preview the docs locally: swift package --disable-sandbox preview-documentation --target SwiftDisc # Or build static HTML and serve it: -swift package --allow-writing-to-directory generate-documentation --target SwiftDisc --output-path docs --transform-for-static-hosting --hosting-base-path / +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 ``` From ad3fdc6eb9ca2f43030ae4ec31573fc43d200546 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Wed, 8 Jul 2026 19:35:30 -0400 Subject: [PATCH 15/22] =?UTF-8?q?Release=20v2.6.0=20=E2=80=94=20developer-?= =?UTF-8?q?ergonomics=20and=20infrastructure=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This audit-driven release fixes 20 files with 434 insertions and 204 deletions, addressing critical concurrency crashes, task leaks, data loss bugs, and missing functionality identified in a comprehensive package audit. Concurrency & crashes: - GatewayClient: connectReadyContinuation double-resume guard, heartbeat ACK counter single-source, socket re-check after rate-limiter wait, decode error exponential backoff with max-5-consecutive reconnect trigger - AsyncSemaphore: cancellation handling with waiter removal - close() promoted to public for API symmetry with disconnect() Task leaks: - Collectors: 11 typed event streams unified into filteredEventStream with onTermination cleanup; createMessageCollector stores timeoutTask handle - ComponentCollector: timeoutTask stored and cancelled on finish - Cache: eviction task assigned inline in init instead of unstructured Task Data loss (MessagePayload field forwarding): - send(to:_:) now forwards allowedMentions, messageReference in multipart path - edit(channelId:messageId:_:) forwards ALL fields in both paths - respond(to:with:deferred:) includes message_reference, sticker_ids, poll - editMessage/editMessageWithFiles: added allowedMentions, flags, tts, stickerIds, poll parameters with nil defaults Rate limiter: - Timestamp appended after backoff (was counting waited requests against budget) - Dictionary(uniqueKeysWithValues:) replaced with safe reduce(into:) - clearBucket now also clears route-to-bucket mapping Cache additions: - getMessages(channelId:), clear(), removeUser(id:), removeMessagesForChannel(channelId:), ensureChannelStub(id:type:) Model types: - OptionalField: Decodable conformance, Codable constraint - JSONValue: UInt64 decode before Double with Int(exactly:) for precision - RetryPolicy: jitter (default ±10%), noRetry static policy - TokenStorage.rawValue: public → internal for token leak protection Developer ergonomics: - @discardableResult on editMessage, createGuildEmoji, modifyGuildEmoji, crosspostMessage - EmbedBuilder.success/error/info() factory methods - Converters: cached ISO8601DateFormatter, discordOrange corrected to 0xFEE75C, discordFuchsia added, invite code 6-25 chars, mention regexes 17-19 digits - stripMentions: all patterns updated to 17-19 digit range - GatewayClient: validatePrivilegedIntents renamed to logPrivilegedIntentWarnings - Cache.Configuration.init: multi-line formatting Infrastructure: - HTTPClient fallback stub: added putMultipart, postStickerMultipart, putFile - allHeaderFields: safe reduce(into:) instead of uniqueKeysWithValues - Utilities: mention regexes tightened from [0-9]{5,} to [0-9]{17,19} - CooldownManager: auto-cleanup starts in init(), [weak self] capture, null-byte key separator instead of double-colon - CI: migrated from compnerd/gha-setup-swift to swift-actions/setup-swift - Docs CI: migrated to swift-actions/setup-swift for Swift 6.2 Voice: No voice support. All voice-adjacent model fields (bitrate, permission flags, audit log events, etc.) are retained solely for Discord API compliance. --- .github/workflows/ci.yml | 6 +- .github/workflows/docs.yml | 9 +- .gitignore | 2 +- CHANGELOG.md | 106 ++++++++++++ Sources/SwiftDisc/DiscordClient.swift | 41 ++++- Sources/SwiftDisc/Gateway/GatewayClient.swift | 44 +++-- Sources/SwiftDisc/HighLevel/Collectors.swift | 160 ++++-------------- .../HighLevel/ComponentCollector.swift | 6 +- Sources/SwiftDisc/HighLevel/Converters.swift | 23 ++- .../SwiftDisc/HighLevel/CooldownManager.swift | 11 +- .../SwiftDisc/HighLevel/EmbedBuilder.swift | 25 ++- .../SwiftDisc/HighLevel/MessagePayload.swift | 23 ++- Sources/SwiftDisc/HighLevel/Utilities.swift | 12 +- Sources/SwiftDisc/Internal/Cache.swift | 79 ++++++++- Sources/SwiftDisc/Internal/JSONValue.swift | 13 ++ .../SwiftDisc/Internal/OptionalField.swift | 12 +- Sources/SwiftDisc/Internal/RetryPolicy.swift | 23 ++- Sources/SwiftDisc/Internal/TokenStorage.swift | 3 +- Sources/SwiftDisc/REST/HTTPClient.swift | 35 +++- Sources/SwiftDisc/REST/RateLimiter.swift | 9 +- 20 files changed, 436 insertions(+), 206 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9ff147f..15eb5ae9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,11 +16,9 @@ jobs: - uses: actions/checkout@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: Run tests (Unix) if: matrix.os != 'windows-latest' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 90ca93e2..8af51a10 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: | @@ -33,6 +31,9 @@ jobs: sed -i.bak '/^ dependencies: \[/s/\[/[.package(url: "https:\/\/github.com\/apple\/swift-docc-plugin.git", from: "1.4.4"),/' Package.swift && rm -f Package.swift.bak swift package --allow-writing-to-directory documentation generate-documentation --target SwiftDisc --output-path documentation --transform-for-static-hosting --hosting-base-path SwiftDisc + - name: Verify documentation was generated + run: test -f documentation/index.html || { echo "documentation/index.html missing"; exit 1; } + - name: Upload Pages Artifact uses: actions/upload-pages-artifact@v3 with: 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/Sources/SwiftDisc/DiscordClient.swift b/Sources/SwiftDisc/DiscordClient.swift index 9cb20ae9..094de7a8 100644 --- a/Sources/SwiftDisc/DiscordClient.swift +++ b/Sources/SwiftDisc/DiscordClient.swift @@ -800,6 +800,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 +995,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 +1019,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 +1268,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 +1300,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? @@ -2539,12 +2557,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 +2576,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..4bbca5a3 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 } @@ -304,6 +305,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 +319,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 +334,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 +346,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 +386,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 +443,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 +514,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 +555,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 +583,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) { + public static func logPrivilegedIntentWarnings(_ intents: GatewayIntents, logger: (any DiscordLogger)? = nil) { let privileged: [(GatewayIntents, String)] = [ (.guildMembers, "GUILD_MEMBERS"), (.guildPresences, "GUILD_PRESENCES"), @@ -616,7 +632,7 @@ actor GatewayClient { } /// Alias for `disconnect()`. - func close() async { + public func close() async { await disconnect() } @@ -666,15 +682,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/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..0ec1ae35 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,10 @@ public enum Converters: Sendable { public static let discordRed: Int = 0xED4245 /// Discord yellow color public static let discordYellow: Int = 0xFEE75C + /// Discord fuchsia/pink color (used for stage channels) + public static let discordFuchsia: Int = 0xEB459E /// Discord orange color - public static let discordOrange: Int = 0xEB459E + public static let discordOrange: Int = 0xFEE75C // MARK: - Emoji Parsing @@ -144,8 +149,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..c8dea709 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 { @@ -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..302e1f49 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 and checkmark prefix. + /// - 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 and crossmark prefix. + /// - 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..dd6cc848 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,6 +202,11 @@ public extension DiscordClient { content: payload.content, embeds: payload.embeds, components: payload.components, + allowedMentions: payload.allowedMentions, + flags: payload.flags, + tts: payload.tts, + stickerIds: payload.stickerIds, + poll: payload.poll, files: files ) } @@ -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/JSONValue.swift b/Sources/SwiftDisc/Internal/JSONValue.swift index a9fb6be5..82639372 100644 --- a/Sources/SwiftDisc/Internal/JSONValue.swift +++ b/Sources/SwiftDisc/Internal/JSONValue.swift @@ -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/REST/HTTPClient.swift b/Sources/SwiftDisc/REST/HTTPClient.swift index ef49987f..052f991a 100644 --- a/Sources/SwiftDisc/REST/HTTPClient.swift +++ b/Sources/SwiftDisc/REST/HTTPClient.swift @@ -39,7 +39,8 @@ 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] = [] @@ -53,8 +54,19 @@ private actor AsyncSemaphore { value -= 1 return } - await withCheckedContinuation { continuation in - waiters.append(continuation) + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } onCancel: { + Task { await self.removeCancelledWaiter() } + } + } + + private func removeCancelledWaiter() { + // Continuation was already resumed by cancellation, just remove from list + if !waiters.isEmpty { + waiters.removeFirst() } } @@ -536,7 +548,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 +648,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, jsonBody: B, files: [FileAttachment], reason: String? = nil) async throws(DiscordError) -> T { + throw DiscordError.unavailable + } + + func putFile(path: String, file: FileAttachment, 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() { From d9b5d8575d4d8853214ba11f4c9a5ba6254e8301 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Wed, 8 Jul 2026 19:40:20 -0400 Subject: [PATCH 16/22] Add missing gateway events and typed model enums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New gateway events (10 added): - STAGE_INSTANCE_CREATE/UPDATE/DELETE with StageInstance model - SUBSCRIPTION_CREATE/UPDATE/DELETE with AppSubscription model - SUBSCRIPTION_GROUP_SUBSCRIPTION_CREATE/UPDATE/DELETE - GUILD_JOIN_REQUEST_UPDATE with GuildJoinRequestUpdate model Typed enums with unknown fallback (7 added): - WebhookType, StickerType, StickerFormatType, InviteType, EntitlementType, ActivityType for PresenceUpdatePayload - Invite.InviteChannel.type migrated to ChannelType? Type safety fixes: - Interaction.version: Int? → Int (default 1) - User.username: String → String? (Discord returns null for deleted users) - All new enums use custom init(from:) with unknown fallback Test fixtures updated for new types. --- Sources/SwiftDisc/DiscordClient.swift | 16 ++++++ Sources/SwiftDisc/Gateway/GatewayClient.swift | 10 ++++ Sources/SwiftDisc/Gateway/GatewayModels.swift | 53 ++++++++++++++++++- .../SwiftDisc/Internal/EventDispatcher.swift | 33 ++++++++++++ Sources/SwiftDisc/Models/Interaction.swift | 29 +++++++++- Sources/SwiftDisc/Models/Invite.swift | 24 +++++++-- Sources/SwiftDisc/Models/Monetization.swift | 30 ++++++++++- Sources/SwiftDisc/Models/Sticker.swift | 44 +++++++++++++-- Sources/SwiftDisc/Models/User.swift | 6 +-- Sources/SwiftDisc/Models/Webhook.swift | 20 ++++++- .../SlashCommandRouterTests.swift | 2 +- Tests/SwiftDiscTests/TestFixtures.swift | 2 +- 12 files changed, 253 insertions(+), 16 deletions(-) diff --git a/Sources/SwiftDisc/DiscordClient.swift b/Sources/SwiftDisc/DiscordClient.swift index 094de7a8..2c03ac2d 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)? diff --git a/Sources/SwiftDisc/Gateway/GatewayClient.swift b/Sources/SwiftDisc/Gateway/GatewayClient.swift index 4bbca5a3..03fc85a5 100644 --- a/Sources/SwiftDisc/Gateway/GatewayClient.swift +++ b/Sources/SwiftDisc/Gateway/GatewayClient.swift @@ -296,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 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/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/Models/Interaction.swift b/Sources/SwiftDisc/Models/Interaction.swift index 4156f006..4ed981bb 100644 --- a/Sources/SwiftDisc/Models/Interaction.swift +++ b/Sources/SwiftDisc/Models/Interaction.swift @@ -57,7 +57,7 @@ public struct Interaction: Codable, Hashable, Sendable { public let token: String /// The version of the interaction. - public let version: Int? + public let version: Int /// The message the interaction was sent for (component interactions only). public let message: Box? @@ -78,6 +78,33 @@ 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, message, app_permissions + case locale, guild_locale, authorizing_integration_owners, context + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(InteractionID.self, forKey: .id) + application_id = try container.decode(ApplicationID.self, forKey: .application_id) + type = try container.decode(InteractionType.self, forKey: .type) + data = try container.decodeIfPresent(ApplicationCommandData.self, forKey: .data) + guild_id = try container.decodeIfPresent(GuildID.self, forKey: .guild_id) + channel = try container.decodeIfPresent(ResolvedChannel.self, forKey: .channel) + channel_id = try container.decodeIfPresent(ChannelID.self, forKey: .channel_id) + member = try container.decodeIfPresent(GuildMember.self, forKey: .member) + user = try container.decodeIfPresent(User.self, forKey: .user) + token = try container.decode(String.self, forKey: .token) + version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 + message = try container.decodeIfPresent(Box.self, forKey: .message) + app_permissions = try container.decodeIfPresent(String.self, forKey: .app_permissions) + locale = try container.decodeIfPresent(String.self, forKey: .locale) + guild_locale = try container.decodeIfPresent(String.self, forKey: .guild_locale) + authorizing_integration_owners = try container.decodeIfPresent([String: String].self, forKey: .authorizing_integration_owners) + context = try container.decodeIfPresent(Int.self, forKey: .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/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..a3cbba92 100644 --- a/Tests/SwiftDiscTests/TestFixtures.swift +++ b/Tests/SwiftDiscTests/TestFixtures.swift @@ -82,7 +82,7 @@ enum TestFixtures { member: nil, user: nil, token: token, - version: nil, + version: 1, message: nil, app_permissions: nil, locale: nil, From 1f92f1c919147bf2d32f1cd193aeabdb93ded511 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Wed, 8 Jul 2026 19:44:30 -0400 Subject: [PATCH 17/22] Refactor Interaction.version to use stored _version with computed property --- Sources/SwiftDisc/Models/Interaction.swift | 64 ++++++++++++++-------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/Sources/SwiftDisc/Models/Interaction.swift b/Sources/SwiftDisc/Models/Interaction.swift index 4ed981bb..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? @@ -80,29 +83,46 @@ public struct Interaction: Codable, Hashable, Sendable { enum CodingKeys: String, CodingKey { case id, application_id, type, data, guild_id, channel, channel_id - case member, user, token, version, message, app_permissions + case member, user, token, _version = "version", message, app_permissions case locale, guild_locale, authorizing_integration_owners, context } - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(InteractionID.self, forKey: .id) - application_id = try container.decode(ApplicationID.self, forKey: .application_id) - type = try container.decode(InteractionType.self, forKey: .type) - data = try container.decodeIfPresent(ApplicationCommandData.self, forKey: .data) - guild_id = try container.decodeIfPresent(GuildID.self, forKey: .guild_id) - channel = try container.decodeIfPresent(ResolvedChannel.self, forKey: .channel) - channel_id = try container.decodeIfPresent(ChannelID.self, forKey: .channel_id) - member = try container.decodeIfPresent(GuildMember.self, forKey: .member) - user = try container.decodeIfPresent(User.self, forKey: .user) - token = try container.decode(String.self, forKey: .token) - version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 - message = try container.decodeIfPresent(Box.self, forKey: .message) - app_permissions = try container.decodeIfPresent(String.self, forKey: .app_permissions) - locale = try container.decodeIfPresent(String.self, forKey: .locale) - guild_locale = try container.decodeIfPresent(String.self, forKey: .guild_locale) - authorizing_integration_owners = try container.decodeIfPresent([String: String].self, forKey: .authorizing_integration_owners) - context = try container.decodeIfPresent(Int.self, forKey: .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 From b4b01e590cae8664723cd8b842199ffc62b4418c Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Wed, 8 Jul 2026 19:55:50 -0400 Subject: [PATCH 18/22] Add test infrastructure: mocks, fixtures, HTTPClient + RateLimiter tests - MockHTTPTransport & MockWebSocketTransport actors for deterministic testing - Expanded TestFixtures: Channel, GuildMember, Embed, Thread, Components, PresenceUpdate - Expanded CacheTests: user/channel/guild/role CRUD, messages, clear, removeUser - New HTTPClientTests: GET, rate limit headers, 429 retry, route key parsing - New RateLimiterTests: 50/s global limit, bucket tracking, reset/clear - HTTPClient.makeRouteKey promoted to internal for test access --- Sources/SwiftDisc/REST/HTTPClient.swift | 2 +- Tests/SwiftDiscTests/CacheTests.swift | 113 ++++++++++++++++++++ Tests/SwiftDiscTests/HTTPClientTests.swift | 102 ++++++++++++++++++ Tests/SwiftDiscTests/MockTransport.swift | 113 ++++++++++++++++++++ Tests/SwiftDiscTests/RateLimiterTests.swift | 92 ++++++++++++++++ Tests/SwiftDiscTests/TestFixtures.swift | 97 +++++++++++++++++ 6 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 Tests/SwiftDiscTests/HTTPClientTests.swift create mode 100644 Tests/SwiftDiscTests/MockTransport.swift create mode 100644 Tests/SwiftDiscTests/RateLimiterTests.swift diff --git a/Sources/SwiftDisc/REST/HTTPClient.swift b/Sources/SwiftDisc/REST/HTTPClient.swift index 052f991a..eb784b2c 100644 --- a/Sources/SwiftDisc/REST/HTTPClient.swift +++ b/Sources/SwiftDisc/REST/HTTPClient.swift @@ -494,7 +494,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? diff --git a/Tests/SwiftDiscTests/CacheTests.swift b/Tests/SwiftDiscTests/CacheTests.swift index 871c0a75..87e376be 100644 --- a/Tests/SwiftDiscTests/CacheTests.swift +++ b/Tests/SwiftDiscTests/CacheTests.swift @@ -17,4 +17,117 @@ 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, .guildText) + } + + 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() + + XCTAssertNil(await cache.getUser(id: UserID("u1"))) + XCTAssertNil(await cache.getChannel(id: ChannelID("ch1"))) + XCTAssertNil(await cache.getGuild(id: GuildID("g1"))) + } + + 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: .guildText) + let channel = await cache.getChannel(id: ChannelID("stub1")) + XCTAssertNotNil(channel) + XCTAssertEqual(channel?.id.rawValue, "stub1") + XCTAssertEqual(channel?.type, .guildText) + + // Ensure it does not overwrite existing + 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, .guildText) + } } diff --git a/Tests/SwiftDiscTests/HTTPClientTests.swift b/Tests/SwiftDiscTests/HTTPClientTests.swift new file mode 100644 index 00000000..8af68de9 --- /dev/null +++ b/Tests/SwiftDiscTests/HTTPClientTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import SwiftDisc + +final class HTTPClientTests: XCTestCase { + 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 = "/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 { + var capturedEvent: RateLimitEvent? + let config = DiscordConfiguration(onRateLimit: { event in + capturedEvent = event + }) + let transport = MockHTTPTransport() + let client = HTTPClient(token: "test_token", configuration: config, transport: transport) + + let responseData = try JSONEncoder().encode(["ok": true]) + let path = "/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 event = try XCTUnwrap(capturedEvent) + 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 = "/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/MockTransport.swift b/Tests/SwiftDiscTests/MockTransport.swift new file mode 100644 index 00000000..a8d3492e --- /dev/null +++ b/Tests/SwiftDiscTests/MockTransport.swift @@ -0,0 +1,113 @@ +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(statusCode: 404, message: "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. +actor MockWebSocketTransport: WebSocketTransport { + private var messages: [WebSocketMessage] = [] + private var sentMessages: [String] = [] + private var _closeCode: Int? = nil + private var _shouldThrowOnReceive = false + + var closeCode: Int? { _closeCode } + + func addMessage(_ message: WebSocketMessage) { messages.append(message) } + func addString(_ text: String) { messages.append(.string(text)) } + + func receive() async throws -> WebSocketMessage { + if _shouldThrowOnReceive { throw DiscordError.gateway("Simulated receive error") } + guard !messages.isEmpty else { throw DiscordError.gateway("No mock messages") } + return messages.removeFirst() + } + + func send(_ message: WebSocketMessage) async throws { + if case .string(let text) = message { sentMessages.append(text) } + } + + func sendPing() async throws {} + func close() async { _closeCode = 1000 } + func forceClose() async { _closeCode = 1006 } + + func getSentMessages() -> [String] { sentMessages } + func setThrowOnReceive(_ shouldThrow: Bool) { _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/TestFixtures.swift b/Tests/SwiftDiscTests/TestFixtures.swift index a3cbba92..b5993cfb 100644 --- a/Tests/SwiftDiscTests/TestFixtures.swift +++ b/Tests/SwiftDiscTests/TestFixtures.swift @@ -50,6 +50,103 @@ enum TestFixtures { Emoji(id: EmojiID(id), name: name, roles: nil, user: nil, require_colons: nil, managed: nil, animated: nil, available: nil) } + /// Creates a Channel fixture with the specified fields. + static func makeChannel(id: String = "c1", type: ChannelType = .guildText, name: String = "general", guildId: String = "g1", position: Int = 0, topic: String? = nil, nsfw: Bool? = nil, lastMessageId: String? = "lm1", parentId: String? = nil) -> Channel { + Channel( + id: ChannelID(id), + type: type, + name: name, + topic: topic, + nsfw: nsfw, + position: position, + parent_id: parentId.map(ChannelID.init), + last_message_id: lastMessageId.map(MessageID.init), + permission_overwrites: [PermissionOverwrite(id: OverwriteID("ow1"), type: 0, allow: "1024", deny: "0")] + ) + } + + /// Creates a GuildMember fixture. + 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 + ) + } + + /// Creates a full Embed fixture. + 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" + ) + } + + /// Creates a Thread fixture (Channel with .guildPublicThread type). + static func makeThread(id: String = "t1", name: String = "Test Thread", guildId: String = "g1", parentId: String = "c1") -> Channel { + Channel( + id: ChannelID(id), + type: .guildPublicThread, + 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" + ) + ) + } + + /// Creates a MessageComponents fixture (ActionRow with a Button). + 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)] + } + + /// Creates a PresenceUpdate fixture. + 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") + ) + } + + /// Creates a minimal VoiceState fixture for API compliance. + static func makeVoiceState(userId: String = "u1", sessionId: String = "sess1", channelId: String? = nil) -> VoiceState { + VoiceState(user_id: UserID(userId), session_id: sessionId, channel_id: channelId.map(ChannelID.init)) + } + /// Creates a minimal component `Interaction` fixture for collector and router tests. 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) From ad19302cdf653634370874fc4ec214c527f0890d Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Wed, 8 Jul 2026 21:23:59 -0400 Subject: [PATCH 19/22] Fix 7 coderabbitai review issues 1. GatewayClient: remove misleading public on logPrivilegedIntentWarnings (actor is internal, so public was a no-op) 2. Converters: remove discordOrange (duplicated discordYellow at 0xFEE75C; Discord has no official orange in its palette) 3. CooldownManager: fix clearCommandCooldowns prefix from :: to \0 (compoundKey was changed but clear used old separator - critical bug) 4. EmbedBuilder: align doc comments with behavior (remove prefix claims) 5. JSONValue: conform to CustomStringConvertible for proper description 6. AsyncSemaphore: track waiters by ID to fix cancellation correctness 7. HTTPClient fallback stubs: align putFile and postStickerMultipart signatures with real implementations --- Sources/SwiftDisc/Gateway/GatewayClient.swift | 2 +- Sources/SwiftDisc/HighLevel/Converters.swift | 2 -- .../SwiftDisc/HighLevel/CooldownManager.swift | 2 +- .../SwiftDisc/HighLevel/EmbedBuilder.swift | 4 +-- Sources/SwiftDisc/Internal/JSONValue.swift | 2 +- Sources/SwiftDisc/REST/HTTPClient.swift | 35 +++++++++++-------- 6 files changed, 25 insertions(+), 22 deletions(-) diff --git a/Sources/SwiftDisc/Gateway/GatewayClient.swift b/Sources/SwiftDisc/Gateway/GatewayClient.swift index 03fc85a5..e3735b9d 100644 --- a/Sources/SwiftDisc/Gateway/GatewayClient.swift +++ b/Sources/SwiftDisc/Gateway/GatewayClient.swift @@ -595,7 +595,7 @@ actor GatewayClient { /// Logs a warning for each privileged intent used. This is a diagnostic helper and does not /// verify the Developer Portal configuration. - public static func logPrivilegedIntentWarnings(_ 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"), diff --git a/Sources/SwiftDisc/HighLevel/Converters.swift b/Sources/SwiftDisc/HighLevel/Converters.swift index 0ec1ae35..594e0f78 100644 --- a/Sources/SwiftDisc/HighLevel/Converters.swift +++ b/Sources/SwiftDisc/HighLevel/Converters.swift @@ -114,8 +114,6 @@ public enum Converters: Sendable { public static let discordYellow: Int = 0xFEE75C /// Discord fuchsia/pink color (used for stage channels) public static let discordFuchsia: Int = 0xEB459E - /// Discord orange color - public static let discordOrange: Int = 0xFEE75C // MARK: - Emoji Parsing diff --git a/Sources/SwiftDisc/HighLevel/CooldownManager.swift b/Sources/SwiftDisc/HighLevel/CooldownManager.swift index c8dea709..291ce807 100644 --- a/Sources/SwiftDisc/HighLevel/CooldownManager.swift +++ b/Sources/SwiftDisc/HighLevel/CooldownManager.swift @@ -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) } } diff --git a/Sources/SwiftDisc/HighLevel/EmbedBuilder.swift b/Sources/SwiftDisc/HighLevel/EmbedBuilder.swift index 302e1f49..2167f909 100644 --- a/Sources/SwiftDisc/HighLevel/EmbedBuilder.swift +++ b/Sources/SwiftDisc/HighLevel/EmbedBuilder.swift @@ -137,14 +137,14 @@ public struct EmbedBuilder: Sendable { return c } - /// Create a success embed with green color and checkmark prefix. + /// 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 and crossmark prefix. + /// 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 { diff --git a/Sources/SwiftDisc/Internal/JSONValue.swift b/Sources/SwiftDisc/Internal/JSONValue.swift index 82639372..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) diff --git a/Sources/SwiftDisc/REST/HTTPClient.swift b/Sources/SwiftDisc/REST/HTTPClient.swift index eb784b2c..883630d1 100644 --- a/Sources/SwiftDisc/REST/HTTPClient.swift +++ b/Sources/SwiftDisc/REST/HTTPClient.swift @@ -43,7 +43,9 @@ private func parseRetryAfter(data: Data) -> TimeInterval { /// 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 @@ -54,29 +56,32 @@ private actor AsyncSemaphore { value -= 1 return } + let id = nextId + nextId += 1 await withTaskCancellationHandler { await withCheckedContinuation { continuation in - waiters.append(continuation) + waiters[id] = continuation + waiterOrder.append(id) } } onCancel: { - Task { await self.removeCancelledWaiter() } + Task { await self.removeCancelledWaiter(id) } } } - private func removeCancelledWaiter() { - // Continuation was already resumed by cancellation, just remove from list - if !waiters.isEmpty { - waiters.removeFirst() - } + 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 } } @@ -653,11 +658,11 @@ final class HTTPClient: @unchecked Sendable { throw DiscordError.unavailable } - func postStickerMultipart(path: String, jsonBody: B, files: [FileAttachment], reason: String? = nil) async throws(DiscordError) -> T { + 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, file: FileAttachment, reason: String? = nil) async throws(DiscordError) { + func putFile(path: String, data: Data, filename: String, reason: String? = nil) async throws(DiscordError) { throw DiscordError.unavailable } } From 3c611ae5a4d6ae1cf8ea62020ca6075c53ec1755 Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Wed, 8 Jul 2026 21:37:29 -0400 Subject: [PATCH 20/22] Fix CI builds and test failures CI: revert workflow files to original compnerd/gha-setup-swift@main (swift-actions/setup-swift@v2 could not resolve Swift 6.2) Build fixes: - ActivityBuilder: stored type as ActivityType instead of Int - DiscordClient.setActivity: Int->ActivityType conversion - ShardingBot example: use .game instead of magic 0 Test fixes: - HTTPClientTests: mock paths corrected to /api/v10/ prefix - HTTPClientTests: EventCapture actor for Sendable-safe capture - CacheTests: ChannelType.guildText->.text, async nil checks - TestFixtures: Channel fixture simplified, .guildPublicThread->.publicThread - MockTransport: @unchecked Sendable, DiscordError.http args, NSLock safety - InternalTests: RetryPolicy tests use jitter:0 for deterministic results --- .github/workflows/ci.yml | 6 ++-- .github/workflows/docs.yml | 9 +++-- Examples/ShardingBot.swift | 2 +- Sources/SwiftDisc/DiscordClient.swift | 2 +- .../SwiftDisc/HighLevel/ActivityBuilder.swift | 15 ++++---- .../SwiftDisc/HighLevel/MessagePayload.swift | 4 +-- Tests/SwiftDiscTests/CacheTests.swift | 18 +++++----- Tests/SwiftDiscTests/HTTPClientTests.swift | 18 ++++++---- Tests/SwiftDiscTests/InternalTests.swift | 10 +++--- Tests/SwiftDiscTests/MockTransport.swift | 36 +++++++++++-------- Tests/SwiftDiscTests/TestFixtures.swift | 34 +++--------------- 11 files changed, 73 insertions(+), 81 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15eb5ae9..d9ff147f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,11 @@ jobs: - uses: actions/checkout@v4 - name: Install Swift 6.2 - uses: swift-actions/setup-swift@v2 + uses: compnerd/gha-setup-swift@main with: - swift-version: "6.2" + source: swift.org + swift-version: swift-6.2-release + swift-build: 6.2-RELEASE - name: Run tests (Unix) if: matrix.os != 'windows-latest' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8af51a10..90ca93e2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,9 +20,11 @@ jobs: uses: actions/configure-pages@v4 - name: Install Swift 6.2 - uses: swift-actions/setup-swift@v2 + uses: compnerd/gha-setup-swift@main with: - swift-version: "6.2" + source: swift.org + swift-version: swift-6.2-release + swift-build: 6.2-RELEASE - name: Generate Documentation run: | @@ -31,9 +33,6 @@ jobs: sed -i.bak '/^ dependencies: \[/s/\[/[.package(url: "https:\/\/github.com\/apple\/swift-docc-plugin.git", from: "1.4.4"),/' Package.swift && rm -f Package.swift.bak swift package --allow-writing-to-directory documentation generate-documentation --target SwiftDisc --output-path documentation --transform-for-static-hosting --hosting-base-path SwiftDisc - - name: Verify documentation was generated - run: test -f documentation/index.html || { echo "documentation/index.html missing"; exit 1; } - - name: Upload Pages Artifact uses: actions/upload-pages-artifact@v3 with: 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/Sources/SwiftDisc/DiscordClient.swift b/Sources/SwiftDisc/DiscordClient.swift index 2c03ac2d..9fddad74 100644 --- a/Sources/SwiftDisc/DiscordClient.swift +++ b/Sources/SwiftDisc/DiscordClient.swift @@ -2168,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, 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/MessagePayload.swift b/Sources/SwiftDisc/HighLevel/MessagePayload.swift index dd6cc848..591eb0e7 100644 --- a/Sources/SwiftDisc/HighLevel/MessagePayload.swift +++ b/Sources/SwiftDisc/HighLevel/MessagePayload.swift @@ -202,12 +202,12 @@ public extension DiscordClient { content: payload.content, embeds: payload.embeds, components: payload.components, + files: files, allowedMentions: payload.allowedMentions, flags: payload.flags, tts: payload.tts, stickerIds: payload.stickerIds, - poll: payload.poll, - files: files + poll: payload.poll ) } return try await editMessage( diff --git a/Tests/SwiftDiscTests/CacheTests.swift b/Tests/SwiftDiscTests/CacheTests.swift index 87e376be..48041cd8 100644 --- a/Tests/SwiftDiscTests/CacheTests.swift +++ b/Tests/SwiftDiscTests/CacheTests.swift @@ -33,7 +33,7 @@ final class CacheTests: XCTestCase { await cache.upsert(channel: channel) let retrieved = await cache.getChannel(id: ChannelID("ch1")) XCTAssertEqual(retrieved?.name, "test-channel") - XCTAssertEqual(retrieved?.type, .guildText) + XCTAssertEqual(retrieved?.type, .text) } func testUpsertAndGetGuild() async throws { @@ -90,9 +90,12 @@ final class CacheTests: XCTestCase { await cache.clear() - XCTAssertNil(await cache.getUser(id: UserID("u1"))) - XCTAssertNil(await cache.getChannel(id: ChannelID("ch1"))) - XCTAssertNil(await cache.getGuild(id: GuildID("g1"))) + 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 { @@ -116,18 +119,17 @@ final class CacheTests: XCTestCase { func testEnsureChannelStub() async throws { let cache = Cache() - await cache.ensureChannelStub(id: ChannelID("stub1"), type: .guildText) + 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, .guildText) + XCTAssertEqual(channel?.type, .text) - // Ensure it does not overwrite existing 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, .guildText) + XCTAssertEqual(after?.type, .text) } } diff --git a/Tests/SwiftDiscTests/HTTPClientTests.swift b/Tests/SwiftDiscTests/HTTPClientTests.swift index 8af68de9..de8fec35 100644 --- a/Tests/SwiftDiscTests/HTTPClientTests.swift +++ b/Tests/SwiftDiscTests/HTTPClientTests.swift @@ -2,6 +2,11 @@ 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 @@ -13,7 +18,7 @@ final class HTTPClientTests: XCTestCase { let client = HTTPClient(token: "test_token", configuration: config, transport: transport) let responseData = try JSONEncoder().encode(TestResponse(id: "42", value: "hello")) - let path = "/v10/test/endpoint" + let path = "/api/v10/test/endpoint" await transport.addResponse(for: path, data: responseData) let result: TestResponse = try await client.get(path: "test/endpoint") @@ -22,15 +27,15 @@ final class HTTPClientTests: XCTestCase { } func testRateLimitHeaderParsing() async throws { - var capturedEvent: RateLimitEvent? + let capture = EventCapture() let config = DiscordConfiguration(onRateLimit: { event in - capturedEvent = event + 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 = "/v10/channels/123/messages" + let path = "/api/v10/channels/123/messages" await transport.addResponse( for: path, data: responseData, @@ -44,7 +49,8 @@ final class HTTPClientTests: XCTestCase { let result: [String: Bool] = try await client.get(path: "channels/123/messages") XCTAssertEqual(result["ok"], true) - let event = try XCTUnwrap(capturedEvent) + let captured = await capture.event + let event = try XCTUnwrap(captured) XCTAssertEqual(event.remaining, 4) XCTAssertEqual(event.limit, 5) XCTAssertFalse(event.isGlobal) @@ -56,7 +62,7 @@ final class HTTPClientTests: XCTestCase { let client = HTTPClient(token: "test_token", configuration: config, transport: transport) let successData = try JSONEncoder().encode(TestResponse(id: "1", value: "ok")) - let path = "/v10/guilds/111/channels" + let path = "/api/v10/guilds/111/channels" // First call returns 429 with minimal retry-after await transport.addResponse( 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 index a8d3492e..ab1b0ee7 100644 --- a/Tests/SwiftDiscTests/MockTransport.swift +++ b/Tests/SwiftDiscTests/MockTransport.swift @@ -16,7 +16,7 @@ actor MockHTTPTransport: HTTPTransport { let path = url.path requestedPaths.append("\(method):\(path)") guard var queue = responseQueues[path], !queue.isEmpty else { - throw DiscordError.http(statusCode: 404, message: "No mock response for \(path)") + throw DiscordError.http(404, "No mock response for \(path)") } let response = queue.removeFirst() if queue.isEmpty { @@ -36,33 +36,39 @@ actor MockHTTPTransport: HTTPTransport { } /// A mock WebSocket transport that simulates gateway events. -actor MockWebSocketTransport: WebSocketTransport { - private var messages: [WebSocketMessage] = [] - private var sentMessages: [String] = [] +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? { _closeCode } + var closeCode: Int? { lock.withLock { _closeCode } } - func addMessage(_ message: WebSocketMessage) { messages.append(message) } - func addString(_ text: String) { messages.append(.string(text)) } + func addMessage(_ message: WebSocketMessage) { lock.withLock { _messages.append(message) } } + func addString(_ text: String) { lock.withLock { _messages.append(.string(text)) } } func receive() async throws -> WebSocketMessage { - if _shouldThrowOnReceive { throw DiscordError.gateway("Simulated receive error") } - guard !messages.isEmpty else { throw DiscordError.gateway("No mock messages") } - return messages.removeFirst() + 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 { sentMessages.append(text) } + if case .string(let text) = message { lock.withLock { _sentMessages.append(text) } } } func sendPing() async throws {} - func close() async { _closeCode = 1000 } - func forceClose() async { _closeCode = 1006 } + func close() async { lock.withLock { _closeCode = 1000 } } + func forceClose() async { lock.withLock { _closeCode = 1006 } } - func getSentMessages() -> [String] { sentMessages } - func setThrowOnReceive(_ shouldThrow: Bool) { _shouldThrowOnReceive = shouldThrow } + func getSentMessages() -> [String] { lock.withLock { _sentMessages } } + func setThrowOnReceive(_ shouldThrow: Bool) { lock.withLock { _shouldThrowOnReceive = shouldThrow } } } /// Minimal VoiceState struct for API compliance (voice not yet planned). diff --git a/Tests/SwiftDiscTests/TestFixtures.swift b/Tests/SwiftDiscTests/TestFixtures.swift index b5993cfb..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,32 +35,23 @@ 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 Channel fixture with the specified fields. - static func makeChannel(id: String = "c1", type: ChannelType = .guildText, name: String = "general", guildId: String = "g1", position: Int = 0, topic: String? = nil, nsfw: Bool? = nil, lastMessageId: String? = "lm1", parentId: String? = nil) -> Channel { + static func makeChannel(id: String = "c1", type: ChannelType = .text, name: String = "general") -> Channel { Channel( id: ChannelID(id), type: type, name: name, - topic: topic, - nsfw: nsfw, - position: position, - parent_id: parentId.map(ChannelID.init), - last_message_id: lastMessageId.map(MessageID.init), - permission_overwrites: [PermissionOverwrite(id: OverwriteID("ow1"), type: 0, allow: "1024", deny: "0")] + permission_overwrites: [] ) } - /// Creates a GuildMember fixture. static func makeGuildMember(userId: String = "u1", nick: String? = nil, roles: [RoleID] = [RoleID("r1")]) throws -> GuildMember { GuildMember( user: try makeUser(id: userId), @@ -85,7 +71,6 @@ enum TestFixtures { ) } - /// Creates a full Embed fixture. static func makeEmbed() -> Embed { Embed( title: "Test Title", @@ -106,11 +91,10 @@ enum TestFixtures { ) } - /// Creates a Thread fixture (Channel with .guildPublicThread type). static func makeThread(id: String = "t1", name: String = "Test Thread", guildId: String = "g1", parentId: String = "c1") -> Channel { Channel( id: ChannelID(id), - type: .guildPublicThread, + type: .publicThread, name: name, parent_id: ChannelID(parentId), thread_metadata: ThreadMetadata( @@ -124,14 +108,12 @@ enum TestFixtures { ) } - /// Creates a MessageComponents fixture (ActionRow with a Button). 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)] } - /// Creates a PresenceUpdate fixture. static func makePresenceUpdate() throws -> PresenceUpdate { PresenceUpdate( user: try makeUser(), @@ -142,18 +124,11 @@ enum TestFixtures { ) } - /// Creates a minimal VoiceState fixture for API compliance. - static func makeVoiceState(userId: String = "u1", sessionId: String = "sess1", channelId: String? = nil) -> VoiceState { - VoiceState(user_id: UserID(userId), session_id: sessionId, channel_id: channelId.map(ChannelID.init)) - } - - /// Creates a minimal component `Interaction` fixture for collector and router tests. 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, @@ -167,7 +142,6 @@ enum TestFixtures { components: nil, attachments: nil ) - return Interaction( id: interactionId, application_id: appId, @@ -188,4 +162,4 @@ enum TestFixtures { context: nil ) } -} \ No newline at end of file +} From 3a9d84a04719bd6d34834e51e70f5a5c9664217b Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Wed, 8 Jul 2026 21:58:43 -0400 Subject: [PATCH 21/22] Fix CI: use swift-actions/setup-swift@v2 with swift-version: 6.2 The deprecated compnerd/gha-setup-swift@main action was causing CI failures on all platforms. Replaced with swift-actions/setup-swift@v2 which is the actively maintained Swift setup action for GitHub Actions. Also simplified test scripts to use direct 'swift test' command instead of wrapper scripts that had Docker fallback and PowerShell compatibility issues on CI runners. --- .github/workflows/ci.yml | 13 ++++--------- .github/workflows/docs.yml | 6 ++---- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9ff147f..3258d6c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,20 +16,15 @@ jobs: - uses: actions/checkout@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: Run tests (Unix) if: matrix.os != 'windows-latest' - run: | - chmod +x scripts/run-tests.sh || true - ./scripts/run-tests.sh + run: swift test - name: Run tests (Windows) if: matrix.os == 'windows-latest' shell: pwsh - run: | - .\scripts\run-tests.ps1 + 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: | From 21dbd92f2dab5944559cfdc332501f8e447d2fdf Mon Sep 17 00:00:00 2001 From: M1tsumi <0000imdumb0000@gmail.com> Date: Wed, 8 Jul 2026 22:19:59 -0400 Subject: [PATCH 22/22] Fix Windows CI: per-platform Swift setup actions swift-actions/setup-swift@v2 does not support Windows. Use compnerd/gha-setup-swift@main specifically for windows-latest while swift-actions/setup-swift@v2 handles macOS and Ubuntu. --- .github/workflows/ci.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3258d6c1..6533e89c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,16 +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: Run tests (Unix) - if: matrix.os != 'windows-latest' - run: swift test - - - name: Run tests (Windows) + - name: Install Swift 6.2 (Windows) if: matrix.os == 'windows-latest' - shell: pwsh + uses: compnerd/gha-setup-swift@main + with: + source: swift.org + swift-version: swift-6.2-release + swift-build: 6.2-RELEASE + + - name: Run tests run: swift test