diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1acad31..987bae8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,10 +12,12 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v4 - - name: Select Xcode 16.4 - uses: maxim-lobanov/setup-xcode@v1 + - name: Install Swift 6.2 + uses: compnerd/gha-setup-swift@main with: - xcode-version: '16.4' + source: swift.org + swift-version: swift-6.2-release + swift-build: 6.2-RELEASE - name: Toolchain diagnostics run: | xcodebuild -version diff --git a/.gitignore b/.gitignore index c432582..19e26d4 100644 --- a/.gitignore +++ b/.gitignore @@ -36,11 +36,15 @@ Secrets/ *.profraw coverage/ -# Local reports (not committed) +# Local reports / debug logs (not committed) advaithpr.ts VoiceActivity.txt percentage.txt Reports/ +errors/ + +# Internal planning documents +v2.0.0.txt # Carthage / CocoaPods (not used, but ignore if present) Carthage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b9a8b16..2e622d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,210 @@ 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.0.0] - 2026-03-02 + +### Overview +SwiftDisc 2.0.0 is a major release delivering a complete Swift 6 strict-concurrency +migration, typed throws throughout the REST layer, 32 new gateway event callbacks, +a fully expanded Guild model, critical bug fixes, and high-impact developer-experience +improvements including `message.reply()`, `client.sendDM()`, typed slash-option +accessors, `EmbedBuilder.timestamp(Date)`, a public `CooldownManager`, filtered +event-stream helpers, and a background cache-eviction task. + +### Breaking Changes +- **`DiscordClient`** is now a `public actor` (was `public final class`). All method + calls from outside the actor require `await`. Immutable `let` properties (`token`, + `cache`) remain accessible without `await`. +- **`CommandRouter`**, **`SlashCommandRouter`**, **`AutocompleteRouter`**, + **`ShardManager`** are now `actor` types. All mutating methods (e.g. `register`, + `registerPath`, `useCommands`) require `await`. +- **`CooldownManager`** is now a `public actor`. `isOnCooldown` and `setCooldown` + require `await`. +- All stored handler closures (`onReady`, `onMessage`, `onVoiceFrame`, `Handler` + typealiases, etc.) are now `@Sendable`. +- `SwiftDiscExtension` protocol now requires `Sendable` conformance. +- `VoiceAudioSource` protocol now requires `Sendable` conformance. +- `WebSocketClient` protocol now requires `Sendable` conformance. +- `HTTPClient` REST methods now declare `throws(DiscordError)` instead of untyped + `throws`. Call sites using `catch { }` will need to handle `DiscordError` directly. +- `Guild` model expanded from 4 fields to ~50 fields — any code that relied on the + minimal stub may need to handle new optionals. + +### Added — Developer Experience +- **`message.reply(...)`** — reply to any `Message` in one call. Sets + `message_reference` automatically and optionally suppresses the mention: + ```swift + let response = try await message.reply(client: client, content: "Pong!") + let quiet = try await message.reply(client: client, content: "...", mention: false) + ``` +- **`client.sendDM(userId:content:embeds:components:)`** — open a DM channel and + send a message in a single awaitable call, replacing the two-step + `createDM` + `sendMessage` pattern: + ```swift + try await client.sendDM(userId: userId, content: "Welcome to the server!") + ``` +- **`SlashCommandRouter.Context` typed option accessors** — resolve Discord + interaction option values to strongly-typed objects using the `resolved` map: + ```swift + let target = ctx.user("target") // → User? + let destination = ctx.channel("channel") // → Interaction.ResolvedChannel? + let role = ctx.role("role") // → Interaction.ResolvedRole? + let file = ctx.attachment("upload") // → Interaction.ResolvedAttachment? + ``` +- **`EmbedBuilder.timestamp(_ date: Date)`** — pass a `Date` directly instead of + manually formatting an ISO 8601 string: + ```swift + EmbedBuilder().timestamp(Date()).build() + ``` +- **`CooldownManager`** is now `public` — use it directly in any bot code, not just + inside the built-in command routers. +- **Filtered event-stream helpers** on `DiscordClient` — typed `AsyncStream` + subscriptions without manual `switch event` boilerplate: + ```swift + for await message in client.messageEvents() { ... } + for await reaction in client.reactionAddEvents() { ... } + for await interaction in client.interactionEvents() { ... } + for await member in client.memberAddEvents() { ... } + ``` +- **`MessagePayload` fluent builder** — composable payload type covering every + message-send option. Automatically dispatches to multipart when files are present: + ```swift + try await client.send(to: channelId, MessagePayload() + .content("Hello!") + .embed(EmbedBuilder().title("World").build()) + .ephemeral() + .silent()) + try await client.edit(channelId: cid, messageId: mid, MessagePayload().content("Updated")) + try await client.respond(to: interaction, with: MessagePayload().content("OK").ephemeral()) + ``` +- **`WebhookClient`** — standalone token-free webhook client (uses `URLSession` + directly, no bot token required). Parse from URL or supply ID + token directly: + ```swift + let hook = WebhookClient(url: "https://discord.com/api/webhooks/123/abc")! + let msg = try await hook.execute(content: "Hi!", wait: true) + try await hook.editMessage(messageId: msg!.id.rawValue, content: "Updated") + try await hook.deleteMessage(messageId: msg!.id.rawValue) + ``` +- **`EmojiRef` typed enum** — type-safe emoji references for all reaction APIs: + ```swift + try await client.addReaction(channelId: cid, messageId: mid, emoji: .unicode("👍")) + try await client.addReaction(channelId: cid, messageId: mid, emoji: .custom(name: "uwu", id: emojiId)) + ``` +- **`DiscordClient.archiveThread(channelId:locked:)`** — archive (and optionally + lock) a thread in one call via `PATCH /channels/{id}`. +- **`DiscordClient.syncCommands(_:guildId:)`** — smart command sync that fetches + existing commands, diffs the name sets, and only calls `bulkOverwrite` when there + is an actual change. Avoids rate-limit churn on every restart: + ```swift + try await client.syncCommands(myCommands) // global + try await client.syncCommands(myCommands, guildId: guildId) // guild + ``` +- **Middleware for `CommandRouter` and `SlashCommandRouter`** — register + cross-cutting concerns (logging, auth gates, rate-limiting) independently of + command handlers: + ```swift + router.use { ctx, next in + guard ctx.isAdmin else { + try await ctx.message.reply(client: ctx.client, content: "🚫 Admins only.") + return + } + try await next(ctx) + } + ``` +- **Permission helpers on `Context`** — both `CommandRouter.Context` and + `SlashCommandRouter.Context` gain `hasPermission(_:)`, `isAdmin`, and + `memberHasRole(_:)`: + ```swift + guard ctx.isAdmin else { return } + guard ctx.hasPermission(1 << 5) else { return } // MANAGE_MESSAGES + guard ctx.memberHasRole(modRoleId) else { return } + ``` +- **Cache role + emoji storage** — `Cache` now stores per-guild roles and emojis: + ```swift + cache.upsert(role: role, guildId: guildId) + let allRoles = cache.getRoles(guildId: guildId) + cache.upsert(emojis: guild.emojis ?? [], guildId: guild.id) + let emojis = cache.getEmojis(guildId: guildId) + ``` +- **`GuildMember.permissions`** — added `permissions: String?` field that Discord + includes in interaction and some gateway member payloads (effective permission + bitfield as a decimal string). + +### Added — Discord API Coverage +- **32 new gateway event callbacks** added to `DiscordClient` — every previously + missing event now has a dedicated `@Sendable` callback property: + - `onMessageDeleteBulk`, `onReactionAdd`, `onReactionRemove`, + `onReactionRemoveAll`, `onReactionRemoveEmoji` + - `onGuildUpdate`, `onGuildDelete` + - `onGuildMemberAdd`, `onGuildMemberRemove`, `onGuildMemberUpdate` + - `onChannelCreate`, `onChannelUpdate`, `onChannelDelete` + - `onThreadCreate`, `onThreadUpdate`, `onThreadDelete` + - `onGuildRoleCreate`, `onGuildRoleUpdate`, `onGuildRoleDelete` + - `onGuildBanAdd`, `onGuildBanRemove`, `onAutoModerationActionExecution` + - `onInteractionCreate` + - `onTypingStart`, `onPresenceUpdate`, `onVoiceStateUpdate` + - `onGuildScheduledEventCreate`, `onGuildScheduledEventUpdate`, `onGuildScheduledEventDelete` + - `onPollVoteAdd`, `onPollVoteRemove` + - `onEntitlementCreate`, `onEntitlementUpdate`, `onEntitlementDelete` + - `onSoundboardSoundCreate`, `onSoundboardSoundUpdate`, `onSoundboardSoundDelete` +- **Full `Guild` model** — expanded from a 4-field stub to a complete ~50-field + model matching Discord's `GUILD_CREATE` and REST guild response, including + `roles`, `emojis`, `features`, `stickers`, `members`, `channels`, `threads`, + `presences`, `stage_instances`, `guild_scheduled_events`, and more. +- **`EventDispatcher` full rewire** — all previously-stubbed `break` dispatches now + invoke the appropriate callbacks. `GUILD_CREATE` seeds the full channel, thread, + and member user cache. Presence, member, and thread events update the cache. + +### Fixed +- **HTTPClient double-wrap bug** — errors thrown inside the inner `do {}` block + (e.g. `DiscordError.decoding`, `.http`, `.api`) were being caught by the outer + `catch {}` and re-wrapped as `DiscordError.network(DiscordError.xxx)`. Fixed by + inserting `catch let de as DiscordError { throw de }` before each generic catch. +- **Soundboard gateway event names** — three event strings were incorrect, causing + soundboard events to be silently dropped: + - `SOUND_BOARD_SOUND_CREATE/UPDATE/DELETE` → `SOUNDBOARD_SOUND_CREATE/UPDATE/DELETE` +- **`DiscordError` not `Sendable`** — error values could not safely cross actor + boundaries. Added explicit `Sendable` conformance. + +### Changed — Concurrency Architecture +- **`Package.swift`**: upgraded to `swift-tools-version:6.2` with + `.swiftLanguageMode(.v6)` for both `SwiftDisc` and `SwiftDiscTests` targets. +- **`DiscordClient`**: converted to `actor`. Callback properties typed as + `@Sendable`. All REST and gateway methods are actor-isolated async. `let` + properties are `nonisolated`. +- **`CommandRouter`**, **`SlashCommandRouter`**, **`AutocompleteRouter`**: converted + to `actor`. `Handler` typealiases updated to `@Sendable`. `Context` types conform + to `Sendable`. Static helpers marked `nonisolated`. +- **`ShardManager`**: converted to `actor`. +- **`CooldownManager`**: replaced `NSLock`-guarded `final class` with a clean + `public actor`. All synchronization is now compiler-enforced. +- **`EventDispatcher`**: all `client.*` property accesses use `await`. +- **`GatewayClient`**: `connect` and `readLoop` `eventSink` parameters typed + `@escaping @Sendable`. Soundboard event name strings corrected. +- **`ViewManager`**: `ViewHandler` typealias is now `@Sendable`. +- **`Cache`**: added background periodic eviction task (runs every 60 s) for + entries with configured TTL. No longer requires manual `pruneIfNeeded()` calls. + +### Changed — REST Layer +- **Typed throws** — all `HTTPClient` and `RateLimiter` methods now declare + `throws(DiscordError)`. Call sites no longer need `as? DiscordError` casts. +- **`DiscordError`**: added `Sendable` conformance, `.unavailable` case (replaces + the internal `HTTPUnavailable` struct), and doc comments on every case. +- **`RateLimiter.waitTurn(routeKey:)`**: `throws(DiscordError)`. Wraps + `Task.sleep` `CancellationError` as `DiscordError.cancelled`. + +### Changed — Types Marked Sendable +- `Box` (`Message.swift`): `@unchecked Sendable`. +- `HTTPClient`: `@unchecked Sendable`. +- `VoiceClient`, `VoiceGateway`, `RTPVoiceSender`, `RTPVoiceReceiver`, + `PipeOpusSource`, `URLSessionWebSocketAdapter`: `@unchecked Sendable`. +- `VoiceEncryptor`, `OpusFrame`, `VoiceAudioSource`, `WebSocketClient`, + `DiscordEvent`: explicit `Sendable` conformance. + +### Changed — Tests +- All test methods calling actor methods updated with `await`. +- `CooldownTests.testCooldownSetAndCheck()` made `async`. + ## [1.3.1] - 2026-02-22 ### Fixed diff --git a/InstallGuide.txt b/InstallGuide.txt index be7157a..fe7aef9 100644 --- a/InstallGuide.txt +++ b/InstallGuide.txt @@ -7,8 +7,8 @@ SwiftDisc is a Swift-native Discord API library supporting iOS, macOS, tvOS, wat Requirements ----------- -- Swift 5.9+ -- On Apple platforms: Xcode 15+ recommended +- Swift 6.2+ +- On Apple platforms: Xcode 16.4+ recommended - A Discord Bot token from the Developer Portal - If using message content, ensure the privileged intent is enabled on your application @@ -17,7 +17,7 @@ Install via Swift Package Manager (SPM) Option A) Add to Package.swift (for server/CLI projects) 1) In your Package.swift dependencies, add: - .package(url: "https://github.com/M1tsumi/SwiftDisc.git", from: "0.10.2") + .package(url: "https://github.com/M1tsumi/SwiftDisc.git", from: "2.0.0") 2) Add "SwiftDisc" to your target dependencies. @@ -25,7 +25,7 @@ Option B) Add to Xcode project (for App targets) 1) Xcode > File > Add Packages... 2) Enter URL: https://github.com/M1tsumi/SwiftDisc.git -3) Choose version: Up to Next Major from 0.10.2 +3) Choose version: Up to Next Major from 2.0.0 4) Add the SwiftDisc product to your app target Configuring your Token @@ -71,14 +71,14 @@ struct BotMain { Windows Notes ------------- -- If URLSessionWebSocketTask is unavailable on your toolchain, the current Gateway adapter will report that WebSocket is unavailable -- A dedicated Windows WebSocket adapter is planned; track the project CHANGELOG for updates +- SwiftDisc uses URLSessionWebSocketTask for gateway connections. On Windows, this is fully supported via the Swift 6.2+ toolchain. +- If you encounter connection issues, confirm you are running Swift 6.2 or later. Troubleshooting --------------- - Invalid token: Confirm DISCORD_TOKEN is set and correct - No events received: Check intents; ensure messageContent privileged intent is enabled if you expect full message bodies -- Rate limiting: REST requests are minimally throttled; heavy usage may require per-route buckets (planned) +- Rate limiting: Per-route and global rate limit buckets are fully implemented with exponential backoff. If you encounter 429 responses, avoid tight concurrent loops to the same endpoint. - Gateway disconnects: The client has basic heartbeat ACK tracking and will attempt reconnect; intermittent networks may still cause delays Support & Documentation diff --git a/Package.swift b/Package.swift index 9de7aec..f1932d8 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.2 import PackageDescription let package = Package( @@ -13,7 +13,14 @@ let package = Package( .library(name: "SwiftDisc", targets: ["SwiftDisc"]) ], targets: [ - .target(name: "SwiftDisc"), - .testTarget(name: "SwiftDiscTests", dependencies: ["SwiftDisc"]) + .target( + name: "SwiftDisc", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "SwiftDiscTests", + dependencies: ["SwiftDisc"], + swiftSettings: [.swiftLanguageMode(.v6)] + ) ] ) diff --git a/README.md b/README.md index 16c138f..1d4a5eb 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # SwiftDisc [![Discord](https://img.shields.io/discord/1439300942167146508?color=5865F2&label=Discord&logo=discord&logoColor=white)](https://discord.gg/6nS2KqxQtj) -[![Swift Version](https://img.shields.io/badge/Swift-5.9%2B-F05138?logo=swift&logoColor=white)](https://swift.org) +[![Swift Version](https://img.shields.io/badge/Swift-6.2-F05138?logo=swift&logoColor=white)](https://swift.org) [![CI](https://github.com/M1tsumi/SwiftDisc/actions/workflows/ci.yml/badge.svg)](https://github.com/M1tsumi/SwiftDisc/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) @@ -31,7 +31,7 @@ Add SwiftDisc to your Swift package dependencies in `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/M1tsumi/SwiftDisc.git", from: "1.3.0") + .package(url: "https://github.com/M1tsumi/SwiftDisc.git", from: "2.0.0") ] ``` @@ -55,7 +55,7 @@ targets: [ ## Quick Start -Here's a simple bot that responds to messages: +Here's a simple bot that responds to messages using the v2.0 callback API: ```swift import SwiftDisc @@ -65,25 +65,20 @@ struct MyBot { static func main() async { let token = ProcessInfo.processInfo.environment["DISCORD_BOT_TOKEN"] ?? "" let client = DiscordClient(token: token) - + + // Assign callbacks — no switch statement needed + client.onReady = { info in + print("✅ Logged in as \(info.user.username)") + } + + client.onMessage = { message in + guard message.content == "!ping" else { return } + // reply() sets message_reference automatically + try? await message.reply(client: client, content: "🏓 Pong!") + } + do { try await client.loginAndConnect(intents: [.guilds, .guildMessages, .messageContent]) - - for await event in client.events { - switch event { - case .ready(let info): - print("✅ Logged in as \(info.user.username)") - - case .messageCreate(let message) where message.content == "!ping": - try await client.sendMessage( - channelId: message.channel_id, - content: "🏓 Pong!" - ) - - default: - break - } - } } catch { print("❌ Error: \(error)") } @@ -91,6 +86,16 @@ struct MyBot { } ``` +Or use typed filtered streams when you need an event loop: + +```swift +for await message in await client.messageEvents() { + if message.content == "!ping" { + try? await message.reply(client: client, content: "🏓 Pong!") + } +} +``` + ## Features ### Core Capabilities @@ -111,6 +116,17 @@ struct MyBot { - **Extensions/Cogs**: Modular architecture for organizing bot features - **Utilities**: Mention formatters, emoji helpers, timestamp formatting, and more +### v2.0 Developer Experience + +- **`message.reply()`** — reply to any message in one line, mention control included +- **`client.sendDM()`** — open a DM and send a message in a single call +- **Typed slash option accessors** — `ctx.user()`, `ctx.channel()`, `ctx.role()`, `ctx.attachment()` +- **Filtered event streams** — `client.messageEvents()`, `client.interactionEvents()`, etc. +- **`EmbedBuilder.timestamp(Date)`** — pass `Date()` directly, no ISO 8601 string needed +- **Public `CooldownManager`** — use it anywhere in your bot, not just command routers +- **32 event callbacks** — one `@Sendable` closure per event, no `switch` boilerplate +- **Background cache eviction** — TTL expiry runs automatically, no manual calls needed + ### What's Included The REST API covers all essential Discord features: @@ -132,20 +148,6 @@ For a complete API checklist, see the [REST API Coverage](#rest-api-coverage) se ## Examples -### Command Framework - -```swift -let router = CommandRouter(prefix: "!") -router.register("ping") { ctx in - try? await ctx.reply("Pong!") -} - -client.onMessageCreate { message in - await router.processMessage(message) -} -``` - - ### Command Framework Create command-based bots easily with the built-in router: @@ -424,9 +426,13 @@ MessageFormat.escapeSpecialCharacters(userInput) ✅ Role connections (linked roles) ✅ Sticker info (read-only) +### Soundboard +✅ Send soundboard sounds +✅ List, create, modify, delete guild soundboard sounds +✅ Soundboard gateway events (`SOUNDBOARD_SOUND_CREATE/UPDATE/DELETE`) + ### Not Yet Implemented ❌ Guild sticker creation/modification -❌ Soundboard endpoints For unsupported endpoints, use the raw HTTP methods: `rawGET`, `rawPOST`, `rawPATCH`, `rawDELETE` @@ -466,7 +472,7 @@ swift test swift test --enable-code-coverage ``` -CI runs on macOS (Xcode 16.4) and Windows (Swift 6.2). +CI runs on macOS (Xcode 16.4) and Windows (Swift 6.2). Requires Swift 6.2+ toolchain. ## Contributing @@ -474,21 +480,24 @@ We welcome contributions! Whether it's bug reports, feature requests, or pull re Before contributing, please: - Check existing issues and PRs to avoid duplicates -- Read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines +- Open a discussion issue for significant changes before starting work - Join our [Discord server](https://discord.gg/6nS2KqxQtj) if you have questions ## Roadmap -### Current Version: v1.3.1 +### Current Version: v2.0.0 -Adds new modal interaction components (Radio Groups, Checkbox Groups, Checkboxes, Labels), community invite management endpoints, gradient role colors, guild tags, voice state REST endpoints, and subscription renewal SKUs. See [CHANGELOG.md](CHANGELOG.md) for the full breakdown. +Major release delivering Swift 6 strict-concurrency, typed throws throughout the +REST layer, 32 new event callbacks, full Guild model, critical bug fixes, and +high-impact DX improvements (`message.reply()`, `sendDM()`, typed slash accessors, +filtered event streams, background cache eviction). See [CHANGELOG.md](CHANGELOG.md). -### Future Plans +### Upcoming -- Soundboard endpoints -- Guild sticker creation/modification -- Enhanced voice support -- Performance optimizations and caching improvements +- Full Components V2 fluent builders (MediaGallery, Section, Container, Separator) +- Guild sticker creation and modification +- Enhanced and stable voice support +- Expanded test coverage across REST and Gateway layers Have ideas? Open an issue or join the discussion on Discord! diff --git a/Sources/SwiftDisc/DiscordClient.swift b/Sources/SwiftDisc/DiscordClient.swift index 87e449d..3d97fe1 100644 --- a/Sources/SwiftDisc/DiscordClient.swift +++ b/Sources/SwiftDisc/DiscordClient.swift @@ -1,16 +1,21 @@ import Foundation -public final class DiscordClient { - public let token: String - private let http: HTTPClient +/// The primary client for interacting with the Discord API. +/// +/// `DiscordClient` is an `actor`, giving every method and stored property +/// automatic data-race safety. `let` stored properties (e.g. `token`, `cache`) +/// are accessible from any context without `await`. +public actor DiscordClient { + public nonisolated let token: String + let http: HTTPClient private let gateway: GatewayClient private let configuration: DiscordConfiguration private let dispatcher = EventDispatcher() private let voiceClient: VoiceClient? private var currentUserId: UserID? - private var eventStream: AsyncStream! - private var eventContinuation: AsyncStream.Continuation! + nonisolated(unsafe) private var eventStream: AsyncStream! + nonisolated(unsafe) private var eventContinuation: AsyncStream.Continuation! public let cache = Cache() @@ -19,16 +24,81 @@ public final class DiscordClient { public var events: AsyncStream { eventStream } - // Phase 3: Callback adapters - public var onReady: ((ReadyEvent) async -> Void)? - public var onMessage: ((Message) async -> Void)? - public var onMessageUpdate: ((Message) async -> Void)? - public var onMessageDelete: ((MessageDelete) async -> Void)? - public var onReactionAdd: ((MessageReactionAdd) async -> Void)? - public var onReactionRemove: ((MessageReactionRemove) async -> Void)? - public var onReactionRemoveAll: ((MessageReactionRemoveAll) async -> Void)? - public var onReactionRemoveEmoji: ((MessageReactionRemoveEmoji) async -> Void)? - public var onGuildCreate: ((Guild) async -> Void)? + // MARK: - Event Callbacks + // Assign any of these to be notified of specific gateway events. + // All callbacks are @Sendable so they can be used safely across actor / task boundaries. + + // -- Ready -- + public var onReady: (@Sendable (ReadyEvent) async -> Void)? + + // -- Messages -- + public var onMessage: (@Sendable (Message) async -> Void)? + public var onMessageUpdate: (@Sendable (Message) async -> Void)? + public var onMessageDelete: (@Sendable (MessageDelete) async -> Void)? + public var onMessageDeleteBulk: (@Sendable (MessageDeleteBulk) async -> Void)? + + // -- Reactions -- + public var onReactionAdd: (@Sendable (MessageReactionAdd) async -> Void)? + public var onReactionRemove: (@Sendable (MessageReactionRemove) async -> Void)? + public var onReactionRemoveAll: (@Sendable (MessageReactionRemoveAll) async -> Void)? + public var onReactionRemoveEmoji: (@Sendable (MessageReactionRemoveEmoji) async -> Void)? + + // -- Guilds -- + public var onGuildCreate: (@Sendable (Guild) async -> Void)? + public var onGuildUpdate: (@Sendable (Guild) async -> Void)? + public var onGuildDelete: (@Sendable (GuildDelete) async -> Void)? + + // -- Members -- + public var onGuildMemberAdd: (@Sendable (GuildMemberAdd) async -> Void)? + public var onGuildMemberRemove: (@Sendable (GuildMemberRemove) async -> Void)? + public var onGuildMemberUpdate: (@Sendable (GuildMemberUpdate) async -> Void)? + + // -- Channels -- + public var onChannelCreate: (@Sendable (Channel) async -> Void)? + public var onChannelUpdate: (@Sendable (Channel) async -> Void)? + public var onChannelDelete: (@Sendable (Channel) async -> Void)? + + // -- Threads -- + public var onThreadCreate: (@Sendable (Channel) async -> Void)? + public var onThreadUpdate: (@Sendable (Channel) async -> Void)? + public var onThreadDelete: (@Sendable (Channel) async -> Void)? + + // -- Roles -- + public var onGuildRoleCreate: (@Sendable (GuildRoleCreate) async -> Void)? + public var onGuildRoleUpdate: (@Sendable (GuildRoleUpdate) async -> Void)? + public var onGuildRoleDelete: (@Sendable (GuildRoleDelete) async -> Void)? + + // -- Moderation -- + public var onGuildBanAdd: (@Sendable (GuildBanAdd) async -> Void)? + public var onGuildBanRemove: (@Sendable (GuildBanRemove) async -> Void)? + public var onAutoModerationActionExecution: (@Sendable (AutoModerationActionExecution) async -> Void)? + + // -- Interactions -- + public var onInteractionCreate: (@Sendable (Interaction) async -> Void)? + + // -- Presence & Typing -- + public var onTypingStart: (@Sendable (TypingStart) async -> Void)? + public var onPresenceUpdate: (@Sendable (PresenceUpdate) async -> Void)? + public var onVoiceStateUpdate: (@Sendable (VoiceState) async -> Void)? + + // -- Scheduled Events -- + public var onGuildScheduledEventCreate: (@Sendable (GuildScheduledEvent) async -> Void)? + public var onGuildScheduledEventUpdate: (@Sendable (GuildScheduledEvent) async -> Void)? + public var onGuildScheduledEventDelete: (@Sendable (GuildScheduledEvent) async -> Void)? + + // -- Polls -- + public var onPollVoteAdd: (@Sendable (PollVote) async -> Void)? + public var onPollVoteRemove: (@Sendable (PollVote) async -> Void)? + + // -- Entitlements / Monetization -- + public var onEntitlementCreate: (@Sendable (Entitlement) async -> Void)? + public var onEntitlementUpdate: (@Sendable (Entitlement) async -> Void)? + public var onEntitlementDelete: (@Sendable (Entitlement) async -> Void)? + + // -- Soundboard -- + public var onSoundboardSoundCreate: (@Sendable (SoundboardSound) async -> Void)? + public var onSoundboardSoundUpdate: (@Sendable (SoundboardSound) async -> Void)? + public var onSoundboardSoundDelete: (@Sendable (SoundboardSound) async -> Void)? // Phase 3: Command framework public var commands: CommandRouter? @@ -49,7 +119,7 @@ public final class DiscordClient { public var autocomplete: AutocompleteRouter? public func useAutocomplete(_ router: AutocompleteRouter) { self.autocomplete = router } - public var onVoiceFrame: ((VoiceFrame) async -> Void)? + public var onVoiceFrame: (@Sendable (VoiceFrame) async -> Void)? public init(token: String, configuration: DiscordConfiguration = .init()) { self.token = token @@ -68,15 +138,6 @@ public final class DiscordClient { self.voiceClient = nil } - if let vc = self.voiceClient { - vc.setOnFrame { [weak self] frame in - guard let self, let cb = self.onVoiceFrame else { return } - Task { - await cb(frame) - } - } - } - var localContinuation: AsyncStream.Continuation! self.eventStream = AsyncStream { continuation in continuation.onTermination = { _ in } @@ -99,15 +160,15 @@ public final class DiscordClient { // MARK: - REST: Bulk Messages and Crosspost // Bulk delete messages (2-100, not older than 14 days) public func bulkDeleteMessages(channelId: ChannelID, messageIds: [MessageID]) async throws { - struct Body: Encodable { let messages: [MessageID] } - struct Ack: Decodable {} + struct Body: Encodable, Sendable { let messages: [MessageID] } + struct Ack: Decodable, Sendable {} let body = Body(messages: messageIds) let _: Ack = try await http.post(path: "/channels/\(channelId)/messages/bulk-delete", body: body) } // Crosspost message public func crosspostMessage(channelId: ChannelID, messageId: MessageID) async throws -> Message { - struct Empty: Encodable {} + struct Empty: Encodable, Sendable {} return try await http.post(path: "/channels/\(channelId)/messages/\(messageId)/crosspost", body: Empty()) } @@ -157,7 +218,7 @@ public final class DiscordClient { poll: Poll? = nil, files: [FileAttachment] ) async throws -> Message { - struct Payload: Encodable { + struct Payload: Encodable, Sendable { let content: String? let embeds: [Embed]? let components: [MessageComponent]? @@ -193,7 +254,7 @@ public final class DiscordClient { files: [FileAttachment]? = nil, attachments: [PartialAttachment]? = nil ) async throws -> Message { - struct Payload: Encodable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]?; let attachments: [PartialAttachment]? } + struct Payload: Encodable, Sendable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]?; let attachments: [PartialAttachment]? } let body = Payload(content: content, embeds: embeds, components: components, attachments: attachments) return try await http.patchMultipart(path: "/channels/\(channelId)/messages/\(messageId)", jsonBody: body, files: files) } @@ -204,7 +265,7 @@ public final class DiscordClient { } public func editOriginalInteractionResponse(applicationId: ApplicationID, interactionToken: String, content: String? = nil, embeds: [Embed]? = nil, components: [MessageComponent]? = nil) async throws -> Message { - struct Body: Encodable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]? } + struct Body: Encodable, Sendable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]? } return try await http.patch(path: "/webhooks/\(applicationId)/\(interactionToken)/messages/@original", body: Body(content: content, embeds: embeds, components: components)) } @@ -213,14 +274,14 @@ public final class DiscordClient { } public func createFollowupMessage(applicationId: ApplicationID, interactionToken: String, content: String? = nil, embeds: [Embed]? = nil, components: [MessageComponent]? = nil, ephemeral: Bool = false) async throws -> Message { - struct Body: Encodable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]?; let flags: Int? } + struct Body: Encodable, Sendable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]?; let flags: Int? } let flags = ephemeral ? 64 : nil return try await http.post(path: "/webhooks/\(applicationId)/\(interactionToken)", body: Body(content: content, embeds: embeds, components: components, flags: flags)) } /// Create a follow-up message with file attachments (multipart). Returns the created `Message` when `wait=true`. public func createFollowupMessageWithFiles(applicationId: ApplicationID, interactionToken: String, content: String? = nil, embeds: [Embed]? = nil, components: [MessageComponent]? = nil, files: [FileAttachment]) async throws -> Message { - struct Body: Encodable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]? } + struct Body: Encodable, Sendable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]? } // Use the webhook endpoint and request a returned message with wait=true return try await http.postMultipart(path: "/webhooks/\(applicationId)/\(interactionToken)?wait=true", jsonBody: Body(content: content, embeds: embeds, components: components), files: files) } @@ -235,7 +296,7 @@ public final class DiscordClient { } public func editFollowupMessage(applicationId: ApplicationID, interactionToken: String, messageId: MessageID, content: String? = nil, embeds: [Embed]? = nil, components: [MessageComponent]? = nil) async throws -> Message { - struct Body: Encodable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]? } + struct Body: Encodable, Sendable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]? } return try await http.patch(path: "/webhooks/\(applicationId)/\(interactionToken)/messages/\(messageId)", body: Body(content: content, embeds: embeds, components: components)) } @@ -245,7 +306,7 @@ public final class DiscordClient { // MARK: - Localization helpers (Application Commands) public func setCommandLocalizations(applicationId: ApplicationID, commandId: ApplicationCommandID, nameLocalizations: [String: String]?, descriptionLocalizations: [String: String]?) async throws -> ApplicationCommand { - struct Body: Encodable { let name_localizations: [String: String]?; let description_localizations: [String: String]? } + struct Body: Encodable, Sendable { let name_localizations: [String: String]?; let description_localizations: [String: String]? } return try await http.patch(path: "/applications/\(applicationId)/commands/\(commandId)", body: Body(name_localizations: nameLocalizations, description_localizations: descriptionLocalizations)) } @@ -331,7 +392,7 @@ public final class DiscordClient { } public func modifyGuildWidgetSettings(guildId: GuildID, enabled: Bool, channelId: ChannelID?) async throws -> GuildWidgetSettings { - struct Body: Encodable { let enabled: Bool; let channel_id: ChannelID? } + struct Body: Encodable, Sendable { let enabled: Bool; let channel_id: ChannelID? } return try await http.patch(path: "/guilds/\(guildId)/widget", body: Body(enabled: enabled, channel_id: channelId)) } @@ -345,12 +406,12 @@ public final class DiscordClient { } public func createGuildEmoji(guildId: GuildID, name: String, image: String, roles: [RoleID]? = nil) async throws -> Emoji { - struct Body: Encodable { let name: String; let image: String; let roles: [RoleID]? } + struct Body: Encodable, Sendable { let name: String; let image: String; let roles: [RoleID]? } return try await http.post(path: "/guilds/\(guildId)/emojis", body: Body(name: name, image: image, roles: roles)) } public func modifyGuildEmoji(guildId: GuildID, emojiId: EmojiID, name: String? = nil, roles: [RoleID]? = nil) async throws -> Emoji { - struct Body: Encodable { let name: String?; let roles: [RoleID]? } + struct Body: Encodable, Sendable { let name: String?; let roles: [RoleID]? } return try await http.patch(path: "/guilds/\(guildId)/emojis/\(emojiId)", body: Body(name: name, roles: roles)) } @@ -361,7 +422,7 @@ public final class DiscordClient { // MARK: - REST: Guild Member Advanced Operations // Add guild member (OAuth2 access token) public func addGuildMember(guildId: GuildID, userId: UserID, accessToken: String, nick: String? = nil, roles: [RoleID]? = nil, mute: Bool? = nil, deaf: Bool? = nil) async throws -> GuildMember { - struct Body: Encodable { let access_token: String; let nick: String?; let roles: [RoleID]?; let mute: Bool?; let deaf: Bool? } + struct Body: Encodable, Sendable { let access_token: String; let nick: String?; let roles: [RoleID]?; let mute: Bool?; let deaf: Bool? } return try await http.put(path: "/guilds/\(guildId)/members/\(userId)", body: Body(access_token: accessToken, nick: nick, roles: roles, mute: mute, deaf: deaf)) } @@ -381,14 +442,14 @@ public final class DiscordClient { banner: String? = nil, bio: String? = nil ) async throws -> GuildMember { - struct Body: Encodable { let nick: String?; let avatar: String?; let banner: String?; let bio: String? } + struct Body: Encodable, Sendable { let nick: String?; let avatar: String?; let banner: String?; let bio: String? } return try await http.patch(path: "/guilds/\(guildId)/members/@me", body: Body(nick: nick, avatar: avatar, banner: banner, bio: bio)) } // Modify current user nickname (deprecated but still available) public func modifyCurrentUserNick(guildId: GuildID, nick: String?) async throws -> String { - struct Body: Encodable { let nick: String? } - struct Resp: Decodable { let nick: String } + struct Body: Encodable, Sendable { let nick: String? } + struct Resp: Decodable, Sendable { let nick: String } let resp: Resp = try await http.patch(path: "/guilds/\(guildId)/members/@me/nick", body: Body(nick: nick)) return resp.nick } @@ -416,7 +477,7 @@ public final class DiscordClient { // Modify current user public func modifyCurrentUser(username: String? = nil, avatar: String? = nil) async throws -> User { - struct Body: Encodable { let username: String?; let avatar: String? } + struct Body: Encodable, Sendable { let username: String?; let avatar: String? } return try await http.patch(path: "/users/@me", body: Body(username: username, avatar: avatar)) } @@ -436,19 +497,39 @@ public final class DiscordClient { // Create DM channel public func createDM(recipientId: UserID) async throws -> Channel { - struct Body: Encodable { let recipient_id: UserID } + struct Body: Encodable, Sendable { let recipient_id: UserID } return try await http.post(path: "/users/@me/channels", body: Body(recipient_id: recipientId)) } + /// Open a DM channel with `userId` and send a message in one call. + /// + /// Internally calls `createDM(recipientId:)` followed by `sendMessage(...)`. + /// - Returns: The sent `Message`. + @discardableResult + public func sendDM( + userId: UserID, + content: String? = nil, + embeds: [Embed]? = nil, + components: [MessageComponent]? = nil + ) async throws -> Message { + let dm = try await createDM(recipientId: userId) + return try await sendMessage( + channelId: dm.id, + content: content, + embeds: embeds, + components: components + ) + } + // Create group DM public func createGroupDM(accessTokens: [String], nicks: [UserID: String]) async throws -> Channel { - struct Body: Encodable { let access_tokens: [String]; let nicks: [UserID: String] } + struct Body: Encodable, Sendable { let access_tokens: [String]; let nicks: [UserID: String] } return try await http.post(path: "/users/@me/channels", body: Body(access_tokens: accessTokens, nicks: nicks)) } // Guild prune (typed) - public struct PrunePayload: Codable { public let days: Int; public let compute_prune_count: Bool?; public let include_roles: [RoleID]? } - public struct PruneResponse: Codable { public let pruned: Int } + public struct PrunePayload: Codable, Sendable { public let days: Int; public let compute_prune_count: Bool?; public let include_roles: [RoleID]? } + public struct PruneResponse: Codable, Sendable { public let pruned: Int } public func getGuildPruneCount(guildId: GuildID, days: Int = 7) async throws -> Int { let resp: PruneResponse = try await http.get(path: "/guilds/\(guildId)/prune?days=\(days)") @@ -465,13 +546,23 @@ public final class DiscordClient { } public func bulkModifyRolePositions(guildId: GuildID, positions: [(id: RoleID, position: Int)]) async throws -> [Role] { - struct Entry: Encodable { let id: RoleID; let position: Int } + struct Entry: Encodable, Sendable { let id: RoleID; let position: Int } let body = positions.map { Entry(id: $0.id, position: $0.position) } return try await http.patch(path: "/guilds/\(guildId)/roles", body: body) } public func loginAndConnect(intents: GatewayIntents) async throws { + if let vc = self.voiceClient { + vc.setOnFrame { [weak self] frame in + guard let self else { return } + Task { [frame] in + if let cb = await self.onVoiceFrame { + await cb(frame) + } + } + } + } try await gateway.connect(intents: intents, shard: nil, eventSink: { [weak self] event in guard let self = self else { return } Task { await self.dispatcher.process(event: event, client: self) } @@ -480,6 +571,16 @@ public final class DiscordClient { // Sharded connect helper public func loginAndConnectSharded(index: Int, total: Int, intents: GatewayIntents) async throws { + if let vc = self.voiceClient { + vc.setOnFrame { [weak self] frame in + guard let self else { return } + Task { [frame] in + if let cb = await self.onVoiceFrame { + await cb(frame) + } + } + } + } try await gateway.connect(intents: intents, shard: (index, total), eventSink: { [weak self] event in guard let self = self else { return } Task { await self.dispatcher.process(event: event, client: self) } @@ -491,13 +592,13 @@ public final class DiscordClient { } public func sendMessage(channelId: ChannelID, content: String) async throws -> Message { - struct Body: Encodable { let content: String } + struct Body: Encodable, Sendable { let content: String } return try await http.post(path: "/channels/\(channelId)/messages", body: Body(content: content)) } // Overload: send message with embeds public func sendMessage(channelId: ChannelID, content: String? = nil, embeds: [Embed]) async throws -> Message { - struct Body: Encodable { let content: String?; let embeds: [Embed] } + struct Body: Encodable, Sendable { let content: String?; let embeds: [Embed] } return try await http.post(path: "/channels/\(channelId)/messages", body: Body(content: content, embeds: embeds)) } @@ -515,7 +616,7 @@ public final class DiscordClient { attachments: [PartialAttachment]? = nil, poll: Poll? = nil ) async throws -> Message { - struct Body: Encodable { + struct Body: Encodable, Sendable { let content: String? let embeds: [Embed]? let components: [MessageComponent]? @@ -544,7 +645,7 @@ public final class DiscordClient { /// End a poll attached to a message (closes voting). public func endPoll(channelId: ChannelID, messageId: MessageID, pollId: String) async throws -> Poll { - struct Empty: Encodable {} + struct Empty: Encodable, Sendable {} return try await http.post(path: "/channels/\(channelId)/messages/\(messageId)/polls/\(pollId)/expire", body: Empty()) } @@ -623,9 +724,9 @@ public final class DiscordClient { // MARK: - Raw REST passthroughs (coverage helper) public func rawGET(_ path: String) async throws -> T { try await http.get(path: path) } - public func rawPOST(_ path: String, body: B) async throws -> T { try await http.post(path: path, body: body) } - public func rawPATCH(_ path: String, body: B) async throws -> T { try await http.patch(path: path, body: body) } - public func rawPUT(_ path: String, body: B) async throws -> T { try await http.put(path: path, body: body) } + public func rawPOST(_ path: String, body: B) async throws -> T { try await http.post(path: path, body: body) } + public func rawPATCH(_ path: String, body: B) async throws -> T { try await http.patch(path: path, body: body) } + public func rawPUT(_ path: String, body: B) async throws -> T { try await http.put(path: path, body: body) } public func rawDELETE(_ path: String) async throws -> T { try await http.delete(path: path) } // MARK: - Generic Application-scoped helpers (for userApps/appEmoji and future endpoints) @@ -693,13 +794,13 @@ public final class DiscordClient { } public func modifyChannelName(id: ChannelID, name: String) async throws -> Channel { - struct Body: Encodable { let name: String } + struct Body: Encodable, Sendable { let name: String } return try await http.patch(path: "/channels/\(id)", body: Body(name: name)) } // Broader channel modify helper public func modifyChannel(id: ChannelID, topic: String? = nil, nsfw: Bool? = nil, position: Int? = nil, parentId: ChannelID? = nil) async throws -> Channel { - struct Body: Encodable { let topic: String?; let nsfw: Bool?; let position: Int?; let parent_id: ChannelID? } + struct Body: Encodable, Sendable { let topic: String?; let nsfw: Bool?; let position: Int?; let parent_id: ChannelID? } return try await http.patch(path: "/channels/\(id)", body: Body(topic: topic, nsfw: nsfw, position: position, parent_id: parentId)) } @@ -714,7 +815,7 @@ public final class DiscordClient { // Message edit (content and/or embeds) public func editMessage(channelId: ChannelID, messageId: MessageID, content: String? = nil, embeds: [Embed]? = nil, components: [MessageComponent]? = nil) async throws -> Message { - struct Body: Encodable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]? } + struct Body: Encodable, Sendable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]? } return try await http.patch(path: "/channels/\(channelId)/messages/\(messageId)", body: Body(content: content, embeds: embeds, components: components)) } @@ -724,6 +825,34 @@ public final class DiscordClient { } // MARK: - Message Reactions + + /// Typed emoji reference for reaction methods. + /// + /// Use `.unicode("👍")` for standard Unicode emoji and + /// `.custom(name:id:)` for guild custom emoji. + /// + /// ```swift + /// try await client.addReaction(channelId: cid, messageId: mid, emoji: .unicode("🔥")) + /// try await client.addReaction(channelId: cid, messageId: mid, emoji: .custom(name: "pepega", id: emojiId)) + /// ``` + public enum EmojiRef: Sendable { + /// A standard Unicode emoji, e.g. `"👍"` or `"🔥"`. + case unicode(String) + /// A custom guild emoji. `name` is the emoji name and `id` is its snowflake. + case custom(name: String, id: EmojiID) + + /// The percent-encoded string Discord expects in reaction URL paths. + public var encoded: String { + switch self { + case .unicode(let char): + return char.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? char + case .custom(let name, let id): + let raw = "\(name):\(id)" + return raw.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? raw + } + } + } + private func encodeEmoji(_ emoji: String) -> String { emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? emoji } @@ -764,6 +893,34 @@ public final class DiscordClient { try await http.delete(path: "/channels/\(channelId)/messages/\(messageId)/reactions/\(e)") } + // MARK: Typed EmojiRef reaction overloads + + /// Add a reaction using a typed ``EmojiRef``. + public func addReaction(channelId: ChannelID, messageId: MessageID, emoji: EmojiRef) async throws { + try await http.put(path: "/channels/\(channelId)/messages/\(messageId)/reactions/\(emoji.encoded)/@me") + } + + /// Remove the bot's own reaction using a typed ``EmojiRef``. + public func removeOwnReaction(channelId: ChannelID, messageId: MessageID, emoji: EmojiRef) async throws { + try await http.delete(path: "/channels/\(channelId)/messages/\(messageId)/reactions/\(emoji.encoded)/@me") + } + + /// Remove another user's reaction using a typed ``EmojiRef``. + public func removeUserReaction(channelId: ChannelID, messageId: MessageID, emoji: EmojiRef, userId: UserID) async throws { + try await http.delete(path: "/channels/\(channelId)/messages/\(messageId)/reactions/\(emoji.encoded)/\(userId)") + } + + /// Fetch all users who reacted with a typed ``EmojiRef``. + public func getReactions(channelId: ChannelID, messageId: MessageID, emoji: EmojiRef, limit: Int? = 25) async throws -> [User] { + let q = limit != nil ? "?limit=\(limit!)" : "" + return try await http.get(path: "/channels/\(channelId)/messages/\(messageId)/reactions/\(emoji.encoded)\(q)") + } + + /// Remove all reactions for a typed ``EmojiRef``. + public func removeAllReactionsForEmoji(channelId: ChannelID, messageId: MessageID, emoji: EmojiRef) async throws { + try await http.delete(path: "/channels/\(channelId)/messages/\(messageId)/reactions/\(emoji.encoded)") + } + // MARK: - Phase 2 REST: Guilds public func getGuild(id: GuildID) async throws -> Guild { try await http.get(path: "/guilds/\(id)") @@ -785,7 +942,7 @@ public final class DiscordClient { // Create/delete channels public func createGuildChannel(guildId: GuildID, name: String, type: Int? = nil, topic: String? = nil, nsfw: Bool? = nil, parentId: ChannelID? = nil, position: Int? = nil) async throws -> Channel { - struct Body: Encodable { let name: String; let type: Int?; let topic: String?; let nsfw: Bool?; let parent_id: ChannelID?; let position: Int? } + struct Body: Encodable, Sendable { let name: String; let type: Int?; let topic: String?; let nsfw: Bool?; let parent_id: ChannelID?; let position: Int? } return try await http.post(path: "/guilds/\(guildId)/channels", body: Body(name: name, type: type, topic: topic, nsfw: nsfw, parent_id: parentId, position: position)) } @@ -795,7 +952,7 @@ public final class DiscordClient { // Bulk modify channel positions (guild) public func bulkModifyGuildChannelPositions(guildId: GuildID, positions: [(id: ChannelID, position: Int)]) async throws -> [Channel] { - struct Entry: Encodable { let id: ChannelID; let position: Int } + struct Entry: Encodable, Sendable { let id: ChannelID; let position: Int } let body = positions.map { Entry(id: $0.id, position: $0.position) } return try await http.patch(path: "/guilds/\(guildId)/channels", body: body) } @@ -803,8 +960,8 @@ public final class DiscordClient { // Channel permission overwrites // type: 0 = role, 1 = member public func editChannelPermission(channelId: ChannelID, overwriteId: OverwriteID, type: Int, allow: String? = nil, deny: String? = nil) async throws { - struct Body: Encodable { let allow: String?; let deny: String?; let type: Int } - struct EmptyDecodable: Decodable {} + struct Body: Encodable, Sendable { let allow: String?; let deny: String?; let type: Int } + struct EmptyDecodable: Decodable, Sendable {} let _: EmptyDecodable = try await http.put(path: "/channels/\(channelId)/permissions/\(overwriteId)", body: Body(allow: allow, deny: deny, type: type)) } @@ -814,8 +971,8 @@ public final class DiscordClient { // Channel typing indicator public func triggerTypingIndicator(channelId: ChannelID) async throws { - struct Empty: Encodable {} - struct EmptyDecodable: Decodable {} + struct Empty: Encodable, Sendable {} + struct EmptyDecodable: Decodable, Sendable {} let _: EmptyDecodable = try await http.post(path: "/channels/\(channelId)/typing", body: Empty()) } @@ -830,8 +987,8 @@ public final class DiscordClient { try await http.get(path: "/guilds/\(guildId)/roles/\(roleId)") } - public struct RoleCreate: Codable { public let name: String; public let permissions: String?; public let color: Int?; public let hoist: Bool?; public let icon: String?; public let unicode_emoji: String?; public let mentionable: Bool? } - public struct RoleUpdate: Codable { public let name: String?; public let permissions: String?; public let color: Int?; public let hoist: Bool?; public let icon: String?; public let unicode_emoji: String?; public let mentionable: Bool? } + public struct RoleCreate: Codable, Sendable { public let name: String; public let permissions: String?; public let color: Int?; public let hoist: Bool?; public let icon: String?; public let unicode_emoji: String?; public let mentionable: Bool? } + public struct RoleUpdate: Codable, Sendable { public let name: String?; public let permissions: String?; public let color: Int?; public let hoist: Bool?; public let icon: String?; public let unicode_emoji: String?; public let mentionable: Bool? } public func modifyRole(guildId: GuildID, roleId: RoleID, payload: RoleUpdate) async throws -> Role { try await http.patch(path: "/guilds/\(guildId)/roles/\(roleId)", body: payload) @@ -847,7 +1004,7 @@ public final class DiscordClient { // Application Command default permissions (perms v2 related) public func setApplicationCommandDefaultPermissions(applicationId: ApplicationID, commandId: ApplicationCommandID, defaultMemberPermissions: String?) async throws -> ApplicationCommand { - struct Body: Encodable { let default_member_permissions: String? } + struct Body: Encodable, Sendable { let default_member_permissions: String? } return try await http.patch(path: "/applications/\(applicationId)/commands/\(commandId)", body: Body(default_member_permissions: defaultMemberPermissions)) } @@ -870,12 +1027,12 @@ public final class DiscordClient { } public func banMember(guildId: GuildID, userId: UserID, deleteMessageDays: Int? = nil, reason: String? = nil) async throws { - struct Body: Encodable { let delete_message_days: Int? } + struct Body: Encodable, Sendable { let delete_message_days: Int? } var path = "/guilds/\(guildId)/bans/\(userId)" if let reason, let encoded = reason.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { path += "?reason=\(encoded)" } - struct EmptyResponse: Decodable {} + struct EmptyResponse: Decodable, Sendable {} let _: EmptyResponse = try await http.put(path: path, body: Body(delete_message_days: deleteMessageDays)) } @@ -884,7 +1041,7 @@ public final class DiscordClient { } public func createGuildBan(guildId: GuildID, userId: UserID, deleteMessageSeconds: Int? = nil) async throws { - struct Empty: Encodable {} + struct Empty: Encodable, Sendable {} var path = "/guilds/\(guildId)/bans/\(userId)" if let s = deleteMessageSeconds { path += "?delete_message_seconds=\(s)" } let _: ApplicationCommand = try await http.put(path: path, body: Empty()) @@ -896,13 +1053,13 @@ public final class DiscordClient { public func modifyGuildMember(guildId: GuildID, userId: UserID, nick: String? = nil, roles: [RoleID]? = nil) async throws -> GuildMember { - struct Body: Encodable { let nick: String?; let roles: [RoleID]? } + struct Body: Encodable, Sendable { let nick: String?; let roles: [RoleID]? } return try await http.patch(path: "/guilds/\(guildId)/members/\(userId)", body: Body(nick: nick, roles: roles)) } // Timeout (communication_disabled_until) public func setMemberTimeout(guildId: GuildID, userId: UserID, until date: Date) async throws -> GuildMember { - struct Body: Encodable { + struct Body: Encodable, Sendable { let communication_disabled_until: String } let iso = ISO8601DateFormatter() @@ -912,13 +1069,13 @@ public final class DiscordClient { } public func clearMemberTimeout(guildId: GuildID, userId: UserID) async throws -> GuildMember { - struct Body: Encodable { let communication_disabled_until: String? } + struct Body: Encodable, Sendable { let communication_disabled_until: String? } return try await http.patch(path: "/guilds/\(guildId)/members/\(userId)", body: Body(communication_disabled_until: nil)) } // Guild settings public func modifyGuild(guildId: GuildID, name: String? = nil, verificationLevel: Int? = nil, defaultMessageNotifications: Int? = nil, systemChannelId: ChannelID? = nil, explicitContentFilter: Int? = nil) async throws -> Guild { - struct Body: Encodable { + struct Body: Encodable, Sendable { let name: String? let verification_level: Int? let default_message_notifications: Int? @@ -950,7 +1107,7 @@ public final class DiscordClient { autoArchiveDuration: Int? = nil, rateLimitPerUser: Int? = nil ) async throws -> Channel { - struct Body: Encodable { let name: String; let auto_archive_duration: Int?; let rate_limit_per_user: Int? } + struct Body: Encodable, Sendable { let name: String; let auto_archive_duration: Int?; let rate_limit_per_user: Int? } let body = Body(name: name, auto_archive_duration: autoArchiveDuration, rate_limit_per_user: rateLimitPerUser) return try await http.post(path: "/channels/\(channelId)/messages/\(messageId)/threads", body: body) } @@ -964,7 +1121,7 @@ public final class DiscordClient { invitable: Bool? = nil, rateLimitPerUser: Int? = nil ) async throws -> Channel { - struct Body: Encodable { let name: String; let auto_archive_duration: Int?; let type: Int?; let invitable: Bool?; let rate_limit_per_user: Int? } + struct Body: Encodable, Sendable { let name: String; let auto_archive_duration: Int?; let type: Int?; let invitable: Bool?; let rate_limit_per_user: Int? } let body = Body(name: name, auto_archive_duration: autoArchiveDuration, type: type, invitable: invitable, rate_limit_per_user: rateLimitPerUser) return try await http.post(path: "/channels/\(channelId)/threads", body: body) } @@ -989,6 +1146,19 @@ public final class DiscordClient { try await http.delete(path: "/channels/\(channelId)/thread-members/\(userId)") } + /// Archive (and optionally lock) a thread channel. + /// + /// - Parameters: + /// - channelId: The thread channel ID to archive. + /// - locked: When `true`, only members with `MANAGE_THREADS` can unarchive the thread. + /// Defaults to `false`. + /// - Returns: The updated ``Channel`` object. + @discardableResult + public func archiveThread(channelId: ChannelID, locked: Bool = false) async throws -> Channel { + struct Body: Encodable, Sendable { let archived: Bool = true; let locked: Bool } + return try await http.patch(path: "/channels/\(channelId)", body: Body(locked: locked)) + } + // Get thread member public func getThreadMember(channelId: ChannelID, userId: UserID, withMember: Bool = false) async throws -> ThreadMember { let q = withMember ? "?with_member=true" : "" @@ -1040,18 +1210,18 @@ public final class DiscordClient { // MARK: - Phase 2 REST: Interactions // Minimal interaction response helper (type 4 = ChannelMessageWithSource) public func createInteractionResponse(interactionId: InteractionID, token: String, content: String) async throws { - struct DataObj: Encodable { let content: String } - struct Body: Encodable { let type: Int = 4; let data: DataObj } - struct Ack: Decodable {} + struct DataObj: Encodable, Sendable { let content: String } + struct Body: Encodable, Sendable { let type: Int = 4; let data: DataObj } + struct Ack: Decodable, Sendable {} let body = Body(data: DataObj(content: content)) let _: Ack = try await http.post(path: "/interactions/\(interactionId)/\(token)/callback", body: body) } // Overload: interaction response with embeds public func createInteractionResponse(interactionId: InteractionID, token: String, content: String? = nil, embeds: [Embed]) async throws { - struct DataObj: Encodable { let content: String?; let embeds: [Embed] } - struct Body: Encodable { let type: Int = 4; let data: DataObj } - struct Ack: Decodable {} + struct DataObj: Encodable, Sendable { let content: String?; let embeds: [Embed] } + struct Body: Encodable, Sendable { let type: Int = 4; let data: DataObj } + struct Ack: Decodable, Sendable {} let body = Body(data: DataObj(content: content, embeds: embeds)) let _: Ack = try await http.post(path: "/interactions/\(interactionId)/\(token)/callback", body: body) } @@ -1069,25 +1239,25 @@ public final class DiscordClient { } public func createInteractionResponse(interactionId: InteractionID, token: String, type: InteractionResponseType, content: String? = nil, embeds: [Embed]? = nil) async throws { - struct DataObj: Encodable { let content: String?; let embeds: [Embed]? } - struct Body: Encodable { let type: Int; let data: DataObj? } - struct Ack: Decodable {} + struct DataObj: Encodable, Sendable { let content: String?; let embeds: [Embed]? } + struct Body: Encodable, Sendable { let type: Int; let data: DataObj? } + struct Ack: Decodable, Sendable {} let data = (content == nil && embeds == nil) ? nil : DataObj(content: content, embeds: embeds) let body = Body(type: type.rawValue, data: data) let _: Ack = try await http.post(path: "/interactions/\(interactionId)/\(token)/callback", body: body) } // Autocomplete result helper (type 8) - public struct AutocompleteChoice: Codable { + public struct AutocompleteChoice: Codable, Sendable { public let name: String public let value: String public init(name: String, value: String) { self.name = name; self.value = value } } public func createAutocompleteResponse(interactionId: InteractionID, token: String, choices: [AutocompleteChoice]) async throws { - struct DataObj: Encodable { let choices: [AutocompleteChoice] } - struct Body: Encodable { let type: Int; let data: DataObj } - struct Ack: Decodable {} + struct DataObj: Encodable, Sendable { let choices: [AutocompleteChoice] } + struct Body: Encodable, Sendable { let type: Int; let data: DataObj } + struct Ack: Decodable, Sendable {} let body = Body(type: InteractionResponseType.autocompleteResult.rawValue, data: DataObj(choices: choices)) let _: Ack = try await http.post(path: "/interactions/\(interactionId)/\(token)/callback", body: body) } @@ -1100,9 +1270,9 @@ public final class DiscordClient { customId: String, components: [MessageComponent] ) async throws { - struct DataObj: Encodable { let custom_id: String; let title: String; let components: [MessageComponent] } - struct Body: Encodable { let type: Int; let data: DataObj } - struct Ack: Decodable {} + struct DataObj: Encodable, Sendable { let custom_id: String; let title: String; let components: [MessageComponent] } + struct Body: Encodable, Sendable { let type: Int; let data: DataObj } + struct Ack: Decodable, Sendable {} let body = Body(type: InteractionResponseType.modal.rawValue, data: DataObj(custom_id: customId, title: title, components: components)) let _: Ack = try await http.post(path: "/interactions/\(interactionId)/\(token)/callback", body: body) } @@ -1113,15 +1283,15 @@ public final class DiscordClient { } // MARK: - Phase 4: Slash Commands (minimal) - public struct ApplicationCommand: Codable { + public struct ApplicationCommand: Codable, Sendable { public let id: ApplicationCommandID public let application_id: ApplicationID public let name: String public let description: String } - public struct ApplicationCommandOption: Codable { - public enum ApplicationCommandOptionType: Int, Codable { + public struct ApplicationCommandOption: Codable, Sendable { + public enum ApplicationCommandOptionType: Int, Codable, Sendable { case subCommand = 1 case subCommandGroup = 2 case string = 3 @@ -1138,7 +1308,7 @@ public final class DiscordClient { public let name: String public let description: String public let required: Bool? - public struct Choice: Codable { public let name: String; public let value: String } + public struct Choice: Codable, Sendable { public let name: String; public let value: String } public let choices: [Choice]? public init(type: ApplicationCommandOptionType, name: String, description: String, required: Bool? = nil, choices: [Choice]? = nil) { self.type = type @@ -1149,7 +1319,7 @@ public final class DiscordClient { } } - public struct ApplicationCommandCreate: Encodable { + public struct ApplicationCommandCreate: Encodable, Sendable { public let name: String public let description: String public let options: [ApplicationCommandOption]? @@ -1166,25 +1336,25 @@ public final class DiscordClient { public func createGlobalCommand(name: String, description: String) async throws -> ApplicationCommand { let appId = try await getCurrentUser().id - struct Body: Encodable { let name: String; let description: String } + struct Body: Encodable, Sendable { let name: String; let description: String } return try await http.post(path: "/applications/\(appId)/commands", body: Body(name: name, description: description)) } public func createGuildCommand(guildId: GuildID, name: String, description: String) async throws -> ApplicationCommand { let appId = try await getCurrentUser().id - struct Body: Encodable { let name: String; let description: String } + struct Body: Encodable, Sendable { let name: String; let description: String } return try await http.post(path: "/applications/\(appId)/guilds/\(guildId)/commands", body: Body(name: name, description: description)) } public func createGlobalCommand(name: String, description: String, options: [ApplicationCommandOption]) async throws -> ApplicationCommand { let appId = try await getCurrentUser().id - struct Body: Encodable { let name: String; let description: String; let options: [ApplicationCommandOption] } + struct Body: Encodable, Sendable { let name: String; let description: String; let options: [ApplicationCommandOption] } return try await http.post(path: "/applications/\(appId)/commands", body: Body(name: name, description: description, options: options)) } public func createGuildCommand(guildId: GuildID, name: String, description: String, options: [ApplicationCommandOption]) async throws -> ApplicationCommand { let appId = try await getCurrentUser().id - struct Body: Encodable { let name: String; let description: String; let options: [ApplicationCommandOption] } + struct Body: Encodable, Sendable { let name: String; let description: String; let options: [ApplicationCommandOption] } return try await http.post(path: "/applications/\(appId)/guilds/\(guildId)/commands", body: Body(name: name, description: description, options: options)) } @@ -1228,14 +1398,52 @@ public final class DiscordClient { return try await http.put(path: "/applications/\(appId)/guilds/\(guildId)/commands", body: commands) } + /// Sync the desired application commands with Discord. + /// + /// Fetches the currently registered commands, compares name sets, and only + /// calls `bulkOverwrite` when there is a difference (new commands, deleted + /// commands, or a name change). This avoids unnecessary API writes during + /// repeated bot restarts. + /// + /// - Parameters: + /// - desired: The full list of commands you want registered. + /// - guildId: Target guild for guild-scoped commands, or `nil` for global commands. + /// - Returns: The commands now registered with Discord. + @discardableResult + public func syncCommands( + _ desired: [ApplicationCommandCreate], + guildId: GuildID? = nil + ) async throws -> [ApplicationCommand] { + let existing: [ApplicationCommand] + if let guildId { + existing = try await listGuildCommands(guildId: guildId) + } else { + existing = try await listGlobalCommands() + } + + let existingNames = Set(existing.map(\.name).sorted()) + let desiredNames = Set(desired.map(\.name).sorted()) + + guard existingNames != desiredNames else { + // No structural change — return what Discord already has. + return existing + } + + if let guildId { + return try await bulkOverwriteGuildCommands(guildId: guildId, desired) + } else { + return try await bulkOverwriteGlobalCommands(desired) + } + } + // MARK: - Phase 2 REST: Webhooks public func createWebhook(channelId: ChannelID, name: String) async throws -> Webhook { - struct Body: Encodable { let name: String } + struct Body: Encodable, Sendable { let name: String } return try await http.post(path: "/channels/\(channelId)/webhooks", body: Body(name: name)) } public func createWebhook(channelId: ChannelID, name: String, avatar: String?) async throws -> Webhook { - struct Body: Encodable { let name: String; let avatar: String? } + struct Body: Encodable, Sendable { let name: String; let avatar: String? } return try await http.post(path: "/channels/\(channelId)/webhooks", body: Body(name: name, avatar: avatar)) } @@ -1252,7 +1460,7 @@ public final class DiscordClient { } public func modifyWebhook(webhookId: WebhookID, name: String? = nil, avatar: String? = nil, channelId: ChannelID? = nil) async throws -> Webhook { - struct Body: Encodable { let name: String?; let avatar: String?; let channel_id: ChannelID? } + struct Body: Encodable, Sendable { let name: String?; let avatar: String?; let channel_id: ChannelID? } return try await http.patch(path: "/webhooks/\(webhookId)", body: Body(name: name, avatar: avatar, channel_id: channelId)) } @@ -1261,12 +1469,12 @@ public final class DiscordClient { } public func executeWebhook(webhookId: WebhookID, token: String, content: String) async throws -> Message { - struct Body: Encodable { let content: String } + struct Body: Encodable, Sendable { let content: String } return try await http.post(path: "/webhooks/\(webhookId)/\(token)", body: Body(content: content)) } public func executeWebhook(webhookId: WebhookID, token: String, content: String? = nil, username: String? = nil, avatarUrl: String? = nil, embeds: [Embed]? = nil, wait: Bool = false) async throws -> Message? { - struct Body: Encodable { + struct Body: Encodable, Sendable { let content: String? let username: String? let avatar_url: String? @@ -1277,7 +1485,7 @@ public final class DiscordClient { if wait { return try await http.post(path: "/webhooks/\(webhookId)/\(token)\(waitParam)", body: body) } else { - struct EmptyResponse: Decodable {} + struct EmptyResponse: Decodable, Sendable {} let _: EmptyResponse = try await http.post(path: "/webhooks/\(webhookId)/\(token)", body: body) return nil } @@ -1295,7 +1503,7 @@ public final class DiscordClient { roleIds: [RoleID]? = nil, targetUsersFile: FileAttachment? = nil ) async throws -> Invite { - struct Body: Encodable { + struct Body: Encodable, Sendable { let max_age: Int? let max_uses: Int? let temporary: Bool? @@ -1347,7 +1555,7 @@ public final class DiscordClient { /// 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 {} + struct Empty: Encodable, Sendable {} return try await http.patchMultipart(path: "/invites/\(code)/users", jsonBody: Empty(), files: [file]) } @@ -1366,17 +1574,17 @@ public final class DiscordClient { } public func createGuildTemplate(guildId: GuildID, name: String, description: String? = nil) async throws -> Template { - struct Body: Encodable { let name: String; let description: String? } + struct Body: Encodable, Sendable { let name: String; let description: String? } return try await http.post(path: "/guilds/\(guildId)/templates", body: Body(name: name, description: description)) } public func modifyGuildTemplate(guildId: GuildID, code: String, name: String? = nil, description: String? = nil) async throws -> Template { - struct Body: Encodable { let name: String?; let description: String? } + struct Body: Encodable, Sendable { let name: String?; let description: String? } return try await http.patch(path: "/guilds/\(guildId)/templates/\(code)", body: Body(name: name, description: description)) } public func syncGuildTemplate(guildId: GuildID, code: String) async throws -> Template { - struct Empty: Encodable {} + struct Empty: Encodable, Sendable {} return try await http.put(path: "/guilds/\(guildId)/templates/\(code)", body: Empty()) } @@ -1390,7 +1598,7 @@ public final class DiscordClient { } public func listStickerPacks() async throws -> [StickerPack] { - struct Packs: Decodable { let sticker_packs: [StickerPack] } + struct Packs: Decodable, Sendable { let sticker_packs: [StickerPack] } let resp: Packs = try await http.get(path: "/sticker-packs") return resp.sticker_packs } @@ -1404,13 +1612,13 @@ public final class DiscordClient { } public func createGuildSticker(guildId: GuildID, name: String, description: String? = nil, tags: String, file: FileAttachment) async throws -> Sticker { - struct Payload: Encodable { let name: String; let description: String?; let tags: String } + struct Payload: Encodable, Sendable { let name: String; let description: String?; let tags: String } let payload = Payload(name: name, description: description, tags: tags) return try await http.postMultipart(path: "/guilds/\(guildId)/stickers", jsonBody: payload, files: [file]) } public func modifyGuildSticker(guildId: GuildID, stickerId: StickerID, name: String? = nil, description: String? = nil, tags: String? = nil) async throws -> Sticker { - struct Payload: Encodable { let name: String?; let description: String?; let tags: String? } + struct Payload: Encodable, Sendable { let name: String?; let description: String?; let tags: String? } return try await http.patch(path: "/guilds/\(guildId)/stickers/\(stickerId)", body: Payload(name: name, description: description, tags: tags)) } @@ -1429,8 +1637,8 @@ public final class DiscordClient { autoArchiveDuration: Int? = nil, rateLimitPerUser: Int? = nil ) async throws -> Channel { - struct Msg: Encodable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]? } - struct Body: Encodable { + struct Msg: Encodable, Sendable { let content: String?; let embeds: [Embed]?; let components: [MessageComponent]? } + struct Body: Encodable, Sendable { let name: String let auto_archive_duration: Int? let rate_limit_per_user: Int? @@ -1486,7 +1694,7 @@ public final class DiscordClient { exemptRoles: [RoleID]? = nil, exemptChannels: [ChannelID]? = nil ) async throws -> AutoModerationRule { - struct Body: Encodable { + struct Body: Encodable, Sendable { let name: String let event_type: Int let trigger_type: Int @@ -1520,7 +1728,7 @@ public final class DiscordClient { exemptRoles: [RoleID]? = nil, exemptChannels: [ChannelID]? = nil ) async throws -> AutoModerationRule { - struct Body: Encodable { + struct Body: Encodable, Sendable { let name: String? let event_type: Int? let trigger_metadata: AutoModerationRule.TriggerMetadata? @@ -1562,7 +1770,7 @@ public final class DiscordClient { description: String? = nil, entityMetadata: GuildScheduledEvent.EntityMetadata? = nil ) async throws -> GuildScheduledEvent { - struct Body: Encodable { + struct Body: Encodable, Sendable { let channel_id: ChannelID? let entity_type: Int let name: String @@ -1603,7 +1811,7 @@ public final class DiscordClient { status: GuildScheduledEvent.Status? = nil, entityMetadata: GuildScheduledEvent.EntityMetadata? = nil ) async throws -> GuildScheduledEvent { - struct Body: Encodable { + struct Body: Encodable, Sendable { let channel_id: ChannelID? let entity_type: Int? let name: String? @@ -1652,7 +1860,7 @@ public final class DiscordClient { // MARK: - REST: Stage Instances public func createStageInstance(channelId: ChannelID, topic: String, privacyLevel: Int = 2, guildScheduledEventId: GuildScheduledEventID? = nil) async throws -> StageInstance { - struct Body: Encodable { + struct Body: Encodable, Sendable { let channel_id: ChannelID let topic: String let privacy_level: Int @@ -1667,7 +1875,7 @@ public final class DiscordClient { } public func modifyStageInstance(channelId: ChannelID, topic: String? = nil, privacyLevel: Int? = nil) async throws -> StageInstance { - struct Body: Encodable { let topic: String?; let privacy_level: Int? } + struct Body: Encodable, Sendable { let topic: String?; let privacy_level: Int? } return try await http.patch(path: "/stage-instances/\(channelId)", body: Body(topic: topic, privacy_level: privacyLevel)) } @@ -1689,7 +1897,7 @@ public final class DiscordClient { } public func updateUserApplicationRoleConnection(applicationId: ApplicationID, platformName: String? = nil, platformUsername: String? = nil, metadata: [String: String] = [:]) async throws -> ApplicationRoleConnection { - struct Body: Encodable { + struct Body: Encodable, Sendable { let platformName: String? let platformUsername: String? let metadata: [String: String] @@ -1711,7 +1919,7 @@ public final class DiscordClient { } public func createSoundboardSound(guildId: GuildID, name: String, emojiId: EmojiID? = nil, emojiName: String? = nil, volume: Double? = nil, sound: FileAttachment) async throws -> SoundboardSound { - struct Payload: Encodable { + struct Payload: Encodable, Sendable { let name: String let emoji_id: EmojiID? let emoji_name: String? @@ -1722,7 +1930,7 @@ public final class DiscordClient { } public func modifySoundboardSound(guildId: GuildID, soundId: SoundboardSoundID, name: String? = nil, emojiId: EmojiID? = nil, emojiName: String? = nil, volume: Double? = nil) async throws -> SoundboardSound { - struct Payload: Encodable { let name: String?; let emoji_id: EmojiID?; let emoji_name: String?; let volume: Double? } + struct Payload: Encodable, Sendable { let name: String?; let emoji_id: EmojiID?; let emoji_name: String?; let volume: Double? } return try await http.patch(path: "/guilds/\(guildId)/soundboard-sounds/\(soundId)", body: Payload(name: name, emoji_id: emojiId, emoji_name: emojiName, volume: volume)) } @@ -1757,7 +1965,7 @@ public final class DiscordClient { /// Create a test entitlement for validation in non-production contexts. public func createTestEntitlement(applicationId: ApplicationID, skuId: SKUID, ownerId: String, ownerType: Int = 1) async throws -> Entitlement { - struct Body: Encodable { let sku_id: SKUID; let owner_id: String; let owner_type: Int } + struct Body: Encodable, Sendable { let sku_id: SKUID; let owner_id: String; let owner_type: Int } return try await http.post(path: "/applications/\(applicationId)/entitlements", body: Body(sku_id: skuId, owner_id: ownerId, owner_type: ownerType)) } @@ -1784,7 +1992,7 @@ public final class DiscordClient { mode: Int, defaultRecommendationChannelIds: [ChannelID]? = nil ) async throws -> Onboarding { - struct Body: Encodable { + struct Body: Encodable, Sendable { let prompts: [OnboardingPrompt] let default_channel_ids: [ChannelID] let enabled: Bool diff --git a/Sources/SwiftDisc/Gateway/GatewayClient.swift b/Sources/SwiftDisc/Gateway/GatewayClient.swift index 0cd6853..6e7129f 100644 --- a/Sources/SwiftDisc/Gateway/GatewayClient.swift +++ b/Sources/SwiftDisc/Gateway/GatewayClient.swift @@ -39,7 +39,7 @@ actor GatewayClient { private var status: Status = .disconnected private var lastIntents: GatewayIntents = [] - private var lastEventSink: ((DiscordEvent) -> Void)? + private var lastEventSink: (@Sendable (DiscordEvent) -> Void)? private var lastShard: (index: Int, total: Int)? init(token: String, configuration: DiscordConfiguration) { @@ -61,7 +61,7 @@ actor GatewayClient { } } - func connect(intents: GatewayIntents, shard: (index: Int, total: Int)? = nil, eventSink: @escaping (DiscordEvent) -> Void) async throws { + func connect(intents: GatewayIntents, shard: (index: Int, total: Int)? = nil, eventSink: @escaping @Sendable (DiscordEvent) -> Void) async throws { guard let url = URL(string: "\(configuration.gatewayBaseURL.absoluteString)?v=\(configuration.apiVersion)&encoding=json") else { throw DiscordError.gateway("Invalid gateway URL") } @@ -123,7 +123,7 @@ actor GatewayClient { } } - private func readLoop(eventSink: @escaping (DiscordEvent) -> Void) async { + private func readLoop(eventSink: @escaping @Sendable (DiscordEvent) -> Void) async { guard let socket = self.socket else { return } let dec = JSONDecoder() while true { @@ -366,15 +366,15 @@ actor GatewayClient { if let payload = try? dec.decode(GatewayPayload.self, from: data), let ev = payload.d { eventSink(.pollVoteRemove(ev)) } - } else if t == "SOUND_BOARD_SOUND_CREATE" { + } else if t == "SOUNDBOARD_SOUND_CREATE" { if let payload = try? dec.decode(GatewayPayload.self, from: data), let ev = payload.d { eventSink(.soundboardSoundCreate(ev)) } - } else if t == "SOUND_BOARD_SOUND_UPDATE" { + } else if t == "SOUNDBOARD_SOUND_UPDATE" { if let payload = try? dec.decode(GatewayPayload.self, from: data), let ev = payload.d { eventSink(.soundboardSoundUpdate(ev)) } - } else if t == "SOUND_BOARD_SOUND_DELETE" { + } else if t == "SOUNDBOARD_SOUND_DELETE" { if let payload = try? dec.decode(GatewayPayload.self, from: data), let ev = payload.d { eventSink(.soundboardSoundDelete(ev)) } diff --git a/Sources/SwiftDisc/Gateway/GatewayModels.swift b/Sources/SwiftDisc/Gateway/GatewayModels.swift index 30c2d31..07f1ee5 100644 --- a/Sources/SwiftDisc/Gateway/GatewayModels.swift +++ b/Sources/SwiftDisc/Gateway/GatewayModels.swift @@ -1,10 +1,10 @@ import Foundation -public struct GatewayHello: Codable { +public struct GatewayHello: Codable, Sendable { public let heartbeat_interval: Int } -public struct ThreadMembersUpdate: Codable, Hashable { +public struct ThreadMembersUpdate: Codable, Hashable, Sendable { public let id: ChannelID public let guild_id: GuildID public let member_count: Int @@ -12,20 +12,20 @@ public struct ThreadMembersUpdate: Codable, Hashable { public let removed_member_ids: [UserID]? } -public struct VoiceState: Codable, Hashable { +public struct VoiceState: Codable, Hashable, Sendable { public let guild_id: GuildID? public let channel_id: ChannelID? public let user_id: UserID public let session_id: String } -public struct VoiceServerUpdate: Codable, Hashable { +public struct VoiceServerUpdate: Codable, Hashable, Sendable { public let token: String public let guild_id: GuildID public let endpoint: String? } -public enum GatewayOpcode: Int, Codable { +public enum GatewayOpcode: Int, Codable, Sendable { case dispatch = 0 case heartbeat = 1 case presenceUpdate = 3 @@ -45,8 +45,9 @@ public struct GatewayPayload: Codable { public let s: Int? public let t: String? } +extension GatewayPayload: Sendable where D: Sendable {} -public enum DiscordEvent: Hashable { +public enum DiscordEvent: Hashable, Sendable { case ready(ReadyEvent) case messageCreate(Message) case messageUpdate(Message) @@ -117,19 +118,19 @@ public enum DiscordEvent: Hashable { case entitlementDelete(Entitlement) } -public struct MessageDelete: Codable, Hashable { +public struct MessageDelete: Codable, Hashable, Sendable { public let id: MessageID public let channel_id: ChannelID public let guild_id: GuildID? } -public struct MessageDeleteBulk: Codable, Hashable { +public struct MessageDeleteBulk: Codable, Hashable, Sendable { public let ids: [MessageID] public let channel_id: ChannelID public let guild_id: GuildID? } -public struct MessageReactionAdd: Codable, Hashable { +public struct MessageReactionAdd: Codable, Hashable, Sendable { public let user_id: UserID public let channel_id: ChannelID public let message_id: MessageID @@ -138,7 +139,7 @@ public struct MessageReactionAdd: Codable, Hashable { public let emoji: PartialEmoji } -public struct MessageReactionRemove: Codable, Hashable { +public struct MessageReactionRemove: Codable, Hashable, Sendable { public let user_id: UserID public let channel_id: ChannelID public let message_id: MessageID @@ -146,27 +147,27 @@ public struct MessageReactionRemove: Codable, Hashable { public let emoji: PartialEmoji } -public struct MessageReactionRemoveAll: Codable, Hashable { +public struct MessageReactionRemoveAll: Codable, Hashable, Sendable { public let channel_id: ChannelID public let message_id: MessageID public let guild_id: GuildID? } -public struct MessageReactionRemoveEmoji: Codable, Hashable { +public struct MessageReactionRemoveEmoji: Codable, Hashable, Sendable { public let channel_id: ChannelID public let message_id: MessageID public let guild_id: GuildID? public let emoji: PartialEmoji } -public struct ReadyEvent: Codable, Hashable { +public struct ReadyEvent: Codable, Hashable, Sendable { public let user: User public let session_id: String? } // Note: Guild model lives in Sources/SwiftDisc/Models/Guild.swift -public struct GuildDelete: Codable, Hashable { +public struct GuildDelete: Codable, Hashable, Sendable { public let id: GuildID public let unavailable: Bool? } @@ -174,7 +175,7 @@ public struct GuildDelete: Codable, Hashable { // Note: Interaction model lives in Sources/SwiftDisc/Models/Interaction.swift // MARK: - Guild Member Events -public struct GuildMemberAdd: Codable, Hashable { +public struct GuildMemberAdd: Codable, Hashable, Sendable { public let guild_id: GuildID public let user: User public let nick: String? @@ -188,12 +189,12 @@ public struct GuildMemberAdd: Codable, Hashable { public let permissions: String? } -public struct GuildMemberRemove: Codable, Hashable { +public struct GuildMemberRemove: Codable, Hashable, Sendable { public let guild_id: GuildID public let user: User } -public struct GuildMemberUpdate: Codable, Hashable { +public struct GuildMemberUpdate: Codable, Hashable, Sendable { public let guild_id: GuildID public let user: User public let nick: String? @@ -203,37 +204,37 @@ public struct GuildMemberUpdate: Codable, Hashable { } // MARK: - Role CRUD Events -public struct GuildRoleCreate: Codable, Hashable { +public struct GuildRoleCreate: Codable, Hashable, Sendable { public let guild_id: GuildID public let role: Role } -public struct GuildRoleUpdate: Codable, Hashable { +public struct GuildRoleUpdate: Codable, Hashable, Sendable { public let guild_id: GuildID public let role: Role } -public struct GuildRoleDelete: Codable, Hashable { +public struct GuildRoleDelete: Codable, Hashable, Sendable { public let guild_id: GuildID public let role_id: RoleID } // MARK: - Emoji / Sticker Update -public struct GuildEmojisUpdate: Codable, Hashable { +public struct GuildEmojisUpdate: Codable, Hashable, Sendable { public let guild_id: GuildID public let emojis: [Emoji] } -public struct GuildStickersUpdate: Codable, Hashable { +public struct GuildStickersUpdate: Codable, Hashable, Sendable { public let guild_id: GuildID public let stickers: [Sticker] } // MARK: - Request/Receive Guild Members -public struct RequestGuildMembers: Codable, Hashable { +public struct RequestGuildMembers: Codable, Hashable, Sendable { public let op: Int = 8 public let d: Payload - public struct Payload: Codable, Hashable { + public struct Payload: Codable, Hashable, Sendable { public let guild_id: GuildID public let query: String? public let limit: Int? @@ -243,9 +244,9 @@ public struct RequestGuildMembers: Codable, Hashable { } } -public struct Presence: Codable, Hashable {} +public struct Presence: Codable, Hashable, Sendable {} -public struct GuildMembersChunk: Codable, Hashable { +public struct GuildMembersChunk: Codable, Hashable, Sendable { public let guild_id: GuildID public let members: [GuildMember] public let chunk_index: Int @@ -255,7 +256,7 @@ public struct GuildMembersChunk: Codable, Hashable { public let nonce: String? } -public struct IdentifyPayload: Codable { +public struct IdentifyPayload: Codable, Sendable { public let token: String public let intents: UInt64 public let properties: IdentifyConnectionProperties @@ -273,7 +274,7 @@ public struct IdentifyPayload: Codable { } } -public struct IdentifyConnectionProperties: Codable { +public struct IdentifyConnectionProperties: Codable, Sendable { public let os: String public let browser: String public let device: String @@ -304,18 +305,18 @@ public struct IdentifyConnectionProperties: Codable { public typealias HeartbeatPayload = Int? -public struct ResumePayload: Codable { +public struct ResumePayload: Codable, Sendable { public let token: String public let session_id: String public let seq: Int } -public struct PresenceUpdatePayload: Codable { - public struct Activity: Codable, Hashable { - public struct Timestamps: Codable, Hashable { public let start: Int64?; public let end: Int64? } - public struct Assets: Codable, Hashable { public let large_image: String?; public let large_text: String?; public let small_image: String?; public let small_text: String? } - public struct Party: Codable, Hashable { public let id: String?; public let size: [Int]? } - public struct Secrets: Codable, Hashable { public let join: String?; public let spectate: String?; public let match: String? } +public struct PresenceUpdatePayload: Codable, Sendable { + public struct Activity: Codable, Hashable, Sendable { + public struct Timestamps: Codable, Hashable, Sendable { public let start: Int64?; public let end: Int64? } + public struct Assets: Codable, Hashable, Sendable { public let large_image: String?; public let large_text: String?; public let small_image: String?; public let small_text: String? } + public struct Party: Codable, Hashable, Sendable { public let id: String?; public let size: [Int]? } + public struct Secrets: Codable, Hashable, Sendable { public let join: String?; public let spectate: String?; public let match: String? } public let name: String public let type: Int public let state: String? @@ -347,7 +348,7 @@ public struct PresenceUpdatePayload: Codable { self.secrets = secrets } } - public struct Data: Codable { + public struct Data: Codable, Sendable { public let since: Int? public let activities: [Activity] public let status: String @@ -358,7 +359,7 @@ public struct PresenceUpdatePayload: Codable { // MARK: - New Gateway Events (v1.1.0) -public struct TypingStart: Codable, Hashable { +public struct TypingStart: Codable, Hashable, Sendable { public let channel_id: ChannelID public let guild_id: GuildID? public let user_id: UserID @@ -366,46 +367,46 @@ public struct TypingStart: Codable, Hashable { public let member: GuildMember? } -public struct ChannelPinsUpdate: Codable, Hashable { +public struct ChannelPinsUpdate: Codable, Hashable, Sendable { public let guild_id: GuildID? public let channel_id: ChannelID public let last_pin_timestamp: String? } -public struct PresenceUpdate: Codable, Hashable { +public struct PresenceUpdate: Codable, Hashable, Sendable { public let user: User public let guild_id: GuildID public let status: String public let activities: [PresenceUpdatePayload.Activity] public let client_status: ClientStatus - public struct ClientStatus: Codable, Hashable { + public struct ClientStatus: Codable, Hashable, Sendable { public let desktop: String? public let mobile: String? public let web: String? } } -public struct GuildBanAdd: Codable, Hashable { +public struct GuildBanAdd: Codable, Hashable, Sendable { public let guild_id: GuildID public let user: User } -public struct GuildBanRemove: Codable, Hashable { +public struct GuildBanRemove: Codable, Hashable, Sendable { public let guild_id: GuildID public let user: User } -public struct WebhooksUpdate: Codable, Hashable { +public struct WebhooksUpdate: Codable, Hashable, Sendable { public let guild_id: GuildID public let channel_id: ChannelID } -public struct GuildIntegrationsUpdate: Codable, Hashable { +public struct GuildIntegrationsUpdate: Codable, Hashable, Sendable { public let guild_id: GuildID } -public struct InviteCreate: Codable, Hashable { +public struct InviteCreate: Codable, Hashable, Sendable { public let channel_id: ChannelID public let code: String public let created_at: String @@ -419,7 +420,7 @@ public struct InviteCreate: Codable, Hashable { public let temporary: Bool public let uses: Int - public struct PartialApplication: Codable, Hashable { + public struct PartialApplication: Codable, Hashable, Sendable { public let id: ApplicationID public let name: String public let icon: String? @@ -427,7 +428,7 @@ public struct InviteCreate: Codable, Hashable { } } -public struct InviteDelete: Codable, Hashable { +public struct InviteDelete: Codable, Hashable, Sendable { public let channel_id: ChannelID public let guild_id: GuildID? public let code: String @@ -435,7 +436,7 @@ public struct InviteDelete: Codable, Hashable { // MARK: - Auto Moderation -public struct AutoModerationActionExecution: Codable, Hashable { +public struct AutoModerationActionExecution: Codable, Hashable, Sendable { public let guild_id: GuildID public let action: AutoModerationRule.Action public let rule_id: AutoModerationRuleID @@ -451,14 +452,14 @@ public struct AutoModerationActionExecution: Codable, Hashable { // MARK: - Audit Log -public struct GuildAuditLogEntryCreate: Codable, Hashable { +public struct GuildAuditLogEntryCreate: Codable, Hashable, Sendable { public let guild_id: GuildID public let entry: AuditLogEntry } // MARK: - Poll Votes -public struct PollVote: Codable, Hashable { +public struct PollVote: Codable, Hashable, Sendable { public let user_id: UserID public let channel_id: ChannelID public let guild_id: GuildID? @@ -468,7 +469,7 @@ public struct PollVote: Codable, Hashable { // MARK: - Soundboard -public struct SoundboardSound: Codable, Hashable { +public struct SoundboardSound: Codable, Hashable, Sendable { public let id: SoundboardSoundID public let guild_id: GuildID? public let name: String diff --git a/Sources/SwiftDisc/Gateway/Intents.swift b/Sources/SwiftDisc/Gateway/Intents.swift index b409a2a..5473762 100644 --- a/Sources/SwiftDisc/Gateway/Intents.swift +++ b/Sources/SwiftDisc/Gateway/Intents.swift @@ -1,6 +1,6 @@ import Foundation -public struct GatewayIntents: OptionSet, Codable, Hashable { +public struct GatewayIntents: OptionSet, Codable, Hashable, Sendable { public let rawValue: UInt64 public init(rawValue: UInt64) { self.rawValue = rawValue } diff --git a/Sources/SwiftDisc/Gateway/WebSocket.swift b/Sources/SwiftDisc/Gateway/WebSocket.swift index 4307f03..e0f33a6 100644 --- a/Sources/SwiftDisc/Gateway/WebSocket.swift +++ b/Sources/SwiftDisc/Gateway/WebSocket.swift @@ -3,18 +3,20 @@ import Foundation import FoundationNetworking #endif -enum WebSocketMessage { +enum WebSocketMessage: Sendable { case string(String) case data(Data) } -protocol WebSocketClient { +/// A type-erased WebSocket connection. All conforming types must be safe to use +/// across actor/task boundaries (`Sendable`). +protocol WebSocketClient: Sendable { func send(_ message: WebSocketMessage) async throws func receive() async throws -> WebSocketMessage func close() async } -final class URLSessionWebSocketAdapter: WebSocketClient { +final class URLSessionWebSocketAdapter: WebSocketClient, @unchecked Sendable { private let task: URLSessionWebSocketTask private let session: URLSession @@ -55,7 +57,7 @@ final class URLSessionWebSocketAdapter: WebSocketClient { } } -final class UnavailableWebSocketAdapter: WebSocketClient { +final class UnavailableWebSocketAdapter: WebSocketClient, Sendable { func send(_ message: WebSocketMessage) async throws { throw DiscordError.gateway("WebSocket unavailable on this platform") } func receive() async throws -> WebSocketMessage { throw DiscordError.gateway("WebSocket unavailable on this platform") } func close() async { } diff --git a/Sources/SwiftDisc/HighLevel/AutocompleteRouter.swift b/Sources/SwiftDisc/HighLevel/AutocompleteRouter.swift index 68315d5..bdf53bf 100644 --- a/Sources/SwiftDisc/HighLevel/AutocompleteRouter.swift +++ b/Sources/SwiftDisc/HighLevel/AutocompleteRouter.swift @@ -1,12 +1,20 @@ import Foundation -public final class AutocompleteRouter { - public struct Context { +/// Routes autocomplete interactions to per-option providers. +/// Declared as an `actor` so provider registration and dispatch are +/// data-race free across concurrent tasks. +public actor AutocompleteRouter { + /// Per-invocation context provided to every autocomplete provider. + public struct Context: Sendable { public let client: DiscordClient public let interaction: Interaction + /// The resolved command path (shares logic with `SlashCommandRouter`). public let path: String + /// The name of the currently-focused option, if any. public let focusedOption: String? + /// The current partial value typed by the user, if any. public let focusedValue: String? + public init(client: DiscordClient, interaction: Interaction) { self.client = client self.interaction = interaction @@ -17,7 +25,7 @@ public final class AutocompleteRouter { if let opts = interaction.data?.options { func walk(_ options: [Interaction.ApplicationCommandData.Option]) { for o in options { - if let t = o.type, t == 1 || t == 2 { // subcommand/group + if let t = o.type, t == 1 || t == 2 { walk(o.options ?? []) } else if o.focused == true { fName = o.name @@ -32,28 +40,38 @@ public final class AutocompleteRouter { } } - public typealias Provider = (Context) async throws -> [DiscordClient.AutocompleteChoice] + /// The async, Sendable provider type that returns autocomplete choices. + public typealias Provider = @Sendable (Context) async throws -> [DiscordClient.AutocompleteChoice] - private var providers: [String: Provider] = [:] // key: "path|option" + /// key: "path|option" + private var providers: [String: Provider] = [:] public init() {} + /// Register an autocomplete provider for a specific command path + option name. public func register(path: String, option: String, provider: @escaping Provider) { providers[AutocompleteRouter.key(path: path, option: option)] = provider } + /// Dispatch an autocomplete interaction to the matching provider. public func handle(interaction: Interaction, client: DiscordClient) async { let ctx = Context(client: client, interaction: interaction) guard let opt = ctx.focusedOption else { return } - let key = AutocompleteRouter.key(path: ctx.path, option: opt) - guard let provider = providers[key] else { return } + let k = AutocompleteRouter.key(path: ctx.path, option: opt) + guard let provider = providers[k] else { return } do { let choices = try await provider(ctx) - try await client.createAutocompleteResponse(interactionId: interaction.id, token: interaction.token, choices: choices) + try await client.createAutocompleteResponse( + interactionId: interaction.id, + token: interaction.token, + choices: choices + ) } catch { - // swallow autocomplete errors to avoid noisy logs for users + // Autocomplete errors are silenced to avoid noisy logs during typing } } - private static func key(path: String, option: String) -> String { path.lowercased() + "|" + option.lowercased() } + nonisolated private static func key(path: String, option: String) -> String { + path.lowercased() + "|" + option.lowercased() + } } diff --git a/Sources/SwiftDisc/HighLevel/Collectors.swift b/Sources/SwiftDisc/HighLevel/Collectors.swift index 3b0755a..54523b0 100644 --- a/Sources/SwiftDisc/HighLevel/Collectors.swift +++ b/Sources/SwiftDisc/HighLevel/Collectors.swift @@ -8,7 +8,7 @@ public extension DiscordClient { /// - timeout: optional timeout after which the stream finishes /// - maxMessages: optional maximum number of messages to collect /// - filter: predicate to decide whether to yield a message - func createMessageCollector(channelId: ChannelID? = nil, timeout: TimeInterval? = nil, maxMessages: Int? = nil, filter: @escaping (Message) -> Bool = { _ in true }) -> AsyncStream { + func createMessageCollector(channelId: ChannelID? = nil, timeout: TimeInterval? = nil, maxMessages: Int? = nil, filter: @escaping @Sendable (Message) -> Bool = { _ in true }) -> AsyncStream { AsyncStream { continuation in var collected = 0 let task = Task { @@ -66,4 +66,88 @@ public extension DiscordClient { } } } + + // MARK: - Typed Event Stream Helpers + + /// A filtered `AsyncStream` that yields only incoming `Message` objects. + /// + /// Equivalent to listening to `events` and matching `.messageCreate`, but + /// without any boilerplate switch statement. + /// ```swift + /// for await message in await client.messageEvents() { + /// print(message.content ?? "") + /// } + /// ``` + func messageEvents() -> AsyncStream { + AsyncStream { continuation in + Task { + for await event in self.events { + if case .messageCreate(let msg) = event { continuation.yield(msg) } + } + continuation.finish() + } + } + } + + /// A filtered `AsyncStream` that yields every `MessageReactionAdd` event. + func reactionAddEvents() -> AsyncStream { + AsyncStream { continuation in + Task { + for await event in self.events { + if case .messageReactionAdd(let ev) = event { continuation.yield(ev) } + } + continuation.finish() + } + } + } + + /// A filtered `AsyncStream` that yields every incoming `Interaction`. + /// + /// Useful for bots that handle interactions outside of `SlashCommandRouter`. + func interactionEvents() -> AsyncStream { + AsyncStream { continuation in + Task { + for await event in self.events { + if case .interactionCreate(let interaction) = event { continuation.yield(interaction) } + } + continuation.finish() + } + } + } + + /// A filtered `AsyncStream` that yields `GuildMemberAdd` events. + func memberAddEvents() -> AsyncStream { + AsyncStream { continuation in + Task { + for await event in self.events { + if case .guildMemberAdd(let ev) = event { continuation.yield(ev) } + } + continuation.finish() + } + } + } + + /// A filtered `AsyncStream` that yields `GuildMemberRemove` events. + func memberRemoveEvents() -> AsyncStream { + AsyncStream { continuation in + Task { + for await event in self.events { + if case .guildMemberRemove(let ev) = event { continuation.yield(ev) } + } + continuation.finish() + } + } + } + + /// A filtered `AsyncStream` that yields `PresenceUpdate` events. + func presenceUpdateEvents() -> AsyncStream { + AsyncStream { continuation in + Task { + for await event in self.events { + if case .presenceUpdate(let ev) = event { continuation.yield(ev) } + } + continuation.finish() + } + } + } } diff --git a/Sources/SwiftDisc/HighLevel/CommandRouter.swift b/Sources/SwiftDisc/HighLevel/CommandRouter.swift index 21d0952..6970a42 100644 --- a/Sources/SwiftDisc/HighLevel/CommandRouter.swift +++ b/Sources/SwiftDisc/HighLevel/CommandRouter.swift @@ -1,9 +1,28 @@ import Foundation -public final class CommandRouter { - public typealias Handler = (Context) async throws -> Void +/// Routes prefix-based text commands. Declared as an `actor` so handler +/// registration and dispatch are data-race free across concurrent tasks. +public actor CommandRouter { + /// The async, Sendable handler type invoked when a command matches. + public typealias Handler = @Sendable (Context) async throws -> Void - public struct Context { + /// Middleware type. A sendable closure that receives the context and a `next` + /// handler. Call `try await next(ctx)` to continue the chain, or + /// throw / return early to halt further processing. + /// + /// ```swift + /// router.use { ctx, next in + /// guard ctx.isAdmin else { + /// try await ctx.message.reply(client: ctx.client, content: "🚫 Admins only.") + /// return + /// } + /// try await next(ctx) + /// } + /// ``` + public typealias Middleware = @Sendable (Context, @escaping Handler) async throws -> Void + + /// Per-invocation context provided to every command handler. + public struct Context: Sendable { public let client: DiscordClient public let message: Message public let args: [String] @@ -12,8 +31,31 @@ public final class CommandRouter { self.message = message self.args = args } + + // MARK: - Permission helpers + + /// Returns `true` if the message author has the given raw permission bit set. + /// + /// Uses `member.permissions`, which Discord provides in guild message events. + /// Returns `false` for DMs (no member attached) or if the field is absent. + public func hasPermission(_ bit: UInt64) -> Bool { + guard let permStr = message.member?.permissions, + let permInt = UInt64(permStr) else { return false } + return (permInt & bit) != 0 + } + + /// Returns `true` if the member holds the `ADMINISTRATOR` permission (`1 << 3`). + /// + /// Administrators bypass all channel-level permission overwrites. + public var isAdmin: Bool { hasPermission(1 << 3) } + + /// Returns `true` if the member has the specified role. + public func memberHasRole(_ roleId: RoleID) -> Bool { + message.member?.roles.contains(roleId) ?? false + } } + /// Metadata exposed via `listCommands()`. public struct CommandMeta: Sendable { public let name: String public let description: String @@ -22,22 +64,36 @@ public final class CommandRouter { private var prefix: String private var handlers: [String: Handler] = [:] private var metadata: [String: CommandMeta] = [:] - public var onError: ((Error, Context) -> Void)? + private var middlewares: [Middleware] = [] + /// Optional error handler invoked when a command handler throws. + public var onError: (@Sendable (Error, Context) -> Void)? public init(prefix: String = "!") { self.prefix = prefix } + /// Update the command prefix at runtime. public func use(prefix: String) { self.prefix = prefix } + /// Register a middleware to run before every command handler. + /// + /// Middlewares execute in registration order. Each middleware **must** call + /// `next(ctx)` to proceed to the next middleware (or the final handler). + /// Omitting the call acts as an early-exit / guard. + public func use(_ middleware: @escaping Middleware) { + middlewares.append(middleware) + } + + /// Register a command name (case-insensitive) with a handler. public func register(_ name: String, description: String = "", handler: @escaping Handler) { let key = name.lowercased() handlers[key] = handler metadata[key] = CommandMeta(name: name, description: description) } + /// Check whether a message is a command and dispatch it. public func handleIfCommand(message: Message, client: DiscordClient) async { guard let content = message.content, !content.isEmpty else { return } guard content.hasPrefix(prefix) else { return } @@ -46,18 +102,28 @@ public final class CommandRouter { guard let cmd = parts.first?.lowercased() else { return } let args = Array(parts.dropFirst()) guard let handler = handlers[cmd] else { return } + let ctx = Context(client: client, message: message, args: args) do { - try await handler(Context(client: client, message: message, args: args)) + // Build the middleware chain from back to front so the first registered + // middleware is the outermost wrapper. + var chain: Handler = handler + for mw in middlewares.reversed() { + let next = chain + let m = mw + chain = { @Sendable ctx in try await m(ctx, next) } + } + try await chain(ctx) } catch { - let ctx = Context(client: client, message: message, args: args) if let onError { onError(error, ctx) } } } + /// Return all registered commands sorted alphabetically. public func listCommands() -> [CommandMeta] { metadata.values.sorted { $0.name < $1.name } } + /// Generate a human-readable help string listing all commands. public func helpText(header: String = "Available commands:") -> String { let lines = listCommands().map { meta in if meta.description.isEmpty { return "\(prefix)\(meta.name)" } diff --git a/Sources/SwiftDisc/HighLevel/CooldownManager.swift b/Sources/SwiftDisc/HighLevel/CooldownManager.swift index 9b1b6ab..607f6e3 100644 --- a/Sources/SwiftDisc/HighLevel/CooldownManager.swift +++ b/Sources/SwiftDisc/HighLevel/CooldownManager.swift @@ -1,12 +1,15 @@ import Foundation /// Simple cooldown manager keyed by command name + key (user/guild/global). -final class CooldownManager { +/// Declared as a `public actor` so concurrent accesses from multiple commands are +/// data-race free without any manual locking. Use it anywhere in your bot code. +public actor CooldownManager { private var store: [String: Date] = [:] - private let lock = NSLock() - func isOnCooldown(command: String, key: String) -> Bool { - lock.lock(); defer { lock.unlock() } + public init() {} + + /// Returns `true` if the given command + key combination is still on cooldown. + public func isOnCooldown(command: String, key: String) -> Bool { let compound = compoundKey(command: command, key: key) if let until = store[compound] { return Date() < until @@ -14,12 +17,31 @@ final class CooldownManager { return false } - func setCooldown(command: String, key: String, duration: TimeInterval) { - lock.lock(); defer { lock.unlock() } + /// Returns the remaining cooldown seconds, or `nil` if not on cooldown. + public func remaining(command: String, key: String) -> TimeInterval? { + let compound = compoundKey(command: command, key: key) + guard let until = store[compound] else { return nil } + let remaining = until.timeIntervalSinceNow + return remaining > 0 ? remaining : nil + } + + /// Sets a cooldown for `duration` seconds on the given command + key. + public func setCooldown(command: String, key: String, duration: TimeInterval) { let compound = compoundKey(command: command, key: key) store[compound] = Date().addingTimeInterval(duration) } + /// Clears the cooldown for a specific command + key (e.g. on admin override). + public func clearCooldown(command: String, key: String) { + store.removeValue(forKey: compoundKey(command: command, key: key)) + } + + /// Removes all expired entries. Called automatically but safe to call manually. + public func purgeExpired() { + let now = Date() + store = store.filter { now < $0.value } + } + private func compoundKey(command: String, key: String) -> String { return "\(command)::\(key)" } diff --git a/Sources/SwiftDisc/HighLevel/EmbedBuilder.swift b/Sources/SwiftDisc/HighLevel/EmbedBuilder.swift index 8aa2807..e59d497 100644 --- a/Sources/SwiftDisc/HighLevel/EmbedBuilder.swift +++ b/Sources/SwiftDisc/HighLevel/EmbedBuilder.swift @@ -1,6 +1,19 @@ import Foundation -/// A fluent builder for `Embed` objects. +/// A fluent, value-type builder for Discord `Embed` objects. +/// +/// Chain builder methods to compose an embed, then call ``build()`` to produce +/// the final `Embed` value ready for sending. +/// +/// ```swift +/// let embed = EmbedBuilder() +/// .title("Server Status") +/// .description("All systems operational.") +/// .color(0x57F287) +/// .footer(text: "Last checked") +/// .timestamp(Date()) +/// .build() +/// ``` public struct EmbedBuilder { private var title: String? private var description: String? @@ -13,20 +26,120 @@ public struct EmbedBuilder { private var image: Embed.Image? private var timestamp: String? + /// Creates a new, empty `EmbedBuilder`. public init() {} + /// Sets the embed title. + /// - Parameter t: The title text (max 256 characters per Discord API limits). + /// - Returns: A new builder with the title applied. public func title(_ t: String) -> EmbedBuilder { var c = self; c.title = t; return c } + + /// Sets the embed description body text. + /// - Parameter d: The description text (max 4096 characters per Discord API limits). + /// - Returns: A new builder with the description applied. public func description(_ d: String) -> EmbedBuilder { var c = self; c.description = d; return c } + + /// Sets the URL the embed title links to when clicked. + /// - Parameter u: A valid URL string. + /// - Returns: A new builder with the URL applied. public func url(_ u: String) -> EmbedBuilder { var c = self; c.url = u; return c } + + /// Sets the embed's left-border accent color. + /// - Parameter cval: An RGB integer (e.g. `0x5865F2` for Discord Blurple, `0x57F287` for green). + /// - Returns: A new builder with the color applied. public func color(_ cval: Int) -> EmbedBuilder { var c = self; c.color = cval; return c } - public func footer(text: String, iconURL: String? = nil) -> EmbedBuilder { var c = self; c.footer = .init(text: text, icon_url: iconURL); return c } - public func author(name: String, url: String? = nil, iconURL: String? = nil) -> EmbedBuilder { var c = self; c.author = .init(name: name, url: url, icon_url: iconURL); return c } - public func addField(name: String, value: String, inline: Bool = false) -> EmbedBuilder { var c = self; c.fields.append(.init(name: name, value: value, inline: inline)); return c } - public func thumbnail(url: String) -> EmbedBuilder { var c = self; c.thumbnail = .init(url: url); return c } - public func image(url: String) -> EmbedBuilder { var c = self; c.image = .init(url: url); return c } - public func timestamp(_ iso8601: String) -> EmbedBuilder { var c = self; c.timestamp = iso8601; return c } + /// Sets the embed footer displayed at the bottom of the embed. + /// - Parameters: + /// - text: Footer text (max 2048 characters). + /// - iconURL: Optional direct URL for a small icon displayed beside the footer text. + /// - Returns: A new builder with the footer applied. + public func footer(text: String, iconURL: String? = nil) -> EmbedBuilder { + var c = self; c.footer = .init(text: text, icon_url: iconURL); return c + } + + /// Sets the embed author block displayed above the title. + /// - Parameters: + /// - name: The author's display name (max 256 characters). + /// - url: Optional URL the author name links to when clicked. + /// - iconURL: Optional direct URL for a small icon displayed beside the author name. + /// - Returns: A new builder with the author applied. + public func author(name: String, url: String? = nil, iconURL: String? = nil) -> EmbedBuilder { + var c = self; c.author = .init(name: name, url: url, icon_url: iconURL); return c + } + + /// Appends a field to the embed. + /// + /// Discord renders up to 25 fields per embed. Adjacent inline fields are grouped + /// side-by-side in rows of up to three. + /// + /// - Parameters: + /// - name: The field heading (max 256 characters, required). + /// - value: The field body text (max 1024 characters, required). + /// - inline: When `true`, Discord renders this field side-by-side with adjacent inline fields. + /// - Returns: A new builder with the field appended. + public func addField(name: String, value: String, inline: Bool = false) -> EmbedBuilder { + var c = self; c.fields.append(.init(name: name, value: value, inline: inline)); return c + } + + /// Sets the embed thumbnail — a small image displayed in the top-right corner. + /// - Parameter url: A direct URL to the thumbnail image. + /// - Returns: A new builder with the thumbnail applied. + public func thumbnail(url: String) -> EmbedBuilder { + var c = self; c.thumbnail = .init(url: url); return c + } + + /// Sets the embed's main large image, displayed below the description and fields. + /// - Parameter url: A direct URL to the image. + /// - Returns: A new builder with the image applied. + public func image(url: String) -> EmbedBuilder { + var c = self; c.image = .init(url: url); return c + } + + /// Sets the embed timestamp from a pre-formatted ISO 8601 string. + /// + /// Prefer the `timestamp(_:)` overload that accepts a `Date` when working with + /// Swift date values, as it handles formatting automatically. + /// + /// - Parameter iso8601: An ISO 8601 date-time string, e.g. `"2026-03-05T12:00:00.000Z"`. + /// - Returns: A new builder with the timestamp applied. + public func timestamp(_ iso8601: String) -> EmbedBuilder { + var c = self; c.timestamp = iso8601; return c + } + + /// Sets the embed timestamp from a `Date`, automatically formatting it as ISO 8601. + /// + /// Discord displays this timestamp in the user's local timezone with relative + /// hover text (e.g. "3 hours ago"). + /// + /// - Parameter date: The date to display. Pass `Date()` for the current moment. + /// - 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) + return c + } + + /// Finalizes the builder and returns the composed `Embed`. + /// + /// The returned value is ready to be passed to any Discord API method that + /// accepts embeds, such as `DiscordClient.sendMessage(channelId:embeds:)`. + /// + /// - Returns: A fully composed `Embed` value. public func build() -> Embed { - return Embed(title: title, description: description, url: url, color: color, footer: footer, author: author, fields: fields.isEmpty ? nil : fields, thumbnail: thumbnail, image: image, timestamp: timestamp) + return Embed( + title: title, + description: description, + url: url, + color: color, + footer: footer, + author: author, + fields: fields.isEmpty ? nil : fields, + thumbnail: thumbnail, + image: image, + timestamp: timestamp + ) } } diff --git a/Sources/SwiftDisc/HighLevel/Extensions.swift b/Sources/SwiftDisc/HighLevel/Extensions.swift index 9988780..468c880 100644 --- a/Sources/SwiftDisc/HighLevel/Extensions.swift +++ b/Sources/SwiftDisc/HighLevel/Extensions.swift @@ -1,6 +1,9 @@ import Foundation -public protocol SwiftDiscExtension { +/// Adopt this protocol to create loadable extension modules ("cogs") for a bot. +/// Conforming types must be `Sendable` since they are stored inside the +/// `DiscordClient` actor and dispatched across task boundaries. +public protocol SwiftDiscExtension: Sendable { func onRegister(client: DiscordClient) async func onUnload(client: DiscordClient) async } @@ -10,15 +13,22 @@ public extension SwiftDiscExtension { func onUnload(client: DiscordClient) async {} } +/// A closure-based convenience implementation of `SwiftDiscExtension`. public final class Cog: SwiftDiscExtension { public let name: String - private let registerBlock: (DiscordClient) async -> Void - private let unloadBlock: (DiscordClient) async -> Void - public init(name: String, onRegister: @escaping (DiscordClient) async -> Void, onUnload: @escaping (DiscordClient) async -> Void = { _ in }) { + private let registerBlock: @Sendable (DiscordClient) async -> Void + private let unloadBlock: @Sendable (DiscordClient) async -> Void + + public init( + name: String, + onRegister: @escaping @Sendable (DiscordClient) async -> Void, + onUnload: @escaping @Sendable (DiscordClient) async -> Void = { _ in } + ) { self.name = name self.registerBlock = onRegister self.unloadBlock = onUnload } + public func onRegister(client: DiscordClient) async { await registerBlock(client) } public func onUnload(client: DiscordClient) async { await unloadBlock(client) } } diff --git a/Sources/SwiftDisc/HighLevel/MessagePayload.swift b/Sources/SwiftDisc/HighLevel/MessagePayload.swift new file mode 100644 index 0000000..3c71f53 --- /dev/null +++ b/Sources/SwiftDisc/HighLevel/MessagePayload.swift @@ -0,0 +1,223 @@ +import Foundation + +/// A fluent, composable payload for sending or editing Discord messages. +/// +/// `MessagePayload` consolidates every message-send overload into one type. +/// Build it with chained calls then pass it to `client.send(to:_:)` or +/// `client.edit(channelId:messageId:_:)`. +/// +/// ```swift +/// let payload = MessagePayload() +/// .content("Hello!") +/// .embed(EmbedBuilder().title("World").color(0x5865F2).build()) +/// .component(ActionRowBuilder().add(ButtonBuilder().label("OK").customId("ok").build()).build()) +/// .ephemeral() // marks as ephemeral (interaction-only) +/// +/// try await client.send(to: channelId, payload) +/// ``` +public struct MessagePayload: Sendable { + public var content: String? + public var embeds: [Embed]? + public var components: [MessageComponent]? + public var allowedMentions: AllowedMentions? + public var messageReference: MessageReference? + public var tts: Bool? + public var flags: Int? + public var stickerIds: [StickerID]? + public var files: [FileAttachment]? + + public init() {} + + // MARK: - Content + + /// Set the plain-text message content. + public func content(_ text: String) -> Self { var c = self; c.content = text; return c } + + /// Append a single `Embed` to the message. + public func embed(_ embed: Embed) -> Self { + var c = self + c.embeds = (c.embeds ?? []) + [embed] + return c + } + + /// Set the full list of embeds (replaces any previously added embeds). + public func embeds(_ embeds: [Embed]) -> Self { var c = self; c.embeds = embeds; return c } + + // MARK: - Components + + /// Append a single component (typically an `ActionRow`) to the message. + public func component(_ row: MessageComponent) -> Self { + var c = self + c.components = (c.components ?? []) + [row] + return c + } + + /// Set the full list of component rows (replaces previously added rows). + public func components(_ rows: [MessageComponent]) -> Self { var c = self; c.components = rows; return c } + + // MARK: - Replies & Mentions + + /// Turn this message into a reply to `target`. + /// + /// Sets `message_reference` automatically. Pass `mention: false` to suppress the + /// @-ping on the replied-to author (defaults to `true`). + public func reply(to target: Message, mention: Bool = true) -> Self { + var c = self + c.messageReference = MessageReference( + message_id: target.id, + channel_id: target.channel_id, + guild_id: target.guild_id, + fail_if_not_exists: false + ) + if !mention { + c.allowedMentions = AllowedMentions( + parse: [], + roles: nil, + users: nil, + replied_user: false + ) + } + return c + } + + /// Override the allowed-mentions control object. + public func allowedMentions(_ am: AllowedMentions) -> Self { var c = self; c.allowedMentions = am; return c } + + // MARK: - Flags + + /// 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 + } + + /// Suppress link embeds (Discord flag bit 2). + public func suppressEmbeds() -> Self { + var c = self; c.flags = (c.flags ?? 0) | (1 << 2); 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 + } + + /// Set raw message flags (replaces any previously OR'd flags). + public func flags(_ f: Int) -> Self { var c = self; c.flags = f; return c } + + // MARK: - TTS & Stickers + + /// Send with text-to-speech. + public func tts(_ enabled: Bool = true) -> Self { var c = self; c.tts = enabled; return c } + + /// Attach sticker IDs to the message. + public func stickers(_ ids: [StickerID]) -> Self { var c = self; c.stickerIds = ids; return c } + + // MARK: - Files + + /// Append a single file attachment. + public func file(_ f: FileAttachment) -> Self { + var c = self + c.files = (c.files ?? []) + [f] + return c + } + + /// Set the full list of file attachments (replaces any previously added files). + public func files(_ fs: [FileAttachment]) -> Self { var c = self; c.files = fs; return c } +} + +// MARK: - DiscordClient send / edit convenience + +public extension DiscordClient { + /// Send a `MessagePayload` to a channel, automatically choosing between + /// multipart (when files are present) and JSON requests. + /// + /// ```swift + /// try await client.send(to: channelId, MessagePayload() + /// .content("Hello") + /// .embed(embed) + /// .ephemeral()) + /// ``` + @discardableResult + func send(to channelId: ChannelID, _ payload: MessagePayload) async throws -> Message { + if let files = payload.files, !files.isEmpty { + return try await sendMessageWithFiles( + channelId: channelId, + content: payload.content, + embeds: payload.embeds, + components: payload.components, + tts: payload.tts, + flags: payload.flags, + files: files + ) + } + return try await sendMessage( + channelId: channelId, + content: payload.content, + embeds: payload.embeds, + components: payload.components, + allowedMentions: payload.allowedMentions, + messageReference: payload.messageReference, + tts: payload.tts, + flags: payload.flags, + stickerIds: payload.stickerIds + ) + } + + /// Edit an existing message using a `MessagePayload`. + @discardableResult + func edit(channelId: ChannelID, messageId: MessageID, _ payload: MessagePayload) async throws -> Message { + if let files = payload.files, !files.isEmpty { + return try await editMessageWithFiles( + channelId: channelId, + messageId: messageId, + content: payload.content, + embeds: payload.embeds, + components: payload.components, + files: files + ) + } + return try await editMessage( + channelId: channelId, + messageId: messageId, + content: payload.content, + embeds: payload.embeds, + components: payload.components + ) + } + + /// Respond to an interaction with a `MessagePayload`. + /// + /// Automatically uses `createInteractionResponse` with type 4 (channel message) + /// or type 5 (deferred) when `payload.content` and `.embeds` are both nil. + func respond( + to interaction: Interaction, + with payload: MessagePayload, + deferred: Bool = false + ) async throws { + let type: InteractionResponseType = deferred + ? .deferredChannelMessageWithSource + : .channelMessageWithSource + struct DataObj: Encodable { + let content: String? + let embeds: [Embed]? + let components: [MessageComponent]? + let flags: Int? + let tts: Bool? + let allowed_mentions: AllowedMentions? + } + struct Body: Encodable { let type: Int; let data: DataObj } + let data = DataObj( + content: payload.content, + embeds: payload.embeds, + components: payload.components, + flags: payload.flags, + tts: payload.tts, + allowed_mentions: payload.allowedMentions + ) + struct Ack: Decodable {} + let _: Ack = try await http.post( + path: "/interactions/\(interaction.id)/\(interaction.token)/callback", + body: Body(type: type.rawValue, data: data) + ) + } +} diff --git a/Sources/SwiftDisc/HighLevel/ShardManager.swift b/Sources/SwiftDisc/HighLevel/ShardManager.swift index 71cfe9b..b641ef8 100644 --- a/Sources/SwiftDisc/HighLevel/ShardManager.swift +++ b/Sources/SwiftDisc/HighLevel/ShardManager.swift @@ -1,6 +1,9 @@ import Foundation -public final class ShardManager { +/// A lightweight manager that owns N `DiscordClient` instances, one per shard. +/// Declared as an `actor` so the `clients` array is safely accessible from +/// concurrent contexts. +public actor ShardManager { public let token: String public let totalShards: Int public let configuration: DiscordConfiguration @@ -19,7 +22,8 @@ public final class ShardManager { for (idx, client) in clients.enumerated() { group.addTask { try await client.loginAndConnectSharded(index: idx, total: self.totalShards, intents: intents) - for await _ in client.events { /* keep alive per shard */ } + let eventStream = await client.events + for await _ in eventStream { /* keep shard task alive */ } } } try await group.waitForAll() diff --git a/Sources/SwiftDisc/HighLevel/ShardingGatewayManager.swift b/Sources/SwiftDisc/HighLevel/ShardingGatewayManager.swift index ac0ceab..c0c7d9e 100644 --- a/Sources/SwiftDisc/HighLevel/ShardingGatewayManager.swift +++ b/Sources/SwiftDisc/HighLevel/ShardingGatewayManager.swift @@ -1,6 +1,6 @@ import Foundation -public struct ShardedEvent { +public struct ShardedEvent: Sendable { public let shardId: Int public let event: DiscordEvent public let receivedAt: Date @@ -107,7 +107,7 @@ public actor ShardingGatewayManager { // Logging private enum LogLevel: String { case info = "INFO", warning = "WARN", error = "ERROR", debug = "DEBUG" } - private static let logDateFormatter: ISO8601DateFormatter = { + nonisolated(unsafe) private static let logDateFormatter: ISO8601DateFormatter = { let f = ISO8601DateFormatter() return f }() @@ -117,7 +117,7 @@ public actor ShardingGatewayManager { } // Per-shard - public struct ShardHandle { + public struct ShardHandle: Sendable { public let id: Int fileprivate let client: GatewayClient public func heartbeatLatency() async -> TimeInterval? { await client.heartbeatLatency() } diff --git a/Sources/SwiftDisc/HighLevel/SlashCommandRouter.swift b/Sources/SwiftDisc/HighLevel/SlashCommandRouter.swift index 8a9bdd3..7fb3a1d 100644 --- a/Sources/SwiftDisc/HighLevel/SlashCommandRouter.swift +++ b/Sources/SwiftDisc/HighLevel/SlashCommandRouter.swift @@ -1,11 +1,16 @@ import Foundation -public final class SlashCommandRouter { - public struct Context { +/// Routes application (slash) command interactions. Declared as an `actor` so +/// handler registration and dispatch are data-race free across concurrent tasks. +public actor SlashCommandRouter { + /// Per-invocation context provided to every slash-command handler. + public struct Context: Sendable { public let client: DiscordClient public let interaction: Interaction + /// The resolved command path (e.g. `"admin ban"` for a subcommand). public let path: String private let optionMap: [String: String] + public init(client: DiscordClient, interaction: Interaction) { self.client = client self.interaction = interaction @@ -13,50 +18,144 @@ public final class SlashCommandRouter { self.path = p self.optionMap = m } - public func option(_ name: String) -> String? { - optionMap[name] - } + + public func option(_ name: String) -> String? { optionMap[name] } public func string(_ name: String) -> String? { option(name) } public func bool(_ name: String) -> Bool? { option(name).flatMap { Bool($0) } } public func int(_ name: String) -> Int? { option(name).flatMap { Int($0) } } public func double(_ name: String) -> Double? { option(name).flatMap { Double($0) } } + + // MARK: Resolved option accessors + + /// Resolve a `user` option to the full `User` object from the interaction's resolved map. + public func user(_ name: String) -> User? { + guard let rawId = option(name) else { return nil } + return interaction.data?.resolved?.users?[UserID(rawId)] + } + + /// Resolve a `channel` option to a `ResolvedChannel` from the interaction's resolved map. + public func channel(_ name: String) -> Interaction.ResolvedChannel? { + guard let rawId = option(name) else { return nil } + return interaction.data?.resolved?.channels?[ChannelID(rawId)] + } + + /// Resolve a `role` option to a `ResolvedRole` from the interaction's resolved map. + public func role(_ name: String) -> Interaction.ResolvedRole? { + guard let rawId = option(name) else { return nil } + return interaction.data?.resolved?.roles?[RoleID(rawId)] + } + + /// Resolve an `attachment` option to a `ResolvedAttachment` from the interaction's resolved map. + public func attachment(_ name: String) -> Interaction.ResolvedAttachment? { + guard let rawId = option(name) else { return nil } + return interaction.data?.resolved?.attachments?[AttachmentID(rawId)] + } + + /// Resolve a `user` option to the guild member (if present in the interaction's resolved map). + public func member(_ name: String) -> Interaction.ResolvedMember? { + guard let rawId = option(name) else { return nil } + return interaction.data?.resolved?.members?[UserID(rawId)] + } + + // MARK: - Permission helpers + + /// Returns `true` if the invoking member has the given raw permission bit set. + /// + /// Uses `interaction.member?.permissions`, which Discord provides in guild + /// application command interactions. Returns `false` for DMs (no member) or + /// if the field is absent. + public func hasPermission(_ bit: UInt64) -> Bool { + guard let permStr = interaction.member?.permissions, + let permInt = UInt64(permStr) else { return false } + return (permInt & bit) != 0 + } + + /// Returns `true` if the invoking member holds the `ADMINISTRATOR` permission (`1 << 3`). + public var isAdmin: Bool { hasPermission(1 << 3) } + + /// Returns `true` if the invoking member has the specified role. + public func memberHasRole(_ roleId: RoleID) -> Bool { + interaction.member?.roles.contains(roleId) ?? false + } } - public typealias Handler = (Context) async throws -> Void + /// The async, Sendable handler type invoked when a slash command matches. + public typealias Handler = @Sendable (Context) async throws -> Void + + /// Middleware type. A sendable closure that receives the context and a `next` + /// handler. Call `try await next(ctx)` to continue the chain, or + /// throw / return early to halt further processing. + /// + /// ```swift + /// router.use { ctx, next in + /// guard ctx.isAdmin else { + /// try await ctx.client.createInteractionResponse( + /// id: ctx.interaction.id, + /// token: ctx.interaction.token, + /// response: ["type": 4, "data": ["content": "🚫 Admins only.", "flags": 64]] + /// ) + /// return + /// } + /// try await next(ctx) + /// } + /// ``` + public typealias Middleware = @Sendable (Context, @escaping Handler) async throws -> Void private var handlers: [String: Handler] = [:] - public var onError: ((Error, Context) -> Void)? + private var middlewares: [Middleware] = [] + /// Optional error handler invoked when a command handler throws. + public var onError: (@Sendable (Error, Context) -> Void)? public init() {} + /// Register a top-level command name. public func register(_ name: String, handler: @escaping Handler) { handlers[name.lowercased()] = handler } - // Register using full path, e.g. "echo" or "admin ban" or "admin user info" + /// Register using a full path, e.g. `"echo"` or `"admin ban"` or `"admin user info"`. public func registerPath(_ path: String, handler: @escaping Handler) { handlers[path.lowercased()] = handler } + /// Register a middleware to run before every slash-command handler. + /// + /// Middlewares execute in registration order. Each middleware **must** call + /// `next(ctx)` to proceed to the next middleware (or the final handler). + public func use(_ middleware: @escaping Middleware) { + middlewares.append(middleware) + } + + /// Dispatch an incoming interaction to the matching handler. public func handle(interaction: Interaction, client: DiscordClient) async { guard interaction.data?.name.isEmpty == false else { return } let ctx = Context(client: client, interaction: interaction) guard let handler = handlers[ctx.path.lowercased()] ?? handlers[interaction.data!.name.lowercased()] else { return } - do { try await handler(ctx) } catch { if let onError { onError(error, ctx) } } + do { + var chain: Handler = handler + for mw in middlewares.reversed() { + let next = chain + let m = mw + chain = { @Sendable ctx in try await m(ctx, next) } + } + try await chain(ctx) + } catch { if let onError { onError(error, ctx) } } } // MARK: - Path and options resolution - static func computePathAndOptions(from interaction: Interaction) -> (String, [String: String]) { + + /// Compute the resolved path and option map for an interaction. + /// Marked `nonisolated` so it can be called without an actor hop from `Context.init`. + nonisolated static func computePathAndOptions(from interaction: Interaction) -> (String, [String: String]) { guard let data = interaction.data else { return ("", [:]) } var components: [String] = [data.name] var cursorOptions = data.options ?? [] var leafOptions: [Interaction.ApplicationCommandData.Option] = [] - // Drill into subcommand/subcommand group options if present - while let first = cursorOptions.first, let type = first.type, (type == 1 || type == 2) { // 1=subcommand, 2=subcommand group + // Drill into subcommand / subcommand-group levels + while let first = cursorOptions.first, let type = first.type, (type == 1 || type == 2) { components.append(first.name) cursorOptions = first.options ?? [] } - // remaining are leaf options leafOptions = cursorOptions var map: [String: String] = [:] for opt in leafOptions { diff --git a/Sources/SwiftDisc/HighLevel/Utilities.swift b/Sources/SwiftDisc/HighLevel/Utilities.swift index 066bcdd..5afe981 100644 --- a/Sources/SwiftDisc/HighLevel/Utilities.swift +++ b/Sources/SwiftDisc/HighLevel/Utilities.swift @@ -1,7 +1,18 @@ import Foundation +/// A collection of static utility helpers for common bot development tasks. public enum BotUtils { - // Splits content into Discord-safe chunks (approx 2000 chars) + /// Splits a long string into Discord-safe message chunks. + /// + /// Discord enforces a 2000-character message limit. This helper splits content + /// on newline boundaries to avoid cutting words mid-line while keeping each + /// chunk within the specified `maxLength`. + /// + /// - Parameters: + /// - content: The full text to split. + /// - maxLength: Maximum character length per chunk. Defaults to `1900` to leave + /// headroom for prefixes or suffixes your bot may append. + /// - Returns: An array of strings, each at most `maxLength` characters long. public static func chunkMessage(_ content: String, maxLength: Int = 1900) -> [String] { guard content.count > maxLength else { return [content] } var chunks: [String] = [] @@ -19,13 +30,25 @@ public enum BotUtils { return chunks } - // Returns true if message starts with any of the provided prefixes + /// Returns `true` if the message content begins with any of the provided prefix strings. + /// + /// Useful for multi-prefix bots that support more than one command trigger character. + /// + /// - Parameters: + /// - content: The message text to check. + /// - prefixes: An array of prefix strings to test against. + /// - Returns: `true` if `content` starts with at least one of the given prefixes. public static func hasPrefix(_ content: String, prefixes: [String]) -> Bool { for p in prefixes where content.hasPrefix(p) { return true } return false } - // Extract user mentions in <@id> or <@!id> format + /// Extracts user IDs from Discord mention tags embedded in a message string. + /// + /// Parses both `<@userID>` and legacy `<@!userID>` mention formats. + /// + /// - 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,})>"# guard let re = try? NSRegularExpression(pattern: pattern) else { return [] } @@ -39,6 +62,12 @@ public enum BotUtils { return ids } + /// Returns `true` if the message content contains a mention of the given bot user ID. + /// + /// - Parameters: + /// - content: The message text to check. + /// - botId: The bot's user ID as a string. + /// - Returns: `true` if a mention matching `botId` appears anywhere in `content`. public static func mentionsBot(_ content: String, botId: String) -> Bool { extractMentions(content).contains(botId) } diff --git a/Sources/SwiftDisc/HighLevel/ViewManager.swift b/Sources/SwiftDisc/HighLevel/ViewManager.swift index 0a2cba0..f3ce689 100644 --- a/Sources/SwiftDisc/HighLevel/ViewManager.swift +++ b/Sources/SwiftDisc/HighLevel/ViewManager.swift @@ -1,6 +1,6 @@ import Foundation -public typealias ViewHandler = (Interaction, DiscordClient) async -> Void +public typealias ViewHandler = @Sendable (Interaction, DiscordClient) async -> Void /// Pattern matching type for view custom_id routing. public enum MatchType { @@ -10,7 +10,9 @@ public enum MatchType { } /// A persistent view with handlers keyed by `custom_id` or matching prefixes. -public struct View { +/// Marked `@unchecked Sendable` because the `state` dictionary uses `Any` +/// values; callers are responsible for thread-safe state access. +public struct View: @unchecked Sendable { public let id: String public let timeout: TimeInterval? /// Patterns: (pattern string, match type, handler) @@ -90,7 +92,8 @@ public actor ViewManager { if await listeningTask != nil { return } let task = Task.detached { [weak client] in guard let client else { return } - for await event in client.events { + let eventStream = await client.events + for await event in eventStream { switch event { case .interactionCreate(let interaction): if let data = interaction.data, let cid = data.custom_id { diff --git a/Sources/SwiftDisc/HighLevel/WebhookClient.swift b/Sources/SwiftDisc/HighLevel/WebhookClient.swift new file mode 100644 index 0000000..22b618c --- /dev/null +++ b/Sources/SwiftDisc/HighLevel/WebhookClient.swift @@ -0,0 +1,238 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// A lightweight Discord webhook client that operates without a bot token. +/// +/// Create a `WebhookClient` from a webhook URL or by supplying the ID and token +/// directly, then call ``execute(content:username:avatarUrl:embeds:wait:)`` to +/// post messages, or ``editMessage(messageId:)`` / ``deleteMessage(messageId:)`` +/// to manage previously-sent webhook messages. +/// +/// ```swift +/// let hook = WebhookClient(url: "https://discord.com/api/webhooks/12345/abcdef")! +/// let sent = try await hook.execute(content: "Hello from Swift!") +/// try await hook.deleteMessage(messageId: sent!.id.rawValue) +/// ``` +public struct WebhookClient: Sendable { + public let id: WebhookID + public let token: String + + private static let apiBase = "https://discord.com/api/v10" + private static let decoder: JSONDecoder = { + let d = JSONDecoder() + d.keyDecodingStrategy = .convertFromSnakeCase + return d + }() + private static let encoder: JSONEncoder = { + let e = JSONEncoder() + e.keyEncodingStrategy = .convertToSnakeCase + return e + }() + + // MARK: - Init + + /// Create from a known webhook ID and token. + public init(id: WebhookID, token: String) { + self.id = id + self.token = token + } + + /// Parse a standard Discord webhook URL of the form + /// `https://discord.com/api/webhooks//`. + /// + /// Returns `nil` if the URL cannot be parsed. + public init?(url: String) { + // Matches both discord.com and canary/ptb variants. + guard let parsed = URL(string: url) else { return nil } + let parts = parsed.pathComponents + // pathComponents example: ["/", "api", "webhooks", "12345", "token"] + guard + let webhookIdx = parts.firstIndex(of: "webhooks"), + parts.index(after: webhookIdx) < parts.endIndex, + parts.index(webhookIdx, offsetBy: 2) < parts.endIndex + else { return nil } + + let rawId = parts[parts.index(after: webhookIdx)] + let rawToken = parts[parts.index(webhookIdx, offsetBy: 2)] + self.id = WebhookID(rawId) + self.token = rawToken + } + + // MARK: - Execute + + /// Post a message through the webhook. + /// + /// - Parameters: + /// - content: Plain-text body. + /// - username: Override the webhook's display name for this message. + /// - avatarUrl: Override the webhook's avatar URL for this message. + /// - embeds: Rich embeds. + /// - components: Message components (buttons, selects, etc.). + /// - wait: When `true`, Discord returns the created ``Message`` object; + /// otherwise returns `nil`. + /// - files: Binary file attachments. + /// - Returns: The sent ``Message`` if `wait == true`, otherwise `nil`. + @discardableResult + public func execute( + content: String? = nil, + username: String? = nil, + avatarUrl: String? = nil, + embeds: [Embed]? = nil, + components: [MessageComponent]? = nil, + wait: Bool = false, + files: [FileAttachment] = [] + ) async throws -> Message? { + var urlStr = "\(Self.apiBase)/webhooks/\(id)/\(token)" + if wait { urlStr += "?wait=true" } + guard let url = URL(string: urlStr) else { + throw WebhookError.invalidURL(urlStr) + } + + struct Body: Encodable { + let content: String? + let username: String? + let avatar_url: String? + let embeds: [Embed]? + let components: [MessageComponent]? + } + let body = Body( + content: content, + username: username, + avatar_url: avatarUrl, + embeds: embeds, + components: components + ) + + var req = URLRequest(url: url) + req.httpMethod = "POST" + + if files.isEmpty { + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try Self.encoder.encode(body) + } else { + let boundary = "WebhookBoundary-\(UUID().uuidString)" + req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + req.httpBody = buildMultipart(jsonBody: try Self.encoder.encode(body), files: files, boundary: boundary) + } + + let (data, response) = try await URLSession.shared.data(for: req) + if let httpResponse = response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) { + if data.isEmpty { throw WebhookError.httpError(httpResponse.statusCode, nil) } + let msg = String(data: data, encoding: .utf8) + throw WebhookError.httpError(httpResponse.statusCode, msg) + } + + if !wait || data.isEmpty { return nil } + return try Self.decoder.decode(Message.self, from: data) + } + + // MARK: - Edit Message + + /// Edit a previously sent webhook message. + /// + /// Pass `@original` as `messageId` to edit the most recent message sent in + /// an interaction context. + @discardableResult + public func editMessage( + messageId: String, + content: String? = nil, + embeds: [Embed]? = nil, + components: [MessageComponent]? = nil, + files: [FileAttachment] = [] + ) async throws -> Message { + let urlStr = "\(Self.apiBase)/webhooks/\(id)/\(token)/messages/\(messageId)" + guard let url = URL(string: urlStr) else { + throw WebhookError.invalidURL(urlStr) + } + + struct PatchBody: Encodable { + let content: String? + let embeds: [Embed]? + let components: [MessageComponent]? + } + let body = PatchBody(content: content, embeds: embeds, components: components) + + var req = URLRequest(url: url) + req.httpMethod = "PATCH" + + if files.isEmpty { + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try Self.encoder.encode(body) + } else { + let boundary = "WebhookBoundary-\(UUID().uuidString)" + req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + req.httpBody = buildMultipart(jsonBody: try Self.encoder.encode(body), files: files, boundary: boundary) + } + + let (data, response) = try await URLSession.shared.data(for: req) + if let httpResponse = response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) { + let msg = data.isEmpty ? nil : String(data: data, encoding: .utf8) + throw WebhookError.httpError(httpResponse.statusCode, msg) + } + return try Self.decoder.decode(Message.self, from: data) + } + + // MARK: - Delete Message + + /// Delete a previously sent webhook message. + /// + /// Pass `@original` as `messageId` to delete the original interaction response. + public func deleteMessage(messageId: String) async throws { + let urlStr = "\(Self.apiBase)/webhooks/\(id)/\(token)/messages/\(messageId)" + guard let url = URL(string: urlStr) else { + throw WebhookError.invalidURL(urlStr) + } + var req = URLRequest(url: url) + req.httpMethod = "DELETE" + let (data, response) = try await URLSession.shared.data(for: req) + if let httpResponse = response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) { + let msg = data.isEmpty ? nil : String(data: data, encoding: .utf8) + throw WebhookError.httpError(httpResponse.statusCode, msg) + } + } + + // MARK: - Private helpers + + private func buildMultipart(jsonBody: Data, files: [FileAttachment], boundary: String) -> Data { + var body = Data() + let crlf = "\r\n" + + // JSON payload part + body.append("--\(boundary)\(crlf)".utf8Data) + body.append("Content-Disposition: form-data; name=\"payload_json\"\(crlf)".utf8Data) + body.append("Content-Type: application/json\(crlf)\(crlf)".utf8Data) + body.append(jsonBody) + body.append(crlf.utf8Data) + + for (index, file) in files.enumerated() { + body.append("--\(boundary)\(crlf)".utf8Data) + body.append("Content-Disposition: form-data; name=\"files[\(index)]\"; filename=\"\(file.filename)\"\(crlf)".utf8Data) + let ct = file.contentType ?? "application/octet-stream" + body.append("Content-Type: \(ct)\(crlf)\(crlf)".utf8Data) + body.append(file.data) + body.append(crlf.utf8Data) + } + + body.append("--\(boundary)--\(crlf)".utf8Data) + return body + } +} + +// MARK: - Error type + +/// Errors thrown by ``WebhookClient``. +public enum WebhookError: Error, Sendable { + case invalidURL(String) + case httpError(Int, String?) +} + +// MARK: - String → Data helper (private) + +private extension String { + var utf8Data: Data { Data(utf8) } +} diff --git a/Sources/SwiftDisc/Internal/Cache.swift b/Sources/SwiftDisc/Internal/Cache.swift index 5583e98..996a766 100644 --- a/Sources/SwiftDisc/Internal/Cache.swift +++ b/Sources/SwiftDisc/Internal/Cache.swift @@ -18,12 +18,27 @@ public actor Cache { private var usersTimed: [UserID: TimedValue] = [:] private var channelsTimed: [ChannelID: TimedValue] = [:] private var guildsTimed: [GuildID: TimedValue] = [:] + private var rolesByGuild: [GuildID: [RoleID: TimedValue]] = [:] + private var emojisByGuild: [GuildID: [Emoji]] = [:] public private(set) var recentMessagesByChannel: [ChannelID: [Message]] = [:] + /// Background task that prunes expired TTL entries every 60 seconds. + /// Only started when at least one TTL is configured. + nonisolated(unsafe) private var evictionTask: Task? + public init(configuration: Configuration = .init()) { self.configuration = configuration + self.evictionTask = nil + let hasTTL = configuration.userTTL != nil + || configuration.channelTTL != nil + || configuration.guildTTL != nil + if hasTTL { + self.evictionTask = Task { await self.evictionLoop() } + } } + deinit { evictionTask?.cancel() } + public func upsert(user: User) { usersTimed[user.id] = TimedValue(value: user, storedAt: Date()) } @@ -40,6 +55,47 @@ public actor Cache { guildsTimed[guild.id] = TimedValue(value: guild, storedAt: Date()) } + // MARK: - Roles + + /// Insert or update a role within a guild's role cache. + public func upsert(role: Role, guildId: GuildID) { + var dict = rolesByGuild[guildId] ?? [:] + dict[role.id] = TimedValue(value: role, storedAt: Date()) + rolesByGuild[guildId] = dict + } + + /// Remove a single role from the cache. + public func removeRole(id: RoleID, guildId: GuildID) { + rolesByGuild[guildId]?.removeValue(forKey: id) + } + + /// Retrieve a single role. + public func getRole(id: RoleID, guildId: GuildID) -> Role? { + rolesByGuild[guildId]?[id]?.value + } + + /// Retrieve all cached roles for a guild. + public func getRoles(guildId: GuildID) -> [Role] { + (rolesByGuild[guildId] ?? [:]).values.map(\.value) + } + + // MARK: - Emojis + + /// Replace the emoji list for a guild. + public func upsert(emojis: [Emoji], guildId: GuildID) { + emojisByGuild[guildId] = emojis + } + + /// Retrieve all cached emojis for a guild. + public func getEmojis(guildId: GuildID) -> [Emoji] { + emojisByGuild[guildId] ?? [] + } + + /// Retrieve a single custom emoji by ID from a guild. + public func getEmoji(id: EmojiID, guildId: GuildID) -> Emoji? { + emojisByGuild[guildId]?.first { $0.id == id } + } + public func add(message: Message) { var arr = recentMessagesByChannel[message.channel_id] ?? [] arr.append(message) @@ -74,4 +130,20 @@ public actor Cache { guildsTimed = guildsTimed.filter { now.timeIntervalSince($0.value.storedAt) < ttl } } } + + /// Cancels the background eviction task (e.g. during teardown). + public func stopEviction() { + evictionTask?.cancel() + evictionTask = nil + } + + // MARK: - Private + + private func evictionLoop() async { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 60_000_000_000) // 60 seconds + guard !Task.isCancelled else { break } + pruneIfNeeded() + } + } } diff --git a/Sources/SwiftDisc/Internal/DiscordConfiguration.swift b/Sources/SwiftDisc/Internal/DiscordConfiguration.swift index 14652a2..7322e3a 100644 --- a/Sources/SwiftDisc/Internal/DiscordConfiguration.swift +++ b/Sources/SwiftDisc/Internal/DiscordConfiguration.swift @@ -1,6 +1,6 @@ import Foundation -public struct DiscordConfiguration { +public struct DiscordConfiguration: Sendable { public var apiBaseURL: URL public var apiVersion: Int public var gatewayBaseURL: URL diff --git a/Sources/SwiftDisc/Internal/DiscordError.swift b/Sources/SwiftDisc/Internal/DiscordError.swift index c49cb44..ecabfe8 100644 --- a/Sources/SwiftDisc/Internal/DiscordError.swift +++ b/Sources/SwiftDisc/Internal/DiscordError.swift @@ -1,12 +1,24 @@ import Foundation -public enum DiscordError: Error { +/// All errors thrown by SwiftDisc REST and gateway operations. +/// Declared `Sendable` so errors can be safely passed across actor/task boundaries. +public enum DiscordError: Error, Sendable { + /// A non-2xx HTTP response with status code and raw body. case http(Int, String) + /// A 4xx/5xx response whose body decoded to Discord's `{message, code}` error shape. case api(message: String, code: Int?) - case decoding(Error) - case encoding(Error) - case network(Error) + /// JSON decoding failed for a successful HTTP response. + case decoding(any Error) + /// JSON encoding failed before sending the request. + case encoding(any Error) + /// A transport-level error (URLError, socket failure, etc.). + case network(any Error) + /// A gateway-level protocol error. case gateway(String) + /// The task was cancelled before the request completed. case cancelled + /// A value failed a precondition check (e.g. file too large). case validation(String) + /// HTTP is not available on this platform build. + case unavailable } diff --git a/Sources/SwiftDisc/Internal/EventDispatcher.swift b/Sources/SwiftDisc/Internal/EventDispatcher.swift index a838f54..9894613 100644 --- a/Sources/SwiftDisc/Internal/EventDispatcher.swift +++ b/Sources/SwiftDisc/Internal/EventDispatcher.swift @@ -3,142 +3,221 @@ import Foundation actor EventDispatcher { func process(event: DiscordEvent, client: DiscordClient) async { switch event { + + // MARK: Ready case .ready(let info): await client.cache.upsert(user: info.user) await client._internalSetCurrentUserId(info.user.id) - if let onReady = client.onReady { await onReady(info) } + if let cb = await client.onReady { await cb(info) } + + // MARK: Messages case .messageCreate(let msg): await client.cache.upsert(user: msg.author) - await client.cache.upsert(channel: Channel(id: msg.channel_id, type: 0, name: nil, topic: nil, nsfw: nil, position: nil, parent_id: nil, rate_limit_per_user: nil, default_auto_archive_duration: nil, available_tags: nil, default_reaction_emoji: nil, default_sort_order: nil, default_forum_layout: nil, permission_overwrites: nil)) + await client.cache.upsert(channel: Channel(id: msg.channel_id, type: 0)) await client.cache.add(message: msg) - if let onMessage = client.onMessage { await onMessage(msg) } - if let router = client.commands { await router.handleIfCommand(message: msg, client: client) } + if let cb = await client.onMessage { await cb(msg) } + if let router = await client.commands { await router.handleIfCommand(message: msg, client: client) } + case .messageUpdate(let msg): await client.cache.upsert(user: msg.author) await client.cache.add(message: msg) - if let cb = client.onMessageUpdate { await cb(msg) } + if let cb = await client.onMessageUpdate { await cb(msg) } + case .messageDelete(let del): await client.cache.removeMessage(id: del.id) - if let cb = client.onMessageDelete { await cb(del) } - case .messageDeleteBulk(_): - break + if let cb = await client.onMessageDelete { await cb(del) } + + case .messageDeleteBulk(let bulk): + for id in bulk.ids { await client.cache.removeMessage(id: id) } + if let cb = await client.onMessageDeleteBulk { await cb(bulk) } + case .messageReactionAdd(let ev): - if let cb = client.onReactionAdd { await cb(ev) } + if let cb = await client.onReactionAdd { await cb(ev) } + case .messageReactionRemove(let ev): - if let cb = client.onReactionRemove { await cb(ev) } + if let cb = await client.onReactionRemove { await cb(ev) } + case .messageReactionRemoveAll(let ev): - if let cb = client.onReactionRemoveAll { await cb(ev) } + if let cb = await client.onReactionRemoveAll { await cb(ev) } + case .messageReactionRemoveEmoji(let ev): - if let cb = client.onReactionRemoveEmoji { await cb(ev) } + if let cb = await client.onReactionRemoveEmoji { await cb(ev) } + + // MARK: Guilds case .guildCreate(let guild): await client.cache.upsert(guild: guild) - if let onGuildCreate = client.onGuildCreate { await onGuildCreate(guild) } - case .guildUpdate(_): - break - case .guildDelete(_): - break - case .guildMemberAdd(_): - break - case .guildMemberRemove(_): - break - case .guildMemberUpdate(_): - break - case .guildRoleCreate(_): - break - case .guildRoleUpdate(_): - break - case .guildRoleDelete(_): - break - case .guildEmojisUpdate(_): - break - case .guildStickersUpdate(_): - break - case .guildMembersChunk(_): + // Eagerly seed channel and user caches from GUILD_CREATE payload + for channel in guild.channels ?? [] { await client.cache.upsert(channel: channel) } + for thread in guild.threads ?? [] { await client.cache.upsert(channel: thread) } + for member in guild.members ?? [] { if let user = member.user { await client.cache.upsert(user: user) } } + if let cb = await client.onGuildCreate { await cb(guild) } + + case .guildUpdate(let guild): + await client.cache.upsert(guild: guild) + if let cb = await client.onGuildUpdate { await cb(guild) } + + case .guildDelete(let ev): + if let cb = await client.onGuildDelete { await cb(ev) } + + // MARK: Members + case .guildMemberAdd(let ev): + await client.cache.upsert(user: ev.user) + if let cb = await client.onGuildMemberAdd { await cb(ev) } + + case .guildMemberRemove(let ev): + await client.cache.upsert(user: ev.user) + if let cb = await client.onGuildMemberRemove { await cb(ev) } + + case .guildMemberUpdate(let ev): + 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) } } + + // MARK: Roles + case .guildRoleCreate(let ev): + if let cb = await client.onGuildRoleCreate { await cb(ev) } + + case .guildRoleUpdate(let ev): + if let cb = await client.onGuildRoleUpdate { await cb(ev) } + + case .guildRoleDelete(let ev): + if let cb = await client.onGuildRoleDelete { await cb(ev) } + + // MARK: Emojis / Stickers (no callback – stream-only) + case .guildEmojisUpdate, .guildStickersUpdate: break - case .channelCreate(let channel), .channelUpdate(let channel): + + // MARK: Channels + case .channelCreate(let channel): await client.cache.upsert(channel: channel) + if let cb = await client.onChannelCreate { await cb(channel) } + + case .channelUpdate(let channel): + await client.cache.upsert(channel: channel) + if let cb = await client.onChannelUpdate { await cb(channel) } + case .channelDelete(let channel): await client.cache.removeChannel(id: channel.id) + if let cb = await client.onChannelDelete { await cb(channel) } + + // MARK: Threads + case .threadCreate(let ch): + await client.cache.upsert(channel: ch) + if let cb = await client.onThreadCreate { await cb(ch) } + + case .threadUpdate(let ch): + await client.cache.upsert(channel: ch) + if let cb = await client.onThreadUpdate { await cb(ch) } + + case .threadDelete(let ch): + await client.cache.removeChannel(id: ch.id) + if let cb = await client.onThreadDelete { await cb(ch) } + + case .threadMemberUpdate, .threadMembersUpdate: + break + + // MARK: Interactions case .interactionCreate(let interaction): if let cid = interaction.channel_id { - await client.cache.upsert(channel: Channel(id: cid, type: 0, name: nil, topic: nil, nsfw: nil, position: nil, parent_id: nil, rate_limit_per_user: nil, default_auto_archive_duration: nil, available_tags: nil, default_reaction_emoji: nil, default_sort_order: nil, default_forum_layout: nil, permission_overwrites: nil)) + await client.cache.upsert(channel: Channel(id: cid, type: 0)) } - if interaction.type == 4, let ac = client.autocomplete { + if let cb = await client.onInteractionCreate { await cb(interaction) } + if interaction.type == 4, let ac = await client.autocomplete { await ac.handle(interaction: interaction, client: client) - } else if let s = client.slashCommands { + } else if let s = await client.slashCommands { await s.handle(interaction: interaction, client: client) } + + // MARK: Voice case .voiceStateUpdate(let state): await client._internalOnVoiceStateUpdate(state) + if let cb = await client.onVoiceStateUpdate { await cb(state) } + case .voiceServerUpdate(let vsu): await client._internalOnVoiceServerUpdate(vsu) - case .raw(_, _): - break - case .threadCreate(_): - break - case .threadUpdate(_): - break - case .threadDelete(_): - break - case .threadMemberUpdate(_): - break - case .threadMembersUpdate(_): - break - case .guildScheduledEventCreate(_): - break - case .guildScheduledEventUpdate(_): - break - case .guildScheduledEventDelete(_): - break - case .guildScheduledEventUserAdd(_): - break - case .guildScheduledEventUserRemove(_): - break - case .typingStart(_): - break - case .channelPinsUpdate(_): - break - case .presenceUpdate(_): - break - case .guildBanAdd(_): - break - case .guildBanRemove(_): - break - case .webhooksUpdate(_): - break - case .guildIntegrationsUpdate(_): - break - case .inviteCreate(_): - break - case .inviteDelete(_): - break - case .autoModerationRuleCreate(_): - break - case .autoModerationRuleUpdate(_): - break - case .autoModerationRuleDelete(_): - break - case .autoModerationActionExecution(_): - break - case .guildAuditLogEntryCreate(_): - break - case .pollVoteAdd(_): - break - case .pollVoteRemove(_): - break - case .soundboardSoundCreate(_): + + // MARK: Presence & Typing + case .typingStart(let ev): + if let cb = await client.onTypingStart { await cb(ev) } + + case .presenceUpdate(let ev): + await client.cache.upsert(user: ev.user) + if let cb = await client.onPresenceUpdate { await cb(ev) } + + case .channelPinsUpdate: break - case .soundboardSoundUpdate(_): + + // MARK: Bans + case .guildBanAdd(let ev): + if let cb = await client.onGuildBanAdd { await cb(ev) } + + 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 - case .soundboardSoundDelete(_): + + // MARK: AutoMod + case .autoModerationRuleCreate, .autoModerationRuleUpdate, .autoModerationRuleDelete: break - case .entitlementCreate(_): + + case .autoModerationActionExecution(let ev): + if let cb = await client.onAutoModerationActionExecution { await cb(ev) } + + // MARK: Audit Log (stream-only) + case .guildAuditLogEntryCreate: break - case .entitlementUpdate(_): + + // MARK: Scheduled Events + case .guildScheduledEventCreate(let ev): + if let cb = await client.onGuildScheduledEventCreate { await cb(ev) } + + case .guildScheduledEventUpdate(let ev): + if let cb = await client.onGuildScheduledEventUpdate { await cb(ev) } + + case .guildScheduledEventDelete(let ev): + if let cb = await client.onGuildScheduledEventDelete { await cb(ev) } + + case .guildScheduledEventUserAdd, .guildScheduledEventUserRemove: break - case .entitlementDelete(_): + + // MARK: Polls + case .pollVoteAdd(let ev): + if let cb = await client.onPollVoteAdd { await cb(ev) } + + case .pollVoteRemove(let ev): + if let cb = await client.onPollVoteRemove { await cb(ev) } + + // MARK: Soundboard + case .soundboardSoundCreate(let ev): + if let cb = await client.onSoundboardSoundCreate { await cb(ev) } + + case .soundboardSoundUpdate(let ev): + if let cb = await client.onSoundboardSoundUpdate { await cb(ev) } + + case .soundboardSoundDelete(let ev): + if let cb = await client.onSoundboardSoundDelete { await cb(ev) } + + // MARK: Entitlements + case .entitlementCreate(let ev): + if let cb = await client.onEntitlementCreate { await cb(ev) } + + case .entitlementUpdate(let ev): + if let cb = await client.onEntitlementUpdate { await cb(ev) } + + case .entitlementDelete(let ev): + if let cb = await client.onEntitlementDelete { await cb(ev) } + + // MARK: Raw / Other + case .raw: break } - client._internalEmitEvent(event) + + await client._internalEmitEvent(event) } } + + diff --git a/Sources/SwiftDisc/Internal/JSONValue.swift b/Sources/SwiftDisc/Internal/JSONValue.swift index d16b359..b958d80 100644 --- a/Sources/SwiftDisc/Internal/JSONValue.swift +++ b/Sources/SwiftDisc/Internal/JSONValue.swift @@ -1,6 +1,6 @@ import Foundation -public enum JSONValue: Codable, Hashable { +public enum JSONValue: Codable, Hashable, Sendable { case string(String) case number(Double) case int(Int) diff --git a/Sources/SwiftDisc/Models/AdvancedMessagePayloads.swift b/Sources/SwiftDisc/Models/AdvancedMessagePayloads.swift index 06c66b7..91671b4 100644 --- a/Sources/SwiftDisc/Models/AdvancedMessagePayloads.swift +++ b/Sources/SwiftDisc/Models/AdvancedMessagePayloads.swift @@ -1,6 +1,6 @@ import Foundation -public struct V2MessagePayload: Encodable { +public struct V2MessagePayload: Encodable, Sendable { public var content: String? public var flags: Int? public var components: [JSONValue]? @@ -20,7 +20,7 @@ public struct V2MessagePayload: Encodable { } } -public struct PollPayload: Encodable { +public struct PollPayload: Encodable, Sendable { public var question: String public var answers: [String] public var durationSeconds: Int diff --git a/Sources/SwiftDisc/Models/AppInstallations.swift b/Sources/SwiftDisc/Models/AppInstallations.swift index 6fd7321..5c83e7e 100644 --- a/Sources/SwiftDisc/Models/AppInstallations.swift +++ b/Sources/SwiftDisc/Models/AppInstallations.swift @@ -1,6 +1,6 @@ import Foundation -public struct AppInstallation: Codable, Hashable { +public struct AppInstallation: Codable, Hashable, Sendable { public let id: Snowflake public let application_id: ApplicationID public let user_id: UserID? @@ -10,7 +10,7 @@ public struct AppInstallation: Codable, Hashable { public let created_at: String? } -public struct AppSubscription: Codable, Hashable { +public struct AppSubscription: Codable, Hashable, Sendable { public let id: Snowflake public let application_id: ApplicationID public let sku_id: SKUID diff --git a/Sources/SwiftDisc/Models/ApplicationRoleConnection.swift b/Sources/SwiftDisc/Models/ApplicationRoleConnection.swift index eb99ff4..c9f0a7d 100644 --- a/Sources/SwiftDisc/Models/ApplicationRoleConnection.swift +++ b/Sources/SwiftDisc/Models/ApplicationRoleConnection.swift @@ -1,6 +1,6 @@ import Foundation -public struct ApplicationRoleConnection: Codable, Hashable { +public struct ApplicationRoleConnection: Codable, Hashable, Sendable { public let platformName: String? public let platformUsername: String? public let metadata: [String: String] @@ -18,7 +18,7 @@ public struct ApplicationRoleConnection: Codable, Hashable { } } -public struct ApplicationRoleConnectionMetadata: Codable, Hashable { +public struct ApplicationRoleConnectionMetadata: Codable, Hashable, Sendable { public let type: RoleConnectionMetadataType public let key: String public let name: String @@ -42,7 +42,7 @@ public struct ApplicationRoleConnectionMetadata: Codable, Hashable { } } -public enum RoleConnectionMetadataType: Int, Codable, Hashable, CaseIterable { +public enum RoleConnectionMetadataType: Int, Codable, Hashable, Sendable, CaseIterable { case integerLessThanOrEqual = 1 case integerGreaterThanOrEqual = 2 case integerEqual = 3 diff --git a/Sources/SwiftDisc/Models/Attachment.swift b/Sources/SwiftDisc/Models/Attachment.swift index ba19875..4a4fa83 100644 --- a/Sources/SwiftDisc/Models/Attachment.swift +++ b/Sources/SwiftDisc/Models/Attachment.swift @@ -1,6 +1,6 @@ import Foundation -public struct Attachment: Codable, Hashable { +public struct Attachment: Codable, Hashable, Sendable { public let id: AttachmentID public let filename: String public let size: Int? diff --git a/Sources/SwiftDisc/Models/AuditLog.swift b/Sources/SwiftDisc/Models/AuditLog.swift index c66d345..1a5fc06 100644 --- a/Sources/SwiftDisc/Models/AuditLog.swift +++ b/Sources/SwiftDisc/Models/AuditLog.swift @@ -1,18 +1,18 @@ import Foundation -public struct AuditLog: Codable, Hashable { +public struct AuditLog: Codable, Hashable, Sendable { public let audit_log_entries: [AuditLogEntry] public let users: [User]? public let webhooks: [Webhook]? } -public struct AuditLogEntry: Codable, Hashable { - public struct Change: Codable, Hashable { +public struct AuditLogEntry: Codable, Hashable, Sendable { + public struct Change: Codable, Hashable, Sendable { public let key: String public let new_value: CodableValue? public let old_value: CodableValue? } - public struct OptionalInfo: Codable, Hashable { + public struct OptionalInfo: Codable, Hashable, Sendable { public let channel_id: ChannelID? public let count: String? public let delete_member_days: String? @@ -32,7 +32,7 @@ public struct AuditLogEntry: Codable, Hashable { public let reason: String? } -public enum CodableValue: Codable, Hashable { +public enum CodableValue: Codable, Hashable, Sendable { case string(String) case int(Int) case double(Double) diff --git a/Sources/SwiftDisc/Models/AutoModeration.swift b/Sources/SwiftDisc/Models/AutoModeration.swift index a6f7927..be0b119 100644 --- a/Sources/SwiftDisc/Models/AutoModeration.swift +++ b/Sources/SwiftDisc/Models/AutoModeration.swift @@ -1,15 +1,15 @@ import Foundation -public struct AutoModerationRule: Codable, Hashable { - public struct TriggerMetadata: Codable, Hashable { +public struct AutoModerationRule: Codable, Hashable, Sendable { + public struct TriggerMetadata: Codable, Hashable, Sendable { public let keyword_filter: [String]? public let presets: [Int]? public let allow_list: [String]? public let mention_total_limit: Int? public let mention_raid_protection_enabled: Bool? } - public struct Action: Codable, Hashable { - public struct Metadata: Codable, Hashable { + public struct Action: Codable, Hashable, Sendable { + public struct Metadata: Codable, Hashable, Sendable { public let channel_id: ChannelID? public let duration_seconds: Int? public let custom_message: String? diff --git a/Sources/SwiftDisc/Models/Channel.swift b/Sources/SwiftDisc/Models/Channel.swift index 97a6021..4cb4554 100644 --- a/Sources/SwiftDisc/Models/Channel.swift +++ b/Sources/SwiftDisc/Models/Channel.swift @@ -1,6 +1,6 @@ import Foundation -public struct Channel: Codable, Hashable { +public struct Channel: Codable, Hashable, Sendable { public let id: ChannelID public let type: Int public let name: String? @@ -112,7 +112,7 @@ public struct Channel: Codable, Hashable { } } -public struct ForumTag: Codable, Hashable { +public struct ForumTag: Codable, Hashable, Sendable { public let id: ForumTagID public let name: String public let moderated: Bool? @@ -120,12 +120,12 @@ public struct ForumTag: Codable, Hashable { public let emoji_name: String? } -public struct DefaultReaction: Codable, Hashable { +public struct DefaultReaction: Codable, Hashable, Sendable { public let emoji_id: EmojiID? public let emoji_name: String? } -public struct PermissionOverwrite: Codable, Hashable { +public struct PermissionOverwrite: Codable, Hashable, Sendable { // type: 0 role, 1 member public let id: OverwriteID public let type: Int @@ -133,7 +133,7 @@ public struct PermissionOverwrite: Codable, Hashable { public let deny: String } -public struct ThreadMetadata: Codable, Hashable { +public struct ThreadMetadata: Codable, Hashable, Sendable { public let archived: Bool? public let auto_archive_duration: Int? public let archive_timestamp: String? diff --git a/Sources/SwiftDisc/Models/Embed.swift b/Sources/SwiftDisc/Models/Embed.swift index f7a1c3b..b57753a 100644 --- a/Sources/SwiftDisc/Models/Embed.swift +++ b/Sources/SwiftDisc/Models/Embed.swift @@ -1,9 +1,9 @@ import Foundation -public struct Embed: Codable, Hashable { - public struct Footer: Codable, Hashable { public let text: String; public let icon_url: String? } - public struct Author: Codable, Hashable { public let name: String; public let url: String?; public let icon_url: String? } - public struct Field: Codable, Hashable { public let name: String; public let value: String; public let inline: Bool? } +public struct Embed: Codable, Hashable, Sendable { + public struct Footer: Codable, Hashable, Sendable { public let text: String; public let icon_url: String? } + public struct Author: Codable, Hashable, Sendable { public let name: String; public let url: String?; public let icon_url: String? } + public struct Field: Codable, Hashable, Sendable { public let name: String; public let value: String; public let inline: Bool? } public let title: String? public let description: String? public let url: String? @@ -15,7 +15,7 @@ public struct Embed: Codable, Hashable { public let image: Image? public let timestamp: String? - public struct Image: Codable, Hashable { public let url: String } + public struct Image: Codable, Hashable, Sendable { public let url: String } public init(title: String? = nil, description: String? = nil, url: String? = nil, color: Int? = nil, footer: Footer? = nil, author: Author? = nil, fields: [Field]? = nil, thumbnail: Image? = nil, image: Image? = nil, timestamp: String? = nil) { self.title = title diff --git a/Sources/SwiftDisc/Models/Emoji.swift b/Sources/SwiftDisc/Models/Emoji.swift index 5b264db..608dcfe 100644 --- a/Sources/SwiftDisc/Models/Emoji.swift +++ b/Sources/SwiftDisc/Models/Emoji.swift @@ -1,6 +1,6 @@ import Foundation -public struct Emoji: Codable, Hashable { +public struct Emoji: Codable, Hashable, Sendable { public let id: EmojiID? public let name: String? public let roles: [RoleID]? diff --git a/Sources/SwiftDisc/Models/Files.swift b/Sources/SwiftDisc/Models/Files.swift index 3558d4f..5e66e68 100644 --- a/Sources/SwiftDisc/Models/Files.swift +++ b/Sources/SwiftDisc/Models/Files.swift @@ -1,6 +1,6 @@ import Foundation -public struct FileAttachment { +public struct FileAttachment: Sendable { public let filename: String public let data: Data public let description: String? @@ -14,7 +14,7 @@ public struct FileAttachment { } } -public struct PartialAttachment: Encodable, Hashable { +public struct PartialAttachment: Encodable, Hashable, Sendable { public let id: AttachmentID public let description: String? diff --git a/Sources/SwiftDisc/Models/Guild.swift b/Sources/SwiftDisc/Models/Guild.swift index e957a88..365ba32 100644 --- a/Sources/SwiftDisc/Models/Guild.swift +++ b/Sources/SwiftDisc/Models/Guild.swift @@ -1,8 +1,121 @@ import Foundation -public struct Guild: Codable, Hashable { +/// A full Discord Guild (server) object. +/// Fields such as `members`, `channels`, and `threads` are only populated +/// in the `GUILD_CREATE` gateway event; REST responses return `nil` for them. +public struct Guild: Codable, Hashable, Sendable { + // MARK: - Core Identity public let id: GuildID public let name: String + public let icon: String? + public let icon_hash: String? + public let splash: String? + public let discovery_splash: String? + + // MARK: - Ownership + public let owner: Bool? public let owner_id: UserID? + public let permissions: String? + + // MARK: - AFK + public let afk_channel_id: ChannelID? + public let afk_timeout: Int? + + // MARK: - Widget + public let widget_enabled: Bool? + public let widget_channel_id: ChannelID? + + // MARK: - Moderation + public let verification_level: Int? + public let default_message_notifications: Int? + public let explicit_content_filter: Int? + public let mfa_level: Int? + public let nsfw_level: Int? + + // MARK: - Content + public let roles: [Role]? + public let emojis: [Emoji]? + public let features: [String]? + public let stickers: [Sticker]? + + // MARK: - System Channels + public let application_id: ApplicationID? + public let system_channel_id: ChannelID? + public let system_channel_flags: Int? + public let rules_channel_id: ChannelID? + + // MARK: - Size + public let max_presences: Int? + public let max_members: Int? public let member_count: Int? + public let large: Bool? + public let unavailable: Bool? + + // MARK: - Branding + public let vanity_url_code: String? + public let description: String? + public let banner: String? + + // MARK: - Nitro / Boost + public let premium_tier: Int? + public let premium_subscription_count: Int? + public let premium_progress_bar_enabled: Bool? + + // MARK: - Locale & Update Channels + public let preferred_locale: String? + public let public_updates_channel_id: ChannelID? + public let safety_alerts_channel_id: ChannelID? + + // MARK: - Capacity + public let max_video_channel_users: Int? + public let max_stage_video_channel_users: Int? + public let approximate_member_count: Int? + public let approximate_presence_count: Int? + + // MARK: - GUILD_CREATE only + /// ISO 8601 timestamp when the bot joined the guild. Present in GUILD_CREATE only. + public let joined_at: String? + /// Member list. Present in GUILD_CREATE only. + public let members: [GuildMember]? + /// Channel list. Present in GUILD_CREATE only. + public let channels: [Channel]? + /// Active thread list. Present in GUILD_CREATE only. + public let threads: [Channel]? + + // MARK: - Convenience initializer (tests, cache building) + public init( + id: GuildID, + name: String, + owner_id: UserID? = nil, + member_count: Int? = nil, + roles: [Role]? = nil, + emojis: [Emoji]? = nil, + channels: [Channel]? = nil, + members: [GuildMember]? = nil, + features: [String]? = nil, + icon: String? = nil, + description: String? = nil, + banner: String? = nil, + preferred_locale: String? = nil, + premium_tier: Int? = nil, + nsfw_level: Int? = nil + ) { + self.id = id; self.name = name; self.icon = icon; self.icon_hash = nil + self.splash = nil; self.discovery_splash = nil; self.owner = nil + self.owner_id = owner_id; self.permissions = nil; self.afk_channel_id = nil + 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.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 + self.vanity_url_code = nil; self.description = description; self.banner = banner + self.premium_tier = premium_tier; self.premium_subscription_count = nil + self.premium_progress_bar_enabled = nil; self.preferred_locale = preferred_locale + self.public_updates_channel_id = nil; self.safety_alerts_channel_id = nil + 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 + } } diff --git a/Sources/SwiftDisc/Models/GuildBan.swift b/Sources/SwiftDisc/Models/GuildBan.swift index 5f625bd..968a486 100644 --- a/Sources/SwiftDisc/Models/GuildBan.swift +++ b/Sources/SwiftDisc/Models/GuildBan.swift @@ -1,6 +1,6 @@ import Foundation -public struct GuildBan: Codable, Hashable { +public struct GuildBan: Codable, Hashable, Sendable { public let reason: String? public let user: User } diff --git a/Sources/SwiftDisc/Models/GuildMember.swift b/Sources/SwiftDisc/Models/GuildMember.swift index ee485e0..58079ac 100644 --- a/Sources/SwiftDisc/Models/GuildMember.swift +++ b/Sources/SwiftDisc/Models/GuildMember.swift @@ -1,6 +1,6 @@ import Foundation -public struct GuildMember: Codable, Hashable { +public struct GuildMember: Codable, Hashable, Sendable { public let user: User? public let nick: String? public let avatar: String? @@ -8,4 +8,7 @@ public struct GuildMember: Codable, Hashable { public let joined_at: String? public let deaf: Bool? public let mute: Bool? + /// Effective permissions bitfield (decimal string) included by Discord in + /// interaction payloads and some gateway member events. + public let permissions: String? } diff --git a/Sources/SwiftDisc/Models/GuildPreview.swift b/Sources/SwiftDisc/Models/GuildPreview.swift index af0c607..ab8be8f 100644 --- a/Sources/SwiftDisc/Models/GuildPreview.swift +++ b/Sources/SwiftDisc/Models/GuildPreview.swift @@ -1,6 +1,6 @@ import Foundation -public struct GuildPreview: Codable, Hashable { +public struct GuildPreview: Codable, Hashable, Sendable { public let id: GuildID public let name: String public let icon: String? diff --git a/Sources/SwiftDisc/Models/GuildWidgetSettings.swift b/Sources/SwiftDisc/Models/GuildWidgetSettings.swift index 5d34f30..b45bab2 100644 --- a/Sources/SwiftDisc/Models/GuildWidgetSettings.swift +++ b/Sources/SwiftDisc/Models/GuildWidgetSettings.swift @@ -1,6 +1,6 @@ import Foundation -public struct GuildWidgetSettings: Codable, Hashable { +public struct GuildWidgetSettings: Codable, Hashable, Sendable { public let enabled: Bool public let channel_id: ChannelID? } diff --git a/Sources/SwiftDisc/Models/Interaction.swift b/Sources/SwiftDisc/Models/Interaction.swift index 0b0d715..f95e95d 100644 --- a/Sources/SwiftDisc/Models/Interaction.swift +++ b/Sources/SwiftDisc/Models/Interaction.swift @@ -3,7 +3,7 @@ import Foundation /// Discord interaction payload (slash command, component, modal submit, autocomplete, ping). /// This mirrors the Discord HTTP and gateway shape closely so all option/value types decode correctly, /// including attachment command options and resolved maps. -public struct Interaction: Codable, Hashable { +public struct Interaction: Codable, Hashable, Sendable { public let id: InteractionID public let application_id: ApplicationID public let type: Int @@ -24,7 +24,7 @@ public struct Interaction: Codable, Hashable { // MARK: - Nested Types - public struct ResolvedChannel: Codable, Hashable { + public struct ResolvedChannel: Codable, Hashable, Sendable { public let id: ChannelID public let type: Int public let name: String? @@ -33,7 +33,7 @@ public struct Interaction: Codable, Hashable { public let flags: Int? } - public struct ResolvedRole: Codable, Hashable { + public struct ResolvedRole: Codable, Hashable, Sendable { public let id: RoleID public let name: String? public let color: Int? @@ -47,7 +47,7 @@ public struct Interaction: Codable, Hashable { public let flags: Int? } - public struct ResolvedMember: Codable, Hashable { + public struct ResolvedMember: Codable, Hashable, Sendable { public let roles: [RoleID] public let premium_since: String? public let pending: Bool? @@ -58,7 +58,7 @@ public struct Interaction: Codable, Hashable { public let joined_at: String? } - public struct ResolvedAttachment: Codable, Hashable { + public struct ResolvedAttachment: Codable, Hashable, Sendable { public let id: AttachmentID public let filename: String public let size: Int? @@ -73,7 +73,7 @@ public struct Interaction: Codable, Hashable { public let ephemeral: Bool? } - public struct ResolvedData: Codable, Hashable { + public struct ResolvedData: Codable, Hashable, Sendable { public let users: [UserID: User]? public let members: [UserID: ResolvedMember]? public let roles: [RoleID: ResolvedRole]? @@ -82,7 +82,7 @@ public struct Interaction: Codable, Hashable { public let attachments: [AttachmentID: ResolvedAttachment]? } - public struct ApplicationCommandData: Codable, Hashable { + public struct ApplicationCommandData: Codable, Hashable, Sendable { public let id: InteractionID? public let name: String public let type: Int? @@ -99,7 +99,7 @@ public struct Interaction: Codable, Hashable { // Attachment command input public let attachments: [ResolvedAttachment]? - public struct Option: Codable, Hashable { + public struct Option: Codable, Hashable, Sendable { public let name: String public let type: Int? public let value: JSONValue? diff --git a/Sources/SwiftDisc/Models/Invite.swift b/Sources/SwiftDisc/Models/Invite.swift index bbd4c33..29ee2b1 100644 --- a/Sources/SwiftDisc/Models/Invite.swift +++ b/Sources/SwiftDisc/Models/Invite.swift @@ -1,11 +1,11 @@ import Foundation -public struct Invite: Codable, Hashable { - public struct InviteGuild: Codable, Hashable { public let id: GuildID; public let name: String? } - public struct InviteChannel: Codable, Hashable { public let id: ChannelID; public let name: String?; public let type: Int? } +public struct Invite: Codable, Hashable, Sendable { + public struct InviteGuild: Codable, Hashable, Sendable { public let id: GuildID; public let name: String? } + public struct InviteChannel: Codable, Hashable, Sendable { public let id: ChannelID; public let name: String?; public let type: Int? } /// Partial role returned on community invite objects. Only contains id, name, /// position, color, colors, icon, and unicode_emoji per the 2026-02-05 breaking change. - public struct PartialInviteRole: Codable, Hashable { + public struct PartialInviteRole: Codable, Hashable, Sendable { public let id: RoleID public let name: String? public let position: Int? diff --git a/Sources/SwiftDisc/Models/Message.swift b/Sources/SwiftDisc/Models/Message.swift index bd2af3a..a98a631 100644 --- a/Sources/SwiftDisc/Models/Message.swift +++ b/Sources/SwiftDisc/Models/Message.swift @@ -1,7 +1,9 @@ import Foundation -// Box class to break infinite recursion for value types -public final class Box: Codable, Hashable { +// Box class to break infinite recursion for value types. +// Marked `@unchecked Sendable` because the single stored property is immutable (`let`), +// making instances effectively thread-safe despite being a class. +public final class Box: Codable, Hashable, @unchecked Sendable { public let value: T public init(_ value: T) { self.value = value } public static func == (lhs: Box, rhs: Box) -> Bool { lhs.value == rhs.value } @@ -10,7 +12,7 @@ public final class Box: Codable, Hashable { /// Discord message model with broad field coverage, including polls, interaction metadata, /// replies, voice messages, and components. -public struct Message: Codable, Hashable { +public struct Message: Codable, Hashable, Sendable { public let id: MessageID public let channel_id: ChannelID public let guild_id: GuildID? @@ -47,26 +49,26 @@ public struct Message: Codable, Hashable { public let attachments_sync_status: Int? } -public struct ChannelMention: Codable, Hashable { +public struct ChannelMention: Codable, Hashable, Sendable { public let id: ChannelID public let guild_id: GuildID public let type: Int public let name: String } -public struct MessageReference: Codable, Hashable { +public struct MessageReference: Codable, Hashable, Sendable { public let message_id: MessageID? public let channel_id: ChannelID? public let guild_id: GuildID? public let fail_if_not_exists: Bool? } -public struct MessageActivity: Codable, Hashable { +public struct MessageActivity: Codable, Hashable, Sendable { public let type: Int public let party_id: String? } -public struct MessageApplication: Codable, Hashable { +public struct MessageApplication: Codable, Hashable, Sendable { public let id: ApplicationID public let cover_image: String? public let description: String @@ -74,7 +76,7 @@ public struct MessageApplication: Codable, Hashable { public let name: String } -public struct MessageInteractionMetadata: Codable, Hashable { +public struct MessageInteractionMetadata: Codable, Hashable, Sendable { public let id: InteractionID public let type: Int public let user: User? @@ -84,14 +86,14 @@ public struct MessageInteractionMetadata: Codable, Hashable { public let triggering_interaction_metadata: Box? } -public struct RoleSubscriptionData: Codable, Hashable { +public struct RoleSubscriptionData: Codable, Hashable, Sendable { public let role_subscription_listing_id: Snowflake public let tier_name: String public let total_months_subscribed: Int public let is_renewal: Bool } -public struct ResolvedData: Codable, Hashable { +public struct ResolvedData: Codable, Hashable, Sendable { public let attachments: [AttachmentID: Attachment]? public let users: [UserID: User]? public let members: [UserID: GuildMember]? @@ -100,19 +102,19 @@ public struct ResolvedData: Codable, Hashable { public let messages: [MessageID: Box]? } -public struct Poll: Codable, Hashable { - public struct Media: Codable, Hashable { +public struct Poll: Codable, Hashable, Sendable { + public struct Media: Codable, Hashable, Sendable { public let text: String? public let emoji: PartialEmoji? } - public struct Answer: Codable, Hashable { + public struct Answer: Codable, Hashable, Sendable { public let answer_id: Int public let poll_media: Media } - public struct Results: Codable, Hashable { - public struct Count: Codable, Hashable { + public struct Results: Codable, Hashable, Sendable { + public struct Count: Codable, Hashable, Sendable { public let id: Int public let count: Int public let me_voted: Bool? @@ -130,21 +132,62 @@ public struct Poll: Codable, Hashable { public let results: Results? } -public struct AllowedMentions: Codable, Hashable { +public struct AllowedMentions: Codable, Hashable, Sendable { public let parse: [String]? public let roles: [RoleID]? public let users: [UserID]? public let replied_user: Bool? } -public struct Reaction: Codable, Hashable { +public struct Reaction: Codable, Hashable, Sendable { public let count: Int public let me: Bool public let emoji: PartialEmoji } -public struct PartialEmoji: Codable, Hashable { +public struct PartialEmoji: Codable, Hashable, Sendable { public let id: EmojiID? public let name: String? public let animated: Bool? } + +// MARK: - Reply convenience + +public extension Message { + /// Reply to this message in the same channel, setting `message_reference` automatically. + /// + /// - Parameters: + /// - client: The `DiscordClient` to use for the HTTP call. + /// - content: Plain-text content for the reply. + /// - embeds: Optional embeds to attach. + /// - components: Optional component rows to attach. + /// - mention: When `false`, suppresses the @mention ping on the replied-to author. + /// Defaults to `true` (Discord default behaviour). + /// - Returns: The newly created reply `Message`. + @discardableResult + func reply( + client: DiscordClient, + content: String? = nil, + embeds: [Embed]? = nil, + components: [MessageComponent]? = nil, + mention: Bool = true + ) async throws -> Message { + let ref = MessageReference( + message_id: id, + channel_id: channel_id, + guild_id: guild_id, + fail_if_not_exists: false + ) + let allowed: AllowedMentions? = mention + ? nil + : AllowedMentions(parse: [], roles: nil, users: nil, replied_user: false) + return try await client.sendMessage( + channelId: channel_id, + content: content, + embeds: embeds, + components: components, + allowedMentions: allowed, + messageReference: ref + ) + } +} diff --git a/Sources/SwiftDisc/Models/MessageComponents.swift b/Sources/SwiftDisc/Models/MessageComponents.swift index 88aac64..e3be876 100644 --- a/Sources/SwiftDisc/Models/MessageComponents.swift +++ b/Sources/SwiftDisc/Models/MessageComponents.swift @@ -1,6 +1,6 @@ import Foundation -public enum MessageComponent: Codable, Hashable { +public enum MessageComponent: Codable, Hashable, Sendable { case actionRow(ActionRow) case button(Button) case select(SelectMenu) @@ -47,13 +47,13 @@ public enum MessageComponent: Codable, Hashable { private enum CodingKeys: String, CodingKey { case type } - public struct ActionRow: Codable, Hashable { + public struct ActionRow: Codable, Hashable, Sendable { public let type: Int = 1 public let components: [MessageComponent] public init(components: [MessageComponent]) { self.components = components } } - public struct Button: Codable, Hashable { + public struct Button: Codable, Hashable, Sendable { public let type: Int = 2 public let style: Int public let label: String? @@ -69,8 +69,8 @@ public enum MessageComponent: Codable, Hashable { } } - public struct SelectMenu: Codable, Hashable { - public struct Option: Codable, Hashable { + public struct SelectMenu: Codable, Hashable, Sendable { + public struct Option: Codable, Hashable, Sendable { public let label: String public let value: String public let description: String? @@ -94,8 +94,8 @@ public enum MessageComponent: Codable, Hashable { } } - public struct TextInput: Codable, Hashable { - public enum Style: Int, Codable { case short = 1, paragraph = 2 } + public struct TextInput: Codable, Hashable, Sendable { + public enum Style: Int, Codable, Sendable { case short = 1, paragraph = 2 } public let type: Int = 4 public let custom_id: String public let style: Style @@ -121,7 +121,7 @@ public enum MessageComponent: Codable, Hashable { /// Label layout component (type 21). Top-level container for modal components. /// Provides a `label` and optional `description`, and wraps a single interactive component. - public struct Label: Codable, Hashable { + public struct Label: Codable, Hashable, Sendable { public let type: Int = 21 public let label: String public let description: String? @@ -135,12 +135,12 @@ public enum MessageComponent: Codable, Hashable { } /// Radio Group component (type 22). Single-selection picker for modals; must be inside a Label. - public struct RadioGroup: Codable, Hashable { + public struct RadioGroup: Codable, Hashable, Sendable { public let type: Int = 22 public let custom_id: String public let options: [RadioOption] public let required: Bool? - public struct RadioOption: Codable, Hashable { + public struct RadioOption: Codable, Hashable, Sendable { public let label: String public let value: String public let description: String? @@ -160,13 +160,13 @@ public enum MessageComponent: Codable, Hashable { } /// Checkbox Group component (type 23). Multi-selection picker for modals; must be inside a Label. - public struct CheckboxGroup: Codable, Hashable { + public struct CheckboxGroup: Codable, Hashable, Sendable { public let type: Int = 23 public let custom_id: String public let options: [CheckboxOption] public let min_values: Int? public let max_values: Int? - public struct CheckboxOption: Codable, Hashable { + public struct CheckboxOption: Codable, Hashable, Sendable { public let label: String public let value: String public let description: String? @@ -187,7 +187,7 @@ public enum MessageComponent: Codable, Hashable { } /// Checkbox component (type 24). Boolean yes/no toggle for modals; must be inside a Label. - public struct Checkbox: Codable, Hashable { + public struct Checkbox: Codable, Hashable, Sendable { public let type: Int = 24 public let custom_id: String public let required: Bool? diff --git a/Sources/SwiftDisc/Models/Monetization.swift b/Sources/SwiftDisc/Models/Monetization.swift index dc7b6fe..d301d09 100644 --- a/Sources/SwiftDisc/Models/Monetization.swift +++ b/Sources/SwiftDisc/Models/Monetization.swift @@ -1,6 +1,6 @@ import Foundation -public struct SKU: Codable, Hashable { +public struct SKU: Codable, Hashable, Sendable { public let id: SKUID public let type: Int public let application_id: ApplicationID @@ -10,7 +10,7 @@ public struct SKU: Codable, Hashable { public let access_type: Int? } -public struct Entitlement: Codable, Hashable { +public struct Entitlement: Codable, Hashable, Sendable { public let id: EntitlementID public let sku_id: SKUID public let application_id: ApplicationID diff --git a/Sources/SwiftDisc/Models/Onboarding.swift b/Sources/SwiftDisc/Models/Onboarding.swift index f756a11..743dbe8 100644 --- a/Sources/SwiftDisc/Models/Onboarding.swift +++ b/Sources/SwiftDisc/Models/Onboarding.swift @@ -1,6 +1,6 @@ import Foundation -public struct Onboarding: Codable, Hashable { +public struct Onboarding: Codable, Hashable, Sendable { public let guild_id: GuildID public let prompts: [OnboardingPrompt] public let default_channel_ids: [ChannelID] @@ -9,7 +9,7 @@ public struct Onboarding: Codable, Hashable { public let default_recommendation_channel_ids: [ChannelID]? } -public struct OnboardingPrompt: Codable, Hashable { +public struct OnboardingPrompt: Codable, Hashable, Sendable { public let id: Snowflake public let type: Int public let options: [OnboardingPromptOption] @@ -19,7 +19,7 @@ public struct OnboardingPrompt: Codable, Hashable { public let in_onboarding: Bool } -public struct OnboardingPromptOption: Codable, Hashable { +public struct OnboardingPromptOption: Codable, Hashable, Sendable { public let id: Snowflake public let channel_ids: [ChannelID]? public let role_ids: [RoleID]? diff --git a/Sources/SwiftDisc/Models/PartialGuild.swift b/Sources/SwiftDisc/Models/PartialGuild.swift index c260bac..c46a7ee 100644 --- a/Sources/SwiftDisc/Models/PartialGuild.swift +++ b/Sources/SwiftDisc/Models/PartialGuild.swift @@ -1,6 +1,6 @@ import Foundation -public struct PartialGuild: Codable, Hashable { +public struct PartialGuild: Codable, Hashable, Sendable { public let id: GuildID public let name: String } diff --git a/Sources/SwiftDisc/Models/PermissionBitset.swift b/Sources/SwiftDisc/Models/PermissionBitset.swift index 3fca61f..bc5fc84 100644 --- a/Sources/SwiftDisc/Models/PermissionBitset.swift +++ b/Sources/SwiftDisc/Models/PermissionBitset.swift @@ -1,6 +1,6 @@ import Foundation -public struct PermissionBitset: OptionSet, Codable, Hashable { +public struct PermissionBitset: OptionSet, Codable, Hashable, Sendable { public let rawValue: UInt64 public init(rawValue: UInt64) { self.rawValue = rawValue } diff --git a/Sources/SwiftDisc/Models/Role.swift b/Sources/SwiftDisc/Models/Role.swift index edcea46..267eaf3 100644 --- a/Sources/SwiftDisc/Models/Role.swift +++ b/Sources/SwiftDisc/Models/Role.swift @@ -1,7 +1,7 @@ import Foundation /// Represents a single stop in a gradient role color. -public struct RoleColorStop: Codable, Hashable { +public struct RoleColorStop: Codable, Hashable, Sendable { public let color: Int public let position: Double? public init(color: Int, position: Double? = nil) { @@ -12,7 +12,7 @@ public struct RoleColorStop: Codable, Hashable { /// Gradient role colors object. Present when the guild has `ENHANCED_ROLE_COLORS` feature. /// Added 2025-07-02 per Discord API changelog. -public struct RoleColors: Codable, Hashable { +public struct RoleColors: Codable, Hashable, Sendable { /// Primary (single) color as an integer, for backwards compatibility. public let primary_color: Int? /// Gradient color stops. When present the role displays a gradient. @@ -23,7 +23,7 @@ public struct RoleColors: Codable, Hashable { } } -public struct Role: Codable, Hashable { +public struct Role: Codable, Hashable, Sendable { public let id: RoleID public let name: String /// Deprecated in favour of `colors`; still returned for backwards compatibility. diff --git a/Sources/SwiftDisc/Models/RoleMemberCount.swift b/Sources/SwiftDisc/Models/RoleMemberCount.swift index 95136c9..036b599 100644 --- a/Sources/SwiftDisc/Models/RoleMemberCount.swift +++ b/Sources/SwiftDisc/Models/RoleMemberCount.swift @@ -1,6 +1,6 @@ import Foundation -public struct RoleMemberCount: Decodable { +public struct RoleMemberCount: Decodable, Sendable { public let role_id: RoleID public let count: Int } diff --git a/Sources/SwiftDisc/Models/ScheduledEvent.swift b/Sources/SwiftDisc/Models/ScheduledEvent.swift index 4604b90..c4fce28 100644 --- a/Sources/SwiftDisc/Models/ScheduledEvent.swift +++ b/Sources/SwiftDisc/Models/ScheduledEvent.swift @@ -1,8 +1,8 @@ import Foundation -public struct GuildScheduledEvent: Codable, Hashable { - public enum EntityType: Int, Codable { case stageInstance = 1, voice = 2, external = 3 } - public enum Status: Int, Codable { case scheduled = 1, active = 2, completed = 3, canceled = 4 } +public struct GuildScheduledEvent: Codable, Hashable, Sendable { + public enum EntityType: Int, Codable, Sendable { case stageInstance = 1, voice = 2, external = 3 } + public enum Status: Int, Codable, Sendable { case scheduled = 1, active = 2, completed = 3, canceled = 4 } public let id: GuildScheduledEventID public let guild_id: GuildID public let channel_id: ChannelID? @@ -18,7 +18,7 @@ public struct GuildScheduledEvent: Codable, Hashable { public let entity_metadata: EntityMetadata? public let user_count: Int? - public struct EntityMetadata: Codable, Hashable { + public struct EntityMetadata: Codable, Hashable, Sendable { public let location: String? } } diff --git a/Sources/SwiftDisc/Models/ScheduledEventUser.swift b/Sources/SwiftDisc/Models/ScheduledEventUser.swift index d9d93b2..a471049 100644 --- a/Sources/SwiftDisc/Models/ScheduledEventUser.swift +++ b/Sources/SwiftDisc/Models/ScheduledEventUser.swift @@ -1,6 +1,6 @@ import Foundation -public struct GuildScheduledEventUser: Codable, Hashable { +public struct GuildScheduledEventUser: Codable, Hashable, Sendable { public let guild_scheduled_event_id: GuildScheduledEventID public let user: User public let member: GuildMember? diff --git a/Sources/SwiftDisc/Models/Snowflake.swift b/Sources/SwiftDisc/Models/Snowflake.swift index c00cdb3..cf4d5d2 100644 --- a/Sources/SwiftDisc/Models/Snowflake.swift +++ b/Sources/SwiftDisc/Models/Snowflake.swift @@ -7,6 +7,10 @@ public struct Snowflake: Hashable, Codable, CustomStringConvertible, Expressi public var description: String { rawValue } } +// Snowflake only stores a plain `String`; the phantom type parameter `T` is never +// stored at runtime, so this conformance is sound unconditionally. +extension Snowflake: @unchecked Sendable {} + public typealias UserID = Snowflake public typealias ChannelID = Snowflake public typealias MessageID = Snowflake diff --git a/Sources/SwiftDisc/Models/StageInstance.swift b/Sources/SwiftDisc/Models/StageInstance.swift index 0e9b1e5..334a336 100644 --- a/Sources/SwiftDisc/Models/StageInstance.swift +++ b/Sources/SwiftDisc/Models/StageInstance.swift @@ -1,6 +1,6 @@ import Foundation -public struct StageInstance: Codable, Hashable { +public struct StageInstance: Codable, Hashable, Sendable { public let id: StageInstanceID public let guild_id: GuildID public let channel_id: ChannelID diff --git a/Sources/SwiftDisc/Models/Sticker.swift b/Sources/SwiftDisc/Models/Sticker.swift index 0ed4ba5..c07d691 100644 --- a/Sources/SwiftDisc/Models/Sticker.swift +++ b/Sources/SwiftDisc/Models/Sticker.swift @@ -1,6 +1,6 @@ import Foundation -public struct Sticker: Codable, Hashable { +public struct Sticker: Codable, Hashable, Sendable { public let id: StickerID public let name: String public let description: String? @@ -11,13 +11,13 @@ public struct Sticker: Codable, Hashable { public let guild_id: GuildID? } -public struct StickerItem: Codable, Hashable { +public struct StickerItem: Codable, Hashable, Sendable { public let id: StickerID public let name: String public let format_type: Int } -public struct StickerPack: Codable, Hashable { +public struct StickerPack: Codable, Hashable, Sendable { public let id: StickerPackID public let stickers: [Sticker] public let name: String diff --git a/Sources/SwiftDisc/Models/Template.swift b/Sources/SwiftDisc/Models/Template.swift index 3551742..6cb7350 100644 --- a/Sources/SwiftDisc/Models/Template.swift +++ b/Sources/SwiftDisc/Models/Template.swift @@ -1,7 +1,7 @@ import Foundation -public struct Template: Codable, Hashable { - public struct TemplateGuild: Codable, Hashable { public let id: GuildID?; public let name: String? } +public struct Template: Codable, Hashable, Sendable { + public struct TemplateGuild: Codable, Hashable, Sendable { public let id: GuildID?; public let name: String? } public let code: String public let name: String public let description: String? diff --git a/Sources/SwiftDisc/Models/Thread.swift b/Sources/SwiftDisc/Models/Thread.swift index a10d7f8..bd37a54 100644 --- a/Sources/SwiftDisc/Models/Thread.swift +++ b/Sources/SwiftDisc/Models/Thread.swift @@ -1,6 +1,6 @@ import Foundation -public struct ThreadMember: Codable, Hashable { +public struct ThreadMember: Codable, Hashable, Sendable { public let id: ChannelID? public let user_id: UserID? public let join_timestamp: String @@ -8,7 +8,7 @@ public struct ThreadMember: Codable, Hashable { public let member: GuildMember? } -public struct ThreadListResponse: Codable, Hashable { +public struct ThreadListResponse: Codable, Hashable, Sendable { public let threads: [Channel] public let members: [ThreadMember] public let has_more: Bool diff --git a/Sources/SwiftDisc/Models/User.swift b/Sources/SwiftDisc/Models/User.swift index 27d7969..096e0f5 100644 --- a/Sources/SwiftDisc/Models/User.swift +++ b/Sources/SwiftDisc/Models/User.swift @@ -2,7 +2,7 @@ import Foundation /// Represents the user's primary guild (guild tag) information. /// Added 2025-07-02 per Discord API changelog (Guild Tags). -public struct UserPrimaryGuild: Codable, Hashable { +public struct UserPrimaryGuild: Codable, Hashable, Sendable { /// The ID of the guild the user has set as their primary guild. public let guild_id: String? /// The badge / tag string displayed next to the user's display name (1–4 characters). @@ -12,7 +12,7 @@ public struct UserPrimaryGuild: Codable, Hashable { public let identity_guild_id: String? } -public struct User: Codable, Hashable { +public struct User: Codable, Hashable, Sendable { public let id: UserID public let username: String public let discriminator: String? diff --git a/Sources/SwiftDisc/Models/VanityURL.swift b/Sources/SwiftDisc/Models/VanityURL.swift index 42f8ef5..6c7f2e3 100644 --- a/Sources/SwiftDisc/Models/VanityURL.swift +++ b/Sources/SwiftDisc/Models/VanityURL.swift @@ -1,6 +1,6 @@ import Foundation -public struct VanityURL: Codable, Hashable { +public struct VanityURL: Codable, Hashable, Sendable { public let code: String? public let uses: Int } diff --git a/Sources/SwiftDisc/Models/Webhook.swift b/Sources/SwiftDisc/Models/Webhook.swift index 886aab3..48cc14b 100644 --- a/Sources/SwiftDisc/Models/Webhook.swift +++ b/Sources/SwiftDisc/Models/Webhook.swift @@ -1,6 +1,6 @@ import Foundation -public struct Webhook: Codable, Hashable { +public struct Webhook: Codable, Hashable, Sendable { public let id: WebhookID public let type: Int public let channel_id: ChannelID? diff --git a/Sources/SwiftDisc/REST/HTTPClient.swift b/Sources/SwiftDisc/REST/HTTPClient.swift index 7d5f491..8da1629 100644 --- a/Sources/SwiftDisc/REST/HTTPClient.swift +++ b/Sources/SwiftDisc/REST/HTTPClient.swift @@ -7,7 +7,7 @@ private struct APIErrorBody: Decodable { let message: String; let code: Int? } #if canImport(FoundationNetworking) || os(macOS) || os(iOS) || os(tvOS) || os(watchOS) -final class HTTPClient { +final class HTTPClient: @unchecked Sendable { private let token: String private let configuration: DiscordConfiguration private let session: URLSession @@ -33,12 +33,12 @@ final class HTTPClient { self.session = URLSession(configuration: config) } - func get(path: String) async throws -> T { + func get(path: String) async throws(DiscordError) -> T { try await request(method: "GET", path: path, body: Optional.none) } /// Fetch raw response bytes without JSON decoding. Useful for non-JSON endpoints (e.g. CSV). - func getRaw(path: String) async throws -> Data { + func getRaw(path: String) async throws(DiscordError) -> Data { let trimmed = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let routeKey = makeRouteKey(method: "GET", path: trimmed) var attempt = 0; let maxAttempts = 4 @@ -67,6 +67,8 @@ final class HTTPClient { throw DiscordError.api(message: apiErr.message, code: apiErr.code) } throw DiscordError.http(http.statusCode, String(data: data, encoding: .utf8) ?? "") + } catch let de as DiscordError { + throw de } catch { if (error as? URLError)?.code == .cancelled { throw DiscordError.cancelled } if attempt < maxAttempts { @@ -78,40 +80,40 @@ final class HTTPClient { } } - func post(path: String, body: B) async throws -> T { + func post(path: String, body: B) async throws(DiscordError) -> T { let data: Data do { data = try JSONEncoder().encode(body) } catch { throw DiscordError.encoding(error) } return try await request(method: "POST", path: path, body: data) } - func patch(path: String, body: B) async throws -> T { + func patch(path: String, body: B) async throws(DiscordError) -> T { let data: Data do { data = try JSONEncoder().encode(body) } catch { throw DiscordError.encoding(error) } return try await request(method: "PATCH", path: path, body: data) } - func put(path: String, body: B) async throws -> T { + func put(path: String, body: B) async throws(DiscordError) -> T { let data: Data do { data = try JSONEncoder().encode(body) } catch { throw DiscordError.encoding(error) } return try await request(method: "PUT", path: path, body: data) } // Convenience: PUT with no body and expecting no content (204) - func put(path: String) async throws { + func put(path: String) async throws(DiscordError) { let _: EmptyResponse = try await request(method: "PUT", path: path, body: Optional.none) } - func delete(path: String) async throws -> T { + func delete(path: String) async throws(DiscordError) -> T { try await request(method: "DELETE", path: path, body: Optional.none) } - func delete(path: String) async throws { + func delete(path: String) async throws(DiscordError) { let _: EmptyResponse = try await request(method: "DELETE", path: path, body: Optional.none) } private struct EmptyResponse: Decodable {} - private func request(method: String, path: String, body: Data?) async throws -> T { + private func request(method: String, path: String, body: Data?) async throws(DiscordError) -> T { let trimmed = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let routeKey = makeRouteKey(method: method, path: trimmed) @@ -159,6 +161,9 @@ final class HTTPClient { } let message = String(data: data, encoding: .utf8) ?? "" throw DiscordError.http(http.statusCode, message) + } catch let de as DiscordError { + // Re-throw typed DiscordErrors without wrapping them + throw de } catch { if (error as? URLError)?.code == .cancelled { throw DiscordError.cancelled } if attempt < maxAttempts { @@ -228,7 +233,7 @@ final class HTTPClient { return body } - func postMultipart(path: String, jsonBody: B?, files: [FileAttachment]) async throws -> T { + func postMultipart(path: String, jsonBody: B?, files: [FileAttachment]) async throws(DiscordError) -> T { let trimmed = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let routeKey = makeRouteKey(method: "POST", path: trimmed) @@ -275,6 +280,8 @@ final class HTTPClient { } let message = String(data: data, encoding: .utf8) ?? "" throw DiscordError.http(http.statusCode, message) + } catch let de as DiscordError { + throw de } catch { if (error as? URLError)?.code == .cancelled { throw DiscordError.cancelled } if attempt < maxAttempts { @@ -287,7 +294,7 @@ final class HTTPClient { } } - func patchMultipart(path: String, jsonBody: B?, files: [FileAttachment]?) async throws -> T { + func patchMultipart(path: String, jsonBody: B?, files: [FileAttachment]?) async throws(DiscordError) -> T { let trimmed = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let routeKey = makeRouteKey(method: "PATCH", path: trimmed) @@ -335,6 +342,8 @@ final class HTTPClient { } let message = String(data: data, encoding: .utf8) ?? "" throw DiscordError.http(http.statusCode, message) + } catch let de as DiscordError { + throw de } catch { if (error as? URLError)?.code == .cancelled { throw DiscordError.cancelled } if attempt < maxAttempts { @@ -376,7 +385,7 @@ final class HTTPClient { #else -final class HTTPClient { +final class HTTPClient: @unchecked Sendable { private let token: String private let configuration: DiscordConfiguration @@ -385,46 +394,44 @@ final class HTTPClient { self.configuration = configuration } - struct HTTPUnavailable: Error {} - - func get(path: String) async throws -> T { - throw HTTPUnavailable() + func get(path: String) async throws(DiscordError) -> T { + throw DiscordError.unavailable } - func post(path: String, body: B) async throws -> T { - throw HTTPUnavailable() + func post(path: String, body: B) async throws(DiscordError) -> T { + throw DiscordError.unavailable } - func patch(path: String, body: B) async throws -> T { - throw HTTPUnavailable() + func patch(path: String, body: B) async throws(DiscordError) -> T { + throw DiscordError.unavailable } - func put(path: String, body: B) async throws -> T { - throw HTTPUnavailable() + func put(path: String, body: B) async throws(DiscordError) -> T { + throw DiscordError.unavailable } - func put(path: String) async throws { - throw HTTPUnavailable() + func put(path: String) async throws(DiscordError) { + throw DiscordError.unavailable } - func delete(path: String) async throws -> T { - throw HTTPUnavailable() + func delete(path: String) async throws(DiscordError) -> T { + throw DiscordError.unavailable } - func delete(path: String) async throws { - throw HTTPUnavailable() + func delete(path: String) async throws(DiscordError) { + throw DiscordError.unavailable } - func postMultipart(path: String, jsonBody: B?, files: [FileAttachment]) async throws -> T { - throw HTTPUnavailable() + func postMultipart(path: String, jsonBody: B?, files: [FileAttachment]) async throws(DiscordError) -> T { + throw DiscordError.unavailable } - func patchMultipart(path: String, jsonBody: B?, files: [FileAttachment]?) async throws -> T { - throw HTTPUnavailable() + func patchMultipart(path: String, jsonBody: B?, files: [FileAttachment]?) async throws(DiscordError) -> T { + throw DiscordError.unavailable } - func getRaw(path: String) async throws -> Data { - throw HTTPUnavailable() + func getRaw(path: String) async throws(DiscordError) -> Data { + throw DiscordError.unavailable } } diff --git a/Sources/SwiftDisc/REST/RateLimiter.swift b/Sources/SwiftDisc/REST/RateLimiter.swift index a747c66..8e54d79 100644 --- a/Sources/SwiftDisc/REST/RateLimiter.swift +++ b/Sources/SwiftDisc/REST/RateLimiter.swift @@ -10,13 +10,14 @@ actor RateLimiter { private var buckets: [String: BucketState] = [:] private var globalResetAt: Date? - func waitTurn(routeKey: String) async throws { + func waitTurn(routeKey: String) async throws(DiscordError) { // Respect global rate limit if active if let greset = globalResetAt { let now = Date() if greset > now { let delay = greset.timeIntervalSince(now) - try await Task.sleep(nanoseconds: UInt64(max(0, delay) * 1_000_000_000)) + do { try await Task.sleep(nanoseconds: UInt64(max(0, delay) * 1_000_000_000)) } + catch { throw DiscordError.cancelled } } else { globalResetAt = nil } @@ -28,7 +29,8 @@ actor RateLimiter { let now = Date() if resetAt > now { let delay = resetAt.timeIntervalSince(now) - try await Task.sleep(nanoseconds: UInt64(max(0, delay) * 1_000_000_000)) + do { try await Task.sleep(nanoseconds: UInt64(max(0, delay) * 1_000_000_000)) } + catch { throw DiscordError.cancelled } } // After reset, clear remaining; let next response headers set correct values buckets[routeKey]?.remaining = nil diff --git a/Sources/SwiftDisc/Voice/AudioSource.swift b/Sources/SwiftDisc/Voice/AudioSource.swift index 97cae69..f7da93f 100644 --- a/Sources/SwiftDisc/Voice/AudioSource.swift +++ b/Sources/SwiftDisc/Voice/AudioSource.swift @@ -1,6 +1,6 @@ import Foundation -public struct OpusFrame { +public struct OpusFrame: Sendable { public let data: Data // One Opus packet (20ms recommended) public let durationMs: Int public init(data: Data, durationMs: Int = 20) { @@ -9,7 +9,9 @@ public struct OpusFrame { } } -public protocol VoiceAudioSource { +/// A source of Opus audio frames. Conforming types must be `Sendable` so they +/// can be passed into async voice-playback tasks. +public protocol VoiceAudioSource: Sendable { // Returns the next Opus frame or nil when finished func nextFrame() async throws -> OpusFrame? } diff --git a/Sources/SwiftDisc/Voice/PipeOpusSource.swift b/Sources/SwiftDisc/Voice/PipeOpusSource.swift index 4127c08..691430a 100644 --- a/Sources/SwiftDisc/Voice/PipeOpusSource.swift +++ b/Sources/SwiftDisc/Voice/PipeOpusSource.swift @@ -2,7 +2,9 @@ import Foundation // Reads length-prefixed Opus frames from a FileHandle. // Format: [u32 little-endian length][ bytes payload] repeated. -public final class PipeOpusSource: VoiceAudioSource { +// Marked `@unchecked Sendable` because `FileHandle` is not Sendable in Swift 6; +// callers are responsible for ensuring single-threaded access to the handle. +public final class PipeOpusSource: VoiceAudioSource, @unchecked Sendable { private let handle: FileHandle private let defaultFrameDurationMs: Int diff --git a/Sources/SwiftDisc/Voice/VoiceClient.swift b/Sources/SwiftDisc/Voice/VoiceClient.swift index 73e64ba..45a6cfb 100644 --- a/Sources/SwiftDisc/Voice/VoiceClient.swift +++ b/Sources/SwiftDisc/Voice/VoiceClient.swift @@ -3,10 +3,10 @@ import Foundation import Network #endif -final class VoiceClient { +final class VoiceClient: @unchecked Sendable { private let token: String private let configuration: DiscordConfiguration - private let sendVoiceStateUpdate: (GuildID, ChannelID?, Bool, Bool) async -> Void + private let sendVoiceStateUpdate: @Sendable (GuildID, ChannelID?, Bool, Bool) async -> Void private struct Session { var guildId: GuildID @@ -25,15 +25,15 @@ final class VoiceClient { private var sessions: [GuildID: Session] = [:] private var botUserId: UserID? - private var onFrame: ((VoiceFrame) -> Void)? + private var onFrame: (@Sendable (VoiceFrame) -> Void)? - init(token: String, configuration: DiscordConfiguration, sendVoiceStateUpdate: @escaping (GuildID, ChannelID?, Bool, Bool) async -> Void) { + init(token: String, configuration: DiscordConfiguration, sendVoiceStateUpdate: @escaping @Sendable (GuildID, ChannelID?, Bool, Bool) async -> Void) { self.token = token self.configuration = configuration self.sendVoiceStateUpdate = sendVoiceStateUpdate } - func setOnFrame(_ handler: @escaping (VoiceFrame) -> Void) { + func setOnFrame(_ handler: @escaping @Sendable (VoiceFrame) -> Void) { self.onFrame = handler } diff --git a/Sources/SwiftDisc/Voice/VoiceGateway.swift b/Sources/SwiftDisc/Voice/VoiceGateway.swift index 7c1f7ea..4247e80 100644 --- a/Sources/SwiftDisc/Voice/VoiceGateway.swift +++ b/Sources/SwiftDisc/Voice/VoiceGateway.swift @@ -1,6 +1,6 @@ import Foundation -final class VoiceGateway { +final class VoiceGateway: @unchecked Sendable { struct Hello: Codable { let heartbeat_interval: Int } struct Ready: Codable { let ssrc: UInt32; let port: UInt16; let modes: [String] } struct SessionDescription: Codable { let mode: String; let secret_key: [UInt8] } diff --git a/Sources/SwiftDisc/Voice/VoiceModels.swift b/Sources/SwiftDisc/Voice/VoiceModels.swift index 540e860..69a3801 100644 --- a/Sources/SwiftDisc/Voice/VoiceModels.swift +++ b/Sources/SwiftDisc/Voice/VoiceModels.swift @@ -28,7 +28,7 @@ public struct VoiceConnectionInfo { } } -public struct VoiceFrame { +public struct VoiceFrame: Sendable { public let guildId: GuildID public let ssrc: UInt32 public let sequence: UInt16 diff --git a/Sources/SwiftDisc/Voice/VoiceReceiver.swift b/Sources/SwiftDisc/Voice/VoiceReceiver.swift index c2f05af..ec46159 100644 --- a/Sources/SwiftDisc/Voice/VoiceReceiver.swift +++ b/Sources/SwiftDisc/Voice/VoiceReceiver.swift @@ -2,14 +2,14 @@ import Foundation #if canImport(Network) import Network -final class RTPVoiceReceiver { +final class RTPVoiceReceiver: @unchecked Sendable { private let ssrc: UInt32 private let key: [UInt8] private let encryptor: Secretbox private let connection: NWConnection - private let onFrame: (UInt16, UInt32, Data) -> Void + private let onFrame: @Sendable (UInt16, UInt32, Data) -> Void - init(ssrc: UInt32, key: [UInt8], host: String, port: UInt16, onFrame: @escaping (UInt16, UInt32, Data) -> Void) { + init(ssrc: UInt32, key: [UInt8], host: String, port: UInt16, onFrame: @escaping @Sendable (UInt16, UInt32, Data) -> Void) { self.ssrc = ssrc self.key = key self.encryptor = Secretbox() @@ -64,8 +64,8 @@ final class RTPVoiceReceiver { #else -final class RTPVoiceReceiver { - init(ssrc: UInt32, key: [UInt8], host: String, port: UInt16, onFrame: @escaping (UInt16, UInt32, Data) -> Void) {} +final class RTPVoiceReceiver: Sendable { + init(ssrc: UInt32, key: [UInt8], host: String, port: UInt16, onFrame: @escaping @Sendable (UInt16, UInt32, Data) -> Void) {} func start() {} func stop() {} } diff --git a/Sources/SwiftDisc/Voice/VoiceSender.swift b/Sources/SwiftDisc/Voice/VoiceSender.swift index c1504fc..47928ed 100644 --- a/Sources/SwiftDisc/Voice/VoiceSender.swift +++ b/Sources/SwiftDisc/Voice/VoiceSender.swift @@ -3,11 +3,11 @@ import Foundation import Network #endif -protocol VoiceEncryptor { +protocol VoiceEncryptor: Sendable { func seal(nonce: Data, key: [UInt8], plaintext: Data) throws -> Data } -final class RTPVoiceSender { +final class RTPVoiceSender: @unchecked Sendable { private var sequence: UInt16 = 0 private var timestamp: UInt32 = 0 private let ssrc: UInt32 diff --git a/SwiftDiscDocs.txt b/SwiftDiscDocs.txt index 02e9b24..8852e99 100644 --- a/SwiftDiscDocs.txt +++ b/SwiftDiscDocs.txt @@ -62,10 +62,10 @@ High-Level Routers - Subcommands/groups supported via full-path resolution; optional `onError`. ## Role Connections and Linked Roles -SwiftDisc has added robust support for Discord's role connections feature. This means you can now define custom metadata schemas and update user connections with ease, which is great for creating bots that assign roles based on external factors like premium status or account levels. It's designed to feel intuitive, with typed models and methods that handle the underlying API details, so you can focus on building awesome features without getting bogged down in complexity. +SwiftDisc provides full support for Discord's role connections (Linked Roles) feature. You can define application metadata schemas and update per-user connection records via the typed REST endpoints. This enables bots to conditionally gate roles based on external criteria such as account tier, verification status, or platform-level activity — without any manual HTTP plumbing. ## Typed Permissions and Cache Integration -We've upgraded the permissions system in SwiftDisc to use a typed bitset, which makes your code cleaner and less prone to mistakes. On top of that, we've integrated it with the cache to speed things up—now permission checks can be lightning-fast by pulling data from memory instead of querying the API repeatedly. This not only boosts performance but also sets things up for future expansions, like handling more advanced permission scenarios, all while keeping the API straightforward and easy to use. +Permission values are represented as a `PermissionBitset` — a typed `OptionSet` over a `UInt64` bitfield — rather than raw integers. This provides compile-time safe permission checks and eliminates error-prone manual bit arithmetic. Guild roles and members are stored in the `Cache` on `GUILD_CREATE` and relevant update events, so permission resolution can be performed locally without additional REST round-trips. Usage Examples -------------- @@ -152,12 +152,13 @@ Versioning & Changelog - Semantic Versioning (MAJOR.MINOR.PATCH) with pre-release tags (e.g., 0.1.0-alpha). - All changes documented in CHANGELOG.md. -Roadmap Highlights ------------------- -- Gateway: Resume/reconnect, heartbeat ACK tracking, presence updates, sharding (implemented). -- REST: Per-route buckets, error model decoding, expanding endpoints coverage (implemented and growing). -- High-level: Command helpers, caching layer, callback adapter for UI frameworks. -- Cross-platform: Windows WebSocket adapter and CI (implemented). +Implemented Capabilities Summary +--------------------------------- +- Gateway: Session resume, heartbeat ACK tracking, exponential reconnect backoff, presence updates, full sharding via ShardManager. +- REST: Per-route and global rate limit buckets, typed throws (DiscordError), typed error responses, broad endpoint coverage. +- High-level: CommandRouter, SlashCommandRouter, AutocompleteRouter, ViewManager, CooldownManager, ComponentsBuilder, EmbedBuilder, WebhookClient. +- Concurrency: Full Swift 6 strict-concurrency compliance — all public types are Sendable or actor-isolated. +- Cross-platform: macOS, iOS, tvOS, watchOS, and Windows (Swift 6.2+ toolchain). Support ------- diff --git a/Tests/SwiftDiscTests/CollectorsTests.swift b/Tests/SwiftDiscTests/CollectorsTests.swift index 44ad144..53aa71a 100644 --- a/Tests/SwiftDiscTests/CollectorsTests.swift +++ b/Tests/SwiftDiscTests/CollectorsTests.swift @@ -4,7 +4,7 @@ import XCTest final class CollectorsTests: XCTestCase { func testCreateMessageCollectorReturnsStream() async { let client = DiscordClient(token: "") - let stream = client.createMessageCollector() + let stream = await client.createMessageCollector() // Ensure the returned type is an AsyncStream by obtaining it and then cancelling. var iter = stream.makeAsyncIterator() _ = try? await Task.checkCancellation() @@ -14,7 +14,7 @@ final class CollectorsTests: XCTestCase { func testStreamGuildMembersReturnsStream() async { let client = DiscordClient(token: "") - let stream = client.streamGuildMembers(guildId: "0") + let stream = await client.streamGuildMembers(guildId: "0") var iter = stream.makeAsyncIterator() XCTAssertNotNil(iter) } diff --git a/Tests/SwiftDiscTests/ComponentCollectorTests.swift b/Tests/SwiftDiscTests/ComponentCollectorTests.swift index b817cf2..82d5313 100644 --- a/Tests/SwiftDiscTests/ComponentCollectorTests.swift +++ b/Tests/SwiftDiscTests/ComponentCollectorTests.swift @@ -4,7 +4,7 @@ import XCTest final class ComponentCollectorTests: XCTestCase { func testCreateComponentCollectorReturnsStream() async { let client = DiscordClient(token: "") - let stream = client.createComponentCollector(customId: nil) + let stream = await client.createComponentCollector(customId: nil) var iter = stream.makeAsyncIterator() XCTAssertNotNil(iter) } diff --git a/Tests/SwiftDiscTests/CooldownTests.swift b/Tests/SwiftDiscTests/CooldownTests.swift index 24cf0d7..2049f56 100644 --- a/Tests/SwiftDiscTests/CooldownTests.swift +++ b/Tests/SwiftDiscTests/CooldownTests.swift @@ -2,12 +2,14 @@ import XCTest @testable import SwiftDisc final class CooldownTests: XCTestCase { - func testCooldownSetAndCheck() { + func testCooldownSetAndCheck() async { let manager = CooldownManager() let cmd = "testcmd" let key = "user123" - XCTAssertFalse(manager.isOnCooldown(command: cmd, key: key)) - manager.setCooldown(command: cmd, key: key, duration: 1.0) - XCTAssertTrue(manager.isOnCooldown(command: cmd, key: key)) + let initialCooldown = await manager.isOnCooldown(command: cmd, key: key) + XCTAssertFalse(initialCooldown) + await manager.setCooldown(command: cmd, key: key, duration: 1.0) + let afterCooldown = await manager.isOnCooldown(command: cmd, key: key) + XCTAssertTrue(afterCooldown) } } diff --git a/Tests/SwiftDiscTests/ReleaseV1Tests.swift b/Tests/SwiftDiscTests/ReleaseV1Tests.swift index b82e147..af7bd25 100644 --- a/Tests/SwiftDiscTests/ReleaseV1Tests.swift +++ b/Tests/SwiftDiscTests/ReleaseV1Tests.swift @@ -14,7 +14,7 @@ final class ReleaseV1Tests: XCTestCase { func testPinsStreamType() async { // Ensure the client provides a streaming API for pins (no network call here — just type check) let client = DiscordClient(token: "", configuration: .init()) - let stream = client.streamChannelPins(channelId: "0") + let stream = await client.streamChannelPins(channelId: "0") var iter = stream.makeAsyncIterator() // The iterator should conform; we won't await a value since there's no network in tests here _ = iter diff --git a/Tests/SwiftDiscTests/SlashCommandRouterTests.swift b/Tests/SwiftDiscTests/SlashCommandRouterTests.swift index 6e4e097..6d4fd96 100644 --- a/Tests/SwiftDiscTests/SlashCommandRouterTests.swift +++ b/Tests/SwiftDiscTests/SlashCommandRouterTests.swift @@ -12,7 +12,7 @@ final class SlashCommandRouterTests: XCTestCase { let client = DiscordClient(token: "x") let router = SlashCommandRouter() let exp = expectation(description: "handler") - router.registerPath("admin ban") { ctx in + await router.registerPath("admin ban") { ctx in XCTAssertEqual(ctx.path, "admin ban") XCTAssertEqual(ctx.string("user"), "123") exp.fulfill() diff --git a/Tests/SwiftDiscTests/ViewManagerTests.swift b/Tests/SwiftDiscTests/ViewManagerTests.swift index 707da32..df67af7 100644 --- a/Tests/SwiftDiscTests/ViewManagerTests.swift +++ b/Tests/SwiftDiscTests/ViewManagerTests.swift @@ -5,10 +5,9 @@ final class ViewManagerTests: XCTestCase { func testRegisterAndUnregisterView() async { let client = DiscordClient(token: "") let manager = ViewManager() - client.useViewManager(manager) + await client.useViewManager(manager) - var called = false - let handlers: [String: ViewHandler] = ["btn_ok": { _, _ in called = true }] + let handlers: [String: ViewHandler] = ["btn_ok": { _, _ in }] let view = View(id: "v1", timeout: 0.1, handlers: handlers, oneShot: false) await manager.register(view, client: client)