diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7bcca3c9..70503e3c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,6 +72,13 @@ jobs: restore-keys: | deriveddata-macos + - name: Force Git to use HTTPS + run: | + # rewrite any git@github.com:... clones to https://github.com/... + git config --global url."https://github.com/".insteadOf "git@github.com:" + # optional: debug the rule + git config --get-regexp '^url\.' || true + - name: Build macOS .app run: | COMMIT_HASH=$(git rev-parse --short HEAD) @@ -226,6 +233,13 @@ jobs: restore-keys: | deriveddata-ios + - name: Force Git to use HTTPS + run: | + # rewrite any git@github.com:... clones to https://github.com/... + git config --global url."https://github.com/".insteadOf "git@github.com:" + # optional: debug the rule + git config --get-regexp '^url\.' || true + - name: Build iOS .ipa run: | COMMIT_HASH=$(git rev-parse --short HEAD) diff --git a/.github/workflows/build_pr.yml b/.github/workflows/build_pr.yml index e8f968b8..c68e2870 100644 --- a/.github/workflows/build_pr.yml +++ b/.github/workflows/build_pr.yml @@ -33,6 +33,13 @@ jobs: deriveddata-macos-pr-${{ github.event.pull_request.number }}- deriveddata-macos-pr- + - name: Force Git to use HTTPS + run: | + # rewrite any git@github.com:... clones to https://github.com/... + git config --global url."https://github.com/".insteadOf "git@github.com:" + # optional: debug the rule + git config --get-regexp '^url\.' || true + - name: Build macOS id: build run: | @@ -77,6 +84,13 @@ jobs: deriveddata-ios-pr-${{ github.event.pull_request.number }}- deriveddata-ios-pr- + - name: Force Git to use HTTPS + run: | + # rewrite any git@github.com:... clones to https://github.com/... + git config --global url."https://github.com/".insteadOf "git@github.com:" + # optional: debug the rule + git config --get-regexp '^url\.' || true + - name: Build iOS id: build run: | diff --git a/.github/workflows/release_pr.yml b/.github/workflows/release_pr.yml index e54f82a5..dd57c134 100644 --- a/.github/workflows/release_pr.yml +++ b/.github/workflows/release_pr.yml @@ -102,6 +102,13 @@ jobs: restore-keys: | pr-release-deriveddata-macos- + - name: Force Git to use HTTPS + run: | + # rewrite any git@github.com:... clones to https://github.com/... + git config --global url."https://github.com/".insteadOf "git@github.com:" + # optional: debug the rule + git config --get-regexp '^url\.' || true + - name: Build macOS run: | set -euo pipefail @@ -194,6 +201,13 @@ jobs: echo "ldid installed at: $(command -v ldid)" fi + - name: Force Git to use HTTPS + run: | + # rewrite any git@github.com:... clones to https://github.com/... + git config --global url."https://github.com/".insteadOf "git@github.com:" + # optional: debug the rule + git config --get-regexp '^url\.' || true + - name: Build iOS run: | set -euo pipefail diff --git a/DiscordMarkdownParser/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist b/DiscordMarkdownParser/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist index 4ec1b314..306f0f0c 100644 --- a/DiscordMarkdownParser/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/DiscordMarkdownParser/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ DiscordMarkdownParser.xcscheme_^#shared#^_ orderHint - 3 + 4 diff --git a/DiscordMarkdownParser/Package.swift b/DiscordMarkdownParser/Package.swift index 87c2bb35..8f197ece 100644 --- a/DiscordMarkdownParser/Package.swift +++ b/DiscordMarkdownParser/Package.swift @@ -12,7 +12,8 @@ let package = Package( // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "DiscordMarkdownParser", - targets: ["DiscordMarkdownParser"]) + targets: ["DiscordMarkdownParser"] + ) ], dependencies: [ .package(path: "../PaicordLib") @@ -24,9 +25,17 @@ let package = Package( name: "DiscordMarkdownParser", dependencies: [ "PaicordLib" - ]), + ], + swiftSettings: [ + .interoperabilityMode(.Cxx) + ] + ), .testTarget( name: "DiscordMarkdownParserTests", - dependencies: ["DiscordMarkdownParser"]), + dependencies: ["DiscordMarkdownParser"], + swiftSettings: [ + .interoperabilityMode(.Cxx) + ] + ), ] ) diff --git a/Feature Checklist.md b/Feature Checklist.md new file mode 100644 index 00000000..2176935a --- /dev/null +++ b/Feature Checklist.md @@ -0,0 +1,327 @@ +# Feature Checklist + +Add features as needed, add features we haven't implemented yet so people can know what to work on! + +## Core Architecture +- [x] REST API client +- [x] Gateway (WebSocket) connection +- [x] Gateway reconnect + resume +- [x] Rate limit handling +- [x] Event dispatcher system +- [x] Captcha handling +- [x] Super-properties +- [x] Error handling + retry logic +- [x] CDN asset fetching (avatars, attachments, emojis etc.) + +--- + +# Authentication & Account + +## Login / Auth +- [x] Login email-password +- [x] 2FA support +- [x] Token refresh +- [x] Multi-account support +- [x] Account switching + +## Account Settings +- [ ] Change username +- [ ] Change avatar +- [ ] Change banner +- [ ] Change bio / profile fields +- [ ] Email / password change +- [ ] Language settings +- [ ] Theme settings +- [ ] Accessibility settings + +## User Profiles +- [x] View profile +- [ ] Mutual servers +- [ ] Mutual friends +- [ ] Custom status +- [x] Activity / presence display +- [x] Profile badges + +--- + +# Friends & Social + +## Friends +- [ ] Send friend request +- [ ] Accept / reject friend request +- [ ] Cancel outgoing request +- [ ] Remove friend +- [ ] Friend list UI +- [x] Online / offline indicators + +## Blocks +- [ ] Block user +- [ ] Unblock user +- [ ] Block list + +## Relationships +- [ ] Incoming requests +- [ ] Outgoing requests + +--- + +# Presence System + +- [x] Online / Idle / DND / Invisible +- [ ] Custom status +- [ ] Activity presence +- [ ] Rich presence display +- [ ] Streaming status +- [ ] Game activity + +--- + +# Messaging + +## Direct Messages +- [x] Open DM +- [ ] Create DM channel +- [x] Group DMs +- [ ] Leave group DM +- [ ] Rename group DM +- [ ] Add / remove participants + +## Sending Messages +- [x] Send text message +- [x] Edit message +- [x] Delete message +- [x] Reply to message +- [ ] Message forwarding +- [ ] Entity autocompletions (mentions, commands etc.) + +## Message Content +- [x] Markdown formatting +- [x] Rich embeds +- [x] Mentions +- [x] Role mentions +- [x] Channel mentions +- [x] Custom emoji +- [x] Unicode emoji +- [x] Stickers +- [ ] Attach files +- [x] Image embeds +- [x] Link previews +- [ ] Spoiler tags +- [x] Code blocks + +## Message Reactions + +- [ ] Create reaction +- [x] Add reaction +- [x] Remove reaction +- [ ] Reaction picker +- [x] Reaction counts + +## Message Interaction +- [ ] Buttons +- [ ] Select menus +- [ ] Slash command responses +- [ ] Modals +- [ ] Components V2 + +## Message Threads +- [ ] Create thread +- [ ] Join thread +- [ ] Leave thread +- [ ] Archive thread +- [ ] Thread list + +## Message Management +- [ ] Pin message +- [ ] Unpin message +- [ ] Message search +- [ ] Jump to message +- [ ] Message history pagination + +--- + +# Notifications + +- [ ] Local notifications +- [ ] Notification badges +- [ ] Mention notifications +- [ ] Role mention notifications +- [ ] Thread notifications +- [ ] Per-channel notification settings +- [ ] Do Not Disturb +- [ ] iOS persistent background gateway connection + +--- + +# Servers (Guilds) + +## Guild Basics +- [ ] Create server +- [ ] Join server +- [ ] Leave server +- [ ] Delete server +- [ ] Server invite links + +## Server Settings +- [x] Server name / icon +- [x] Server banner +- [ ] Server description +- [ ] Community settings +- [ ] Verification level +- [ ] Moderation settings + +## Members +- [x] Member list +- [ ] Member search +- [x] Member roles display +- [ ] Member join / leave events +- [ ] Kick member +- [ ] Ban member +- [ ] Timeout member + +## Roles +- [ ] Create role +- [ ] Edit role +- [ ] Delete role +- [ ] Assign role +- [ ] Role permissions + +## Channels +- [ ] Create channel +- [ ] Edit channel +- [ ] Delete channel +- [ ] Channel categories +- [ ] Channel permissions + +## Channel Types +- [ ] Text channels +- [ ] Voice channels +- [ ] Stage channels +- [ ] Forum channels +- [ ] Announcement channels + +--- + +# Voice & Video + +## Voice Connection +- [x] Join voice channel +- [x] Leave voice channel +- [x] Voice gateway connection +- [x] Voice transport encryption +- [x] Voice DAVE E2EE support + +## Voice Controls +- [ ] Mute +- [ ] Deafen +- [ ] Push-to-talk +- [ ] Voice activity detection +- [ ] Input device selection +- [ ] Output device selection +- [ ] Volume control + +## Voice Features +- [ ] Video calls +- [ ] Screen sharing +- [ ] Stream viewing +- [ ] Camera toggle +- [ ] Noise suppression + +## Voice Moderation +- [ ] Server mute +- [ ] Server deafen +- [ ] Move user +- [ ] Disconnect user + +--- + +# Media & Assets + +- [x] Image attachments +- [x] Video attachments +- [x] File uploads +- [x] File downloads +- [x] Animated GIF support +- [x] CDN caching +- [x] Avatar rendering +- [x] Emoji rendering +- [x] Sticker rendering + +--- + +# Emojis & Stickers + +- [ ] Server emoji list +- [ ] Upload emoji +- [ ] Delete emoji +- [ ] Emoji/Sticker/GIF picker component + +--- + +# Search & Discovery + +- [ ] Message search +- [ ] Server search +- [ ] Channel search +- [ ] Member search +- [ ] Emoji search +- [ ] GIF search + +--- + +# Moderation + +- [ ] Audit log viewer +- [ ] Message delete logging +- [ ] Ban list +- [ ] Timeout system +- [ ] Slow mode +- [ ] Auto moderation + +--- + +# Integrations + +- [ ] Slash commands +- [ ] Application commands +- [ ] Webhooks +- [ ] External integrations + +--- + +# Events + +- [ ] Scheduled events +- [ ] Event creation +- [ ] Event reminders +- [ ] Event management + +--- + +# UI / Client + +## Layout +- [x] Server list +- [x] Channel list +- [x] Member list +- [x] Chat view +- [ ] Thread sidebar + +## UI Features +- [x] Dark mode +- [x] Light mode +- [ ] Theme customization +- [ ] Compact mode +- [x] Font scaling +- [ ] Accessibility features + +--- + +# Advanced Features + +- [ ] Message drafts +- [x] Typing indicators +- [ ] Read receipts +- [ ] Read state sync +- [x] Message queue +- [ ] Custom themes diff --git a/Paicord.xcodeproj/project.pbxproj b/Paicord.xcodeproj/project.pbxproj index a2c6107d..da434cc7 100644 --- a/Paicord.xcodeproj/project.pbxproj +++ b/Paicord.xcodeproj/project.pbxproj @@ -42,6 +42,10 @@ AA078FCA2EC8220E00EDFFA8 /* PaicordSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA078FC92EC8220900EDFFA8 /* PaicordSection.swift */; }; AA0BAA792ECE9A3600365661 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0BAA782ECE9A3100365661 /* Color.swift */; }; AA0C43D72E9F190B000FA834 /* AttributedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0C43D62E9F18AD000FA834 /* AttributedText.swift */; }; + AA0E4FD82F663F09007105F6 /* BorderlessHoverEffectButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0E4FD72F663F08007105F6 /* BorderlessHoverEffectButtonStyle.swift */; }; + AA0E4FDB2F6643CC007105F6 /* VoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0E4FDA2F6643BF007105F6 /* VoiceView.swift */; }; + AA0E4FDD2F6643D8007105F6 /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0E4FDC2F6643D2007105F6 /* CallView.swift */; }; + AA0E4FE02F6645CC007105F6 /* FriendsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0E4FDF2F6645C8007105F6 /* FriendsView.swift */; }; AA1097142E64C181005BC3D2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA1097102E64C181005BC3D2 /* Assets.xcassets */; }; AA1097152E64C181005BC3D2 /* LargeBaseplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1097042E64C181005BC3D2 /* LargeBaseplate.swift */; }; AA1097172E64C181005BC3D2 /* SmallBaseplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1097062E64C181005BC3D2 /* SmallBaseplate.swift */; }; @@ -63,6 +67,7 @@ AA21D2872EAB090200C75093 /* MemberSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA21D2862EAB08FE00C75093 /* MemberSidebarView.swift */; }; AA2278C52EA44828002C335F /* ProfilePopoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2278C42EA4481F002C335F /* ProfilePopoutView.swift */; }; AA2278C72EA448C7002C335F /* Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2278C62EA448BD002C335F /* Profile.swift */; }; + AA2610F72F6191280078A870 /* PCMFloatRingBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2610F62F6191280078A870 /* PCMFloatRingBuffer.swift */; }; AA2F51C32EE4ABCE00F18DB7 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2F51C22EE4ABCA00F18DB7 /* Storage.swift */; }; AA321F162F0E84B300D48332 /* SponsorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA321F152F0E84AF00D48332 /* SponsorSheet.swift */; }; AA409ABC2EC6909800848045 /* Theming.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA409ABB2EC6909300848045 /* Theming.swift */; }; @@ -112,6 +117,7 @@ AA8ABACC2F2A794E00557278 /* SwiftEmojiIndex in Frameworks */ = {isa = PBXBuildFile; productRef = AA8ABACB2F2A794E00557278 /* SwiftEmojiIndex */; }; AA8ABACF2F2A79B500557278 /* Loupe in Frameworks */ = {isa = PBXBuildFile; productRef = AA8ABACE2F2A79B500557278 /* Loupe */; }; AA8ABAD22F2A7A0800557278 /* MijickCamera in Frameworks */ = {isa = PBXBuildFile; platformFilter = ios; productRef = AA8ABAD12F2A7A0800557278 /* MijickCamera */; }; + AA8CC3D12F62F5FC00BFB9B2 /* VoiceChannelsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8CC3D02F62F5E800BFB9B2 /* VoiceChannelsStore.swift */; }; AA9C81832E6660BE0086B1DA /* GuildButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9C81822E6660BE0086B1DA /* GuildButton.swift */; }; AA9C81862E66670E0086B1DA /* CornerRadius.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9C81852E66670E0086B1DA /* CornerRadius.swift */; }; AA9C818C2E6702930086B1DA /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = AA9C818B2E6702930086B1DA /* SwiftUIIntrospect */; }; @@ -159,6 +165,9 @@ AAEEC71F2E65120000EB5FC9 /* TokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAEEC71E2E6511F800EB5FC9 /* TokenStore.swift */; }; AAEEC7222E6515C400EB5FC9 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = AAEEC7212E6515C400EB5FC9 /* KeychainAccess */; }; AAEEF5012E9900F60034FA04 /* Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAEEF5002E9900E50034FA04 /* Default.swift */; }; + AAFBC52E2F4C946800C5B644 /* VoiceConnectionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFBC52D2F4C945F00C5B644 /* VoiceConnectionStore.swift */; }; + AAFBC5312F4CA9C000C5B644 /* Copus in Frameworks */ = {isa = PBXBuildFile; productRef = AAFBC5302F4CA9C000C5B644 /* Copus */; }; + AAFBC5332F4CA9C000C5B644 /* Opus in Frameworks */ = {isa = PBXBuildFile; productRef = AAFBC5322F4CA9C000C5B644 /* Opus */; }; AAFC9DDA2EB7DAE300BB8028 /* VariableBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC9DD92EB7DAE200BB8028 /* VariableBlurView.swift */; }; AAFD41382E92FA43002BC9BE /* Array+safe.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFD41372E92FA42002BC9BE /* Array+safe.swift */; }; /* End PBXBuildFile section */ @@ -194,6 +203,10 @@ AA078FC92EC8220900EDFFA8 /* PaicordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaicordSection.swift; sourceTree = ""; }; AA0BAA782ECE9A3100365661 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; AA0C43D62E9F18AD000FA834 /* AttributedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedText.swift; sourceTree = ""; }; + AA0E4FD72F663F08007105F6 /* BorderlessHoverEffectButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BorderlessHoverEffectButtonStyle.swift; sourceTree = ""; }; + AA0E4FDA2F6643BF007105F6 /* VoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceView.swift; sourceTree = ""; }; + AA0E4FDC2F6643D2007105F6 /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = ""; }; + AA0E4FDF2F6645C8007105F6 /* FriendsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsView.swift; sourceTree = ""; }; AA1096DB2E63BE84005BC3D2 /* Paicord.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Paicord.app; sourceTree = BUILT_PRODUCTS_DIR; }; AA1097042E64C181005BC3D2 /* LargeBaseplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeBaseplate.swift; sourceTree = ""; }; AA1097062E64C181005BC3D2 /* SmallBaseplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallBaseplate.swift; sourceTree = ""; }; @@ -213,6 +226,7 @@ AA21D2862EAB08FE00C75093 /* MemberSidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberSidebarView.swift; sourceTree = ""; }; AA2278C42EA4481F002C335F /* ProfilePopoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePopoutView.swift; sourceTree = ""; }; AA2278C62EA448BD002C335F /* Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Profile.swift; sourceTree = ""; }; + AA2610F62F6191280078A870 /* PCMFloatRingBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PCMFloatRingBuffer.swift; sourceTree = ""; }; AA2F51C22EE4ABCA00F18DB7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; AA321F152F0E84AF00D48332 /* SponsorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorSheet.swift; sourceTree = ""; }; AA409ABB2EC6909300848045 /* Theming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theming.swift; sourceTree = ""; }; @@ -255,6 +269,7 @@ AA7B0B692EE5D804003F0CE9 /* ChatHeaders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHeaders.swift; sourceTree = ""; }; AA7B38F12EB37F9500CA4A3C /* PermsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermsHelper.swift; sourceTree = ""; }; AA7B38F32EB50EFD00CA4A3C /* MessageDrainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDrainStore.swift; sourceTree = ""; }; + AA8CC3D02F62F5E800BFB9B2 /* VoiceChannelsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceChannelsStore.swift; sourceTree = ""; }; AA9C81822E6660BE0086B1DA /* GuildButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuildButton.swift; sourceTree = ""; }; AA9C81852E66670E0086B1DA /* CornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CornerRadius.swift; sourceTree = ""; }; AA9D26B02EC95EE3006071FE /* FlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowLayout.swift; sourceTree = ""; }; @@ -297,6 +312,7 @@ AAEB3D942ED60812008BDD1D /* ImpactGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImpactGenerator.swift; sourceTree = ""; }; AAEEC71E2E6511F800EB5FC9 /* TokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenStore.swift; sourceTree = ""; }; AAEEF5002E9900E50034FA04 /* Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Default.swift; sourceTree = ""; }; + AAFBC52D2F4C945F00C5B644 /* VoiceConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceConnectionStore.swift; sourceTree = ""; }; AAFC9DD92EB7DAE200BB8028 /* VariableBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariableBlurView.swift; sourceTree = ""; }; AAFD41372E92FA42002BC9BE /* Array+safe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+safe.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -316,6 +332,8 @@ AA8ABACA2F2A794E00557278 /* SwiftEmoji in Frameworks */, AAD446A22ED07916006CB07C /* SettingsKit in Frameworks */, 579DF34B2E6D9B9800BD8B97 /* SwiftUIX in Frameworks */, + AAFBC5312F4CA9C000C5B644 /* Copus in Frameworks */, + AAFBC5332F4CA9C000C5B644 /* Opus in Frameworks */, AA47AF622EDF1FF7008A50C9 /* Conditionals in Frameworks */, AA21D2832EAA557C00C75093 /* Sparkle in Frameworks */, AAA9EEFD2F23062B00770CAD /* CodeScanner in Frameworks */, @@ -365,6 +383,7 @@ isa = PBXGroup; children = ( AA2F51C22EE4ABCA00F18DB7 /* Storage.swift */, + AA2610F82F6191300078A870 /* Audio */, AA078FC52EC80D2100EDFFA8 /* LocalConsoleManager */, AA409ABD2EC74F3100848045 /* Theming */, AABED5A72E7FF970005BDD63 /* PaicordLib++ */, @@ -514,6 +533,23 @@ path = Markdown; sourceTree = ""; }; + AA0E4FD92F6641B8007105F6 /* Voice */ = { + isa = PBXGroup; + children = ( + AA0E4FDC2F6643D2007105F6 /* CallView.swift */, + AA0E4FDA2F6643BF007105F6 /* VoiceView.swift */, + ); + path = Voice; + sourceTree = ""; + }; + AA0E4FDE2F6645B7007105F6 /* Friends */ = { + isa = PBXGroup; + children = ( + AA0E4FDF2F6645C8007105F6 /* FriendsView.swift */, + ); + path = Friends; + sourceTree = ""; + }; AA1096D22E63BE84005BC3D2 = { isa = PBXGroup; children = ( @@ -635,6 +671,14 @@ path = Profiles; sourceTree = ""; }; + AA2610F82F6191300078A870 /* Audio */ = { + isa = PBXGroup; + children = ( + AA2610F62F6191280078A870 /* PCMFloatRingBuffer.swift */, + ); + path = Audio; + sourceTree = ""; + }; AA36D5A52EB60F23006612F8 /* Input */ = { isa = PBXGroup; children = ( @@ -876,8 +920,10 @@ children = ( 57A4E7092E6B315700470131 /* Login */, 57DA04282E79893B00DB4C7C /* Launch */, + AA0E4FDE2F6645B7007105F6 /* Friends */, 57A55C552E7560BB005C8226 /* Guilds */, AA1097082E64C181005BC3D2 /* Chat */, + AA0E4FD92F6641B8007105F6 /* Voice */, AA4207CD2F2D1165006B8227 /* Emoji Picker */, AA21D2852EAB08E500C75093 /* Member Sidebar */, 57DA042D2E79DC4400DB4C7C /* Settings */, @@ -892,6 +938,7 @@ AA9C81812E6660AB0086B1DA /* Components */ = { isa = PBXGroup; children = ( + AA0E4FD72F663F08007105F6 /* BorderlessHoverEffectButtonStyle.swift */, AAC07FC22F2D9E460077B8FA /* DownloadButton.swift */, AA9D26B02EC95EE3006071FE /* FlowLayout.swift */, AAFC9DD92EB7DAE200BB8028 /* VariableBlurView.swift */, @@ -978,6 +1025,8 @@ AABED5A32E7F4DAE005BDD63 /* GuildStore.swift */, AABED59D2E7F4637005BDD63 /* ChannelStore.swift */, AA7B38F32EB50EFD00CA4A3C /* MessageDrainStore.swift */, + AA8CC3D02F62F5E800BFB9B2 /* VoiceChannelsStore.swift */, + AAFBC52D2F4C945F00C5B644 /* VoiceConnectionStore.swift */, AABED5A52E7F5148005BDD63 /* SettingsStore.swift */, AAAF797F2ED1E9ED004B5B3F /* ExternalBadgeStore.swift */, AA79479D2EDC7B9400B7A1EE /* PresenceStore.swift */, @@ -1034,6 +1083,8 @@ AA8ABACB2F2A794E00557278 /* SwiftEmojiIndex */, AA8ABACE2F2A79B500557278 /* Loupe */, AA8ABAD12F2A7A0800557278 /* MijickCamera */, + AAFBC5302F4CA9C000C5B644 /* Copus */, + AAFBC5322F4CA9C000C5B644 /* Opus */, ); productName = PaiCord; productReference = AA1096DB2E63BE84005BC3D2 /* Paicord.app */; @@ -1083,6 +1134,7 @@ AA8ABAC82F2A794E00557278 /* XCRemoteSwiftPackageReference "SwiftEmoji" */, AA8ABACD2F2A79B500557278 /* XCRemoteSwiftPackageReference "Loupe" */, AA8ABAD02F2A7A0800557278 /* XCRemoteSwiftPackageReference "Camera" */, + AAFBC52F2F4CA9C000C5B644 /* XCRemoteSwiftPackageReference "swift-opus" */, ); preferredProjectObjectVersion = 77; productRefGroup = AA1096DC2E63BE84005BC3D2 /* Products */; @@ -1122,6 +1174,7 @@ AA1097182E64C181005BC3D2 /* HomeView.swift in Sources */, AA7B38F42EB50F0100CA4A3C /* MessageDrainStore.swift in Sources */, AA74E4392EBC411C0031B285 /* EntityContextMenu.swift in Sources */, + AA2610F72F6191280078A870 /* PCMFloatRingBuffer.swift in Sources */, AA10971D2E64C18C005BC3D2 /* ChatView.swift in Sources */, AA9C81862E66670E0086B1DA /* CornerRadius.swift in Sources */, AA47AF1D2EDEF019008A50C9 /* FamilyCentreSection.swift in Sources */, @@ -1132,6 +1185,7 @@ AAB905362E8451B000EA171B /* ProfileBar.swift in Sources */, 57E05C5B2E6C745900B81AA7 /* String+LocalizedError.swift in Sources */, AABED5A42E7F4DAE005BDD63 /* GuildStore.swift in Sources */, + AA0E4FE02F6645CC007105F6 /* FriendsView.swift in Sources */, AAB905322E83047D00EA171B /* Extensions.swift in Sources */, AAB50A522E9AC9CB0048E8B0 /* ChannelHeader.swift in Sources */, AA47AF3B2EDEF990008A50C9 /* ProfilesSection.swift in Sources */, @@ -1158,6 +1212,7 @@ AAB50A542E9AD4470048E8B0 /* MessageBody.swift in Sources */, 57E05C592E6C6AE400B81AA7 /* Design Constants.swift in Sources */, AAFC9DDA2EB7DAE300BB8028 /* VariableBlurView.swift in Sources */, + AA0E4FDD2F6643D8007105F6 /* CallView.swift in Sources */, AABED5A62E7F514B005BDD63 /* SettingsStore.swift in Sources */, 8702A5382EAA6753008DD55A /* MemberRowView.swift in Sources */, AA49F5172EF2CF7200C46339 /* NitroHelper.swift in Sources */, @@ -1175,6 +1230,7 @@ AA47AF1F2EDEF04F008A50C9 /* AuthorisedAppsSection.swift in Sources */, AA21D2802EAA220300C75093 /* MaskEdgesModifier.swift in Sources */, 57F5AF552E7CBDF400AD5674 /* MFAView.swift in Sources */, + AAFBC52E2F4C946800C5B644 /* VoiceConnectionStore.swift in Sources */, AA7B38F22EB37F9B00CA4A3C /* PermsHelper.swift in Sources */, AA63EB722EA711D000A5F21D /* EmbedsView.swift in Sources */, AAB50A582E9AD4BB0048E8B0 /* Utilities.swift in Sources */, @@ -1204,7 +1260,9 @@ AA1878F32E81A30C009C7E40 /* GuildView.swift in Sources */, AA63EB762EA712D900A5F21D /* ReactionsView.swift in Sources */, AABED5A22E7F4950005BDD63 /* DiscordDataStoreProtocol.swift in Sources */, + AA0E4FDB2F6643CC007105F6 /* VoiceView.swift in Sources */, AAEEF5012E9900F60034FA04 /* Default.swift in Sources */, + AA0E4FD82F663F09007105F6 /* BorderlessHoverEffectButtonStyle.swift in Sources */, 57A4E7152E6B44C900470131 /* CGSize.swift in Sources */, AA47AF262EDEF14F008A50C9 /* ClipsSection.swift in Sources */, AAFD41382E92FA43002BC9BE /* Array+safe.swift in Sources */, @@ -1222,6 +1280,7 @@ AAB50A5A2E9AD5790048E8B0 /* MessageAuthor.swift in Sources */, AAB905342E8334AC00EA171B /* SlideoverDoubleView.swift in Sources */, AA47AF362EDEF1E2008A50C9 /* AdvancedSection.swift in Sources */, + AA8CC3D12F62F5FC00BFB9B2 /* VoiceChannelsStore.swift in Sources */, 57F5AF4B2E7CB13F00AD5674 /* Commands.swift in Sources */, AADD7F8D2E99EB0B0025B644 /* Attachments.swift in Sources */, 57AA8A032E7875D100B4CA9C /* Sidebar.swift in Sources */, @@ -1387,10 +1446,10 @@ ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; - ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; - ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = YES; ENABLE_RESOURCE_ACCESS_CONTACTS = NO; ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PRINTING = NO; @@ -1425,6 +1484,7 @@ SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_INTEROP_MODE = objcxx; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; @@ -1450,10 +1510,10 @@ ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; - ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES; ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; - ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = YES; ENABLE_RESOURCE_ACCESS_CONTACTS = NO; ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PRINTING = NO; @@ -1488,6 +1548,7 @@ SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_INTEROP_MODE = objcxx; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; XROS_DEPLOYMENT_TARGET = 1.0; @@ -1662,6 +1723,14 @@ kind = branch; }; }; + AAFBC52F2F4CA9C000C5B644 /* XCRemoteSwiftPackageReference "swift-opus" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/alta/swift-opus.git"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1773,6 +1842,16 @@ package = AAEEC7202E6515C400EB5FC9 /* XCRemoteSwiftPackageReference "KeychainAccess" */; productName = KeychainAccess; }; + AAFBC5302F4CA9C000C5B644 /* Copus */ = { + isa = XCSwiftPackageProductDependency; + package = AAFBC52F2F4CA9C000C5B644 /* XCRemoteSwiftPackageReference "swift-opus" */; + productName = Copus; + }; + AAFBC5322F4CA9C000C5B644 /* Opus */ = { + isa = XCSwiftPackageProductDependency; + package = AAFBC52F2F4CA9C000C5B644 /* XCRemoteSwiftPackageReference "swift-opus" */; + productName = Opus; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = AA1096D32E63BE84005BC3D2 /* Project object */; diff --git a/Paicord.xcodeproj/xcshareddata/xcschemes/Paicord Release.xcscheme b/Paicord.xcodeproj/xcshareddata/xcschemes/Paicord Release.xcscheme index 44f0b00c..a52ad10e 100644 --- a/Paicord.xcodeproj/xcshareddata/xcschemes/Paicord Release.xcscheme +++ b/Paicord.xcodeproj/xcshareddata/xcschemes/Paicord Release.xcscheme @@ -50,6 +50,13 @@ ReferencedContainer = "container:Paicord.xcodeproj"> + + + + + + - - - - - - - - - - diff --git a/Paicord/App/Commands.swift b/Paicord/App/Commands.swift index 92d4778e..6f97757d 100644 --- a/Paicord/App/Commands.swift +++ b/Paicord/App/Commands.swift @@ -54,7 +54,7 @@ struct PaicordCommands: Commands { } } .disabled( - gatewayStore.accounts.currentAccountID != nil + gatewayStore.accounts.currentAccountID == nil ) } // add reload button to the system's View menu diff --git a/Paicord/App/PaicordApp.swift b/Paicord/App/PaicordApp.swift index 1db90f95..35c6bfbf 100644 --- a/Paicord/App/PaicordApp.swift +++ b/Paicord/App/PaicordApp.swift @@ -48,13 +48,13 @@ struct PaicordApp: App { init() { console.startIntercepting() - // #if DEBUG - // DiscordGlobalConfiguration.makeLogger = { loggerLabel in - // var logger = Logger(label: loggerLabel) - // logger.logLevel = .trace - // return logger - // } - // #endif +// #if DEBUG +// DiscordGlobalConfiguration.makeLogger = { loggerLabel in +// var logger = Logger(label: loggerLabel) +// logger.logLevel = .trace +// return logger +// } +// #endif #if canImport(Sparkle) && !DEBUG updaterController = SPUStandardUpdaterController( startingUpdater: true, @@ -131,7 +131,8 @@ struct PaicordApp: App { // Note this intermediate view is necessary for the disabled state on the menu item to work properly before Monterey. // See https://stackoverflow.com/questions/68553092/menu-not-updating-swiftui-bug for more info struct CheckForUpdatesView: View { - @ObservedObject private var checkForUpdatesViewModel: CheckForUpdatesViewModel + @ObservedObject private var checkForUpdatesViewModel: + CheckForUpdatesViewModel private let updater: SPUUpdater init(updater: SPUUpdater) { diff --git a/Paicord/App/PaicordAppState.swift b/Paicord/App/PaicordAppState.swift index ffa249af..ffc9e671 100644 --- a/Paicord/App/PaicordAppState.swift +++ b/Paicord/App/PaicordAppState.swift @@ -9,6 +9,106 @@ import PaicordLib import SwiftUIX +enum PaicordGuildNavigation: RawRepresentable, Hashable { + init?(rawValue: String) { + let components = rawValue.split(separator: ":", maxSplits: 1) + guard components.count == 2 else { return nil } + let type = components[0] + let value = components[1] + + switch type { + case "guild": + self = .guild(GuildSnowflake(String(value))) + case "directMessages": + self = .directMessages + default: + return nil + } + } + + var rawValue: String { + switch self { + case .guild(let guildId): + return "guild:\(guildId.rawValue)" + case .directMessages: + return "directMessages: " + } + } + + case guild(GuildSnowflake) + case directMessages + + static func make(from dict: [String: String]) -> [PaicordGuildNavigation: + PaicordChannelNavigation]? + { + var result: [PaicordGuildNavigation: PaicordChannelNavigation] = [:] + for (key, value) in dict { + if let guildNav = PaicordGuildNavigation(rawValue: key), + let channelNav = PaicordChannelNavigation(rawValue: value) + { + result[guildNav] = channelNav + } + } + return result + } + + static func make(from: [PaicordGuildNavigation: PaicordChannelNavigation]) -> [String: String] { + var result: [String: String] = [:] + for (key, value) in from { + result[key.rawValue] = value.rawValue + } + return result + } +} + +enum PaicordChannelNavigation: RawRepresentable, Hashable { + init?(rawValue: String) { + let components = rawValue.split(separator: ":", maxSplits: 1) + guard components.count == 2 else { return nil } + let type = components[0] + let value = components[1] + + switch type { + case "textChannel": + self = .textChannel(ChannelSnowflake(String(value))) + case "voiceChannel": + self = .voiceChannel(ChannelSnowflake(String(value))) + case "thread": + self = .thread(ChannelSnowflake(String(value))) + case "dashboard": + self = .dashboard + case "friends": + self = .friends + default: + return nil + } + } + + var rawValue: String { + switch self { + case .textChannel(let channelId): + return "textChannel:\(channelId.rawValue)" + case .voiceChannel(let channelId): + return "voiceChannel:\(channelId.rawValue)" + case .thread(let channelId): + return "thread:\(channelId.rawValue)" + case .dashboard: + return "dashboard: " + case .friends: + return "friends: " + } + } + + case dashboard + + case textChannel(ChannelSnowflake) + case voiceChannel(ChannelSnowflake) + case thread(ChannelSnowflake) + + // special dms navigation destinations + case friends +} + @Observable final class PaicordAppState { // each window gets its own app state @@ -18,12 +118,10 @@ final class PaicordAppState { Self.instances[id] = self loadPrevSelectedChannels() - if let lastDM = self.rawPrevSelectedChannels[ - self.selectedGuild?.rawValue ?? "nil" - ] { - self.selectedChannel = ChannelSnowflake(lastDM) + if let lastKnownChannel = self.rawPrevSelectedChannels[self.selectedGuild] { + self.selectedChannel = lastKnownChannel } else { - self.selectedChannel = nil + self.selectedChannel = .dashboard } } deinit { @@ -43,53 +141,48 @@ final class PaicordAppState { private let storageKey = "AppState.PrevSelectedChannels" var suppressChannelSave = false - private var _selectedGuild: GuildSnowflake? = nil { + private var _selectedGuild: PaicordGuildNavigation = .directMessages { didSet { UserDefaults.standard.set( - _selectedGuild?.rawValue, + _selectedGuild.rawValue, forKey: "AppState.PrevSelectedGuild" ) } } - var selectedGuild: GuildSnowflake? { + var selectedGuild: PaicordGuildNavigation { get { _selectedGuild } set { - let newGuildKey = newValue?.rawValue ?? "nil" - suppressChannelSave = true defer { suppressChannelSave = false } - let lastChannel = rawPrevSelectedChannels[newGuildKey] + let lastChannel = rawPrevSelectedChannels[newValue] if let lastChannel { - selectedChannel = ChannelSnowflake(lastChannel) + selectedChannel = lastChannel } else { - selectedChannel = nil + selectedChannel = .dashboard } _selectedGuild = newValue } } - var selectedChannel: ChannelSnowflake? { + var selectedChannel: PaicordChannelNavigation = .dashboard { didSet { guard !suppressChannelSave else { return } - let key = selectedGuild?.rawValue ?? "nil" - if let channel = selectedChannel { - rawPrevSelectedChannels[key] = channel.rawValue - } else { - rawPrevSelectedChannels.removeValue(forKey: key) - } + let key = selectedGuild + rawPrevSelectedChannels[key] = selectedChannel savePrevSelectedChannels() } } // persistent mapping as [String: String] where key == guild.rawValue or "nil" @ObservationIgnored - private var rawPrevSelectedChannels: [String: String] = [:] + private var rawPrevSelectedChannels: + [PaicordGuildNavigation: PaicordChannelNavigation] = [:] func resetStore() { - selectedGuild = nil - selectedChannel = nil + selectedGuild = .directMessages + selectedChannel = .dashboard rawPrevSelectedChannels = [:] UserDefaults.standard.removeObject( forKey: "AppState.PrevSelectedGuild" @@ -100,13 +193,16 @@ final class PaicordAppState { // MARK: - Persistence Helpers func loadPrevGuild() { - let guildIdString = UserDefaults.standard.string( + let guildValue = UserDefaults.standard.string( forKey: "AppState.PrevSelectedGuild" ) - guard let guildIdString else { return } - let guildId = GuildSnowflake(guildIdString) - guard GatewayStore.shared.user.guilds.keys.contains(guildId) else { return } - self.selectedGuild = GuildSnowflake(guildId) + guard let guildValue else { return } + if case .guild(let guildId) = PaicordGuildNavigation(rawValue: guildValue) { + guard GatewayStore.shared.user.guilds.keys.contains(guildId) else { + return + } + self.selectedGuild = .guild(guildId) + } } private func loadPrevSelectedChannels() { @@ -114,7 +210,9 @@ final class PaicordAppState { if let data = defaults.data(forKey: storageKey) { if let obj = try? JSONSerialization.jsonObject(with: data), - let dict = obj as? [String: String] + let dict = PaicordGuildNavigation.make( + from: obj as? [String: String] ?? [:] + ) { rawPrevSelectedChannels = dict return @@ -125,8 +223,8 @@ final class PaicordAppState { } private func savePrevSelectedChannels() { - let json = rawPrevSelectedChannels - if let data = try? JSONSerialization.data(withJSONObject: json) { + let json = PaicordGuildNavigation.make(from: rawPrevSelectedChannels) + if let data = try? JSONSerialization.data(withJSONObject: json) { // Thread 1: Swift runtime failure: unhandled C++ / Objective-C exception UserDefaults.standard.set(data, forKey: storageKey) } else { // fallback: write dictionary directly @@ -143,3 +241,27 @@ final class PaicordAppState { } } } + +extension PaicordGuildNavigation { + var guildID: GuildSnowflake? { + switch self { + case .guild(let guildId): + return guildId + case .directMessages: + return nil + } + } +} + +extension PaicordChannelNavigation { + var channelID: ChannelSnowflake? { + switch self { + case .textChannel(let channelId), + .voiceChannel(let channelId), + .thread(let channelId): + return channelId + case .dashboard, .friends: + return nil + } + } +} diff --git a/Paicord/Baseplates/LargeBaseplate.swift b/Paicord/Baseplates/LargeBaseplate.swift index 52c8f3fd..67e68864 100644 --- a/Paicord/Baseplates/LargeBaseplate.swift +++ b/Paicord/Baseplates/LargeBaseplate.swift @@ -53,17 +53,20 @@ struct LargeBaseplate: View { } detail: { Group { if let currentChannelStore { - ChatView(vm: currentChannelStore) - .inspector(isPresented: $showingInspector) { - MemberSidebarView( - guildStore: currentGuildStore, - channelStore: currentChannelStore - ) - .inspectorColumnWidth(min: 250, ideal: 250, max: 360) - } - .id(currentChannelStore.channelId) // force view update - .environment(\.guildStore, currentGuildStore) - .environment(\.channelStore, currentChannelStore) + switch appState.selectedChannel { + case .textChannel, .thread: + textChannelLayout(currentChannelStore) + case .voiceChannel: + voiceChannelLayout(currentChannelStore) + case .dashboard: + Text(":3") + .font(.largeTitle) + .foregroundStyle(.secondary) + case .friends: + Text(":3c") + .font(.largeTitle) + .foregroundStyle(.secondary) + } } else { // placeholder VStack { @@ -92,6 +95,24 @@ struct LargeBaseplate: View { } } .toolbar { + if let vm = currentChannelStore, + vm.channel?.type == .dm || vm.channel?.type == .groupDm, + gw.voice.channelId != vm.channelId + { + Button { + Task { + await gw.voice.updateVoiceConnection( + .join( + channelId: vm.channelId, + guildId: nil, + ) + ) + } + } label: { + Label("Start Call", systemImage: "phone.fill") + } + } + Button { showingInspector.toggle() } label: { @@ -99,14 +120,14 @@ struct LargeBaseplate: View { } } .task(id: appState.selectedGuild) { - if let selected = appState.selectedGuild { + if let selected = appState.selectedGuild.guildID { self.currentGuildStore = gw.getGuildStore(for: selected) } else { self.currentGuildStore = nil } } .task(id: appState.selectedChannel) { - if let selected = appState.selectedChannel { + if let selected = appState.selectedChannel.channelID { // there is a likelihood that currentGuildStore is wrong when this runs // but i dont think it will be a problem maybe. self.currentChannelStore = gw.getChannelStore( @@ -118,6 +139,44 @@ struct LargeBaseplate: View { } } } + + @State var panelSize: CGSize = .zero + @ViewBuilder + func textChannelLayout(_ channelStore: ChannelStore) -> some View { + VStack(spacing: 0) { + CallView(panelSize: panelSize) + .zIndex(1) + ChatView(vm: channelStore) + .inspector(isPresented: $showingInspector) { + MemberSidebarView( + guildStore: currentGuildStore, + channelStore: currentChannelStore + ) + .inspectorColumnWidth(min: 250, ideal: 250, max: 360) + } + .zIndex(0) + } + .id(channelStore.channelId) // force view update + .environment(\.guildStore, currentGuildStore) + .environment(\.channelStore, currentChannelStore) + .onGeometryChange( + for: CGSize.self, + of: { $0.size }, + action: { self.panelSize = $0 } + ) + } + + @ViewBuilder + func voiceChannelLayout(_ channelStore: ChannelStore) -> some View { + VoiceView(vm: channelStore) + .inspector(isPresented: $showingInspector) { + ChatView(vm: channelStore) + .inspectorColumnWidth(min: 400, ideal: 450, max: 750) + } + .id(channelStore.channelId) // force view update + .environment(\.guildStore, currentGuildStore) + .environment(\.channelStore, currentChannelStore) + } } #Preview { diff --git a/Paicord/Baseplates/SmallBaseplate.swift b/Paicord/Baseplates/SmallBaseplate.swift index 03089244..9158be58 100644 --- a/Paicord/Baseplates/SmallBaseplate.swift +++ b/Paicord/Baseplates/SmallBaseplate.swift @@ -114,14 +114,14 @@ struct SmallBaseplate: View { } .slideoverDisabled(disableSlideover) .task(id: appState.selectedGuild) { - if let selected = appState.selectedGuild { + if let selected = appState.selectedGuild.guildID { self.currentGuildStore = gw.getGuildStore(for: selected) } else { self.currentGuildStore = nil } } .task(id: appState.selectedChannel) { - if let selected = appState.selectedChannel { + if let selected = appState.selectedChannel.channelID { // there is a likelihood that currentGuildStore is wrong when this runs // but i dont think it will be a problem maybe. self.currentChannelStore = gw.getChannelStore( diff --git a/Paicord/Common/Chat/ChannelHeader.swift b/Paicord/Common/Chat/ChannelHeader.swift index a9f53852..9c3a0046 100644 --- a/Paicord/Common/Chat/ChannelHeader.swift +++ b/Paicord/Common/Chat/ChannelHeader.swift @@ -81,7 +81,8 @@ extension ChatView { .frame(width: 36, height: 36) Text( - vm.channel?.name + verbatim: + vm.channel?.name ?? ppl.map({ $0.global_name ?? $0.username }).joined(separator: ", ") @@ -91,13 +92,29 @@ extension ChatView { } default: HStack(spacing: 4) { - Image(systemName: "number") - .foregroundStyle(.secondary) - .imageScale(idiom == .phone ? .medium : .large) - let name = vm.channel?.name ?? "Unknown Channel" - Text(name) - .font(idiom == .phone ? .headline : .title3) - .fontWeight(.semibold) + switch vm.channel?.type { + case .guildText, .guildAnnouncement: + Image(systemName: "number") + .foregroundStyle(.secondary) + .imageScale(idiom == .phone ? .medium : .large) + case .guildVoice: + Image(systemName: "speaker.wave.2.fill") + .foregroundStyle(.secondary) + .imageScale(idiom == .phone ? .medium : .large) + default: + Image(systemName: "number") + .foregroundStyle(.secondary) + .imageScale(idiom == .phone ? .medium : .large) + } + if let name = vm.channel?.name { + Text(verbatim: name) + .font(idiom == .phone ? .headline : .title3) + .fontWeight(.semibold) + } else { + Text("Unknown Channel") + .font(idiom == .phone ? .headline : .title3) + .fontWeight(.semibold) + } } } } diff --git a/Paicord/Common/Chat/ChatView.swift b/Paicord/Common/Chat/ChatView.swift index da2195bc..90a93323 100644 --- a/Paicord/Common/Chat/ChatView.swift +++ b/Paicord/Common/Chat/ChatView.swift @@ -151,7 +151,7 @@ struct ChatView: View { .scrollPosition(id: $currentScrollPosition, anchor: .bottom) // causes issues with input bar height changes: // currently, the input bar changing size can cause the scrollview position to jump unexpectedly. // not sure how to fix. - .bottomAnchored() +// .bottomAnchored() .scrollClipDisabled() .maxHeight(.infinity) .overlay(alignment: .bottomTrailing) { diff --git a/Paicord/Common/Chat/Input/InputBar.swift b/Paicord/Common/Chat/Input/InputBar.swift index 1efb4cdd..d355ad1d 100644 --- a/Paicord/Common/Chat/Input/InputBar.swift +++ b/Paicord/Common/Chat/Input/InputBar.swift @@ -551,7 +551,7 @@ extension ChatView { guard !msg.isEmpty || inputVM.uploadItems.isEmpty == false else { return } - guard let channelId = appState.selectedChannel else { return } + guard let channelId = appState.selectedChannel.channelID else { return } // create a copy of the vm let toSend = inputVM.copy() inputVM.reset() diff --git a/Paicord/Common/Friends/FriendsView.swift b/Paicord/Common/Friends/FriendsView.swift new file mode 100644 index 00000000..bfa2de30 --- /dev/null +++ b/Paicord/Common/Friends/FriendsView.swift @@ -0,0 +1,19 @@ +// +// FriendsView.swift +// Paicord +// +// Created by Lakhan Lothiyi on 15/03/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import SwiftUIX +import PaicordLib + +struct FriendsView: View { + @Environment(\.gateway) var gw + var currentUser: CurrentUserStore { gw.user } + + var body: some View { + + } +} diff --git a/Paicord/Common/Guilds/ChannelButton.swift b/Paicord/Common/Guilds/ChannelButton.swift index ad63caa3..be0fab7e 100644 --- a/Paicord/Common/Guilds/ChannelButton.swift +++ b/Paicord/Common/Guilds/ChannelButton.swift @@ -13,6 +13,7 @@ import SwiftUIX struct ChannelButton: View { @Environment(\.gateway) var gw @Environment(\.appState) var appState + @Environment(\.guildStore) var guild var channels: [ChannelSnowflake: DiscordChannel] var channel: DiscordChannel @@ -21,7 +22,7 @@ struct ChannelButton: View { switch channel.type { case .dm: textChannelButton { hovered in - let selected = appState.selectedChannel == channel.id + let selected = appState.selectedChannel.channelID == channel.id HStack { if let user = channel.recipients?.first { Profile.AvatarWithPresence( @@ -109,11 +110,17 @@ struct ChannelButton: View { } .aspectRatio(1, contentMode: .fit) } - Text( - channel.name ?? channel.recipients?.map({ - $0.global_name ?? $0.username - }).joined(separator: ", ") ?? "Unknown Group DM" - ) + VStack(alignment: .leading, spacing: 2){ + Text( + channel.name ?? channel.recipients?.map({ + $0.global_name ?? $0.username + }).joined(separator: ", ") ?? "Unknown Group DM" + ) + .lineLimit(1) + + Text("\(channel.recipients?.count ?? 0) members") + .font(.caption) + } } .frame(maxWidth: .infinity, alignment: .leading) .frame(height: 38) @@ -126,7 +133,17 @@ struct ChannelButton: View { let expectedParentID = channel.id let childChannels = channels.values .filter { $0.parent_id ?? (try! .makeFake()) == expectedParentID } - .sorted { ($0.position ?? 0) < ($1.position ?? 0) } + // sort by type and position +// .sorted { ($0.position ?? 0) < ($1.position ?? 0) } + .sorted { lhs, rhs in + let lhsType = [DiscordChannel.Kind.guildVoice, .guildStageVoice].contains(lhs.type ?? .guildText) + let rhsType = [DiscordChannel.Kind.guildVoice, .guildStageVoice].contains(rhs.type ?? .guildText) + if lhsType == rhsType { + return (lhs.position ?? 0) < (rhs.position ?? 0) + } else { + return (lhsType && !rhsType) + } + } .map { $0.id } category(channelIDs: childChannels) @@ -156,18 +173,33 @@ struct ChannelButton: View { } .tint(.primary) case .guildVoice: - textChannelButton { _ in + voiceChannelButton { hovered in HStack { - Image(systemName: "speaker.wave.2.fill") - .imageScale(.medium) + if guild?.hasPermission(channel: channel, .connect) == false { + Image(systemName: "lock.fill") + .imageScale(.medium) + } else { + Image(systemName: "speaker.wave.2.fill") + .imageScale(.medium) + } Text(channel.name ?? "unknown") + Spacer() + + if hovered { + Button { + appState.selectedChannel = .voiceChannel(channel.id) + } label: { + Image(systemName: "bubble.fill") + .imageScale(.small) + } + .buttonStyle(.borderless) + } } .frame(maxWidth: .infinity, alignment: .leading) .minHeight(35) .padding(.horizontal, 12) } .tint(.primary) - .disabled(true) default: textChannelButton { _ in HStack { @@ -206,7 +238,7 @@ struct ChannelButton: View { var body: some View { if !shouldHide { Button { - appState.selectedChannel = channel.id + appState.selectedChannel = .textChannel(channel.id) #if os(iOS) withAnimation { appState.chatOpen.toggle() @@ -247,7 +279,7 @@ struct ChannelButton: View { ) .background( Group { - if appState.selectedChannel == channel.id { + if appState.selectedChannel.channelID == channel.id { Color.gray.opacity(0.13) } else { Color.clear @@ -258,6 +290,208 @@ struct ChannelButton: View { } } + struct VoiceChannelButton: View { + @Environment(\.appState) var appState + @Environment(\.gateway) var gw + @Environment(\.guildStore) var guild + @State private var isHovered = false + var channels: [ChannelSnowflake: DiscordChannel] + var channel: DiscordChannel + var content: (_ hovered: Bool) -> Content + + var shouldHide: Bool { + guard let guild else { return false } + return guild.hasPermission( + channel: channel, + .viewChannel + ) == false + } + var canConnect: Bool { + guard let guild else { return false } + return guild.hasPermission( + channel: channel, + .connect + ) + } + var body: some View { + if !shouldHide { + Button { + Task { + appState.selectedChannel = .voiceChannel(channel.id) + guard let guildID = appState.selectedGuild.guildID, canConnect else { return } + await gw.voice.updateVoiceConnection( + .join( + channelId: channel.id, + guildId: guildID, + ) + ) + } + } label: { + content(isHovered) + } + .onHover { isHovered = $0 } + .buttonStyle(.borderless) + .disabled(!canConnect) + } + } + } + + struct VoiceChannelUsers: View { + @Environment(\.gateway) var gw + @Environment(\.appState) var appState + var channel: DiscordChannel + + var body: some View { + let voiceChannels = gw.voiceChannels + if let guildID = appState.selectedGuild.guildID, let voiceStates = voiceChannels.voiceStates[guildID]?[ + channel.id + ], !voiceStates.isEmpty { + LazyVStack(spacing: 2) { + ForEach(voiceStates.values) { state in + UserButton(state: state) + } + } + .padding(.leading, 32) + .padding(.bottom, 4) + } + } + + struct UserButton: View { + var state: VoiceState + @Environment(\.guildStore) var guildStore + @Environment(\.gateway) var gw + var vgw: VoiceConnectionStore { gw.voice } + @State var showPopover = false + + var isDeafened: Bool { + state.self_deaf || state.deaf + } + + var isServerDeafened: Bool { + state.deaf + } + + var isMuted: Bool { + state.self_mute || state.mute + } + + var isServerMuted: Bool { + state.mute + } + + var isSpeaking: Bool { + if let state = vgw.usersSpeakingState[state.user_id] { + return state.isEmpty == false + } + return false + } + + var member: Guild.PartialMember? { + state.member ?? guildStore?.members[state.user_id] + } + + var user: PartialUser? { + state.member?.user?.toPartialUser() ?? gw.user.users[state.user_id] + } + + var body: some View { + Button { + if user != nil { + showPopover.toggle() + } + } label: { + HStack { + Profile.Avatar( + member: member, + user: user + ) + .frame(maxWidth: 20, maxHeight: 20) + .overlay( + Circle() + .fill(Color.clear) + .stroke(isSpeaking ? Color.green : Color.clear, lineWidth: 2) + ) + + Text( + state.member?.nick ?? user?.global_name ?? user?.username + ?? "Unknown User" + ) + .lineLimit(1) + .foregroundStyle(isSpeaking ? .primary : .secondary) + + Spacer() + + if isMuted { + Image(systemName: "mic.slash.fill") + .imageScale(.small) + .foregroundStyle(isServerMuted ? .red : .secondary) + } + + if isDeafened { + Image(systemName: "headphones.slash") + .imageScale(.small) + .foregroundStyle(isServerDeafened ? .red : .secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + .padding(.horizontal, 8) + } + .buttonStyle(.borderlessHoverEffect(isSelected: showPopover, selectionShape: .init(.rounded))) + .popover(isPresented: $showPopover) { + if let user { + ProfilePopoutView( + guild: guildStore, + member: member, + user: user + ) + } + } + } + } + } + + /// Button that triggers voice channel actions. + @ViewBuilder + func voiceChannelButton( + @ViewBuilder label: @escaping (_ hovered: Bool) -> Content + ) + -> some View + { + LazyVStack(spacing: 2) { + VoiceChannelButton( + channels: channels, + channel: channel + ) { hovered in + label(hovered) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + .background( + Group { + if hovered { + Color.gray.opacity(0.2) + } else { + Color.clear + } + } + .clipShape(.rounded) + ) + .background( + Group { + if appState.selectedChannel.channelID == channel.id { + Color.gray.opacity(0.13) + } else { + Color.clear + } + } + .clipShape(.rounded) + ) + } + + VoiceChannelUsers(channel: channel) + } + } + struct CategoryButton: View { @Environment(\.userInterfaceIdiom) var idiom @Environment(\.guildStore) var guild diff --git a/Paicord/Common/Guilds/GuildButton.swift b/Paicord/Common/Guilds/GuildButton.swift index a2610623..66dde5ef 100644 --- a/Paicord/Common/Guilds/GuildButton.swift +++ b/Paicord/Common/Guilds/GuildButton.swift @@ -44,7 +44,7 @@ struct GuildButton: View { } else { let height: CGFloat = { // if the guild is selected - if appState.selectedGuild == guild?.id { + if appState.selectedGuild.guildID == guild?.id { return 38 } else if isHovering { return 20 @@ -260,14 +260,24 @@ struct GuildButton: View { /// A button representing a guild or DMs func guildButton(from guild: Guild?) -> some View { Button { - ImpactGenerator.impact(style: .light) - appState.selectedGuild = guild?.id + if appState.selectedGuild.guildID == guild?.id { + #if os(iOS) + appState.chatOpen = true + #endif + } else { + ImpactGenerator.impact(style: .light) + if let id = guild?.id { + appState.selectedGuild = .guild(id) + } else { + appState.selectedGuild = .directMessages + } + } } label: { - let isSelected = appState.selectedGuild == guild?.id + let isSelected = appState.selectedGuild.guildID == guild?.id Group { if let id = guild?.id { Group { - let shouldAnimate = appState.selectedGuild == id + let shouldAnimate = appState.selectedGuild.guildID == id if let icon = guild?.icon, let url = iconURL(id: id, icon: icon, animated: shouldAnimate) { diff --git a/Paicord/Common/Guilds/GuildView.swift b/Paicord/Common/Guilds/GuildView.swift index f809cc9e..1086d4b7 100644 --- a/Paicord/Common/Guilds/GuildView.swift +++ b/Paicord/Common/Guilds/GuildView.swift @@ -46,18 +46,21 @@ struct GuildView: View { // also, while sorting ($0.position ?? 0) < ($1.position ?? 0), sort channels to the top and categories to the bottom let uncategorizedChannels = guild.channels.values .filter { $0.parent_id == nil } - // .sorted { ($0.position ?? 0) < ($1.position ?? 0) } .sorted { lhs, rhs in let lhsIsCategory = lhs.type == .guildCategory let rhsIsCategory = rhs.type == .guildCategory if lhsIsCategory == rhsIsCategory { + // positions can be undefined sometimes, usually defaulting to 0 + if lhs.position == 0 && rhs.position == 0 { + return lhs.id < rhs.id + } return (lhs.position ?? 0) < (rhs.position ?? 0) } else { return !lhsIsCategory && rhsIsCategory } } - VStack(spacing: 1) { + LazyVStack(spacing: 1) { ForEach(uncategorizedChannels) { channel in ChannelButton(channels: guild.channels, channel: channel) .padding(.horizontal, 4) diff --git a/Paicord/Common/Member Sidebar/MemberRowView.swift b/Paicord/Common/Member Sidebar/MemberRowView.swift index 1facca71..875280dd 100644 --- a/Paicord/Common/Member Sidebar/MemberRowView.swift +++ b/Paicord/Common/Member Sidebar/MemberRowView.swift @@ -8,6 +8,13 @@ import PaicordLib import SwiftUIX +private struct Pair: Identifiable { + var id: UserSnowflake { user.id } + + var member: Guild.PartialMember? + var user: DiscordUser +} + extension MemberSidebarView { struct MemberRowView: View { @Environment(\.guildStore) var guildStore @@ -15,11 +22,11 @@ extension MemberSidebarView { var user: DiscordUser @State var isHovering: Bool = false - @State var showPopover: Bool = false + @State private var showPopoverItem: Pair? = nil var body: some View { Button { - showPopover = true + showPopoverItem = .init(member: member, user: user) } label: { HStack { Profile.AvatarWithPresence( @@ -73,11 +80,11 @@ extension MemberSidebarView { } .buttonStyle(.plain) .onHover { self.isHovering = $0 } - .popover(isPresented: $showPopover) { + .popover(item: $showPopoverItem) { pair in ProfilePopoutView( guild: guildStore, - member: member, - user: user.toPartialUser() + member: pair.member, + user: pair.user.toPartialUser() ) } } diff --git a/Paicord/Common/Quick Switcher/QuickSwitcherModifier.swift b/Paicord/Common/Quick Switcher/QuickSwitcherModifier.swift index b3b873bc..0c77d979 100644 --- a/Paicord/Common/Quick Switcher/QuickSwitcherModifier.swift +++ b/Paicord/Common/Quick Switcher/QuickSwitcherModifier.swift @@ -479,20 +479,24 @@ struct QuickSwitcherView: View { let res = try await gw.client.createDm(payload: .init(recipient: user.id)) try res.guardSuccess() let channel = try res.decode() - appState.selectedGuild = nil - appState.selectedChannel = channel.id + appState.selectedGuild = .directMessages + appState.selectedChannel = .textChannel(channel.id) case .groupDM(let channel): - appState.selectedGuild = nil - appState.selectedChannel = channel.id + appState.selectedGuild = .directMessages + appState.selectedChannel = .textChannel(channel.id) case .guildChannel(let channel, _, let guild): - appState.selectedGuild = guild.id + appState.selectedGuild = .guild(guild.id) switch channel.type { case .guildText, .guildAnnouncement: - appState.selectedChannel = channel.id + appState.selectedChannel = .textChannel(channel.id) + case .guildVoice: + appState.selectedChannel = .voiceChannel(channel.id) + case .publicThread, .privateThread, .announcementThread: + appState.selectedChannel = .thread(channel.id) default: break } case .guild(let guild): - appState.selectedGuild = guild.id + appState.selectedGuild = .guild(guild.id) } } } diff --git a/Paicord/Common/Utilities/Components/BorderlessHoverEffectButtonStyle.swift b/Paicord/Common/Utilities/Components/BorderlessHoverEffectButtonStyle.swift new file mode 100644 index 00000000..7fc869d7 --- /dev/null +++ b/Paicord/Common/Utilities/Components/BorderlessHoverEffectButtonStyle.swift @@ -0,0 +1,84 @@ +// +// BorderlessHoverEffectButtonStyle.swift +// Paicord +// +// Created by Lakhan Lothiyi on 15/03/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import SwiftUI + +// button style thats borderless, and has configurable hover effects +extension ButtonStyle where Self == BorderlessHoverEffectButtonStyle { + static func borderlessHoverEffect( + hoverColor: Color = .gray, + pressedColor: Color = .gray, + persistentBackground: AnyShapeStyle? = nil, + isSelected: Bool = false, + selectionShape: AnyShape = .init(.rect(cornerRadius: 8)), + ) -> some ButtonStyle { + BorderlessHoverEffectButtonStyle( + hoverColor: hoverColor, + pressedColor: pressedColor, + persistentBackground: persistentBackground, + isSelected: isSelected, + selectionShape: selectionShape + ) + } +} + +struct BorderlessHoverEffectButtonStyle: ButtonStyle { + var hoverColor: Color + var pressedColor: Color + var persistentBackground: AnyShapeStyle? + var isSelected = false + var selectionShape: AnyShape + @State private var isHovered = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background { + ZStack { + if let persistentBackground { + Rectangle() + .fill(persistentBackground) + } + if configuration.isPressed { + pressedColor + .opacity(0.12) + } + if isHovered { + hoverColor.opacity(0.2) + } + if isSelected { + pressedColor + .opacity(0.2) + .opacity(0.8) + } + } + .clipShape(selectionShape) + } + .foregroundColor(isSelected ? pressedColor : nil) + .onHover { isHovered = $0 } + } +} + +#Preview("button test") { + @Previewable @State var selected = false + Button { + selected.toggle() + } label: { + Image(systemName: "checkmark") + .padding(10) + } + .buttonStyle( + .borderlessHoverEffect( + hoverColor: .blue, + pressedColor: .blue, + persistentBackground: .init(.ultraThinMaterial), + isSelected: selected, + selectionShape: .init(.rect), + ) + ) + .padding() +} diff --git a/Paicord/Common/Utilities/Modifiers/EntityContextMenu.swift b/Paicord/Common/Utilities/Modifiers/EntityContextMenu.swift index f58b7d58..0841908a 100644 --- a/Paicord/Common/Utilities/Modifiers/EntityContextMenu.swift +++ b/Paicord/Common/Utilities/Modifiers/EntityContextMenu.swift @@ -214,7 +214,7 @@ struct EntityContextMenu: ViewModifier { Label("Copy Text", systemImage: "document.on.document.fill") } Button { - let guildID = appState.selectedGuild?.rawValue ?? "@me" + let guildID = appState.selectedGuild.guildID?.rawValue ?? "@me" let channelID = message.channel_id.rawValue let messageID = message.id.rawValue copyText( diff --git a/Paicord/Common/Utilities/Profile.swift b/Paicord/Common/Utilities/Profile.swift index 3647f17a..e060c579 100644 --- a/Paicord/Common/Utilities/Profile.swift +++ b/Paicord/Common/Utilities/Profile.swift @@ -16,6 +16,7 @@ extension EnvironmentValues { @Entry var profileHideOfflinePresence: Bool = false @Entry var nameplateAnimated: Bool = false + @Entry var nameplateImageOpacity: CGFloat = 1 } extension View { @@ -38,6 +39,11 @@ extension View { func nameplateAnimated(_ animated: Bool = true) -> some View { environment(\.nameplateAnimated, animated) } + + /// Opacity of the image in the nameplate + func nameplateImageOpacity(_ opacity: CGFloat = 1) -> some View { + environment(\.nameplateImageOpacity, opacity) + } } /// Collection of ui components for profiles @@ -233,6 +239,7 @@ enum Profile { struct NameplateView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.nameplateAnimated) var animated + @Environment(\.nameplateImageOpacity) var opacity let nameplate: DiscordUser.Collectibles.Nameplate var color: Color { @@ -295,11 +302,13 @@ enum Profile { .clipped() } } + .opacity(opacity) } else { WebImage(url: staticURL) .resizable() .scaledToFill() .clipped() + .opacity(opacity) } } } @@ -495,6 +504,15 @@ struct AvatarDecorationView: View { flags: .init(rawValue: 4_194_352), premium_type: nil, public_flags: .init(rawValue: 4_194_304), + collectibles: .init( + nameplate: + .init( + asset: "nameplates/nameplates_v3/bonsai/", + sku_id: SKUSnowflake("1382845914225442886"), + label: "COLLECTIBLES_NAMEPLATES_VOL_3_BONSAI_A11Y", + palette: .bubble_gum + ) + ), avatar_decoration_data: decoration ) Group { diff --git a/Paicord/Common/Voice/CallView.swift b/Paicord/Common/Voice/CallView.swift new file mode 100644 index 00000000..c8656798 --- /dev/null +++ b/Paicord/Common/Voice/CallView.swift @@ -0,0 +1,445 @@ +// +// CallView.swift +// Paicord +// +// Created by Lakhan Lothiyi on 15/03/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +// stacked on top of chat view in dms. + +import AVFAudio +import Collections +import PaicordLib +import SwiftUIX + +struct CallView: View { + @Environment(\.gateway) var gw + @Environment(\.channelStore) var channel + var vcs: VoiceChannelsStore { gw.voiceChannels } + var currentUser: CurrentUserStore { gw.user } + @ViewStorage var timer: Timer? = nil + @State var showingVoiceUI = false + + // the large baseplate will handle stacking this view vertically above the chat. + // this just needs to handle sizing itself, and switching to the standard VoiceView + // when video is enabled, activities are happening etc. + var body: some View { + if let channelID = channel?.channelId, + let states = vcs.voiceStates[nil]?[channelID], + let call = vcs.calls[channelID] + { + callInterface( + states: states.values, + call: call + ) + .maxWidth(.infinity) + .maxHeight(viewHeight) + .background(.black) + .overlay(alignment: .bottom) { drawerResizeGrabber } + .overlay(alignment: .bottom) { + if showingVoiceUI + || !states.keys.contains(currentUser.currentUser?.id ?? .init("0")) + { + BottomCallBar() + .padding(.bottom, 10) + } + } + .onContinuousHover(coordinateSpace: .local) { phase in + switch phase { + case .active: + if !showingVoiceUI { + showingVoiceUI = true + } + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { + _ in + self.showingVoiceUI = false + } + case .ended: break + } + } + } else { + EmptyView() + } + } + + @ViewBuilder + func callInterface( + states: OrderedDictionary.Values, + call: Gateway.CallCreate + ) -> some View { + VStack(spacing: 0) { + HStack { + let ids = states.map(\.user_id) + call.ringing + ForEach(ids) { id in + CallParticipantView( + channelID: call.channel_id, + userID: id, + isRinging: call.ringing.contains(id) + ) + } + } + } + } + + struct BottomCallBar: View { + @Environment(\.gateway) var gw + @Environment(\.channelStore) var channel + var vgw: VoiceConnectionStore { gw.voice } + var call: Gateway.CallCreate? { + guard let channelID = channel?.channelId else { return nil } + let call = gw.voiceChannels.calls[channelID] + return call + } + var states: OrderedDictionary? { + guard let channelID = channel?.channelId else { return nil } + let states = gw.voiceChannels.voiceStates[nil]?[channelID] + return states + } + @State var micError = false + @ViewStorage var didDeafenBeforeMute = false + + var body: some View { + HStack { + // 3 states. not in call but is ringing, call active without us but not ringing us, or in a call. + let userID: UserSnowflake = gw.user.currentUser?.id ?? .init("0") + if let states, states.keys.contains(userID) { // ongoing call, user is in it + microphoneButton + deafenButton + hangupButton + } else if let call, call.ringing.contains(userID) { // not in call but ongoing call and ringing + callButton + hangupButton + } else if call != nil { // not in call, but ongoing call and not ringing + callButton + } + } + } + + @ViewBuilder + var microphoneButton: some View { + // shows when in call + Button { + Task { + switch AVAudioApplication.shared.recordPermission { + case .granted: + // if deafened whilst unmuting, undeafen + await vgw.updateVoiceState( + isMuted: !gw.voice.isMuted, + isDeafened: vgw.isDeafened && gw.voice.isMuted ? false : nil + ) + case .denied: + micError = true + case .undetermined: + if await AVAudioApplication.requestRecordPermission() { + await vgw.updateVoiceState(isMuted: false) + } + @unknown default: + fatalError() + } + } + } label: { + if #available(macOS 15.0, iOS 18.0, *) { + Image(systemName: vgw.isMuted ? "mic.slash.fill" : "mic.fill") + .contentTransition( + .symbolEffect( + .replace.magic(fallback: .upUp.byLayer), + options: .nonRepeating + ) + ) + .font(.title2) + .maxWidth(35) + .maxHeight(35) + } else { + Image(systemName: vgw.isMuted ? "mic.slash.fill" : "mic.fill") + .contentTransition( + .symbolEffect(.replace.wholeSymbol, options: .nonRepeating) + ) + .font(.title2) + .maxWidth(35) + .maxHeight(35) + } + } + .buttonStyle( + .borderlessHoverEffect( + pressedColor: .red, + isSelected: vgw.isMuted + ) + ) + .alert("Microphone Unavailable", isPresented: $micError) { + Button("OK", role: .cancel) {} + } message: { + Text( + "Please allow microphone access in your system settings to unmute yourself in voice channels." + ) + } + } + + @ViewBuilder + var deafenButton: some View { + // shows when in call + Button { + Task { + // if going to deafen and not currently muted, deafen and mute. if coming back, undeafen and unmute too. + var deaf = vgw.isDeafened + var mute = vgw.isMuted + if !deaf && !mute { + didDeafenBeforeMute = true + mute = true + } else if vgw.isDeafened && didDeafenBeforeMute { + mute = false + didDeafenBeforeMute = false + } + deaf.toggle() + await vgw.updateVoiceState(isMuted: mute, isDeafened: deaf) + } + } label: { + if #available(macOS 15.0, iOS 18.0, *) { + Image( + systemName: gw.voice.isDeafened + ? "headphones.slash" : "headphones" + ) + .contentTransition( + .symbolEffect( + .replace.magic(fallback: .upUp.byLayer), + options: .nonRepeating + ) + ) + .font(.title2) + .maxWidth(35) + .maxHeight(35) + } else { + Image( + systemName: gw.voice.isDeafened + ? "headphones.slash" : "headphones" + ) + .contentTransition( + .symbolEffect(.replace.wholeSymbol, options: .nonRepeating) + ) + .font(.title2) + .maxWidth(35) + .maxHeight(35) + } + } + .buttonStyle( + .borderlessHoverEffect( + pressedColor: .red, + isSelected: vgw.isDeafened + ) + ) + } + + @ViewBuilder + var callButton: some View { + // shows when not in call, ringing or not ringing + Button { + Task { + if let channelId = call?.channel_id { + await vgw.updateVoiceConnection( + .join(channelId: channelId, guildId: nil) + ) + } + } + } label: { + Image(systemName: "phone.fill") + .font(.title2) + .maxWidth(35) + .maxHeight(35) + } + .buttonStyle( + .borderlessHoverEffect( + pressedColor: .green, + isSelected: true + ) + ) + } + + @ViewBuilder + var hangupButton: some View { + // shows when not in call and ringing, or when in call. + Button { + Task { + // if in call, leave. + // if not in call but ringing, hit the stopringing endpoint. + if states?.keys.contains(gw.user.currentUser?.id ?? .init("0")) + == true + { + await vgw.updateVoiceConnection(.disconnect) + } else if let channelId = call?.channel_id, + call?.ringing.contains(gw.user.currentUser?.id ?? .init("0")) + == true + { + try? await gw.client.stopRingingChannelRecipients( + channelID: channelId, + payload: .init() + ).guardSuccess() + } + } + } label: { + Image(systemName: "phone.down.fill") + .font(.title2) + .maxWidth(35) + .maxHeight(35) + } + .buttonStyle( + .borderlessHoverEffect( + pressedColor: .red, + isSelected: true + ) + ) + } + } + + struct CallParticipantView: View { + @Environment(\.gateway) var gw + var vgw: VoiceConnectionStore { gw.voice } + var vc: VoiceChannelsStore { gw.voiceChannels } + + var previewUser: DiscordUser? = nil + + var channelID: ChannelSnowflake + var userID: UserSnowflake + var isRinging: Bool + + var user: PartialUser? { + gw.user.users[userID] ?? previewUser?.toPartialUser() + } + + var state: VoiceState? { + vc.voiceStates[nil]?[channelID]?[userID] + } + + var isDeafened: Bool { + state?.self_deaf == true || state?.deaf == true + } + + var isServerDeafened: Bool { + state?.deaf == true + } + + var isMuted: Bool { + state?.self_mute == true || state?.mute == true + } + + var isServerMuted: Bool { + state?.mute == true + } + + var isSpeaking: Bool { + if let state = vgw.usersSpeakingState[userID] { + return state.isEmpty == false + } + return false + } + + var body: some View { + Profile.Avatar( + member: nil, + user: user + ) + .frame(width: 80, height: 80) + .overlay { + if isRinging { + Circle() + .fill(.black.opacity(0.5)) + } + } + .background { + if isRinging { + Circle() + .fill(.clear) + .strokeBorder(.primary, style: .init(lineWidth: 2)) + .phaseAnimator([0, 1, 2, 3]) { view, phase in + // 0, 1 pulse, 2, 3 do nothing, then repeat. + // scale up from 0.8 to 1.25 while fading out from 1 to 0. + view + .scaleEffect(phase == 0 ? 0.8 : (phase == 1 ? 1.25 : 0.8)) + .opacity(phase == 0 ? 1 : (phase == 1 ? 0 : 0)) + } + } + } + .overlay { + if isSpeaking { + ZStack { + Circle() + .strokeBorder(.black, lineWidth: 4) + Circle() + .strokeBorder(.green, lineWidth: 2) + } + } + } + .overlay(alignment: .bottomTrailing) { + if isDeafened || isMuted { + Group { + if isDeafened { + Image(systemName: "headphones.slash") + .imageScale(.large) + } else if isMuted { + Image(systemName: "mic.slash.fill") + .imageScale(.large) + } + } + .foregroundStyle(.white) + .padding(4) + .background(.red, in: .circle) + .overlay { + Circle() + .strokeBorder(.black, lineWidth: 4) + } + } + } + } + } + + var panelSize: CGSize = .zero + @State var viewHeight: CGFloat = 200 + @State var isDragging = false + @State var isHovering = false + + @ViewBuilder var drawerResizeGrabber: some View { + ZStack { + Rectangle() + .fill(Color.tertiarySystemFill) + .frame(height: 4) + Rectangle() + .fill(Color.primary) + .frame(width: 100, height: 6) + .clipShape(.capsule) + .onHover { hovering in + let cursor = NSCursor.resizeUpDown + if hovering { + cursor.push() + } else { + NSCursor.pop() + } + } + .gesture( + DragGesture() + .onChanged { value in + if !isDragging { isDragging = true } + let newHeight = viewHeight + value.translation.height + viewHeight = newHeight.clamped( + to: 200...(max(210, panelSize.height * 0.7)) + ) + } + .onEnded { _ in + isDragging = false + } + ) + .onChange(of: isDragging) { + let cursor = NSCursor.resizeUpDown + if isDragging { + cursor.push() + } else { + NSCursor.pop() + } + } + } + .frame(height: 6) + .offset(y: 3) + .opacity(isHovering || isDragging ? 1 : 0.001) + .onHover { self.isHovering = $0 } + .animation(.easeInOut, value: isHovering || isDragging) + } +} diff --git a/Paicord/Common/Voice/VoiceView.swift b/Paicord/Common/Voice/VoiceView.swift new file mode 100644 index 00000000..c684b8cd --- /dev/null +++ b/Paicord/Common/Voice/VoiceView.swift @@ -0,0 +1,322 @@ +// +// VoiceView.swift +// Paicord +// +// Created by Lakhan Lothiyi on 15/03/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import Algorithms +import Collections +import ColorCube +import PaicordLib +import SDWebImage +import SwiftUIX + +struct VoiceView: View { + @Environment(\.gateway) var gw + @Environment(\.appState) var appState + var vm: ChannelStore + var vgw: VoiceConnectionStore? { gw.voice } + + @Namespace private var voiceGridAnimations + @ViewStorage var frame: CGRect = .zero + @ViewStorage var timer: Timer? = nil + @State var showingVoiceUI = false + + var body: some View { + Group { + VStack(spacing: 15) { + let voiceChannels = gw.voiceChannels + let guildID = vm.guildStore?.guildId + let voiceStates = + voiceChannels.voiceStates[guildID]?[vm.channelId] ?? [:] + if !voiceStates.isEmpty { + CurrentPeopleGrid( + members: voiceStates, + showingVoiceUI: $showingVoiceUI, + namespace: voiceGridAnimations + ) + .padding(.vertical, 30) + .animation(.spring, value: voiceStates) + } + if vgw?.channelId != vm.channelId { + Text(vm.channel?.name ?? "Unknown Channel") + .font(.largeTitle) + + if voiceStates.isEmpty { + Text("No one is currently in voice") + .foregroundStyle(.white.secondary) + } else { + let firstTwo = voiceStates.prefix(2).compactMap { + let member = $0.value.member ?? vm.guildStore?.members[$0.key] + let user = member?.user?.toPartialUser() ?? gw.user.users[$0.key] + return member?.nick ?? user?.global_name ?? user?.username + ?? "Unknown User" + } + let remainderCount = voiceStates.count - firstTwo.count + Text( + "\(firstTwo.joined(separator: voiceStates.count == 2 ? " and " : ", "))\(remainderCount > 0 ? " and \(remainderCount) other\(remainderCount == 1 ? "" : "s")" : "") \(voiceStates.count == 1 ? "is" : "are") currently in voice" + ) + } + + Button { + Task { + do { + let channelID = vm.channelId + guard let guildID = vm.guildStore?.guildId else { return } + await gw.voice.updateVoiceConnection( + .join( + channelId: channelID, + guildId: guildID, + ) + ) + } catch { + print("Failed to leave voice channel with error: \(error)") + } + } + + } label: { + Text("Join Voice") + } + .disabled( + !(vm.guildStore?.hasPermission(channel: vm, .connect) ?? true) + ) + } + } + .foregroundStyle(.white.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.black) + .overlay(alignment: .topLeading) { + if vgw?.channelId == vm.channelId && showingVoiceUI { + HStack { + Image(systemName: "speaker.wave.2.fill") + .imageScale(.large) + Text(vm.channel?.name ?? "unknown") + .font(.headline) + } + .foregroundStyle(.white) + .padding(8) + .transition(.offset(x: -20).combined(with: .opacity)) + } + } + .animation(.spring, value: showingVoiceUI) + .onGeometryChange( + for: CGRect.self, + of: { $0.frame(in: .local) }, + action: { frame = $0 } + ) + .onContinuousHover(coordinateSpace: .local) { phase in + switch phase { + case .active: + if !showingVoiceUI { + showingVoiceUI = true + } + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { + _ in + self.showingVoiceUI = false + } + case .ended: break + } + } + } + + // shown before joining. smaller grid. + struct CurrentPeopleGrid: View { + @Environment(\.gateway) var gw + @Environment(\.channelStore) var channelStore + var vgw: VoiceConnectionStore? { gw.voice } + var members: OrderedDictionary + @State var contentSize: CGSize = .zero + @Binding var showingVoiceUI: Bool + var namespace: Namespace.ID + + var itemSize: CGFloat? { + vgw?.channelId != channelStore?.channelId ? 150 : nil + } + + var body: some View { + let chunks: + ChunksOfCountCollection< + OrderedDictionary.Values + > = { + // chunk by powers of 2 to make a nicely expanding grid + let count = members.count + let chunkSize: Int = { + var size = 1 + while Int(pow(2.0, Double(size))) < count { + size += 1 + } + return size + (itemSize != nil ? 1 : 0) + }() + return members.values.chunks(ofCount: chunkSize) + }() + VStack(alignment: .center, spacing: 0) { + ForEach(Array(chunks.enumerated()), id: \.offset) { _, chunk in + HStack(alignment: .center, spacing: 0) { + ForEach(Array(chunk), id: \.self) { voiceState in + GridCell(showingVoiceUI: $showingVoiceUI, state: voiceState) + .maxWidth(itemSize ?? .infinity) + .matchedGeometryEffect( + id: voiceState.user_id, + in: namespace, + properties: .frame + ) + } + } + } + } + .minWidth(itemSize) + .maxWidth(itemSize == nil ? nil : min(contentSize.width, itemSize! * 4)) + .padding() + .onGeometryChange( + for: CGSize.self, + of: { $0.size }, + action: { + contentSize = $0 + } + ) + .animation(.spring, value: itemSize) + } + + struct GridCell: View { + @Environment(\.gateway) var gw + @Environment(\.channelStore) var channelStore + @Environment(\.guildStore) var guildStore + @Binding var showingVoiceUI: Bool + var vgw: VoiceConnectionStore? { gw.voice } + var state: VoiceState + + var isDeafened: Bool { + state.self_deaf || state.deaf + } + + var isServerDeafened: Bool { + state.deaf + } + + var isMuted: Bool { + state.self_mute || state.mute + } + + var isServerMuted: Bool { + state.mute + } + + var isSpeaking: Bool { + if let state = vgw?.usersSpeakingState[state.user_id] { + return state.isEmpty == false + } + return false + } + + var member: Guild.PartialMember? { + state.member ?? guildStore?.members[state.user_id] + } + + var user: PartialUser? { + state.member?.user?.toPartialUser() ?? gw.user.users[state.user_id] + } + + @State var accentColor = Color.white + + var body: some View { + VStack { + Profile.Avatar( + member: member, + user: user + ) + .width(50) + .height(50) + .padding() + .maxWidth(.infinity) + .maxHeight(.infinity) + } + .aspectRatio(1.8, contentMode: .fit) + .background(accentColor) + .overlay(alignment: .bottomLeading) { + if (isMuted || isDeafened || showingVoiceUI) + && vgw?.channelId == channelStore?.channelId + { + HStack { + if isDeafened { + Image(systemName: "headphones.slash") + .foregroundStyle(isServerDeafened ? .red : .white) + } else if isMuted { + Image(systemName: "mic.slash") + .foregroundStyle(isServerMuted ? .red : .white) + } + if showingVoiceUI { + Text( + member?.nick ?? user?.global_name ?? user?.username + ?? "Unknown User" + ) + .foregroundStyle(.white) + .lineLimit(1) + } + } + .padding(6) + .background(.black.opacity(0.5)) + .clipShape(.rounded) + .padding(6) + } + } + .clipShape(.rounded) + .overlay { + if isSpeaking { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(.black, lineWidth: 4) + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(.green, lineWidth: 2) + } + } + } + .padding(2) + .transaction(value: isSpeaking) { t in + t.disableAnimations() + } + .task(id: user, grabColor) + .task(id: member, grabColor) + } + + @Sendable + func grabColor() async { + let cc = CCColorCube() + // use sdwebimage's image manager, get the avatar image and extract colors using colorcube + let m: Guild.PartialMember? = member + guard + let avatarURL = Utils.fetchUserAvatarURL( + member: m, + guildId: guildStore?.guildId, + user: user, + animated: false + ) + else { + return + } + let imageManager: SDWebImageManager = .shared + imageManager.loadImage( + with: avatarURL, + progress: nil + ) { image, _, error, _, _, _ in + guard let image else { + return + } + let colors = cc.extractColors( + from: image, + flags: [.orderByBrightness, .avoidBlack, .avoidWhite] + ) + if let firstColor = colors?.first { + DispatchQueue.main.async { + self.accentColor = Color(firstColor) + } + } else { + } + } + } + } + } +} diff --git a/Paicord/Resources/Localizable.xcstrings b/Paicord/Resources/Localizable.xcstrings index 2ffebe0f..efa621a5 100644 --- a/Paicord/Resources/Localizable.xcstrings +++ b/Paicord/Resources/Localizable.xcstrings @@ -24,12 +24,25 @@ }, ":3" : { + }, + ":3c" : { + }, "(edited)" : { }, "%@ unsupported" : { + }, + "%@%@ %@ currently in voice" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@%2$@ %3$@ currently in voice" + } + } + } }, "%lld members" : { @@ -76,6 +89,9 @@ }, "Authenticator App" : { + }, + "Awaiting Audio Setup" : { + }, "Awaiting READY" : { @@ -94,6 +110,9 @@ }, "ComponentsV2 unsupported" : { + }, + "Connecting to Voice" : { + }, "Copy" : { @@ -205,6 +224,9 @@ }, "Join the [Paicord Server](https://discord.gg/fqhPGHPyaK) to manage badges!" : { + }, + "Join Voice" : { + }, "Log In" : { @@ -226,9 +248,15 @@ }, "Mention" : { + }, + "Microphone Unavailable" : { + }, "Multi-Factor Authentication" : { + }, + "No one is currently in voice" : { + }, "Notifications" : { @@ -259,6 +287,9 @@ }, "Playgrounds" : { + }, + "Please allow microphone access in your system settings to unmute yourself in voice channels." : { + }, "Profile" : { @@ -307,6 +338,9 @@ }, "Sponsor" : { + }, + "Start Call" : { + }, "Sticker Picker Coming Soon!" : { @@ -337,6 +371,9 @@ }, "Unimplemented" : { + }, + "Unknown Channel" : { + }, "Unknown User" : { @@ -368,6 +405,12 @@ }, "View Logs" : { + }, + "Voice Connected" : { + + }, + "Voice Disconnected" : { + }, "We're so excited to see you again!" : { diff --git a/Paicord/Stores/ChannelStore.swift b/Paicord/Stores/ChannelStore.swift index 7e79aa57..515a5639 100644 --- a/Paicord/Stores/ChannelStore.swift +++ b/Paicord/Stores/ChannelStore.swift @@ -645,7 +645,9 @@ class ChannelStore: DiscordDataStore { print( "[ChannelStore] Requesting \(unknownMembers.count) unknown members in guild \(guildStore.guildId.rawValue)" ) - await guildStore.requestMembers(for: unknownMembers) + Task { @MainActor in + await guildStore.requestMembers(for: unknownMembers) + } } } } catch { diff --git a/Paicord/Stores/GatewayStore.swift b/Paicord/Stores/GatewayStore.swift index d86e2557..7890a237 100644 --- a/Paicord/Stores/GatewayStore.swift +++ b/Paicord/Stores/GatewayStore.swift @@ -113,14 +113,16 @@ final class GatewayStore { presence.setGateway(self) messageDrain.setGateway(self) switcher.setGateway(self) + voice.setGateway(self) + voiceChannels.setGateway(self) // Update existing channel stores - for channelStore in channels.values { + for channelStore in _channels.values { channelStore.setGateway(self) } // Update existing guild stores - for guildStore in guilds.values { + for guildStore in _guilds.values { guildStore.setGateway(self) } } @@ -133,8 +135,11 @@ final class GatewayStore { presence = .init() messageDrain = .init() switcher = .init() - channels = [:] - guilds = [:] + voice.cancelEventHandling() // cancel any ongoing voice stuff + voice = .init() + voiceChannels = .init() + _channels = [:] + _guilds = [:] subscribedGuilds = [] } @@ -149,30 +154,29 @@ final class GatewayStore { var presence = PresenceStore() var messageDrain = MessageDrainStore() var switcher = QuickSwitcherProviderStore() + var voice = VoiceConnectionStore() + var voiceChannels = VoiceChannelsStore() - private var channels: [ChannelSnowflake: ChannelStore] = [:] + var _channels: [ChannelSnowflake: ChannelStore] = [:] func getChannelStore(for id: ChannelSnowflake, from guild: GuildStore? = nil) -> ChannelStore { - if let store = channels[id] { + if let store = _channels[id] { return store } else { let channel = guild?.channels[id] ?? user.privateChannels[id] let store = ChannelStore(id: id, from: channel, guildStore: guild) store.setGateway(self) - channels[id] = store + _channels[id] = store return store } } private var subscribedGuilds: Set = [] - private var guilds: [GuildSnowflake: GuildStore] = [:] + var _guilds: [GuildSnowflake: GuildStore] = [:] func getGuildStore(for id: GuildSnowflake) -> GuildStore { defer { if !subscribedGuilds.contains(id) { - print( - "[GatewayStore] Subscribing for guild store to \(id.rawValue)" - ) subscribedGuilds.insert(id) Task { await gateway?.updateGuildSubscriptions( @@ -188,17 +192,17 @@ final class GatewayStore { ) ]) ) - print("[GatewayStore] Subscribed to guild \(id.rawValue)") + print("[GatewayStore] Subscribed for GuildStore \(id.rawValue)") } } } - if let store = guilds[id] { + if let store = _guilds[id] { return store } else { let guild = user.guilds[id] let store = GuildStore(id: id, from: guild) store.setGateway(self) - guilds[id] = store + _guilds[id] = store return store } } @@ -206,21 +210,6 @@ final class GatewayStore { // MARK: - Handlers private func handleReady(_ data: Gateway.Ready) { - // send voice states, temporary until paicord has proper voice handling - Task { - await self.gateway?.updateVoiceState( - payload: .init( - guild_id: nil, - channel_id: nil, - self_mute: true, - self_deaf: true, - self_video: false, - preferred_region: nil, - preferred_regions: nil, - flags: [] - ) - ) - } // update user data in account storage accounts.updateProfile(for: data.user.id, data.user) @@ -230,11 +219,15 @@ final class GatewayStore { print( "[GatewayStore] Reconnected, resubscribing to previously subscribed guilds." ) - let channelIds = PaicordAppState.instances.compactMap( - \.value.selectedChannel - ) - channels = channels.filter { channelIds.contains($0.key) } - if let channel = channels.values.first { + let channelIds = PaicordAppState.instances.compactMap { + switch $0.value.selectedChannel { + case .textChannel(let channelId), .thread(let channelId), .voiceChannel(let channelId): + return channelId + default: return nil + } + } + _channels = _channels.filter { channelIds.contains($0.key) } + if let channel = _channels.values.first { print( "[GatewayStore] Refetching messages on behalf of focused channel \(channel.channelId.rawValue)." ) @@ -274,22 +267,22 @@ final class GatewayStore { // Now that we've done that, we need to use this ready data to update any internal stores that need it // guilds need repopulating. also guilds could have been left during the client down time. remove guilds if they don't exist anymore then repopulate. // remove guilds that don't exist anymore, also remove their guildstores and any of their channelstores - for (guildId, guildStore) in guilds { + for (guildId, guildStore) in _guilds { if !existingGuildIds.contains(guildId) { print( "[GatewayStore] Removing guild store for non-existent guild \(guildId.rawValue)." ) // remove their channels from channel stores for channelId in guildStore.channels.keys { - channels.removeValue(forKey: channelId) // only really removes anything if the server that disappeared had a focused channel + _channels.removeValue(forKey: channelId) // only really removes anything if the server that disappeared had a focused channel } // remove the guildstore itself - guilds.removeValue(forKey: guildId) + _guilds.removeValue(forKey: guildId) } } // repopulate guildstores - for guildStore in self.guilds.values { + for guildStore in _guilds.values { if let guild = data.guilds.first(where: { $0.id == guildStore.guildId }) { guildStore.populate(with: guild) } diff --git a/Paicord/Stores/GuildStore.swift b/Paicord/Stores/GuildStore.swift index 99793c19..c613132d 100644 --- a/Paicord/Stores/GuildStore.swift +++ b/Paicord/Stores/GuildStore.swift @@ -64,6 +64,17 @@ class GuildStore: DiscordDataStore { } } + func setGateway(_ gateway: GatewayStore?) { + // override default impl of protocol. + cancelEventHandling() + self.gateway = gateway + if gateway != nil { + setupEventHandling() + } + + fetchVoiceChannelsMembers() + } + // MARK: - Protocol Methods func setupEventHandling() { @@ -72,6 +83,12 @@ class GuildStore: DiscordDataStore { eventTask = Task { @MainActor in for await event in await gateway.events { switch event.data { + case .ready(let readyData): + handleReady(readyData) + + case .resumed: + handleResumed() + case .guildUpdate(let updatedGuild): if updatedGuild.id == guildId { handleGuildUpdate(updatedGuild) @@ -151,6 +168,15 @@ class GuildStore: DiscordDataStore { } // MARK: - Event Handlers + + private func handleReady(_ readyData: Gateway.Ready) { + fetchVoiceChannelsMembers() + } + + private func handleResumed() { + fetchVoiceChannelsMembers() + } + private func handleGuildUpdate(_ updatedGuild: Guild) { guild = updatedGuild @@ -206,8 +232,9 @@ class GuildStore: DiscordDataStore { ) guard membersChunk.guild_id == guildId else { return } for member in membersChunk.members { - if let user = member.user { + if let user = member.user?.toPartialUser() { members[user.id] = member.toPartialMember() + gateway?.user.users[user.id, default: user].update(with: user) } } } @@ -340,7 +367,6 @@ class GuildStore: DiscordDataStore { // also the gateway doesnt take member list ids, we send channel snowflakes let subscriptions: [ChannelSnowflake: [IntPair]] = subscribedMemberListIDs.reduce(into: [:]) { partialResult, element in - let memberListId = element.key let channelSnowflake = element.value.channelID partialResult[channelSnowflake] = element.value.ranges } @@ -355,4 +381,30 @@ class GuildStore: DiscordDataStore { ) ) } + + private func fetchVoiceChannelsMembers() { + // check for people in voice chats, they may not have member data. + let requestingIDs: [UserSnowflake] = + gateway?.voiceChannels.voiceStates[ + guildId, + default: [:] + ].values.flatMap(\.values) + .reduce(into: [UserSnowflake]()) { + partialResult, + state in + if state.member == nil { + partialResult.append(state.user_id) + } + } ?? [] + + if !requestingIDs.isEmpty { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(500)) + print( + "[GuildStore] Requesting \(requestingIDs.count) members in vc in guild \(guildId.rawValue)" + ) + await self.requestMembers(for: .init(requestingIDs)) + } + } + } } diff --git a/Paicord/Stores/ReadStateStore.swift b/Paicord/Stores/ReadStateStore.swift index e23b6f27..ea389b28 100644 --- a/Paicord/Stores/ReadStateStore.swift +++ b/Paicord/Stores/ReadStateStore.swift @@ -12,14 +12,8 @@ import PaicordLib @Observable class ReadStateStore: DiscordDataStore { var gateway: GatewayStore? - var eventTask: Task? - func setGateway(_ gateway: GatewayStore?) { - self.gateway = gateway - setupEventHandling() - } - var readStates: [AnySnowflake: Gateway.ReadState] = [:] func setupEventHandling() { diff --git a/Paicord/Stores/VoiceChannelsStore.swift b/Paicord/Stores/VoiceChannelsStore.swift new file mode 100644 index 00000000..c8b76af6 --- /dev/null +++ b/Paicord/Stores/VoiceChannelsStore.swift @@ -0,0 +1,141 @@ +// +// VoiceChannelsStore.swift +// Paicord +// +// Created by Lakhan Lothiyi on 12/03/2026. +// + +import Collections +import Foundation +import PaicordLib + +@Observable +final class VoiceChannelsStore: DiscordDataStore { + + var eventTask: Task? + var gateway: GatewayStore? + + var startTimes: [ChannelSnowflake: Date] = [:] + + var voiceStates: + [GuildSnowflake?: OrderedDictionary< + ChannelSnowflake, + OrderedDictionary + >] = [:] + + // secondary index + var userChannelIndex: [GuildSnowflake?: [UserSnowflake: ChannelSnowflake]] = + [:] + + var calls: [ChannelSnowflake: Gateway.CallCreate] = [:] + + func setupEventHandling() { + guard let gateway = gateway?.gateway else { return } + + eventTask = Task { @MainActor in + for await event in await gateway.events { + switch event.data { + case .ready(let payload): + handleReady(payload) + case .voiceChannelStartTimeUpdate(let payload): + handleVoiceChannelStartTimeUpdate(payload) + case .voiceStateUpdate(let payload): + handleVoiceStateUpdate(payload) + case .callCreate(let payload): + handleCallCreate(payload) + case .callUpdate(let payload): + handleCallUpdate(payload) + case .callDelete(let payload): + handleCallDelete(payload) + default: + break + } + } + } + } + + func handleReady(_ payload: Gateway.Ready) { + self.voiceStates.removeAll() + self.userChannelIndex.removeAll() + self.calls.removeAll() + self.startTimes.removeAll() + + for guild in payload.guilds { + for state in guild.voice_states ?? [] { + guard let channelID = state.channel_id else { continue } + let guildID = guild.id + let userID = state.user_id + + voiceStates[guildID, default: [:]][channelID, default: [:]][userID] = + state + userChannelIndex[guildID, default: [:]][userID] = channelID + } + } + } + + func handleVoiceChannelStartTimeUpdate( + _ payload: Gateway.VoiceChannelStartTimeUpdate + ) { + if let startTime = payload.voice_start_time?.date { + startTimes[payload.id] = startTime + } else { + startTimes.removeValue(forKey: payload.id) + } + } + + func handleVoiceStateUpdate(_ payload: VoiceState) { + let guildID = payload.guild_id + let userID = payload.user_id + let newChannel = payload.channel_id + + if let member = payload.member, let user = member.user?.toPartialUser() { + // update member store if we have the member cached + gateway?.user.users[ + payload.user_id, + default: user + ].update(with: user) + } + if let guildId = payload.guild_id, let member = payload.member, + let guildStore = gateway?._guilds[guildId] + { + guildStore.members[payload.user_id, default: member].update(with: member) + } + + let oldChannel = userChannelIndex[guildID]?[userID] + + // remove prev user state + if let oldChannel { + voiceStates[guildID]?[oldChannel]?.removeValue(forKey: userID) + + if voiceStates[guildID]?[oldChannel]?.isEmpty == true { + voiceStates[guildID]?.removeValue(forKey: oldChannel) + } + } + + // add new user state + if let newChannel { + voiceStates[guildID, default: [:]][newChannel, default: [:]][userID] = + payload + userChannelIndex[guildID, default: [:]][userID] = newChannel + } else { + // handle disconnect + userChannelIndex[guildID]?.removeValue(forKey: userID) + + if userChannelIndex[guildID]?.isEmpty == true { + userChannelIndex.removeValue(forKey: guildID) + } + } + } + + func handleCallCreate(_ payload: Gateway.CallCreate) { + calls[payload.channel_id] = payload + } + + func handleCallUpdate(_ payload: Gateway.CallUpdate) { + calls[payload.channel_id]?.update(with: payload) + } + + func handleCallDelete(_ payload: Gateway.CallDelete) { + calls.removeValue(forKey: payload.channel_id) + } +} diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift new file mode 100644 index 00000000..902aa182 --- /dev/null +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -0,0 +1,847 @@ +// +// VoiceConnectionStore.swift +// Paicord +// +// Created by Lakhan Lothiyi on 23/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import AVFoundation +import AsyncAlgorithms +import Foundation +import Opus +import PaicordLib + +@Observable +final class VoiceConnectionStore: DiscordDataStore { + init() { + // safe afaik bc all it throws for is invalid format + self.opusEncoder = try! Opus.Encoder( + format: Self.opusFormat, + application: .voip + ) + + if AVAudioApplication.shared.recordPermission == .granted { + self.isMuted = false + } else { + self.isMuted = true + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(audioEngineConfigurationChange), + name: .AVAudioEngineConfigurationChange, + object: audioEngine + ) + } + + deinit { + NotificationCenter.default.removeObserver( + self, + name: .AVAudioEngineConfigurationChange, + object: audioEngine + ) + } + + var gateway: GatewayStore? + var voiceGateway: VoiceGatewayManager? { + didSet { + if voiceGateway != nil { + setupVoiceEventHandling() + // trigger audio engine setup + audioEngineSetup() + } else { + cancelVoiceEventHandling() + + // shutdown audio engine and release resources + audioEngineCleanup() + } + } + } + + var eventTask: Task? + var voiceEventTask: Task? + var voiceErrorEventTask: Task? + + func setupEventHandling() { + guard let gateway = gateway?.gateway else { return } + + eventTask = Task { @MainActor in + for await event in await gateway.events { + switch event.data { + case .ready(let payload): + handleReady(payload) + case .resume(let payload): + handleResume(payload) + case .voiceServerUpdate(let payload): + handleVoiceServerUpdate(payload) + case .voiceStateUpdate(let payload): + handleVoiceStateUpdate(payload) + default: + break + } + } + } + } + + func setupVoiceEventHandling() { + guard let voiceGateway = voiceGateway else { return } + + voiceEventTask = Task { @MainActor in + for await event in await voiceGateway.events { + switch event.data { + case .clientDisconnect(let payload): + await handleClientDisconnect(payload) + case .speaking(let payload): + await handleSpeaking(payload) + default: break + } + } + } + voiceErrorEventTask = Task { @MainActor in + for await (error, buffer) in await voiceGateway.eventFailures { + print("[Voice Error] \(error), \(String(buffer: buffer))") + } + } + } + + // MARK: - State + // our own voice state stuff + private(set) var channelId: ChannelSnowflake? + private(set) var guildId: GuildSnowflake? + private(set) var isMuted: Bool = false + private(set) var isDeafened: Bool = false + private(set) var isVideoEnabled: Bool = false + private(set) var preferredRegion: String? + private(set) var flags: IntBitField = [] + + private(set) var voiceStatus: GatewayState = .stopped { + didSet { + print("[Voice] Voice connection status changed to \(voiceStatus)") + } + } + + // MARK: - Public methods + + func updateVoiceConnection(_ update: VoiceConnectionUpdate) async { + if case .join(let channelId, let guildId) = update, + self.channelId == channelId && self.guildId == guildId + { + return + } + // for changing currently connected channel or disconnecting from voice, we still stop everything + await voiceGateway?.disconnect() + voiceGateway = nil + // if we wanted to disconnect, just return here + if case .disconnect = update { + self.channelId = nil + self.guildId = nil + await gateway?.gateway?.updateVoiceState( + payload: .init( + guild_id: guildId, + channel_id: channelId, + self_mute: self.isMuted, + self_deaf: self.isDeafened, + self_video: self.isVideoEnabled, + preferred_region: self.preferredRegion, + preferred_regions: nil, + flags: flags + ) + ) + print("[Voice] Disconnected from voice channel") + return + } + // well at this point we know we want to connect to a new channel. + // to start a new voice connection, we need to get the necessary data from the gateway. + // we update our voice state on the gateway so we get a voice server update. + guard case .join(let channelId, let guildId) = update else { return } + self.channelId = channelId + self.guildId = guildId + + print( + "[Voice] Attempting to connect to voice channel \(channelId.rawValue) in guild \(guildId?.rawValue ?? "DMs")" + ) + await gateway?.gateway?.updateVoiceState( + payload: .init( + guild_id: guildId, + channel_id: channelId, + self_mute: self.isMuted, + self_deaf: self.isDeafened, + self_video: self.isVideoEnabled, + preferred_region: self.preferredRegion, + preferred_regions: nil, + flags: flags + ) + ) + } + enum VoiceConnectionUpdate { + case join(channelId: ChannelSnowflake, guildId: GuildSnowflake?) + case disconnect + } + + func updateVoiceState( + isMuted: Bool? = nil, + isDeafened: Bool? = nil, + isVideoEnabled: Bool? = nil + ) async { + if let isMuted = isMuted { + if AVAudioApplication.shared.recordPermission == .granted { + self.isMuted = isMuted + } else { + self.isMuted = true + } + } + if let isDeafened = isDeafened { self.isDeafened = isDeafened } + if let isVideoEnabled = isVideoEnabled { + self.isVideoEnabled = isVideoEnabled + } + + self.audioEngine.mainMixerNode.outputVolume = self.isDeafened ? 0 : 1 + + await gateway?.gateway?.updateVoiceState( + payload: .init( + guild_id: self.guildId, + channel_id: self.channelId, + self_mute: self.isMuted, + self_deaf: self.isDeafened, + self_video: self.isVideoEnabled, + preferred_region: self.preferredRegion, + preferred_regions: nil, + flags: flags + ) + ) + } + + // MARK: - Event handling + + private func handleReady(_ payload: Gateway.Ready) { + Task { + await gateway?.gateway?.updateVoiceState( + payload: .init( + guild_id: self.guildId, + channel_id: self.channelId, + self_mute: self.isMuted, + self_deaf: self.isDeafened, + self_video: false, + preferred_region: self.preferredRegion, + preferred_regions: nil, + flags: self.flags + ) + ) + } + } + + private func handleResume(_ payload: Gateway.Resume) { + Task { + await gateway?.gateway?.updateVoiceState( + payload: .init( + guild_id: self.guildId, + channel_id: self.channelId, + self_mute: self.isMuted, + self_deaf: self.isDeafened, + self_video: false, + preferred_region: self.preferredRegion, + preferred_regions: nil, + flags: self.flags + ) + ) + } + } + + private func handleVoiceServerUpdate(_ payload: Gateway.VoiceServerUpdate) { + // if the endpoint is empty/nil, it means we got disconnected from voice, so disconnect from our voice gateway and return early + // else we can start a new voice gateway connection with the new endpoint and token + Task { + guard let endpoint = payload.endpoint, !endpoint.isEmpty, + let channelId, + let sessionId = await gateway?.gateway?.getSessionID(), + let userId = gateway?.user.currentUser?.id + else { + print( + "[Voice] Received voice server update with empty endpoint, disconnecting from voice" + ) + Task { + await voiceGateway?.disconnect() + voiceGateway = nil + await gateway?.gateway?.updateVoiceState( + payload: .init( + guild_id: nil, + channel_id: nil, + self_mute: self.isMuted, + self_deaf: self.isDeafened, + self_video: self.isVideoEnabled, + preferred_region: self.preferredRegion, + preferred_regions: nil, + flags: flags + ) + ) + } + return + } + + print( + "[Voice] Received voice server update, connecting to voice gateway at endpoint \(endpoint) for guild \(guildId?.rawValue ?? "nil") and channel \(channelId.rawValue)" + ) + self.voiceGateway = VoiceGatewayManager.init( + connectionData: .init( + token: payload.token, + guildID: self.guildId ?? .init(channelId.rawValue), + channelID: channelId, + userID: userId, + sessionID: sessionId, + endpoint: endpoint + ), + stateCallback: { state in + Task { @MainActor in + self.voiceStatus = state + } + } + ) + await self.voiceGateway?.connect() + + if AVAudioApplication.shared.recordPermission != .granted { + await AVAudioApplication.requestRecordPermission() + } + + if self.guildId == nil { + print("[Voice] Ringing DM recipients") + try? await gateway?.client.ringChannelRecipients(channelID: channelId, payload: .init()).guardSuccess() + } + } + } + + private func handleVoiceStateUpdate(_ payload: VoiceState) { + // if we receive a voice state payload and it contains a session + // id that isnt this client's current session id, we joined + // from another client and we should destroy this connection. + + // criteria for disconnecting: + // - there actually is a voice connection to disconnect from + // - its a voice state update for our user id + // - session id isnt ours + // - payload guild id matches the current guild id + + Task { + let vUserId = payload.user_id + let vSessionID = payload.session_id + let vGuildID = payload.guild_id + + if + voiceGateway != nil, + self.gateway?.user.currentUser?.id == vUserId, + self.guildId == vGuildID, + await self.gateway?.gateway?.getSessionID() != vSessionID + { + print("[Voice] Another client made this client disconnect") + await voiceGateway?.disconnect() + voiceGateway = nil + } + } + } + + private func handleClientDisconnect(_ payload: VoiceGateway.ClientDisconnect) + async + { + // someone other than us left the voice channel. + let id = payload.user_id + let ssrc = self.knownSSRCs.first(where: { $0.value == id })?.key + if let ssrc { + removeIncomingStreamIfPresent(ssrc: .init(ssrc)) + } + } + + private func handleSpeaking(_ payload: VoiceGateway.Speaking) async { + // someone started or stopped speaking, we can use this to show speaking indicators. + let ssrc = payload.ssrc + + if let id = payload.user_id { + self.knownSSRCs[ssrc] = id + self.usersSpeakingState[id] = payload.speaking + } + ensureIncomingStreamExists(ssrc: .init(ssrc)) + } + + func cancelEventHandling() { + // overrides default impl of protocol + eventTask?.cancel() + eventTask = nil + cancelVoiceEventHandling() + Task { + await voiceGateway?.disconnect() + voiceGateway = nil + } + } + + private func cancelVoiceEventHandling() { + voiceEventTask?.cancel() + voiceEventTask = nil + voiceErrorEventTask?.cancel() + voiceErrorEventTask = nil + voiceStatus = .stopped + } + + // MARK: - Audio Engine implementation + private static let opusFormat = AVAudioFormat( + opusPCMFormat: .float32, + sampleRate: .opus48khz, + channels: 2 + )! + private static let pcmFormat = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: .opus48khz, + channels: 2, + interleaved: false + )! + + @ObservationIgnored + private let opusEncoder: Opus.Encoder + + @ObservationIgnored + private let audioEngine = AVAudioEngine() + @ObservationIgnored + private lazy var inputNode: AVAudioInputNode = { + return audioEngine.inputNode + }() + @ObservationIgnored + private lazy var outputNode: AVAudioOutputNode = { + return audioEngine.outputNode + }() + + // one incoming audio stream per user's audio ssrc to mix. + private final class IncomingStream { + let ssrc: UInt32 + let decoder: Opus.Decoder + let playerNode: AVAudioPlayerNode + let scheduler: PlayerScheduler + + init(ssrc: UInt32) { + self.ssrc = ssrc + // safe afaik bc all it throws for is invalid format + self.decoder = try! Opus.Decoder( + format: VoiceConnectionStore.opusFormat, + application: .voip + ) + self.playerNode = AVAudioPlayerNode() + self.scheduler = PlayerScheduler(node: self.playerNode) + } + + deinit { + playerNode.stop() + try? decoder.reset() + } + } + + private actor PlayerScheduler { + private weak var node: AVAudioPlayerNode? + private var queue: [AVAudioPCMBuffer] = [] + private var draining = false + + init(node: AVAudioPlayerNode) { + self.node = node + } + + func enqueue(_ buffer: AVAudioPCMBuffer) { + queue.append(buffer) + if !draining { + draining = true + Task { await drain() } + } + } + + private func drain() async { + defer { draining = false } + while !queue.isEmpty { + guard let node else { return } + let buf = queue.removeFirst() + node.scheduleBuffer(buf, completionHandler: nil) + await Task.yield() + } + } + } + + @ObservationIgnored + private var incomingStreamsBySSRC: [UInt32: IncomingStream] = [:] + + @ObservationIgnored + private var incomingAudioTask: Task? = nil + + @ObservationIgnored + private var outgoingAudioTask: Task? = nil + + @ObservationIgnored + private let dummyPlayerNode = AVAudioPlayerNode() + + // MARK: - States + + // if in a vc, this contains our speaking state and other ppl's speaking state. + var usersSpeakingState: + [UserSnowflake: IntBitField] = [:] + + private var knownSSRCs: [UInt: UserSnowflake] = [:] + + private(set) var userVolumes: [UserSnowflake: Float] = [:] + + func setVolume(for userId: UserSnowflake, volume: Float) { + userVolumes[userId] = volume + + guard let ssrc = knownSSRCs.first(where: { $0.value == userId })?.key, + let stream = incomingStreamsBySSRC[UInt32(ssrc)] + else { return } + + stream.playerNode.volume = volume + } + + // MARK: - Audio engine setup and handling + + @objc private func audioEngineConfigurationChange() { + print("[Voice] Audio engine configuration changed, resetting audio engine") + self.audioEngineSetup() + } + + private func audioEngineSetup() { + Task { + self.audioEngineCleanup() + + /// avaudioengine will throw a c++ exception if you start it when `inputNode == nullptr || outputNode == nullptr`. + self.ensureDummyNodeAttached() + + /// microphone tap + self.setupTap() + + /// voice processing mode + do { + try inputNode.setVoiceProcessingEnabled(true) + inputNode.isVoiceProcessingAGCEnabled = true + inputNode.isVoiceProcessingBypassed = false + inputNode.isVoiceProcessingInputMuted = false + + } catch { + print("[Voice] Failed to enable voice processing mode:", error) + } + + do { + try audioEngine.start() + } catch { + print("[Voice] Failed to start audio engine:", error) + return + } + print("[Voice] Audio engine started") + + audioEngine.mainMixerNode.outputVolume = self.isDeafened ? 0 : 1 + + guard let voiceGateway = self.voiceGateway else { return } + + incomingAudioTask = Task.detached(priority: .userInitiated) { + [weak self] in + guard let self else { return } + + for await rtpPacket in await voiceGateway.incomingAudioChannel { + if Task.isCancelled { break } + + let ssrc = rtpPacket.ssrc + self.ensureIncomingStreamExists(ssrc: ssrc) + + guard + let stream = self.incomingStreamsBySSRC[ssrc] + else { + continue + } + + do { + let opusFrame = rtpPacket.payload + let decoded = try stream.decoder.decode( + .init(buffer: opusFrame, byteTransferStrategy: .noCopy) + ) + + // manually de-interleave. + guard + let converted = AVAudioPCMBuffer( + pcmFormat: Self.pcmFormat, + frameCapacity: decoded.frameLength + ) + else { continue } + converted.frameLength = decoded.frameLength + + let src = decoded.floatChannelData![0] + let dstL = converted.floatChannelData![0] + let dstR = converted.floatChannelData![1] + for i in 0..() + + private func setupTap() { + let targetFormat = Self.pcmFormat + + let opusFrameCount: AVAudioFrameCount = 960 + let opusFrameSize = Int(opusFrameCount) + + var ring = PCMFloatRingBuffer( + channels: Int(targetFormat.channelCount), + capacityFrames: opusFrameSize * 8 + ) + + let initialTapFormat = inputNode.inputFormat(forBus: 0) + print("[Voice] Installing tap with format:", initialTapFormat) + + // Initialize upfront to prevent clicking artifacts and preserve internal converter state + var converter = AVAudioConverter(from: initialTapFormat, to: targetFormat) + var lastTapFormat = initialTapFormat + + inputNode.installTap( + onBus: 0, + bufferSize: opusFrameCount, + format: nil + ) { [weak self] buffer, _ in + guard let self = self, buffer.frameLength > 0 else { return } + + if lastTapFormat != buffer.format { + lastTapFormat = buffer.format + converter = AVAudioConverter(from: buffer.format, to: targetFormat) + } + + guard let activeConverter = converter else { return } + + let inputRate = buffer.format.sampleRate + let outputRate = targetFormat.sampleRate + let capacityRate = outputRate / inputRate + let capacity = AVAudioFrameCount( + ceil(Double(buffer.frameLength) * capacityRate) + 1 + ) + + guard + let convertedBuffer = AVAudioPCMBuffer( + pcmFormat: targetFormat, + frameCapacity: capacity + ) + else { return } + + var error: NSError? + var inputBlockProvided = false + let status = activeConverter.convert(to: convertedBuffer, error: &error) { + inNumPackets, + outStatus in + if inputBlockProvided { + outStatus.pointee = .noDataNow + return nil + } + inputBlockProvided = true + outStatus.pointee = .haveData + return buffer + } + + if status == .error || error != nil { + print("[Voice] Audio conversion error: \(String(describing: error))") + return + } + + guard convertedBuffer.frameLength > 0, + let src = convertedBuffer.floatChannelData + else { return } + + ring.write( + from: src, + frames: Int(convertedBuffer.frameLength), + srcChannels: Int(targetFormat.channelCount) + ) + + while ring.availableFrames >= opusFrameSize { + guard + let chunk = AVAudioPCMBuffer( + pcmFormat: Self.pcmFormat, + frameCapacity: opusFrameCount + ) + else { + break + } + chunk.frameLength = opusFrameCount + guard let dst = chunk.floatChannelData else { break } + + guard ring.read(into: dst, frames: opusFrameSize) else { break } + Task { await self.micChannel.send(chunk) } + } + } + + outgoingAudioTask = Task(priority: .userInitiated) { [weak self] in + guard let self else { return } + var opusOutputBuffer = Data(count: 1275) + + for await buffer in self.micChannel { + if Task.isCancelled { break } + if self.isMuted { continue } + + do { + guard let interleaved = self.interleavedBuffer(from: buffer) else { + continue + } + let encodedSize = try self.opusEncoder.encode( + interleaved, + to: &opusOutputBuffer + ) + await self.voiceGateway?.enqueueOpusFrame( + opusOutputBuffer.prefix(encodedSize) + ) + } catch { + print("[Voice OPUS] Frame encode error:", error) + } + } + } + } + + @ObservationIgnored + private var interleavedScratch: AVAudioPCMBuffer? + + private func interleavedBuffer(from planar: AVAudioPCMBuffer) + -> AVAudioPCMBuffer? + { + let frames = Int(planar.frameLength) + guard frames > 0 else { return nil } + guard planar.format.channelCount == 2 else { return nil } + guard let src = planar.floatChannelData else { return nil } + + let sampleRate = planar.format.sampleRate + + let needsNew: Bool = { + guard let b = interleavedScratch else { return true } + return b.format.sampleRate != sampleRate + || b.format.channelCount != 2 + || !b.format.isInterleaved + || Int(b.frameCapacity) < frames + }() + + if needsNew { + guard + let fmt = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: sampleRate, + channels: 2, + interleaved: true + ), + let b = AVAudioPCMBuffer( + pcmFormat: fmt, + frameCapacity: AVAudioFrameCount(max(frames, 960)) + ) + else { return nil } + interleavedScratch = b + } + + guard let out = interleavedScratch, + let dst = out.floatChannelData?[0] + else { return nil } + + out.frameLength = AVAudioFrameCount(frames) + + let l = src[0] + let r = src[1] + for i in 0..>, + frames: Int, + srcChannels: Int + ) { + guard frames > 0 else { return } + + // keep tail if overflow. + let framesToWrite = min(frames, capacityFrames) + let dropHead = max(0, availableFrames + framesToWrite - capacityFrames) + if dropHead > 0 { discard(frames: dropHead) } + + let start = frames - framesToWrite + + for i in 0..= 2 { + let s = src[0][start + i] + storage[0][dstIdx] = s + storage[1][dstIdx] = s + } else { + for ch in 0..>, + frames: Int + ) -> Bool { + guard frames <= availableFrames else { return false } + + for i in 0..(_ x: T?, _ y: T?) -> T? where T: Comparable { return nil } } + +func max(_ x: T?, _ y: T?) -> T? where T: Comparable { + if let x, let y { + return Swift.max(x, y) + } else if let x { + return x + } else if let y { + return y + } else { + return nil + } +} + +extension Comparable { + func clamped(to limits: ClosedRange) -> Self { + min(max(self, limits.lowerBound), limits.upperBound) + } + + func clamped(to limits: PartialRangeFrom) -> Self { + max(self, limits.lowerBound) + } + + func clamped(to limits: PartialRangeThrough) -> Self { + min(self, limits.upperBound) + } + + func clamped(to limits: PartialRangeUpTo) -> Self { + min(self, limits.upperBound) + } + + func clamped(to limits: Range) -> Self { + min(max(self, limits.lowerBound), limits.upperBound) + } +} diff --git a/Paicord/Utilities/LocalConsoleManager/LocalConsoleManager.swift b/Paicord/Utilities/LocalConsoleManager/LocalConsoleManager.swift index e08c7a0d..75415ae5 100644 --- a/Paicord/Utilities/LocalConsoleManager/LocalConsoleManager.swift +++ b/Paicord/Utilities/LocalConsoleManager/LocalConsoleManager.swift @@ -53,6 +53,10 @@ class StdOutInterceptor { } func startIntercepting() { + if ProcessInfo.processInfo.environment["DISABLE_STD_INTERCEPT"] == "1" { + return + } + queue.sync { guard !self.isActive else { return } self.isActive = true diff --git a/Paicord/Utilities/PaicordLib++/Conformances.swift b/Paicord/Utilities/PaicordLib++/Conformances.swift index d5990026..4c81a053 100644 --- a/Paicord/Utilities/PaicordLib++/Conformances.swift +++ b/Paicord/Utilities/PaicordLib++/Conformances.swift @@ -13,8 +13,6 @@ extension DiscordProtos_DiscordUsers_V1_PreloadedUserSettings.GuildFolder: @retroactive Identifiable {} -extension Guild: @retroactive Identifiable {} - extension DiscordChannel: @retroactive Identifiable {} extension Snowflake: @retroactive Identifiable { @@ -65,3 +63,10 @@ extension Payloads.CreateMessage: @retroactive Identifiable { .init(self.nonce?.asString ?? "unknown") } } + + +extension VoiceState: @retroactive Identifiable { + public var id: UserSnowflake { + self.user_id + } +} diff --git a/Paicord/Utilities/PaicordLib++/Merging.swift b/Paicord/Utilities/PaicordLib++/Merging.swift index 1322a3b3..43c0a643 100644 --- a/Paicord/Utilities/PaicordLib++/Merging.swift +++ b/Paicord/Utilities/PaicordLib++/Merging.swift @@ -518,3 +518,12 @@ extension RemoteAuthGatewayManager.RemoteAuthPayload.UserPayload { ) } } + +extension Gateway.CallCreate { + mutating func update(with new: Gateway.CallUpdate) { + self.channel_id = new.channel_id + self.message_id = new.message_id + self.region = new.region + self.ringing = new.ringing + } +} diff --git a/Paicord/macOS/Sidebar/ProfileBar.swift b/Paicord/macOS/Sidebar/ProfileBar.swift index 7da2acf6..f2452ef1 100644 --- a/Paicord/macOS/Sidebar/ProfileBar.swift +++ b/Paicord/macOS/Sidebar/ProfileBar.swift @@ -6,291 +6,590 @@ // Copyright © 2025 Lakhan Lothiyi. // +import AVKit import PaicordLib import SDWebImageSwiftUI import SwiftPrettyPrint import SwiftUIX struct ProfileBar: View { - @Environment(\.gateway) var gw - #if os(macOS) - @Environment(\.openWindow) var openWindow - #endif + var body: some View { + VStack(spacing: 0) { + VoiceBarSection() + Divider() + ProfileBarSection() + } + } - @State var showingUsername = false - @State var showingPopover = false - @State var barHovered = false + struct VoiceBarSection: View { + @Environment(\.gateway) var gw + var vgw: VoiceConnectionStore { gw.voice } - var body: some View { - HStack { - Button { - showingPopover.toggle() - } label: { - HStack { - if let user = gw.user.currentUser { - Profile.AvatarWithPresence( - member: nil, - user: user - ) - .maxHeight(40) - .profileAnimated(barHovered) - .profileShowsAvatarDecoration() - } + var body: some View { + if gw.voice.voiceGateway != nil { + VStack(spacing: 2) { + HStack { + Group { + switch vgw.voiceStatus { + case .stopped: + Image(systemName: "nosign") + .foregroundStyle(.red) + case .noConnection: + Image(systemName: "wifi.slash") + .foregroundStyle(.red) + case .connecting: + if #available(macOS 15.0, *) { + Image(systemName: "wifi") + .symbolEffect( + .bounce.up.byLayer, + options: .repeat(.periodic(delay: 0.0)) + ) + .foregroundStyle(.yellow) + } else { + Image(systemName: "wifi.exclamationmark") + .foregroundStyle(.yellow) + } + case .configured: + Image(systemName: "wifi.exclamationmark") + .foregroundStyle(.yellow) + case .connected: + Image(systemName: "wifi") + .foregroundStyle(.green) + .symbolEffect(.bounce.up.byLayer, options: .nonRepeating) + } + } + .imageScale(.large) + .frame(width: 30, height: 30) + .background(Color.black.opacity(0.2)) + .clipShape(.rect(cornerRadius: 5)) - VStack(alignment: .leading) { - Text( - gw.user.currentUser?.global_name ?? gw.user.currentUser?.username - ?? "Unknown User" - ) - .bold() - if showingUsername { - Text(verbatim: "@\(gw.user.currentUser?.username ?? "Unknown User")") - .transition(.opacity) - } else { - if let session = gw.user.sessions.first(where: { $0.id == "all" } - ), - let status = session.activities.first, - status.type == .custom - { - if let emoji = status.emoji { - if let url = emojiURL(for: emoji, animated: true) { - AnimatedImage(url: url) - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) + VStack(alignment: .leading) { + Group { + switch vgw.voiceStatus { + case .stopped, .noConnection: + Text("Voice Disconnected") + .foregroundStyle(.red) + case .connecting: + Text("Connecting to Voice") + .foregroundStyle(.yellow) + case .configured: + Text("Awaiting Audio Setup") + .foregroundStyle(.yellow) + case .connected: + Text("Voice Connected") + .foregroundStyle(.green) + } + } + .font(.headline) + .fontWeight(.semibold) + + let channelStore: ChannelStore? = { + // shouldnt be nil. + guard let channelID = vgw.channelId else { return nil } + if let guildID = vgw.guildId { + let guildStore = gw.getGuildStore(for: guildID) + return gw.getChannelStore(for: channelID, from: guildStore) + } else { + return gw.getChannelStore(for: channelID) + } + }() + if let channel = channelStore?.channel { + Group { + if let guild = channelStore?.guildStore?.guild, + let cName = channel.name + { + Text(verbatim: "\(guild.name) / \(cName)") + } else if let name = channel.name + ?? channel.recipients?.map({ + $0.global_name ?? $0.username + }).joined(separator: ", ") + { + Text(verbatim: name) } else { - Text(emoji.name) - .font(.system(size: 14)) + Text("Unknown Channel") } } + .font(.caption) + } + } + + Spacer() - Text(status.state ?? "") - .transition(.opacity) + Button { + Task { + await vgw.updateVoiceConnection(.disconnect) } + } label: { + // hang up call + Image(systemName: "phone.down.fill") + .font(.title2) + .maxWidth(35) + .maxHeight(35) } - } - .background(.black.opacity(0.001)) - .onHover { showingUsername = $0 } - .animation(.spring(), value: showingUsername) - } - } - .buttonStyle(.plain) - .popover(isPresented: $showingPopover) { - ProfileButtonPopout() - } + .buttonStyle( + .borderlessHoverEffect( + hoverColor: .red, + pressedColor: .red + ) + ) - Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + HStack { - #if os(macOS) - Button { - openWindow(id: "settings") - } label: { - Image(systemName: "gearshape.fill") - .font(.title2) - .padding(5) - .background(.ultraThinMaterial) - .clipShape(.circle) + } + .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.borderless) - #elseif os(iOS) - /// targetting ipad here, ios wouldnt have this at all - // do something - #endif - } - .padding(10) - .background { - if let nameplate = gw.user.currentUser?.collectibles?.nameplate { - Profile.NameplateView(nameplate: nameplate) - .nameplateAnimated(barHovered) - .saturation(0.9) - .brightness(0.1) + .padding(4) + .padding(.horizontal, 6) + } else { + EmptyView() } } - .clipped() - .onHover { barHovered = $0 } } - func emojiURL(for emoji: Gateway.Activity.ActivityEmoji, animated: Bool) - -> URL? - { - guard let id = emoji.id else { return nil } - return URL( - string: CDNEndpoint.customEmoji(emojiId: id).url - + (animated && emoji.animated == true ? ".gif" : ".png") + "?size=44" - ) - } - - struct ProfileButtonPopout: View { + struct ProfileBarSection: View { @Environment(\.gateway) var gw - @Environment(\.appState) var appState - @State var statusSelectionExpanded = false - @State var accountSelectionExpanded = false + #if os(macOS) + @Environment(\.openWindow) var openWindow + #endif + + @State var showingUsername = false + @State var showingPopover = false + @State var barHovered = false + + @State var micError = false + + @ViewStorage var didDeafenBeforeMute = false + + var vgw: VoiceConnectionStore { gw.voice } var body: some View { - List { - HStack { - if let user = gw.user.currentUser { - Profile.AvatarWithPresence( - member: nil, - user: user - ) - .maxWidth(40) - .maxHeight(40) - .profileAnimated(false) - .profileShowsAvatarDecoration() + HStack { + Button { + showingPopover.toggle() + } label: { + HStack { + if let user = gw.user.currentUser { + Profile.AvatarWithPresence( + member: nil, + user: user + ) + .maxHeight(30) + .profileAnimated(barHovered) + .profileShowsAvatarDecoration() + } + + VStack(alignment: .leading) { + Text( + gw.user.currentUser?.global_name ?? gw.user.currentUser? + .username + ?? "Unknown User" + ) + .bold() + if showingUsername { + Text( + verbatim: + "@\(gw.user.currentUser?.username ?? "Unknown User")" + ) + .transition(.opacity) + } else { + if let session = gw.user.sessions.first(where: { + $0.id == "all" + } + ), + let status = session.activities.first, + status.type == .custom + { + if let emoji = status.emoji { + if let url = emojiURL(for: emoji, animated: true) { + AnimatedImage(url: url) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } else { + Text(emoji.name) + .font(.system(size: 14)) + } + } + + Text(status.state ?? "") + .transition(.opacity) + } + } + } + .background(.black.opacity(0.001)) + .onHover { showingUsername = $0 } + .animation(.spring(), value: showingUsername) } + } + .buttonStyle(.plain) + .popover(isPresented: $showingPopover) { + ProfileButtonPopout() + } + + Spacer() - VStack(alignment: .leading) { + HStack { + Button { + Task { + switch AVAudioApplication.shared.recordPermission { + case .granted: + // if deafened whilst unmuting, undeafen + await vgw.updateVoiceState(isMuted: !gw.voice.isMuted, isDeafened: vgw.isDeafened && gw.voice.isMuted ? false : nil) + case .denied: + micError = true + case .undetermined: + if await AVAudioApplication.requestRecordPermission() { + await vgw.updateVoiceState(isMuted: false) + } + @unknown default: + fatalError() + } + } + } label: { + if #available(macOS 15.0, iOS 18.0, *) { + Image(systemName: vgw.isMuted ? "mic.slash.fill" : "mic.fill") + .contentTransition( + .symbolEffect( + .replace.magic(fallback: .upUp.byLayer), + options: .nonRepeating + ) + ) + .font(.title2) + .maxWidth(35) + .maxHeight(35) + } else { + Image(systemName: vgw.isMuted ? "mic.slash.fill" : "mic.fill") + .contentTransition( + .symbolEffect(.replace.wholeSymbol, options: .nonRepeating) + ) + .font(.title2) + .maxWidth(35) + .maxHeight(35) + } + } + .buttonStyle( + .borderlessHoverEffect( + pressedColor: .red, + isSelected: vgw.isMuted + ) + ) + .alert("Microphone Unavailable", isPresented: $micError) { + Button("OK", role: .cancel) {} + } message: { Text( - gw.user.currentUser?.global_name ?? gw.user.currentUser?.username - ?? "Unknown User" + "Please allow microphone access in your system settings to unmute yourself in voice channels." ) - .bold() - Text(verbatim: "@\(gw.user.currentUser?.username ?? "Unknown User")") } - } - .padding(.vertical, 5) - NavigationLink(value: "gm") { - Label("Edit Profile", systemImage: "pencil") - .padding(.vertical, 4) + Button { + Task { + // if going to deafen and not currently muted, deafen and mute. if coming back, undeafen and unmute too. + var deaf = vgw.isDeafened + var mute = vgw.isMuted + if !deaf && !mute { + didDeafenBeforeMute = true + mute = true + } else if vgw.isDeafened && didDeafenBeforeMute { + mute = false + didDeafenBeforeMute = false + } + deaf.toggle() + await vgw.updateVoiceState(isMuted: mute, isDeafened: deaf) + } + } label: { + if #available(macOS 15.0, iOS 18.0, *) { + Image( + systemName: gw.voice.isDeafened + ? "headphones.slash" : "headphones" + ) + .contentTransition( + .symbolEffect( + .replace.magic(fallback: .upUp.byLayer), + options: .nonRepeating + ) + ) + .font(.title2) + .maxWidth(35) + .maxHeight(35) + } else { + Image( + systemName: gw.voice.isDeafened + ? "headphones.slash" : "headphones" + ) + .contentTransition( + .symbolEffect(.replace.wholeSymbol, options: .nonRepeating) + ) + .font(.title2) + .maxWidth(35) + .maxHeight(35) + } + } + .buttonStyle( + .borderlessHoverEffect( + pressedColor: .red, + isSelected: vgw.isDeafened + ) + ) + + #if os(macOS) + Button { + openWindow(id: "settings") + } label: { + Image(systemName: "gearshape.fill") + .font(.title2) + .maxWidth(35) + .maxHeight(35) + } + .buttonStyle( + .borderlessHoverEffect() + ) + #elseif os(iOS) + /// targetting ipad here, ios wouldnt have this at all + // do something + #endif + } + .padding(.vertical, -8) + } + .padding(8) + .background { + if let nameplate = gw.user.currentUser?.collectibles?.nameplate { + Profile.NameplateView(nameplate: nameplate) + .nameplateAnimated(barHovered) + .nameplateImageOpacity(0.4) } - .disabled(true) + } + .clipped() + .onHover { barHovered = $0 } + } - DisclosureGroup(isExpanded: $statusSelectionExpanded) { - let statuses: [Gateway.Status] = [ - .online, - .afk, - .doNotDisturb, - .invisible, - ] + func emojiURL(for emoji: Gateway.Activity.ActivityEmoji, animated: Bool) + -> URL? + { + guard let id = emoji.id else { return nil } + return URL( + string: CDNEndpoint.customEmoji(emojiId: id).url + + (animated && emoji.animated == true ? ".gif" : ".png") + "?size=44" + ) + } - ForEach(statuses, id: \.self) { status in - AsyncButton { - } catch: { error in - appState.error = error + struct ProfileButtonPopout: View { + @Environment(\.gateway) var gw + @Environment(\.appState) var appState + @State var statusSelectionExpanded = false + @State var accountSelectionExpanded = false + + var body: some View { + List { + HStack { + if let user = gw.user.currentUser { + Profile.AvatarWithPresence( + member: nil, + user: user + ) + .maxWidth(40) + .maxHeight(40) + .profileAnimated(false) + .profileShowsAvatarDecoration() + } + + VStack(alignment: .leading) { + Text( + gw.user.currentUser?.global_name ?? gw.user.currentUser? + .username + ?? "Unknown User" + ) + .bold() + Text( + verbatim: "@\(gw.user.currentUser?.username ?? "Unknown User")" + ) + } + } + .padding(.vertical, 5) + + NavigationLink(value: "gm") { + Label("Edit Profile", systemImage: "pencil") + .padding(.vertical, 4) + } + .disabled(true) + + DisclosureGroup(isExpanded: $statusSelectionExpanded) { + let statuses: [Gateway.Status] = [ + .online, + .afk, + .doNotDisturb, + .invisible, + ] + + ForEach(statuses, id: \.self) { status in + AsyncButton { + } catch: { error in + appState.error = error + } label: { + statusItem(status) + .padding(.vertical, 4) + } + .buttonStyle(.borderless) + } + } label: { + Button { + withAnimation { + statusSelectionExpanded.toggle() + } } label: { - statusItem(status) + statusItem(gw.presence.currentClientStatus) .padding(.vertical, 4) } .buttonStyle(.borderless) } - } label: { - Button { - withAnimation { - statusSelectionExpanded.toggle() + + DisclosureGroup(isExpanded: $accountSelectionExpanded) { + ForEach(gw.accounts.accounts, id: \.id) { account in + let isSignedInAccount = account.id == gw.accounts.currentAccountID + AsyncButton { + gw.accounts.currentAccountID = nil + await gw.disconnectIfNeeded() + gw.resetStores() + gw.accounts.currentAccountID = account.id + } catch: { error in + appState.error = error + } label: { + HStack { + Profile.AvatarWithPresence( + member: nil, + user: account.user + ) + .maxWidth(25) + .maxHeight(25) + .profileAnimated(false) + .profileShowsAvatarDecoration() + + VStack(alignment: .leading) { + Text( + account.user.global_name + ?? account.user.username + ) + .lineSpacing(1) + .bold() + Text(verbatim: "@\(account.user.username)") + .lineSpacing(1) + } + + Spacer() + + if isSignedInAccount { + Image(systemName: "checkmark") + } + } + .padding(.vertical, 2) + } + .buttonStyle(.borderless) + .disabled(isSignedInAccount) } - } label: { - statusItem(gw.presence.currentClientStatus) - .padding(.vertical, 4) - } - .buttonStyle(.borderless) - } - DisclosureGroup(isExpanded: $accountSelectionExpanded) { - ForEach(gw.accounts.accounts, id: \.id) { account in - let isSignedInAccount = account.id == gw.accounts.currentAccountID AsyncButton { gw.accounts.currentAccountID = nil await gw.disconnectIfNeeded() gw.resetStores() - gw.accounts.currentAccountID = account.id } catch: { error in appState.error = error } label: { - HStack { - Profile.AvatarWithPresence( - member: nil, - user: account.user - ) - .maxWidth(25) - .maxHeight(25) - .profileAnimated(false) - .profileShowsAvatarDecoration() - - VStack(alignment: .leading) { - Text( - account.user.global_name - ?? account.user.username - ) - .lineSpacing(1) - .bold() - Text(verbatim: "@\(account.user.username)") - .lineSpacing(1) - } - - Spacer() - - if isSignedInAccount { - Image(systemName: "checkmark") - } - } - .padding(.vertical, 2) + Label("Add Account", systemImage: "person.crop.circle.badge.plus") + .padding(.vertical, 4) } .buttonStyle(.borderless) - .disabled(isSignedInAccount) - } - AsyncButton { - gw.accounts.currentAccountID = nil - await gw.disconnectIfNeeded() - gw.resetStores() - } catch: { error in - appState.error = error } label: { - Label("Add Account", systemImage: "person.crop.circle.badge.plus") - .padding(.vertical, 4) - } - .buttonStyle(.borderless) - - } label: { - Button { - withAnimation { - accountSelectionExpanded.toggle() + Button { + withAnimation { + accountSelectionExpanded.toggle() + } + } label: { + Label("Switch Account", systemImage: "person.crop.circle") + .padding(.vertical, 4) } - } label: { - Label("Switch Account", systemImage: "person.crop.circle") - .padding(.vertical, 4) + .buttonStyle(.borderless) + } - .buttonStyle(.borderless) } - + .minWidth(250) + .minHeight(300) } - .minWidth(250) - .minHeight(300) - } - - @ViewBuilder - func statusItem(_ status: Gateway.Status) -> some View { - let color: Color = { - switch status { - case .online: return .init(hexadecimal6: 0x42a25a) - case .afk: return .init(hexadecimal6: 0xca9653) - case .doNotDisturb: return .init(hexadecimal6: 0xd83a42) - default: return .init(hexadecimal6: 0x82838b) - } - }() - Label { - Text(status.rawValue.capitalized) - } icon: { - Group { + @ViewBuilder + func statusItem(_ status: Gateway.Status) -> some View { + let color: Color = { switch status { - case .online: - StatusIndicatorShapes.OnlineShape() - case .afk: - StatusIndicatorShapes.IdleShape() - case .doNotDisturb: - StatusIndicatorShapes.DNDShape() - default: - StatusIndicatorShapes.InvisibleShape() + case .online: return .init(hexadecimal6: 0x42a25a) + case .afk: return .init(hexadecimal6: 0xca9653) + case .doNotDisturb: return .init(hexadecimal6: 0xd83a42) + default: return .init(hexadecimal6: 0x82838b) } + }() + + Label { + Text(status.rawValue.capitalized) + } icon: { + Group { + switch status { + case .online: + StatusIndicatorShapes.OnlineShape() + case .afk: + StatusIndicatorShapes.IdleShape() + case .doNotDisturb: + StatusIndicatorShapes.DNDShape() + default: + StatusIndicatorShapes.InvisibleShape() + } + } + .foregroundStyle(color) + .frame(width: 15, height: 15) } - .foregroundStyle(color) - .frame(width: 15, height: 15) } } } +} +#Preview("nameplate test") { + let decoration = DiscordUser.AvatarDecoration( + asset: "a_741750ac1c9091a58059be33590c2821", + sku_id: .init("1424960507143524495") + ) + + let llsc12 = DiscordUser( + id: .init("381538809180848128"), + username: "llsc12", + discriminator: "0", + global_name: nil, + avatar: "df71b3f223666fd8331c9940c6f7cbd9", + banner: nil, + bot: false, + system: false, + mfa_enabled: true, + accent_color: nil, + locale: .englishUS, + verified: true, + email: nil, + flags: .init(rawValue: 4_194_352), + premium_type: nil, + public_flags: .init(rawValue: 4_194_304), + collectibles: .init( + nameplate: + .init( + asset: "nameplates/nameplates_v3/bonsai/", + sku_id: SKUSnowflake("1382845914225442886"), + label: "COLLECTIBLES_NAMEPLATES_VOL_3_BONSAI_A11Y", + palette: .bubble_gum + ) + ), + avatar_decoration_data: decoration + ) + Group { + Profile.NameplateView(nameplate: llsc12.collectibles!.nameplate!) + .nameplateAnimated(true) + .nameplateImageOpacity(0.4) + .frame(width: 400, height: 80) + } } diff --git a/PaicordLib/.swiftpm/xcode/xcshareddata/xcschemes/TestCode.xcscheme b/PaicordLib/.swiftpm/xcode/xcshareddata/xcschemes/TestCode.xcscheme new file mode 100644 index 00000000..ecded3e1 --- /dev/null +++ b/PaicordLib/.swiftpm/xcode/xcshareddata/xcschemes/TestCode.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist b/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist index b096bf4f..44d5ff57 100644 --- a/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist @@ -72,7 +72,7 @@ TestCode.xcscheme_^#shared#^_ orderHint - 9 + 3 SuppressBuildableAutocreation @@ -112,6 +112,11 @@ primary + DiscordVoice + + primary + + GenerateAPIEndpointsExec primary diff --git a/PaicordLib/Package.swift b/PaicordLib/Package.swift index 7a61f577..bb646d96 100644 --- a/PaicordLib/Package.swift +++ b/PaicordLib/Package.swift @@ -28,6 +28,10 @@ let package = Package( name: "DiscordGateway", targets: ["DiscordGateway"] ), + .library( + name: "DiscordVoice", + targets: ["DiscordVoice"] + ), .library( name: "DiscordModels", targets: ["DiscordModels"] @@ -74,6 +78,10 @@ let package = Package( url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"5.0.0" ), + .package( + url: "https://github.com/llsc12/DaveKit.git", + branch: "main" + ), ], targets: [ .target( @@ -82,6 +90,7 @@ let package = Package( .target(name: "DiscordAuth"), .target(name: "DiscordHTTP"), .target(name: "DiscordCore"), + .target(name: "DiscordVoice"), .target(name: "DiscordGateway"), .target(name: "DiscordModels"), .target(name: "DiscordUtilities"), @@ -111,9 +120,21 @@ let package = Package( .product(name: "AsyncHTTPClient", package: "async-http-client"), .product(name: "WSClient", package: "swift-websocket"), .product(name: "libzstd", package: "zstd"), - .target(name: "DiscordHTTP"), .product(name: "Crypto", package: "swift-crypto"), .product(name: "_CryptoExtras", package: "swift-crypto"), + .target(name: "DiscordHTTP"), + ], + swiftSettings: swiftSettings + ), + .target( + name: "DiscordVoice", + dependencies: [ + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "WSClient", package: "swift-websocket"), + .product(name: "libzstd", package: "zstd"), + .product(name: "DaveKit", package: "DaveKit"), + .target(name: "DiscordGateway"), ], swiftSettings: swiftSettings ), @@ -122,10 +143,11 @@ let package = Package( dependencies: [ .product(name: "NIOFoundationCompat", package: "swift-nio"), .product(name: "MultipartKit", package: "multipart-kit"), - .target(name: "DiscordCore"), - .target(name: "UnstableEnumMacro"), .product(name: "SwiftProtobuf", package: "swift-protobuf"), .product(name: "UInt128", package: "UInt128"), + .product(name: "DaveKit", package: "DaveKit"), + .target(name: "DiscordCore"), + .target(name: "UnstableEnumMacro"), ], swiftSettings: swiftSettings ), @@ -139,7 +161,12 @@ let package = Package( .target( name: "DiscordAuth", dependencies: [ - .target(name: "DiscordModels") + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "WSClient", package: "swift-websocket"), + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "_CryptoExtras", package: "swift-crypto"), + .target(name: "DiscordGateway"), ], swiftSettings: swiftSettings ), @@ -211,9 +238,10 @@ let package = Package( var featureFlags: [SwiftSetting] { [ + .interoperabilityMode(.Cxx), /// https://github.com/apple/swift-evolution/blob/main/proposals/0335-existential-any.md /// Require `any` for existential types. - .enableUpcomingFeature("ExistentialAny") + .enableUpcomingFeature("ExistentialAny"), // .define("DISCORDBM_ENABLE_LOGGING_DURING_DECODE", .when(configuration: .debug)), ] } diff --git a/PaicordLib/Sources/DiscordAuth/BotAuthManager.swift b/PaicordLib/Sources/DiscordAuth/BotAuthManager.swift deleted file mode 100644 index 279e0f4a..00000000 --- a/PaicordLib/Sources/DiscordAuth/BotAuthManager.swift +++ /dev/null @@ -1,65 +0,0 @@ -import DiscordModels - -private let baseURLs = ( - authorization: "https://discord.com/api/oauth2/authorize", - token: "https://discord.com/api/oauth2/token", - tokenRevocation: "https://discord.com/api/oauth2/token/revoke" -) - -/// For now, only to be able to make bot auth urls dynamically, on demand. -public struct BotAuthManager: Sendable { - - let clientId: String - - public init(clientId: String) { - self.clientId = clientId - } - - /// The bot will immediately join servers which authorize your bot via this URL. - /// https://discord.com/developers/docs/topics/oauth2#bot-authorization-flow - @available( - *, - deprecated, - renamed: "makeBotAuthorizationURL(permissions:guildId:disableGuildSelect:)", - message: "'.applicationsCommands' OAuth scope is automatically included by Discord for bots" - ) - public func makeBotAuthorizationURL( - withApplicationCommands: Bool = true, - permissions: [Permission] = [], - guildId: GuildSnowflake? = nil, - disableGuildSelect: Bool? = nil - ) -> String { - var scopes: [OAuth2Scope] = [.bot] - if withApplicationCommands { - scopes.append(.applicationsCommands) - } - let permissions = IntBitField(permissions).rawValue - let queries: [(String, String?)] = [ - ("client_id", self.clientId), - ("permissions", "\(permissions)"), - ("scope", scopes.map(\.rawValue).joined(separator: " ")), - ("guild_id", guildId?.rawValue), - ("disable_guild_select", disableGuildSelect?.description), - ] - return baseURLs.authorization + queries.makeForURLQuery() - } - - /// The bot will immediately join servers which authorize your bot via this URL. - /// https://discord.com/developers/docs/topics/oauth2#bot-authorization-flow - public func makeBotAuthorizationURL( - permissions: [Permission] = [], - guildId: GuildSnowflake? = nil, - disableGuildSelect: Bool? = nil - ) -> String { - let scopes: [OAuth2Scope] = [.bot] - let permissions = IntBitField(permissions).rawValue - let queries: [(String, String?)] = [ - ("client_id", self.clientId), - ("permissions", "\(permissions)"), - ("scope", scopes.map(\.rawValue).joined(separator: " ")), - ("guild_id", guildId?.rawValue), - ("disable_guild_select", disableGuildSelect?.description), - ] - return baseURLs.authorization + queries.makeForURLQuery() - } -} diff --git a/PaicordLib/Sources/DiscordGateway/RemoteAuthGatewayManager.swift b/PaicordLib/Sources/DiscordAuth/RemoteAuthGatewayManager.swift similarity index 99% rename from PaicordLib/Sources/DiscordGateway/RemoteAuthGatewayManager.swift rename to PaicordLib/Sources/DiscordAuth/RemoteAuthGatewayManager.swift index bd7eda01..03a0dc28 100644 --- a/PaicordLib/Sources/DiscordGateway/RemoteAuthGatewayManager.swift +++ b/PaicordLib/Sources/DiscordAuth/RemoteAuthGatewayManager.swift @@ -9,7 +9,7 @@ import AsyncHTTPClient import Atomics import Crypto -import DiscordHTTP +import DiscordGateway import DiscordModels import Foundation import Logging @@ -133,7 +133,8 @@ public actor RemoteAuthGatewayManager { //MARK: Event streams var eventsStreamContinuations: [AsyncStream.Continuation] = [] - var eventsParseFailureContinuations: [AsyncStream<(any Error, ByteBuffer)>.Continuation] = [] + var eventsParseFailureContinuations: + [AsyncStream<(any Error, ByteBuffer)>.Continuation] = [] /// An async sequence of Gateway events. public var events: DiscordAsyncSequence { @@ -281,7 +282,8 @@ public actor RemoteAuthGatewayManager { ) await self.onSuccessfulConnection() - for try await message in inbound.messages(maxSize: self.maxFrameSize) { + for try await message in inbound.messages(maxSize: self.maxFrameSize) + { await self.processBinaryData( message, forConnectionWithId: connectionId @@ -843,7 +845,9 @@ extension RemoteAuthGatewayManager { /// Use this to exchange a remote auth ticket for a Discord auth token. /// - Parameter ticket: The remote auth ticket received from the gateway. /// - Returns: A token. - public func exchange(ticket: String, client: any DiscordClient) async throws -> Secret { + public func exchange(ticket: String, client: any DiscordClient) async throws + -> Secret + { let req = try await client.exchangeRemoteAuthTicket( payload: .init(ticket: ticket) ) diff --git a/PaicordLib/Sources/DiscordCore/DiscordGlobalConfiguration.swift b/PaicordLib/Sources/DiscordCore/DiscordGlobalConfiguration.swift index 9efbdaeb..e1e5d22d 100644 --- a/PaicordLib/Sources/DiscordCore/DiscordGlobalConfiguration.swift +++ b/PaicordLib/Sources/DiscordCore/DiscordGlobalConfiguration.swift @@ -14,8 +14,7 @@ private class ConfigurationStorage: @unchecked Sendable { /// A container for **on-boot & one-time-only** configuration options. public enum DiscordGlobalConfiguration { - /// Currently only 10 is supported. - /// official client is on v9, not v10. + /// The API and Gateway version used. Official client uses v9. v10 is for bots. public static let apiVersion = 9 /// https://docs.discord.food/topics/gateway-events#qos-payload-structure diff --git a/PaicordLib/Sources/DiscordGateway/Backoff.swift b/PaicordLib/Sources/DiscordGateway/Backoff.swift index ddec223d..2cec2bd5 100644 --- a/PaicordLib/Sources/DiscordGateway/Backoff.swift +++ b/PaicordLib/Sources/DiscordGateway/Backoff.swift @@ -2,7 +2,7 @@ import Foundation import NIOCore /// Exponential backoff -final class Backoff { +final package class Backoff { let base: Double let maxExponentiation: Int @@ -11,7 +11,7 @@ final class Backoff { var tryCount = 0 var previousTry = Date.distantPast.timeIntervalSince1970 - init( + package init( base: Double, maxExponentiation: Int, coefficient: Double, @@ -26,7 +26,7 @@ final class Backoff { /// Returns `nil` if can perform immediately, /// otherwise `Duration` to wait before attempting to perform. /// Assumes you will definitely perform the task after calling this. - func canPerformIn() -> Duration? { + package func canPerformIn() -> Duration? { let tryCount = self.tryCount let previousTry = self.previousTry self.tryCount += 1 @@ -57,11 +57,11 @@ final class Backoff { } } - func resetTryCount() { + package func resetTryCount() { self.tryCount = 0 } - func willTry() { + package func willTry() { self.previousTry = Date().timeIntervalSince1970 } diff --git a/PaicordLib/Sources/DiscordGateway/DiscordAsyncStream.swift b/PaicordLib/Sources/DiscordGateway/DiscordAsyncStream.swift index 02833b47..4222c157 100644 --- a/PaicordLib/Sources/DiscordGateway/DiscordAsyncStream.swift +++ b/PaicordLib/Sources/DiscordGateway/DiscordAsyncStream.swift @@ -3,7 +3,7 @@ public struct DiscordAsyncSequence: Sendable, AsyncSequence { /// DiscordBM's async sequence iterator. public struct AsyncIterator: AsyncIteratorProtocol { - var base: AsyncStream.AsyncIterator + package var base: AsyncStream.AsyncIterator /// Get the next element. public mutating func next() async -> Element? { @@ -11,10 +11,14 @@ public struct DiscordAsyncSequence: Sendable, AsyncSequence { } } - let base: AsyncStream + package let base: AsyncStream /// Make an async iterator. public func makeAsyncIterator() -> AsyncIterator { AsyncIterator(base: base.makeAsyncIterator()) } + + package init(base: AsyncStream) { + self.base = base + } } diff --git a/PaicordLib/Sources/DiscordGateway/DiscordCache.swift b/PaicordLib/Sources/DiscordGateway/DiscordCache.swift deleted file mode 100644 index 5beb2b83..00000000 --- a/PaicordLib/Sources/DiscordGateway/DiscordCache.swift +++ /dev/null @@ -1,1188 +0,0 @@ -import DiscordModels -import Foundation -import Logging -import OrderedCollections - -/// Caches Gateway events. -@dynamicMemberLookup -public actor DiscordCache { - - public enum SnowflakeChoice: Sendable, ExpressibleByArrayLiteral { - case all - case none - case some(Set>) - - public init(arrayLiteral elements: Snowflake...) { - self = .some(Set(elements)) - } - - public init(_ elements: S) where S: Sequence, S.Element == String { - let guildIds = elements.map(Snowflake.init) - self = .some(Set(guildIds)) - } - - public func contains(_ value: Snowflake) -> Bool { - switch self { - case .all: return true - case .none: return false - case .some(let values): return values.contains(value) - } - } - } - - public enum Intents: Sendable, ExpressibleByArrayLiteral { - case all - case some(Set) - - public init(arrayLiteral elements: Gateway.Intent...) { - self = .some(Set(elements)) - } - - public init(_ elements: S) - where S: Sequence, S.Element == Gateway.Intent { - self = .some(.init(elements)) - } - } - - public enum RequestMembers: Sendable { - case disabled - /// Only requests members. - case enabled(guilds: SnowflakeChoice = .all) - /// Requests all members as well as their presences. - case enabledWithPresences(guilds: SnowflakeChoice = .all) - - public static var enabled: RequestMembers { .enabled() } - - public static var enabledWithPresences: RequestMembers { - .enabledWithPresences() - } - - public func isEnabled(for guildId: GuildSnowflake) -> Bool { - switch self { - case .disabled: return false - case .enabled(let guilds), .enabledWithPresences(let guilds): - return guilds.contains(guildId) - } - } - - public func wantsPresences(for guildId: GuildSnowflake) -> Bool { - switch self { - case .disabled, .enabled: return false - case .enabledWithPresences(let guilds): - return guilds.contains(guildId) - } - } - } - - public enum MessageCachingPolicy: Sendable { - - /// `Channels` is for channels that don't belong to a guild. - - /// Caches messages, replaces edited messages with the new message, - /// removes deleted messages from storage. - case normal( - guilds: SnowflakeChoice = .all, - channels: SnowflakeChoice = .all - ) - /// Caches messages, replaces edited messages with the new message, - /// moves deleted messages to another property of the storage. - case saveDeleted( - guilds: SnowflakeChoice = .all, - channels: SnowflakeChoice = .all - ) - /// Caches messages, replaces edited messages with the new message but moves old messages - /// to another property of the storage, removes deleted messages from storage. - case saveEditHistory( - guilds: SnowflakeChoice = .all, - channels: SnowflakeChoice = .all - ) - /// Caches messages, replaces edited messages with the new message but moves old messages - /// to another property of the storage, moves deleted messages to another property of - /// the storage. - case saveEditHistoryAndDeleted( - guilds: SnowflakeChoice = .all, - channels: SnowflakeChoice = .all - ) - - public static var normal: MessageCachingPolicy { .normal() } - - public static var saveDeleted: MessageCachingPolicy { .saveDeleted() } - - public static var saveEditHistory: MessageCachingPolicy { - .saveEditHistory() - } - - public static var saveEditHistoryAndDeleted: MessageCachingPolicy { - .saveEditHistoryAndDeleted() - } - - func shouldSave( - guildId: GuildSnowflake?, - channelId: ChannelSnowflake - ) -> Bool { - switch self { - case .normal(let guilds, let channels), - .saveDeleted(let guilds, let channels), - .saveEditHistory(let guilds, let channels), - .saveEditHistoryAndDeleted(let guilds, let channels): - let guildContains = guildId.map { guilds.contains($0) } ?? false - return guildContains || channels.contains(channelId) - } - } - - func shouldSaveDeleted( - guildId: GuildSnowflake?, - channelId: ChannelSnowflake - ) -> Bool { - switch self { - case .saveDeleted(let guilds, let channels), - .saveEditHistoryAndDeleted(let guilds, let channels): - let guildContains = guildId.map { guilds.contains($0) } ?? false - return guildContains || channels.contains(channelId) - case .normal, .saveEditHistory: return false - } - } - - func shouldSaveHistory( - guildId: GuildSnowflake?, - channelId: ChannelSnowflake - ) -> Bool { - switch self { - case .saveEditHistory(let guilds, let channels), - .saveEditHistoryAndDeleted(let guilds, let channels): - let guildContains = guildId.map { guilds.contains($0) } ?? false - return guildContains || channels.contains(channelId) - case .normal, .saveDeleted: return false - } - } - } - - /// Keeps the storage from using too much memory. Removes the oldest items. - /// - /// Note: The limit policy is applied with a small tolerance, so you can't count on - /// the limits being applied right-away. Realistically this should not matter anyway, - /// as the point of this is to just preserve memory. - public enum ItemsLimit: @unchecked Sendable { - - /// `guilds`, `channels` and `botUser` are intentionally excluded. - /// For `guilds` and `channels`, Discord only sends a limited amount that are related - /// to your Gateway/shard. And `botUser` isn't a collection. - public enum Path: Sendable { - case auditLogs - case integrations - case invites - case messages - case editedMessages - case deletedMessages - case autoModerationRules - case autoModerationExecutions - case applicationCommandPermissions - case messagePollVotes - } - - case disabled - case constant(Int) - case custom([Path: Int]) - - public static let `default` = ItemsLimit.constant(100_000) - - func limit(for path: Path) -> Int? { - switch self { - case .disabled: - return nil - case .constant(let limit): - return limit - case .custom(let custom): - return custom[path] - } - } - - /// Checks for the limit interval and MODIFIES `itemsLimit` to `disabled` if appropriate. - mutating func calculateCheckForLimitEvery() -> Int { - switch self { - case .disabled: return 1 - /// Doesn't matter - case .constant(let limit): - let powed = pow(1 / 2, Double(limit)) - return max(10, Int(powed)) - case .custom(let custom): - guard let minimum = custom.map(\.value).min() else { - assert( - false, - "It's meaningless for 'ItemsLimit.custom' to be empty. Please use `ItemsLimit.disabled` instead" - ) - self = .disabled - return 1/// Doesn't matter - } - let powed = pow(1 / 2, Double(minimum)) - return max(10, Int(powed)) - } - } - } - - /// The assumption is users might want to encode/decode contents of this storage using Codable. - /// So this storage should be codable-backward-compatible. - public struct Storage: @unchecked Sendable, Codable { - - public struct InviteID: Sendable, Codable, Hashable { - public var guildId: GuildSnowflake? - public var channelId: ChannelSnowflake - - public init(guildId: GuildSnowflake? = nil, channelId: ChannelSnowflake) { - self.guildId = guildId - self.channelId = channelId - } - } - - /// Using `OrderedDictionary` for those which can be affected by the `ItemsLimit` - /// so we can remove the oldest items. - - /// `[GuildID: Guild]` - public var guilds: [GuildSnowflake: Gateway.GuildCreate] = [:] - /// `[ChannelID: Channel]` - /// Non-guild channels. - public var channels: [ChannelSnowflake: DiscordChannel] = [:] - /// `[GuildID or TargetID or ""]: [Entry]]` - /// `""` is used for entries that don't have a `guild_id`/`target_id`, if any. - public var auditLogs: OrderedDictionary = - [:] - /// `[GuildID: [Integration]]` - public var integrations: OrderedDictionary = - [:] - /// `[InviteID: [Invite]]` - public var invites: OrderedDictionary = - [:] - /// `[ChannelID: [Message]]` - public var messages: OrderedDictionary = [:] - /// `[ChannelID: [MessageID: [EditedMessage]]]` - /// It's `[EditedMessage]` because it will keep all edited versions of a message. - /// This does not keep the most recent message, which is available in `messages`. - public var editedMessages: - OrderedDictionary< - ChannelSnowflake, [MessageSnowflake: [Gateway.MessageCreate]] - > = - [:] - /// `[ChannelID: [MessageID: [DeletedMessage]]]` - /// It's `[DeletedMessage]` because it might have the edited versions of the message too. - public var deletedMessages: - OrderedDictionary< - ChannelSnowflake, [MessageSnowflake: [Gateway.MessageCreate]] - > = - [:] - /// `[GuildID: [Rule]]` - public var autoModerationRules: OrderedDictionary = [:] - /// `[GuildID: [ActionExecution]]` - public var autoModerationExecutions: - OrderedDictionary = [:] - /// `[CommandID (or ApplicationID): Permissions]` - public var applicationCommandPermissions: - OrderedDictionary = - [:] - /// `[EntitlementID: Entitlement]` - public var entitlements: OrderedDictionary = [:] - /// `[ChannelSnowflake: [MessageSnowflake: [MessagePollVote]]` - public var messagePollVotes: - OrderedDictionary< - ChannelSnowflake, [MessageSnowflake: [Gateway.MessagePollVote]] - > = [:] - /// The current bot-application. - public var application: PartialApplication? - /// The current bot user. - public var botUser: DiscordUser? - - public init( - guilds: [GuildSnowflake: Gateway.GuildCreate] = [:], - channels: [ChannelSnowflake: DiscordChannel] = [:], - auditLogs: OrderedDictionary = [:], - integrations: OrderedDictionary = [:], - invites: OrderedDictionary = [:], - messages: OrderedDictionary = - [:], - editedMessages: OrderedDictionary< - ChannelSnowflake, [MessageSnowflake: [Gateway.MessageCreate]] - > = [:], - deletedMessages: OrderedDictionary< - ChannelSnowflake, [MessageSnowflake: [Gateway.MessageCreate]] - > = [:], - autoModerationRules: OrderedDictionary< - GuildSnowflake, [AutoModerationRule] - > = [:], - autoModerationExecutions: OrderedDictionary< - GuildSnowflake, [AutoModerationActionExecution] - > = [:], - applicationCommandPermissions: OrderedDictionary< - AnySnowflake, GuildApplicationCommandPermissions - > = [:], - entitlements: OrderedDictionary = [:], - application: PartialApplication? = nil, - botUser: DiscordUser? = nil - ) { - self.guilds = guilds - self.channels = channels - self.auditLogs = auditLogs - self.integrations = integrations - self.invites = invites - self.messages = messages - self.editedMessages = editedMessages - self.deletedMessages = deletedMessages - self.autoModerationRules = autoModerationRules - self.autoModerationExecutions = autoModerationExecutions - self.applicationCommandPermissions = applicationCommandPermissions - self.entitlements = entitlements - self.application = application - self.botUser = botUser - } - } - - /// The gateway manager that this `DiscordCache` instance caches from. - let gatewayManager: any GatewayManager - let logger: Logger - /// What intents to cache their related Gateway events. - /// This does not affect what events you receive from Discord. - /// The intents you enter here must have been enabled in your `GatewayManager`. - /// With `.all`, all events will be cached. - public let intents: Set - /// In big guilds/servers, Discord only sends your own member/presence info by default. - /// You need to request the rest of the members, which is what this parameter specifies. - /// Must have `guildMembers` and `guildPresences` intents enabled depending on what you want. - public let requestMembers: RequestMembers - /// How to cache messages. - public let messageCachingPolicy: MessageCachingPolicy - /// Keeps the storage from using too much memory. Removes the oldest items. - public let itemsLimit: ItemsLimit - /// Counter for hitting the items limit. - private var itemsLimitCounter = 0 - /// How often to check and enforce the limit above. - private let checkForLimitEvery: Int - /// The storage of cached stuff. - public var storage: Storage { - didSet { checkItemsLimit() } - } - - #if compiler(<6.0) - /// Utility to access `Storage`. - public subscript( - dynamicMember path: WritableKeyPath - ) -> T { - get { self.storage[keyPath: path] } - set { self.storage[keyPath: path] = newValue } - } - #else - /// Utility to access `Storage`. - public subscript( - dynamicMember path: (any Sendable & WritableKeyPath) - ) -> T { - get { self.storage[keyPath: path] } - set { self.storage[keyPath: path] = newValue } - } - #endif - - /// - Parameters: - /// - gatewayManager: The gateway manager that this `DiscordCache` instance caches from. - /// - intents: What intents to cache their related Gateway events. - /// This does not affect what events you receive from Discord. - /// The intents you enter here must have been enabled in your `GatewayManager`. - /// - requestAllMembers: In big guilds/servers, Discord only sends your own member/presence - /// info by default. You need to request the rest of the members, which is what this - /// parameter specifies. Must have `guildMembers` and `guildPresences` intents enabled - /// depending on what you want. - /// - messageCachingPolicy: How to cache messages. - /// - itemsLimit: Keeps the storage from using too much memory. Removes the oldest items. - /// - storage: The storage of cached stuff. You usually don't need to provide this parameter. - public init( - gatewayManager: any GatewayManager, - logger: Logger = Logger( - label: "no-op", - factory: SwiftLogNoOpLogHandler.init - ), - intents: Intents, - requestAllMembers: RequestMembers, - messageCachingPolicy: MessageCachingPolicy = .normal, - itemsLimit: ItemsLimit = .default, - storage: Storage = Storage() - ) async { - self.gatewayManager = gatewayManager - self.logger = logger - self.intents = DiscordCache.calculateIntentsIntersection( - gatewayManager: gatewayManager, - intents: intents - ) - self.requestMembers = requestAllMembers - self.messageCachingPolicy = messageCachingPolicy - var itemsLimit = itemsLimit - /// Checks for the limit interval and MODIFIES `itemsLimit` to `disabled` if appropriate. - self.checkForLimitEvery = itemsLimit.calculateCheckForLimitEvery() - self.itemsLimit = itemsLimit - self.storage = storage - - Task { - for await event in await gatewayManager.events { - self.handleEvent(event) - } - } - } - - private func handleEvent(_ event: Gateway.Event) { - guard intentsAllowCaching(event: event) else { return } - - logger.trace( - "Will handle an event in DiscordCache", - metadata: [ - "event": .string("\(event)") - ] - ) - - switch event.data { - case .none, .heartbeat, .identify, .hello, .resume, .resumed, - .invalidSession, .requestGuildMembers, - .requestPresenceUpdate, .requestVoiceStateUpdate, .interactionCreate: - break - case .ready(let ready): - self.botUser = ready.user - case .guildCreate(let guildCreate): - self.guilds[guildCreate.id] = guildCreate - if requestMembers.isEnabled(for: guildCreate.id) { - Task { - await gatewayManager.requestGuildMembersChunk( - payload: .init( - guild_id: guildCreate.id, - query: "", - limit: 0, - presences: requestMembers.wantsPresences(for: guildCreate.id), - user_ids: nil, - nonce: nil - ) - ) - } - } - case .guildUpdate(let guild): - self.guilds[guild.id]?.update(with: guild) - case .guildDelete(let guildDelete): - self.guilds.removeValue(forKey: guildDelete.id) - case .channelCreate(let channel), .channelUpdate(let channel): - if let guildId = channel.guild_id { - if let index = self.guilds[guildId]?.channels - .firstIndex(where: { $0.id == channel.id }) - { - self.guilds[guildId]?.channels.remove(at: index) - } - self.guilds[guildId]?.channels.append(channel) - } else { - self.channels[channel.id] = channel - } - case .channelDelete(let channel): - if let guildId = channel.guild_id { - if let index = self.guilds[guildId]?.channels - .firstIndex(where: { $0.id == channel.id }) - { - self.guilds[guildId]?.channels.remove(at: index) - } - } else { - self.channels.removeValue(forKey: channel.id) - } - case .channelPinsUpdate(let pinsUpdate): - if let guildId = pinsUpdate.guild_id { - if let index = self.guilds[guildId]?.channels - .firstIndex(where: { $0.id == pinsUpdate.channel_id }) - { - self.guilds[guildId]!.channels[index] - .last_pin_timestamp = pinsUpdate.last_pin_timestamp - } - } else { - self.channels[pinsUpdate.channel_id]? - .last_pin_timestamp = pinsUpdate.last_pin_timestamp - } - case .threadCreate(let channel): - if let guildId = channel.guild_id { - if let existingIndex = self.guilds[guildId]?.threads - .firstIndex(where: { $0.id == channel.id }) - { - self.guilds[guildId]?.threads[existingIndex] = channel - /// Update `last_message_id` of forums on thread-create. - /// https://discord.com/developers/docs/topics/threads#forum-channel-fields - if channel.type == .guildForum, - let parentId = channel.parent_id, - self.intents.contains(.guilds), - let forumIdx = self.guilds[guildId]?.channels - .firstIndex(where: { $0.id == parentId }) - { - self.guilds[guildId]?.channels[forumIdx].last_message_id = .init( - channel.id - ) - } - } else { - self.guilds[guildId]?.threads.append(channel) - } - } else { - self.channels[channel.id] = channel - /// Update `last_message_id` of forums on thread-create. - /// https://discord.com/developers/docs/topics/threads#forum-channel-fields - if channel.type == .guildForum, - let parentId = channel.parent_id, - self.intents.contains(.guilds) - { - self.channels[Snowflake(parentId)]?.last_message_id = .init( - channel.id - ) - } - } - case .threadUpdate(let channel): - if let guildId = channel.guild_id { - if let existingIndex = self.guilds[guildId]?.threads - .firstIndex(where: { $0.id == channel.id }) - { - self.guilds[guildId]?.threads[existingIndex] = channel - } - } else { - self.channels[channel.id] = channel - } - case .threadDelete(let threadDelete): - if let guildId = threadDelete.guild_id { - if let existingIndex = self.guilds[guildId]?.threads - .firstIndex(where: { $0.id == threadDelete.id }) - { - self.guilds[guildId]?.threads.remove(at: existingIndex) - } - } else { - self.channels.removeValue(forKey: threadDelete.id) - } - case .threadSyncList(let syncList): - var guild: Gateway.GuildCreate? { - get { self.guilds[syncList.guild_id] } - set { self.guilds[syncList.guild_id] = newValue } - } - /// Remove unavailable threads - let allParents = Set(syncList.threads.compactMap(\.parent_id)) - let parentsOfRemovedThreads = - syncList.channel_ids?.filter { channelId in - !allParents.contains(where: { $0 == channelId }) - } ?? [] - guild?.threads.removeAll { - guard let parentId = $0.parent_id else { return false } - return parentsOfRemovedThreads.contains(where: { $0 == parentId }) - } - /// Append the new threads - guild?.threads.append(contentsOf: syncList.threads) - /// Refresh thread members - for member in syncList.members ?? [] { - if let idx = guild?.threads.firstIndex(where: { $0.id == member.id }) { - guild?.threads[idx].member = member - } - } - case .threadMemberUpdate(let threadMember): - if let idx = self.guilds[threadMember.guild_id]?.threads - .firstIndex(where: { $0.id == threadMember.id }) - { - self.guilds[threadMember.guild_id]?.threads[idx].member = .init( - threadMemberUpdate: threadMember - ) - } - case .threadMembersUpdate(let update): - if let idx = self.guilds[update.guild_id]?.threads - .firstIndex(where: { $0.id == update.id }) - { - self.guilds[update.guild_id]!.threads[idx].member_count = - update.member_count - if self.guilds[update.guild_id]!.threads[idx].threadMembers == nil { - if let added = update.added_members { - self.guilds[update.guild_id]!.threads[idx].threadMembers = added - } - } else { - if let removed = update.removed_member_ids { - self.guilds[update.guild_id]!.threads[idx].threadMembers!.removeAll { - guard let id = $0.member.user?.id ?? $0.user_id else { - return false - } - return removed.contains(id) - } - } - if let added = update.added_members { - self.guilds[update.guild_id]!.threads[idx].threadMembers! - .append(contentsOf: added) - } - } - } - case .entitlementCreate(let entitlement), - .entitlementUpdate(let entitlement): - self.entitlements[entitlement.id] = entitlement - case .entitlementDelete(let entitlement): - self.entitlements.removeValue(forKey: entitlement.id) - case .guildBanAdd(let ban): - if let idx = self.guilds[ban.guild_id]?.members - .firstIndex(where: { $0.user?.id == ban.user.id }) - { - self.guilds[ban.guild_id]?.members.remove(at: idx) - } - case .guildBanRemove: break - /// Nothing to do? - case .guildEmojisUpdate(let update): - for emoji in update.emojis { - if let idx = self.guilds[update.guild_id]?.emojis - .firstIndex(where: { $0.id == emoji.id }) - { - self.guilds[update.guild_id]?.emojis[idx] = emoji - } else { - self.guilds[update.guild_id]?.emojis.append(emoji) - } - } - case .guildStickersUpdate(let update): - if self.guilds[update.guild_id]?.stickers == nil { - self.guilds[update.guild_id]?.stickers = [] - } - for sticker in update.stickers { - if let idx = self.guilds[update.guild_id]?.stickers? - .firstIndex(where: { $0.id == sticker.id }) - { - self.guilds[update.guild_id]?.stickers?[idx] = sticker - } else { - self.guilds[update.guild_id]?.stickers?.append(sticker) - } - } - case .guildIntegrationsUpdate: break - /// Nothing to do? - case .guildMemberAdd(let member), .guildMemberUpdate(let member): - if let idx = self.guilds[member.guild_id]?.members - .firstIndex(where: { $0.user?.id == member.user.id }) - { - self.guilds[member.guild_id]?.members.remove(at: idx) - } - self.guilds[member.guild_id]?.members.append( - .init(guildMemberAdd: member) - ) - case .guildMemberRemove(let member): - if let idx = self.guilds[member.guild_id]?.members - .firstIndex(where: { $0.user?.id == member.user.id }) - { - self.guilds[member.guild_id]?.members.remove(at: idx) - } - case .guildMembersChunk(let chunk): - let membersUserIds = Set(chunk.members.compactMap(\.user?.id)) - self.guilds[chunk.guild_id]?.members.removeAll { - guard let id = $0.user?.id else { return false } - return membersUserIds.contains(id) - } - self.guilds[chunk.guild_id]?.members.append(contentsOf: chunk.members) - if let presences = chunk.presences { - let presencesUserIds = Set(presences.compactMap(\.user?.id)) - self.guilds[chunk.guild_id]?.presences.removeAll { - guard let id = $0.user?.id else { return false } - return presencesUserIds.contains(id) - } - self.guilds[chunk.guild_id]?.presences.append(contentsOf: presences) - } - case .guildRoleCreate(let role), .guildRoleUpdate(let role): - if let idx = self.guilds[role.guild_id]?.roles - .firstIndex(where: { $0.id == role.role.id }) - { - self.guilds[role.guild_id]?.roles.remove(at: idx) - } - self.guilds[role.guild_id]?.roles.append(role.role) - case .guildRoleDelete(let role): - if let idx = self.guilds[role.guild_id]?.roles - .firstIndex(where: { $0.id == role.role_id }) - { - self.guilds[role.guild_id]?.roles.remove(at: idx) - } - case .guildScheduledEventCreate(let event), - .guildScheduledEventUpdate(let event): - if let idx = self.guilds[event.guild_id]?.guild_scheduled_events - .firstIndex(where: { $0.id == event.id }) - { - self.guilds[event.guild_id]?.guild_scheduled_events.remove(at: idx) - } - self.guilds[event.guild_id]?.guild_scheduled_events.append(event) - case .guildScheduledEventDelete(let event): - if let idx = self.guilds[event.guild_id]?.guild_scheduled_events - .firstIndex(where: { $0.id == event.id }) - { - self.guilds[event.guild_id]?.guild_scheduled_events.remove(at: idx) - } - case .guildScheduledEventUserAdd(let user): - guard - let idx = self.guilds[user.guild_id]?.guild_scheduled_events - .firstIndex(where: { $0.id == user.guild_scheduled_event_id }) - else { break } - if self.guilds[user.guild_id]?.guild_scheduled_events[idx].user_ids == nil { - self.guilds[user.guild_id]?.guild_scheduled_events[idx] - .user_ids = [user.user_id] - } else { - self.guilds[user.guild_id]?.guild_scheduled_events[idx] - .user_ids?.append(user.user_id) - } - if self.guilds[user.guild_id]?.guild_scheduled_events[idx].user_count - == nil - { - self.guilds[user.guild_id]?.guild_scheduled_events[idx].user_count = 1 - } else { - self.guilds[user.guild_id]!.guild_scheduled_events[idx].user_count! += 1 - } - case .guildScheduledEventUserRemove(let user): - guard - let idx = self.guilds[user.guild_id]?.guild_scheduled_events - .firstIndex(where: { $0.id == user.guild_scheduled_event_id }) - else { break } - if let ind = self.guilds[user.guild_id]?.guild_scheduled_events[idx] - .user_ids?.firstIndex(where: { $0 == user.user_id }) - { - self.guilds[user.guild_id]?.guild_scheduled_events[idx] - .user_ids?.remove(at: ind) - } - if self.guilds[user.guild_id]?.guild_scheduled_events[idx].user_count - == nil - { - self.guilds[user.guild_id]?.guild_scheduled_events[idx].user_count = 0 - } else { - self.guilds[user.guild_id]!.guild_scheduled_events[idx].user_count! -= 1 - } - case .guildAuditLogEntryCreate(let log): - let guildId = log.guild_id.map(AnySnowflake.init) - let targetId = log.target_id.map(AnySnowflake.init) - self.auditLogs[guildId ?? targetId ?? AnySnowflake(""), default: []] - .append(log) - case .integrationCreate(let integration), - .integrationUpdate(let integration): - if let idx = self.integrations[integration.guild_id]? - .firstIndex(where: { $0.id == integration.id }) - { - self.integrations[integration.guild_id]?.remove(at: idx) - } - self.integrations[integration.guild_id, default: []].append( - .init(integrationCreate: integration) - ) - case .integrationDelete(let integration): - if let idx = self.integrations[integration.guild_id]? - .firstIndex(where: { $0.id == integration.id }) - { - self.integrations[integration.guild_id]?.remove(at: idx) - } - case .inviteCreate(let invite): - let id = Storage.InviteID( - guildId: invite.guild_id, - channelId: invite.channel_id - ) - self.invites[id, default: []].append(invite) - case .inviteDelete(let invite): - let id = Storage.InviteID( - guildId: invite.guild_id, - channelId: invite.channel_id - ) - self.invites.removeValue(forKey: id) - case .messageCreate(let message): - if messageCachingPolicy.shouldSave( - guildId: message.guild_id, - channelId: message.channel_id - ) { - self.messages[message.channel_id, default: []].append(message) - } - if self.intents.contains(.guilds) { - if let guildId = message.guild_id { - if let channelIdx = self.guilds[guildId]?.channels - .firstIndex(where: { $0.id == message.channel_id }) - { - self.guilds[guildId]?.channels[channelIdx].last_message_id = - message.id - } else if let threadIdx = self.guilds[guildId]?.threads - .firstIndex(where: { $0.id == message.channel_id }) - { - self.guilds[guildId]?.threads[threadIdx].last_message_id = - message.id - } - } else { - self.channels[message.channel_id]?.last_message_id = message.id - } - } - case .messageUpdate(let message): - if let idx = self.messages[message.channel_id]? - .firstIndex(where: { $0.id == message.id }) - { - self.messages[message.channel_id]![idx].update(with: message) - if messageCachingPolicy.shouldSaveHistory( - guildId: message.guild_id, - channelId: message.channel_id - ) { - self.editedMessages[message.channel_id, default: [:]][ - message.id, - default: [] - ].append( - self.messages[message.channel_id]![idx] - ) - } - } - case .messageDelete(let message): - if let idx = self.messages[message.channel_id]? - .firstIndex(where: { $0.id == message.id }) - { - let deleted = self.messages[message.channel_id]?.remove(at: idx) - if messageCachingPolicy.shouldSaveDeleted( - guildId: message.guild_id, - channelId: message.channel_id - ), - let deleted - { - if messageCachingPolicy.shouldSaveHistory( - guildId: message.guild_id, - channelId: message.channel_id - ) { - let history = - self.editedMessages[message.channel_id]?[message.id] ?? [] - self.deletedMessages[message.channel_id, default: [:]][ - message.id, - default: [] - ].append( - contentsOf: history - ) - } - self.deletedMessages[message.channel_id, default: [:]][ - message.id, - default: [] - ].append( - deleted - ) - } - self.editedMessages[message.channel_id]?.removeValue(forKey: message.id) - } - case .messageDeleteBulk(let bulkDelete): - self.messages[bulkDelete.channel_id]?.removeAll { message in - let shouldBeRemoved = bulkDelete.ids.contains(message.id) - if shouldBeRemoved { - if messageCachingPolicy.shouldSaveDeleted( - guildId: message.guild_id, - channelId: message.channel_id - ) { - if messageCachingPolicy.shouldSaveHistory( - guildId: message.guild_id, - channelId: message.channel_id - ) { - let history = - self.editedMessages[message.channel_id]?[message.id] ?? [] - self.deletedMessages[message.channel_id, default: [:]][ - message.id, - default: [] - ].append( - contentsOf: history - ) - } - self.deletedMessages[message.channel_id, default: [:]][ - message.id, - default: [] - ].append( - message - ) - } - self.editedMessages[message.channel_id]?.removeValue( - forKey: message.id - ) - } - return shouldBeRemoved - } - case .messageReactionAdd(let reaction): - if let idx = self.messages[reaction.channel_id]? - .firstIndex(where: { $0.id == reaction.message_id }) - { - let me = reaction.user_id == self.botUser?.id - let isBurst = reaction.type == .burst - if let index = self.messages[reaction.channel_id]![idx].reactions? - .firstIndex(where: { $0.emoji == reaction.emoji }) - { - self.messages[reaction.channel_id]![idx].reactions![index].count += 1 - if isBurst { - self.messages[reaction.channel_id]![idx].reactions![index] - .count_details.burst += 1 - } else { - self.messages[reaction.channel_id]![idx].reactions![index] - .count_details.normal += 1 - } - } else { - self.messages[reaction.channel_id]![idx].reactions = - self.messages[reaction.channel_id]![idx].reactions ?? [] - self.messages[reaction.channel_id]![idx].reactions!.append( - .init( - count: 1, - count_details: .init( - burst: isBurst ? 1 : 0, - normal: isBurst ? 0 : 1 - ), - me: me, - me_burst: reaction.type == .burst && me, - emoji: reaction.emoji, - burst_colors: [] - ) - ) - } - } - case .messageReactionRemove(let reaction): - if let idx = self.messages[reaction.channel_id]? - .firstIndex(where: { $0.id == reaction.message_id }) - { - if let index = self.messages[reaction.channel_id]?[idx].reactions? - .firstIndex(where: { $0.emoji == reaction.emoji }) - { - if self.messages[reaction.channel_id]![idx].reactions![index].count - == 1 - { - self.messages[reaction.channel_id]?[idx].reactions?.remove( - at: index - ) - } else { - self.messages[reaction.channel_id]![idx].reactions![index].count -= - 1 - } - } - } - case .messageReactionRemoveAll(let reaction): - if let idx = self.messages[reaction.channel_id]? - .firstIndex(where: { $0.id == reaction.message_id }) - { - self.messages[reaction.channel_id]?[idx].reactions = [] - } - case .messageReactionRemoveEmoji(let reaction): - if let idx = self.messages[reaction.channel_id]? - .firstIndex(where: { $0.id == reaction.message_id }) - { - if let index = self.messages[reaction.channel_id]?[idx].reactions? - .firstIndex(where: { $0.emoji == reaction.emoji }) - { - self.messages[reaction.channel_id]?[idx].reactions?.remove(at: index) - } - } - case .presenceUpdate(let presence): - print("Willllll update presence: \(presence)") - guard let guild_id = presence.guild_id else { break } - if let idx = self.guilds[guild_id]?.presences - .firstIndex(where: { $0.user?.id == presence.user.id }) - { - self.guilds[guild_id]?.presences[idx].update(with: presence) - } else { - self.guilds[guild_id]?.presences.append( - .init(presenceUpdate: presence) - ) - } - case .stageInstanceCreate(let stage), .stageInstanceUpdate(let stage): - if let idx = self.guilds[stage.guild_id]?.stage_instances - .firstIndex(where: { $0.id == stage.id }) - { - self.guilds[stage.guild_id]?.stage_instances[idx] = stage - } else { - self.guilds[stage.guild_id]?.stage_instances.append(stage) - } - case .stageInstanceDelete(let stage): - if let idx = self.guilds[stage.guild_id]?.stage_instances - .firstIndex(where: { $0.id == stage.id }) - { - self.guilds[stage.guild_id]?.stage_instances.remove(at: idx) - } - case .typingStart: break - /// Nothing to do? - case .userUpdate(let user): - self.botUser = user - case .voiceStateUpdate(let state): - if let guildId = state.guild_id { - if let idx = self.guilds[guildId]?.voice_states - .firstIndex(where: { $0.session_id == state.session_id }) - { - self.guilds[guildId]?.voice_states[idx] = .init( - voiceState: state - ) - } else { - self.guilds[guildId]?.voice_states.append( - .init(voiceState: state) - ) - } - } - case .voiceServerUpdate: break - /// Nothing to do? - case .webhooksUpdate: break - /// Nothing to do? - case .autoModerationRuleCreate(let autoMod), - .autoModerationRuleUpdate(let autoMod): - if let idx = self.autoModerationRules[autoMod.guild_id]? - .firstIndex(where: { $0.id == autoMod.id }) - { - self.autoModerationRules[autoMod.guild_id]![idx] = autoMod - } else { - self.autoModerationRules[autoMod.guild_id, default: []].append(autoMod) - } - case .autoModerationRuleDelete(let autoMod): - if let idx = self.autoModerationRules[autoMod.guild_id]? - .firstIndex(where: { $0.id == autoMod.id }) - { - self.autoModerationRules[autoMod.guild_id]?.remove(at: idx) - } - case .autoModerationActionExecution(let execution): - self.autoModerationExecutions[execution.guild_id, default: []].append( - execution - ) - case .applicationCommandPermissionsUpdate(let update): - self.applicationCommandPermissions[update.id] = update - case .messagePollVoteAdd(let vote), - .messagePollVoteRemove(let vote): - self.messagePollVotes[vote.channel_id, default: [:]][ - vote.message_id, - default: [] - ].append(vote) - case .__undocumented: - break - default: break // we can't handle user events easily here. - } - } - - private func intentsAllowCaching(event: Gateway.Event) -> Bool { - guard let data = event.data else { return false } - let correspondingIntents = data.correspondingIntents - if correspondingIntents.isEmpty { - return true - } else if correspondingIntents.contains(where: { intents.contains($0) }) { - return true - } else { - return false - } - } - - private func checkItemsLimit() { - if case .disabled = itemsLimit { return } - itemsLimitCounter &+= 1 - if itemsLimitCounter % checkForLimitEvery == 0 { - switch itemsLimit { - case .disabled: return - case .constant(let constant): - guard constant > 0 else { return } - - if self.auditLogs.count > constant { - let extra = self.auditLogs.count - constant - self.auditLogs.removeSubrange(0.. constant { - let extra = self.integrations.count - constant - self.integrations.removeSubrange(0.. constant { - let extra = self.invites.count - constant - self.invites.removeSubrange(0.. constant { - let extra = self.messages.count - constant - self.messages.removeSubrange(0.. constant { - let extra = self.editedMessages.count - constant - self.editedMessages.removeSubrange(0.. constant { - let extra = self.deletedMessages.count - constant - self.deletedMessages.removeSubrange(0.. constant { - let extra = self.autoModerationRules.count - constant - self.autoModerationRules.removeSubrange(0.. constant { - let extra = self.autoModerationExecutions.count - constant - self.autoModerationExecutions.removeSubrange(0.. constant { - let extra = self.applicationCommandPermissions.count - constant - self.applicationCommandPermissions.removeSubrange(0.. constant { - let extra = self.messagePollVotes.count - constant - self.messagePollVotes.removeSubrange(0.. limit - { - let extra = self.auditLogs.count - limit - self.auditLogs.removeSubrange(0.. limit - { - let extra = self.integrations.count - limit - self.integrations.removeSubrange(0.. limit - { - let extra = self.invites.count - limit - self.invites.removeSubrange(0.. limit - { - let extra = self.messages.count - limit - self.messages.removeSubrange(0.. limit - { - let extra = self.editedMessages.count - limit - self.editedMessages.removeSubrange(0.. limit - { - let extra = self.deletedMessages.count - limit - self.deletedMessages.removeSubrange(0.. limit - { - let extra = self.autoModerationRules.count - limit - self.autoModerationRules.removeSubrange(0.. limit - { - let extra = self.autoModerationExecutions.count - limit - self.autoModerationExecutions.removeSubrange(0.. limit - { - let extra = self.applicationCommandPermissions.count - limit - self.applicationCommandPermissions.removeSubrange(0.. limit - { - let extra = self.messagePollVotes.count - limit - self.messagePollVotes.removeSubrange(0.. Set { - var intentsSum = Set() - - let managerIntents = manager.identifyPayload.intents!.representableValues() - - switch intents { - case .all: - intentsSum.formUnion(managerIntents) - case .some(let intents): - intentsSum.formUnion(intents.intersection(managerIntents)) - } - - return intentsSum - } - - #if DEBUG - func _tests_modifyStorage(_ block: @Sendable (inout Storage) -> Void) { - block(&self.storage) - } - #endif -} - -private func == (lhs: Emoji, rhs: Emoji) -> Bool { - lhs.id == rhs.id && lhs.name == rhs.name -} - -//MARK: - WritableKeyPath + Sendable -#if compiler(<6.0) - extension WritableKeyPath: @unchecked Sendable - where Root: Sendable, Value: Sendable {} -#endif diff --git a/PaicordLib/Sources/DiscordGateway/GatewayEventHandler.swift b/PaicordLib/Sources/DiscordGateway/GatewayEventHandler.swift deleted file mode 100644 index d0067fb1..00000000 --- a/PaicordLib/Sources/DiscordGateway/GatewayEventHandler.swift +++ /dev/null @@ -1,1039 +0,0 @@ -import DiscordModels -import Logging - -/// Convenience protocol for handling gateway payloads. -/// -/// Create a type that conforms to `GatewayEventHandler`: -/// ``` -/// struct EventHandler: GatewayEventHandler { -/// let event: Gateway.Event -/// -/// func onMessageCreate(_ payload: Gateway.MessageCreate) async { -/// /// Do what you want -/// } -/// -/// func onInteractionCreate(_ payload: Interaction) async { -/// /// Do what you want -/// } -/// -/// /// Use other functions you'd like ... -/// } -/// ``` -/// -/// Make sure to actually use the type: -/// ``` -/// let bot: any GatewayManager = <#GatewayManager_YOU_MADE_IN_PREVIOUS_STEPS#> -/// -/// for await event in await bot.makeEventsStream() { -/// EventHandler(event: event).handle() -/// } -/// ``` -//public protocol GatewayEventHandler: Sendable { -// var event: Gateway.Event { get } -// var logger: Logger { get } -// -// /// To be executed before handling events. -// /// If returns `false`, the event won't be passed to the functions below anymore. -// func onEventHandlerStart() async throws -> Bool -// func onEventHandlerEnd() async throws -// -// /// MARK: State-management data -// func onHeartbeat(lastSequenceNumber: Int?) async throws -// func onHello(_ payload: Gateway.Hello) async throws -// func onReady(_ payload: Gateway.Ready) async throws -// func onResumed() async throws -// func onInvalidSession(canResume: Bool) async throws -// -// /// MARK: Events -// func onChannelCreate(_ payload: DiscordChannel) async throws -// func onChannelUpdate(_ payload: DiscordChannel) async throws -// func onChannelDelete(_ payload: DiscordChannel) async throws -// func onChannelPinsUpdate(_ payload: Gateway.ChannelPinsUpdate) async throws -// func onThreadCreate(_ payload: DiscordChannel) async throws -// func onThreadUpdate(_ payload: DiscordChannel) async throws -// func onThreadDelete(_ payload: Gateway.ThreadDelete) async throws -// func onThreadSyncList(_ payload: Gateway.ThreadListSync) async throws -// func onThreadMemberUpdate(_ payload: Gateway.ThreadMemberUpdate) async throws -// func onThreadMembersUpdate(_ payload: Gateway.ThreadMembersUpdate) -// async throws -// func onEntitlementCreate(_ payload: Entitlement) async throws -// func onEntitlementUpdate(_ payload: Entitlement) async throws -// func onEntitlementDelete(_ payload: Entitlement) async throws -// func onGuildCreate(_ payload: Gateway.GuildCreate) async throws -// func onGuildUpdate(_ payload: Guild) async throws -// func onGuildDelete(_ payload: UnavailableGuild) async throws -// func onGuildBanAdd(_ payload: Gateway.GuildBan) async throws -// func onGuildBanRemove(_ payload: Gateway.GuildBan) async throws -// func onGuildEmojisUpdate(_ payload: Gateway.GuildEmojisUpdate) async throws -// func onGuildStickersUpdate(_ payload: Gateway.GuildStickersUpdate) -// async throws -// func onGuildIntegrationsUpdate(_ payload: Gateway.GuildIntegrationsUpdate) -// async throws -// func onGuildMemberAdd(_ payload: Gateway.GuildMemberAdd) async throws -// func onGuildMemberRemove(_ payload: Gateway.GuildMemberRemove) async throws -// func onGuildMemberUpdate(_ payload: Gateway.GuildMemberAdd) async throws -// func onGuildMembersChunk(_ payload: Gateway.GuildMembersChunk) async throws -// func onRequestGuildMembers(_ payload: Gateway.RequestGuildMembers) -// async throws -// func onGuildRoleCreate(_ payload: Gateway.GuildRole) async throws -// func onGuildRoleUpdate(_ payload: Gateway.GuildRole) async throws -// func onGuildRoleDelete(_ payload: Gateway.GuildRoleDelete) async throws -// func onGuildScheduledEventCreate(_ payload: GuildScheduledEvent) async throws -// func onGuildScheduledEventUpdate(_ payload: GuildScheduledEvent) async throws -// func onGuildScheduledEventDelete(_ payload: GuildScheduledEvent) async throws -// func onGuildScheduledEventUserAdd(_ payload: Gateway.GuildScheduledEventUser) -// async throws -// func onGuildScheduledEventUserRemove( -// _ payload: Gateway.GuildScheduledEventUser -// ) async throws -// func onGuildAuditLogEntryCreate(_ payload: AuditLog.Entry) async throws -// func onIntegrationCreate(_ payload: Gateway.IntegrationCreate) async throws -// func onIntegrationUpdate(_ payload: Gateway.IntegrationCreate) async throws -// func onIntegrationDelete(_ payload: Gateway.IntegrationDelete) async throws -// func onInteractionCreate(_ payload: Gateway.InteractionCreate) async throws -// func onInviteCreate(_ payload: Gateway.InviteCreate) async throws -// func onInviteDelete(_ payload: Gateway.InviteDelete) async throws -// func onMessageCreate(_ payload: Gateway.MessageCreate) async throws -// func onMessageUpdate(_ payload: DiscordChannel.PartialMessage) async throws -// func onMessageDelete(_ payload: Gateway.MessageDelete) async throws -// func onMessageAcknowledge(_ payload: Gateway.MessageAcknowledge) async throws -// func onChannelPinsAcknowledge(_ payload: Gateway.ChannelPinsAcknowledge) -// async throws -// func onUserNonChannelAcknowledge(_ payload: Gateway.UserNonChannelAcknowledge) -// async throws -// func onMessageDeleteBulk(_ payload: Gateway.MessageDeleteBulk) async throws -// func onMessageReactionAdd(_ payload: Gateway.MessageReactionAdd) async throws -// func onMessageReactionRemove(_ payload: Gateway.MessageReactionRemove) -// async throws -// func onMessageReactionRemoveAll(_ payload: Gateway.MessageReactionRemoveAll) -// async throws -// func onMessageReactionRemoveEmoji( -// _ payload: Gateway.MessageReactionRemoveEmoji -// ) async throws -// func onPresenceUpdate(_ payload: Gateway.PresenceUpdate) async throws -// func onRequestPresenceUpdate(_ payload: Gateway.Identify.Presence) -// async throws -// func onStageInstanceCreate(_ payload: StageInstance) async throws -// func onStageInstanceDelete(_ payload: StageInstance) async throws -// func onStageInstanceUpdate(_ payload: StageInstance) async throws -// func onTypingStart(_ payload: Gateway.TypingStart) async throws -// func onUserUpdate(_ payload: DiscordUser) async throws -// func onVoiceStateUpdate(_ payload: VoiceState) async throws -// func onRequestVoiceStateUpdate(_ payload: VoiceStateUpdate) async throws -// func onVoiceServerUpdate(_ payload: Gateway.VoiceServerUpdate) async throws -// func onWebhooksUpdate(_ payload: Gateway.WebhooksUpdate) async throws -// func onApplicationCommandPermissionsUpdate( -// _ payload: GuildApplicationCommandPermissions -// ) async throws -// func onAutoModerationRuleCreate(_ payload: AutoModerationRule) async throws -// func onAutoModerationRuleUpdate(_ payload: AutoModerationRule) async throws -// func onAutoModerationRuleDelete(_ payload: AutoModerationRule) async throws -// func onAutoModerationActionExecution(_ payload: AutoModerationActionExecution) -// async throws -// func onMessagePollVoteAdd(_ payload: Gateway.MessagePollVote) async throws -// func onMessagePollVoteRemove(_ payload: Gateway.MessagePollVote) async throws -// func onReadySupplemental(_ payload: Gateway.ReadySupplemental) async throws -// func onAuthSessionChange(_ payload: Gateway.AuthSessionChange) async throws -// func onVoiceChannelStatuses(_ payload: Gateway.VoiceChannelStatuses) -// async throws -// func onConversationSummaryUpdate(_ payload: Gateway.ConversationSummaryUpdate) -// async throws -// func onChannelRecipientAdd(_ payload: Gateway.ChannelRecipientAdd) -// async throws -// func onChannelRecipientRemove(_ payload: Gateway.ChannelRecipientRemove) -// async throws -// func onConsoleCommandUpdate(_ payload: Gateway.ConsoleCommandUpdate) -// async throws -// func onDMSettingsShow(_ payload: Gateway.DMSettingsShow) async throws -// func onFriendSuggestionCreate(_ payload: Gateway.FriendSuggestionCreate) -// async throws -// func onFriendSuggestionDelete(_ payload: Gateway.FriendSuggestionDelete) -// async throws -// func onGuildApplicationCommandIndexUpdate( -// _ payload: Gateway.GuildApplicationCommandIndexUpdate -// ) async throws -// func onGuildAppliedBoostsUpdate(_ payload: Guild.PremiumGuildSubscription) -// async throws -// func onGuildScheduledEventExceptionCreate( -// _ payload: GuildScheduledEventException -// ) async throws -// func onGuildScheduledEventExceptionUpdate( -// _ payload: GuildScheduledEventException -// ) async throws -// func onGuildScheduledEventExceptionDelete( -// _ payload: GuildScheduledEventException -// ) async throws -// func onGuildScheduledEventExceptionsDelete( -// _ payload: Gateway.GuildScheduledEventExceptionsDelete -// ) async throws -// func onInteractionFailure(_ payload: Gateway.InteractionFailure) async throws -// func onInteractionSuccess(_ payload: Gateway.InteractionSuccess) async throws -// func onApplicationCommandAutocompleteResponse( -// _ payload: Gateway.ApplicationCommandAutocomplete -// ) async throws -// func onInteractionModalCreate(_ payload: Gateway.InteractionModalCreate) -// async throws -// func onInteractionIFrameModalCreate( -// _ payload: Gateway.InteractionIFrameModalCreate -// ) async throws -// func onMessageReactionAddMany(_ payload: Gateway.MessageReactionAddMany) -// async throws -// func onRecentMentionDelete(_ payload: Gateway.RecentMentionDelete) -// async throws -// func onRequestLastMessages(_ payload: Gateway.RequestLastMessages) -// async throws -// func onLastMessages(_ payload: Gateway.LastMessages) async throws -// func onNotificationSettingsUpdate(_ payload: Gateway.NotificationSettings) -// async throws -// func onRelationshipAdd(_ payload: DiscordRelationship) async throws -// func onRelationshipUpdate(_ payload: Gateway.PartialRelationship) async throws -// func onRelationshipRemove(_ payload: Gateway.PartialRelationship) async throws -// func onSavedMessageCreate(_ payload: Gateway.SavedMessageCreate) async throws -// func onSavedMessageDelete(_ payload: Gateway.SavedMessageDelete) async throws -// func onChannelMemberCountUpdate(_ payload: Gateway.ChannelMemberCountUpdate) -// async throws -// func onRequestChannelMemberCount(_ payload: Gateway.RequestChannelMemberCount) -// async throws -// func onAutoModerationMentionRaidDetection( -// _ payload: AutoModerationMentionRaidDetection -// ) async throws -// func onCallCreate(_ payload: Gateway.CallCreate) async throws -// func onCallUpdate(_ payload: Gateway.CallUpdate) async throws -// func onCallDelete(_ payload: Gateway.CallDelete) async throws -// func onVoiceChannelStatusUpdate(_ payload: Gateway.VoiceChannelStatusUpdate) -// async throws -// func onSessionsReplace(_ payload: Gateway.SessionsReplace) async throws -// func onUserApplicationUpdate(_ payload: Gateway.UserApplicationUpdate) -// async throws -// func onUserApplicationRemove(_ payload: Gateway.UserApplicationRemove) -// async throws -// func onUserConnectionsUpdate(_ payload: Gateway.UserConnectionsUpdate) -// async throws -// func onUserGuildSettingsUpdate(_ payload: Guild.UserGuildSettings) -// async throws -// func onUserNoteUpdate(_ payload: Gateway.UserNote) async throws -// func onUserSettingsUpdate(_ payload: Gateway.UserSettingsProtoUpdate) -// async throws -// func onGuildSoundboardSoundCreate(_ payload: SoundboardSound) async throws -// func onGuildSoundboardSoundUpdate(_ payload: SoundboardSound) async throws -// func onGuildSoundboardSoundDelete(_ payload: Gateway.SoundboardSoundDelete) -// async throws -// func onSoundboardSounds(_ payload: Gateway.SoundboardSounds) async throws -// func onChannelUnreadUpdate(_ payload: Gateway.ChannelUnreadUpdate) -// async throws -// func onGuildMemberListUpdate(_ payload: Gateway.GuildMemberListUpdate) -// async throws -//} -// -//extension GatewayEventHandler { -// -// public var logger: Logger { -// Logger(label: "GatewayEventHandler") -// } -// -// @inlinable -// public func handle() { -// Task { -// await self.handleAsync() -// } -// } -// -// // MARK: - Default Do-Nothings -// -// @inlinable -// public func onEventHandlerStart() async throws -> Bool { true } -// public func onEventHandlerEnd() async throws {} -// -// public func onHeartbeat(lastSequenceNumber _: Int?) async throws {} -// public func onHello(_: Gateway.Hello) async throws {} -// public func onReady(_: Gateway.Ready) async throws {} -// public func onResumed() async throws {} -// public func onInvalidSession(canResume _: Bool) async throws {} -// public func onChannelCreate(_: DiscordChannel) async throws {} -// public func onChannelUpdate(_: DiscordChannel) async throws {} -// public func onChannelDelete(_: DiscordChannel) async throws {} -// public func onChannelPinsUpdate(_: Gateway.ChannelPinsUpdate) async throws {} -// public func onThreadCreate(_: DiscordChannel) async throws {} -// public func onThreadUpdate(_: DiscordChannel) async throws {} -// public func onThreadDelete(_: Gateway.ThreadDelete) async throws {} -// public func onThreadSyncList(_: Gateway.ThreadListSync) async throws {} -// public func onThreadMemberUpdate(_: Gateway.ThreadMemberUpdate) async throws { -// } -// public func onEntitlementCreate(_: Entitlement) async throws {} -// public func onEntitlementUpdate(_: Entitlement) async throws {} -// public func onEntitlementDelete(_: Entitlement) async throws {} -// public func onThreadMembersUpdate(_: Gateway.ThreadMembersUpdate) async throws -// {} -// public func onGuildCreate(_: Gateway.GuildCreate) async throws {} -// public func onGuildUpdate(_: Guild) async throws {} -// public func onGuildDelete(_: UnavailableGuild) async throws {} -// public func onGuildBanAdd(_: Gateway.GuildBan) async throws {} -// public func onGuildBanRemove(_: Gateway.GuildBan) async throws {} -// public func onGuildEmojisUpdate(_: Gateway.GuildEmojisUpdate) async throws {} -// public func onGuildStickersUpdate(_: Gateway.GuildStickersUpdate) async throws -// {} -// public func onGuildIntegrationsUpdate(_: Gateway.GuildIntegrationsUpdate) -// async throws -// {} -// public func onGuildMemberAdd(_: Gateway.GuildMemberAdd) async throws {} -// public func onGuildMemberRemove(_: Gateway.GuildMemberRemove) async throws {} -// public func onGuildMemberUpdate(_: Gateway.GuildMemberAdd) async throws {} -// public func onGuildMembersChunk(_: Gateway.GuildMembersChunk) async throws {} -// public func onRequestGuildMembers(_: Gateway.RequestGuildMembers) async throws -// {} -// public func onGuildRoleCreate(_: Gateway.GuildRole) async throws {} -// public func onGuildRoleUpdate(_: Gateway.GuildRole) async throws {} -// public func onGuildRoleDelete(_: Gateway.GuildRoleDelete) async throws {} -// public func onGuildScheduledEventCreate(_: GuildScheduledEvent) async throws { -// } -// public func onGuildScheduledEventUpdate(_: GuildScheduledEvent) async throws { -// } -// public func onGuildScheduledEventDelete(_: GuildScheduledEvent) async throws { -// } -// public func onGuildScheduledEventUserAdd(_: Gateway.GuildScheduledEventUser) -// async throws -// {} -// public func onGuildScheduledEventUserRemove( -// _: Gateway.GuildScheduledEventUser -// ) async throws {} -// public func onGuildAuditLogEntryCreate(_: AuditLog.Entry) async throws {} -// public func onIntegrationCreate(_: Gateway.IntegrationCreate) async throws {} -// public func onIntegrationUpdate(_: Gateway.IntegrationCreate) async throws {} -// public func onIntegrationDelete(_: Gateway.IntegrationDelete) async throws {} -// public func onInteractionCreate(_: Interaction) async throws {} -// public func onInviteCreate(_: Gateway.InviteCreate) async throws {} -// public func onInviteDelete(_: Gateway.InviteDelete) async throws {} -// public func onMessageCreate(_: Gateway.MessageCreate) async throws {} -// public func onMessageUpdate(_: DiscordChannel.PartialMessage) async throws {} -// public func onMessageDelete(_: Gateway.MessageDelete) async throws {} -// public func onMessageAcknowledge(_ payload: Gateway.MessageAcknowledge) -// async throws -// {} -// public func onChannelPinsAcknowledge( -// _ payload: Gateway.ChannelPinsAcknowledge -// ) async throws {} -// public func onUserNonChannelAcknowledge( -// _ payload: Gateway.UserNonChannelAcknowledge -// ) async throws {} -// public func onMessageDeleteBulk(_: Gateway.MessageDeleteBulk) async throws {} -// public func onMessageReactionAdd(_: Gateway.MessageReactionAdd) async throws { -// } -// public func onMessageReactionRemove(_: Gateway.MessageReactionRemove) -// async throws -// {} -// public func onMessageReactionRemoveAll(_: Gateway.MessageReactionRemoveAll) -// async throws -// {} -// public func onMessageReactionRemoveEmoji( -// _: Gateway.MessageReactionRemoveEmoji -// ) async throws {} -// public func onPresenceUpdate(_: Gateway.PresenceUpdate) async throws {} -// public func onRequestPresenceUpdate(_: Gateway.Identify.Presence) async throws -// {} -// public func onStageInstanceCreate(_: StageInstance) async throws {} -// public func onStageInstanceDelete(_: StageInstance) async throws {} -// public func onStageInstanceUpdate(_: StageInstance) async throws {} -// public func onTypingStart(_: Gateway.TypingStart) async throws {} -// public func onUserUpdate(_: DiscordUser) async throws {} -// public func onVoiceStateUpdate(_: VoiceState) async throws {} -// public func onRequestVoiceStateUpdate(_: VoiceStateUpdate) async throws {} -// public func onVoiceServerUpdate(_: Gateway.VoiceServerUpdate) async throws {} -// public func onWebhooksUpdate(_: Gateway.WebhooksUpdate) async throws {} -// public func onApplicationCommandPermissionsUpdate( -// _: GuildApplicationCommandPermissions -// ) async throws {} -// public func onAutoModerationRuleCreate(_: AutoModerationRule) async throws {} -// public func onAutoModerationRuleUpdate(_: AutoModerationRule) async throws {} -// public func onAutoModerationRuleDelete(_: AutoModerationRule) async throws {} -// public func onAutoModerationActionExecution(_: AutoModerationActionExecution) -// async throws -// {} -// public func onMessagePollVoteAdd(_: Gateway.MessagePollVote) async throws {} -// public func onMessagePollVoteRemove(_: Gateway.MessagePollVote) async throws { -// } -// public func onReadySupplemental(_ payload: Gateway.ReadySupplemental) -// async throws -// {} -// public func onAuthSessionChange(_ payload: Gateway.AuthSessionChange) -// async throws -// {} -// public func onVoiceChannelStatuses(_ payload: Gateway.VoiceChannelStatuses) -// async throws -// {} -// public func onConversationSummaryUpdate( -// _ payload: Gateway.ConversationSummaryUpdate -// ) async throws {} -// public func onChannelRecipientAdd(_ payload: Gateway.ChannelRecipientAdd) -// async throws -// {} -// public func onChannelRecipientRemove( -// _ payload: Gateway.ChannelRecipientRemove -// ) async throws {} -// public func onConsoleCommandUpdate(_ payload: Gateway.ConsoleCommandUpdate) -// async throws -// {} -// public func onDMSettingsShow(_ payload: Gateway.DMSettingsShow) async throws { -// } -// public func onFriendSuggestionCreate( -// _ payload: Gateway.FriendSuggestionCreate -// ) async throws {} -// public func onFriendSuggestionDelete( -// _ payload: Gateway.FriendSuggestionDelete -// ) async throws {} -// public func onGuildApplicationCommandIndexUpdate( -// _ payload: Gateway.GuildApplicationCommandIndexUpdate -// ) async throws {} -// public func onGuildAppliedBoostsUpdate( -// _ payload: Guild.PremiumGuildSubscription -// ) async throws {} -// public func onGuildScheduledEventExceptionCreate( -// _ payload: GuildScheduledEventException -// ) async throws {} -// public func onGuildScheduledEventExceptionUpdate( -// _ payload: GuildScheduledEventException -// ) async throws {} -// public func onGuildScheduledEventExceptionDelete( -// _ payload: GuildScheduledEventException -// ) async throws {} -// public func onGuildScheduledEventExceptionsDelete( -// _ payload: Gateway.GuildScheduledEventExceptionsDelete -// ) async throws {} -// public func onInteractionFailure(_ payload: Gateway.InteractionFailure) -// async throws -// {} -// public func onInteractionSuccess(_ payload: Gateway.InteractionSuccess) -// async throws -// {} -// public func onApplicationCommandAutocompleteResponse( -// _ payload: Gateway.ApplicationCommandAutocomplete -// ) async throws {} -// public func onInteractionModalCreate( -// _ payload: Gateway.InteractionModalCreate -// ) async throws {} -// public func onInteractionIFrameModalCreate( -// _ payload: Gateway.InteractionIFrameModalCreate -// ) async throws {} -// public func onMessageReactionAddMany( -// _ payload: Gateway.MessageReactionAddMany -// ) async throws {} -// public func onRecentMentionDelete(_ payload: Gateway.RecentMentionDelete) -// async throws -// {} -// public func onRequestLastMessages(_ payload: Gateway.RequestLastMessages) -// async throws -// {} -// public func onLastMessages(_ payload: Gateway.LastMessages) async throws {} -// public func onNotificationSettingsUpdate( -// _ payload: Gateway.NotificationSettings -// ) async throws {} -// public func onRelationshipAdd(_ payload: DiscordRelationship) async throws {} -// public func onRelationshipUpdate(_ payload: Gateway.PartialRelationship) -// async throws -// {} -// public func onRelationshipRemove(_ payload: Gateway.PartialRelationship) -// async throws -// {} -// public func onSavedMessageCreate(_ payload: Gateway.SavedMessageCreate) -// async throws -// {} -// public func onSavedMessageDelete(_ payload: Gateway.SavedMessageDelete) -// async throws -// {} -// public func onChannelMemberCountUpdate( -// _ payload: Gateway.ChannelMemberCountUpdate -// ) async throws {} -// public func onRequestChannelMemberCount( -// _ payload: Gateway.RequestChannelMemberCount -// ) async throws {} -// public func onAutoModerationMentionRaidDetection( -// _ payload: AutoModerationMentionRaidDetection -// ) async throws {} -// public func onCallCreate(_ payload: Gateway.CallCreate) async throws {} -// public func onCallUpdate(_ payload: Gateway.CallUpdate) async throws {} -// public func onCallDelete(_ payload: Gateway.CallDelete) async throws {} -// public func onVoiceChannelStatusUpdate( -// _ payload: Gateway.VoiceChannelStatusUpdate -// ) async throws {} -// public func onSessionsReplace(_ payload: Gateway.SessionsReplace) async throws -// { -// } -// public func onUserApplicationUpdate(_ payload: Gateway.UserApplicationUpdate) -// async throws -// {} -// public func onUserApplicationRemove(_ payload: Gateway.UserApplicationRemove) -// async throws -// {} -// public func onUserConnectionsUpdate(_ payload: Gateway.UserConnectionsUpdate) -// async throws -// {} -// public func onUserGuildSettingsUpdate(_ payload: Guild.UserGuildSettings) -// async throws -// {} -// public func onUserNoteUpdate(_ payload: Gateway.UserNote) async throws {} -// public func onUserSettingsUpdate(_ payload: Gateway.UserSettingsProtoUpdate) -// async throws -// {} -// public func onGuildSoundboardSoundCreate(_ payload: SoundboardSound) -// async throws -// {} -// public func onGuildSoundboardSoundUpdate(_ payload: SoundboardSound) -// async throws -// {} -// public func onGuildSoundboardSoundDelete( -// _ payload: Gateway.SoundboardSoundDelete -// ) async throws {} -// public func onSoundboardSounds(_ payload: Gateway.SoundboardSounds) -// async throws -// {} -// public func onGuildMemberListUpdate(_ payload: Gateway.GuildMemberListUpdate) -// async throws -// {} -//} -// -//// MARK: - Handle -//extension GatewayEventHandler { -// @inlinable -// public func handleAsync() async { -// do { -// guard try await self.onEventHandlerStart() else { return } -// } catch { -// logError(function: "onEventHandlerStart", error: error) -// return -// } -// -// switch event.data { -// case .none, .resume, .identify, .updateGuildSubscriptions, .qosHeartbeat, -// .heartbeat, -// .updateTimeSpentSessionId: -// /// Only sent, never received. -// break -// case .hello(let hello): -// await withLogging(for: "onHello") { -// try await onHello(hello) -// } -// case .ready(let ready): -// await withLogging(for: "onReady") { -// try await onReady(ready) -// } -// case .resumed: -// await withLogging(for: "onResumed") { -// try await onResumed() -// } -// case .invalidSession(let canResume): -// await withLogging(for: "onInvalidSession") { -// try await onInvalidSession(canResume: canResume) -// } -// case .channelCreate(let payload): -// await withLogging(for: "onChannelCreate") { -// try await onChannelCreate(payload) -// } -// case .channelUpdate(let payload): -// await withLogging(for: "onChannelUpdate") { -// try await onChannelUpdate(payload) -// } -// case .channelDelete(let payload): -// await withLogging(for: "onChannelDelete") { -// try await onChannelDelete(payload) -// } -// case .channelPinsUpdate(let payload): -// await withLogging(for: "onChannelPinsUpdate") { -// try await onChannelPinsUpdate(payload) -// } -// case .threadCreate(let payload): -// await withLogging(for: "onThreadCreate") { -// try await onThreadCreate(payload) -// } -// case .threadUpdate(let payload): -// await withLogging(for: "onThreadUpdate") { -// try await onThreadUpdate(payload) -// } -// case .threadDelete(let payload): -// await withLogging(for: "onThreadDelete") { -// try await onThreadDelete(payload) -// } -// case .threadSyncList(let payload): -// await withLogging(for: "onThreadSyncList") { -// try await onThreadSyncList(payload) -// } -// case .threadMemberUpdate(let payload): -// await withLogging(for: "onThreadMemberUpdate") { -// try await onThreadMemberUpdate(payload) -// } -// case .entitlementCreate(let payload): -// await withLogging(for: "onEntitlementCreate") { -// try await onEntitlementCreate(payload) -// } -// case .entitlementUpdate(let payload): -// await withLogging(for: "onEntitlementUpdate") { -// try await onEntitlementUpdate(payload) -// } -// case .entitlementDelete(let payload): -// await withLogging(for: "onEntitlementDelete") { -// try await onEntitlementDelete(payload) -// } -// case .threadMembersUpdate(let payload): -// await withLogging(for: "onThreadMembersUpdate") { -// try await onThreadMembersUpdate(payload) -// } -// case .guildCreate(let payload): -// await withLogging(for: "onGuildCreate") { -// try await onGuildCreate(payload) -// } -// case .guildUpdate(let payload): -// await withLogging(for: "onGuildUpdate") { -// try await onGuildUpdate(payload) -// } -// case .guildDelete(let payload): -// await withLogging(for: "onGuildDelete") { -// try await onGuildDelete(payload) -// } -// case .guildBanAdd(let payload): -// await withLogging(for: "onGuildBanAdd") { -// try await onGuildBanAdd(payload) -// } -// case .guildBanRemove(let payload): -// await withLogging(for: "onGuildBanRemove") { -// try await onGuildBanRemove(payload) -// } -// case .guildEmojisUpdate(let payload): -// await withLogging(for: "onGuildEmojisUpdate") { -// try await onGuildEmojisUpdate(payload) -// } -// case .guildStickersUpdate(let payload): -// await withLogging(for: "onGuildStickersUpdate") { -// try await onGuildStickersUpdate(payload) -// } -// case .guildIntegrationsUpdate(let payload): -// await withLogging(for: "onGuildIntegrationsUpdate") { -// try await onGuildIntegrationsUpdate(payload) -// } -// case .guildMemberAdd(let payload): -// await withLogging(for: "onGuildMemberAdd") { -// try await onGuildMemberAdd(payload) -// } -// case .guildMemberRemove(let payload): -// await withLogging(for: "onGuildMemberRemove") { -// try await onGuildMemberRemove(payload) -// } -// case .guildMemberUpdate(let payload): -// await withLogging(for: "onGuildMemberUpdate") { -// try await onGuildMemberUpdate(payload) -// } -// case .guildMembersChunk(let payload): -// await withLogging(for: "onGuildMembersChunk") { -// try await onGuildMembersChunk(payload) -// } -// case .requestGuildMembers(let payload): -// await withLogging(for: "onRequestGuildMembers") { -// try await onRequestGuildMembers(payload) -// } -// case .guildRoleCreate(let payload): -// await withLogging(for: "onGuildRoleCreate") { -// try await onGuildRoleCreate(payload) -// } -// case .guildRoleUpdate(let payload): -// await withLogging(for: "onGuildRoleUpdate") { -// try await onGuildRoleUpdate(payload) -// } -// case .guildRoleDelete(let payload): -// await withLogging(for: "onGuildRoleDelete") { -// try await onGuildRoleDelete(payload) -// } -// case .guildScheduledEventCreate(let payload): -// await withLogging(for: "onGuildScheduledEventCreate") { -// try await onGuildScheduledEventCreate(payload) -// } -// case .guildScheduledEventUpdate(let payload): -// await withLogging(for: "onGuildScheduledEventUpdate") { -// try await onGuildScheduledEventUpdate(payload) -// } -// case .guildScheduledEventDelete(let payload): -// await withLogging(for: "onGuildScheduledEventDelete") { -// try await onGuildScheduledEventDelete(payload) -// } -// case .guildScheduledEventUserAdd(let payload): -// await withLogging(for: "onGuildScheduledEventUserAdd") { -// try await onGuildScheduledEventUserAdd(payload) -// } -// case .guildScheduledEventUserRemove(let payload): -// await withLogging(for: "onGuildScheduledEventUserRemove") { -// try await onGuildScheduledEventUserRemove(payload) -// } -// case .guildAuditLogEntryCreate(let payload): -// await withLogging(for: "onGuildAuditLogEntryCreate") { -// try await onGuildAuditLogEntryCreate(payload) -// } -// case .integrationCreate(let payload): -// await withLogging(for: "onIntegrationCreate") { -// try await onIntegrationCreate(payload) -// } -// case .integrationUpdate(let payload): -// await withLogging(for: "onIntegrationUpdate") { -// try await onIntegrationUpdate(payload) -// } -// case .integrationDelete(let payload): -// await withLogging(for: "onIntegrationDelete") { -// try await onIntegrationDelete(payload) -// } -// case .interactionCreate(let payload): -// await withLogging(for: "onInteractionCreate") { -// try await onInteractionCreate(payload) -// } -// case .inviteCreate(let payload): -// await withLogging(for: "onInviteCreate") { -// try await onInviteCreate(payload) -// } -// case .inviteDelete(let payload): -// await withLogging(for: "onInviteDelete") { -// try await onInviteDelete(payload) -// } -// case .messageCreate(let payload): -// await withLogging(for: "onMessageCreate") { -// try await onMessageCreate(payload) -// } -// case .messageUpdate(let payload): -// await withLogging(for: "onMessageUpdate") { -// try await onMessageUpdate(payload) -// } -// case .messageDelete(let payload): -// await withLogging(for: "onMessageDelete") { -// try await onMessageDelete(payload) -// } -// case .messageAcknowledge(let payload): -// await withLogging(for: "onMessageAcknowledge") { -// try await onMessageAcknowledge(payload) -// } -// case .channelPinsAcknowledge(let payload): -// await withLogging(for: "onChannelPinsAcknowledge") { -// try await onChannelPinsAcknowledge(payload) -// } -// case .userNonChannelAcknowledge(let payload): -// await withLogging(for: "onUserNonChannelAcknowledge") { -// try await onUserNonChannelAcknowledge(payload) -// } -// case .messageDeleteBulk(let payload): -// await withLogging(for: "onMessageDeleteBulk") { -// try await onMessageDeleteBulk(payload) -// } -// case .messageReactionAdd(let payload): -// await withLogging(for: "onMessageReactionAdd") { -// try await onMessageReactionAdd(payload) -// } -// case .messageReactionRemove(let payload): -// await withLogging(for: "onMessageReactionRemove") { -// try await onMessageReactionRemove(payload) -// } -// case .messageReactionRemoveAll(let payload): -// await withLogging(for: "onMessageReactionRemoveAll") { -// try await onMessageReactionRemoveAll(payload) -// } -// case .messageReactionRemoveEmoji(let payload): -// await withLogging(for: "onMessageReactionRemoveEmoji") { -// try await onMessageReactionRemoveEmoji(payload) -// } -// case .presenceUpdate(let payload): -// await withLogging(for: "onPresenceUpdate") { -// try await onPresenceUpdate(payload) -// } -// case .requestPresenceUpdate(let payload): -// await withLogging(for: "onRequestPresenceUpdate") { -// try await onRequestPresenceUpdate(payload) -// } -// case .stageInstanceCreate(let payload): -// await withLogging(for: "onStageInstanceCreate") { -// try await onStageInstanceCreate(payload) -// } -// case .stageInstanceDelete(let payload): -// await withLogging(for: "onStageInstanceDelete") { -// try await onStageInstanceDelete(payload) -// } -// case .stageInstanceUpdate(let payload): -// await withLogging(for: "onStageInstanceUpdate") { -// try await onStageInstanceUpdate(payload) -// } -// case .typingStart(let payload): -// await withLogging(for: "onTypingStart") { -// try await onTypingStart(payload) -// } -// case .userUpdate(let payload): -// await withLogging(for: "onUserUpdate") { -// try await onUserUpdate(payload) -// } -// case .voiceStateUpdate(let payload): -// await withLogging(for: "onVoiceStateUpdate") { -// try await onVoiceStateUpdate(payload) -// } -// case .requestVoiceStateUpdate(let payload): -// await withLogging(for: "onRequestVoiceStateUpdate") { -// try await onRequestVoiceStateUpdate(payload) -// } -// case .voiceServerUpdate(let payload): -// await withLogging(for: "onVoiceServerUpdate") { -// try await onVoiceServerUpdate(payload) -// } -// case .webhooksUpdate(let payload): -// await withLogging(for: "onWebhooksUpdate") { -// try await onWebhooksUpdate(payload) -// } -// case .applicationCommandPermissionsUpdate(let payload): -// await withLogging(for: "onApplicationCommandPermissionsUpdate") { -// try await onApplicationCommandPermissionsUpdate(payload) -// } -// case .autoModerationRuleCreate(let payload): -// await withLogging(for: "onAutoModerationRuleCreate") { -// try await onAutoModerationRuleCreate(payload) -// } -// case .autoModerationRuleUpdate(let payload): -// await withLogging(for: "onAutoModerationRuleUpdate") { -// try await onAutoModerationRuleUpdate(payload) -// } -// case .autoModerationRuleDelete(let payload): -// await withLogging(for: "onAutoModerationRuleDelete") { -// try await onAutoModerationRuleDelete(payload) -// } -// case .autoModerationActionExecution(let payload): -// await withLogging(for: "onAutoModerationActionExecution") { -// try await onAutoModerationActionExecution(payload) -// } -// case .messagePollVoteAdd(let payload): -// await withLogging(for: "onMessagePollVoteAdd") { -// try await onMessagePollVoteAdd(payload) -// } -// case .messagePollVoteRemove(let payload): -// await withLogging(for: "onMessagePollVoteRemove") { -// try await onMessagePollVoteRemove(payload) -// } -// case .readySupplemental(let payload): -// await withLogging(for: "onReadySupplemental") { -// try await onReadySupplemental(payload) -// } -// case .authSessionChange(let payload): -// await withLogging(for: "onAuthSessionChange") { -// try await onAuthSessionChange(payload) -// } -// case .voiceChannelStatuses(let payload): -// await withLogging(for: "onVoiceChannelStatuses") { -// try await onVoiceChannelStatuses(payload) -// } -// case .conversationSummaryUpdate(let payload): -// await withLogging(for: "onConversationSummaryUpdate") { -// try await onConversationSummaryUpdate(payload) -// } -// case .channelRecipientAdd(let payload): -// await withLogging(for: "onChannelRecipientAdd") { -// try await onChannelRecipientAdd(payload) -// } -// case .channelRecipientRemove(let payload): -// await withLogging(for: "onChannelRecipientRemove") { -// try await onChannelRecipientRemove(payload) -// } -// case .consoleCommandUpdate(let payload): -// await withLogging(for: "onConsoleCommandUpdate") { -// try await onConsoleCommandUpdate(payload) -// } -// case .dmSettingsShow(let payload): -// await withLogging(for: "onDMSettingsShow") { -// try await onDMSettingsShow(payload) -// } -// case .friendSuggestionCreate(let payload): -// await withLogging(for: "onFriendSuggestionCreate") { -// try await onFriendSuggestionCreate(payload) -// } -// case .friendSuggestionDelete(let payload): -// await withLogging(for: "onFriendSuggestionDelete") { -// try await onFriendSuggestionDelete(payload) -// } -// case .guildApplicationCommandIndexUpdate(let payload): -// await withLogging(for: "onGuildApplicationCommandIndexUpdate") { -// try await onGuildApplicationCommandIndexUpdate(payload) -// } -// case .guildAppliedBoostsUpdate(let payload): -// await withLogging(for: "onGuildAppliedBoostsUpdate") { -// try await onGuildAppliedBoostsUpdate(payload) -// } -// case .guildScheduledEventExceptionCreate(let payload): -// await withLogging(for: "onGuildScheduledEventExceptionCreate") { -// try await onGuildScheduledEventExceptionCreate(payload) -// } -// case .guildScheduledEventExceptionUpdate(let payload): -// await withLogging(for: "onGuildScheduledEventExceptionUpdate") { -// try await onGuildScheduledEventExceptionUpdate(payload) -// } -// case .guildScheduledEventExceptionDelete(let payload): -// await withLogging(for: "onGuildScheduledEventExceptionDelete") { -// try await onGuildScheduledEventExceptionDelete(payload) -// } -// case .guildScheduledEventExceptionsDelete(let payload): -// await withLogging(for: "onGuildScheduledEventExceptionsDelete") { -// try await onGuildScheduledEventExceptionsDelete(payload) -// } -// case .interactionFailure(let payload): -// await withLogging(for: "onInteractionFailure") { -// try await onInteractionFailure(payload) -// } -// case .interactionSuccess(let payload): -// await withLogging(for: "onInteractionSuccess") { -// try await onInteractionSuccess(payload) -// } -// case .applicationCommandAutocompleteResponse(let payload): -// await withLogging(for: "onApplicationCommandAutocompleteResponse") { -// try await onApplicationCommandAutocompleteResponse(payload) -// } -// case .interactionModalCreate(let payload): -// await withLogging(for: "onInteractionModalCreate") { -// try await onInteractionModalCreate(payload) -// } -// case .interactionIFrameModalCreate(let payload): -// await withLogging(for: "onInteractionIFrameModalCreate") { -// try await onInteractionIFrameModalCreate(payload) -// } -// case .messageReactionAddMany(let payload): -// await withLogging(for: "onMessageReactionAddMany") { -// try await onMessageReactionAddMany(payload) -// } -// case .recentMentionDelete(let payload): -// await withLogging(for: "onRecentMentionDelete") { -// try await onRecentMentionDelete(payload) -// } -// case .requestLastMessages(let payload): -// await withLogging(for: "onRequestLastMessages") { -// try await onRequestLastMessages(payload) -// } -// case .lastMessages(let payload): -// await withLogging(for: "onLastMessages") { -// try await onLastMessages(payload) -// } -// case .notificationSettingsUpdate(let payload): -// await withLogging(for: "onNotificationSettingsUpdate") { -// try await onNotificationSettingsUpdate(payload) -// } -// case .relationshipAdd(let payload): -// await withLogging(for: "onRelationshipAdd") { -// try await onRelationshipAdd(payload) -// } -// case .relationshipUpdate(let payload): -// await withLogging(for: "onRelationshipUpdate") { -// try await onRelationshipUpdate(payload) -// } -// case .relationshipRemove(let payload): -// await withLogging(for: "onRelationshipRemove") { -// try await onRelationshipRemove(payload) -// } -// case .savedMessageCreate(let payload): -// await withLogging(for: "onSavedMessageCreate") { -// try await onSavedMessageCreate(payload) -// } -// case .savedMessageDelete(let payload): -// await withLogging(for: "onSavedMessageDelete") { -// try await onSavedMessageDelete(payload) -// } -// case .channelMemberCountUpdate(let payload): -// await withLogging(for: "onChannelMemberCountUpdate") { -// try await onChannelMemberCountUpdate(payload) -// } -// case .requestChannelMemberCount(let payload): -// await withLogging(for: "onRequestChannelMemberCount") { -// try await onRequestChannelMemberCount(payload) -// } -// case .autoModerationMentionRaidDetection(let payload): -// await withLogging(for: "onAutoModerationMentionRaidDetection") { -// try await onAutoModerationMentionRaidDetection(payload) -// } -// case .callCreate(let payload): -// await withLogging(for: "onCallCreate") { -// try await onCallCreate(payload) -// } -// case .callUpdate(let payload): -// await withLogging(for: "onCallUpdate") { -// try await onCallUpdate(payload) -// } -// case .callDelete(let payload): -// await withLogging(for: "onCallDelete") { -// try await onCallDelete(payload) -// } -// case .voiceChannelStatusUpdate(let payload): -// await withLogging(for: "onVoiceChannelStatusUpdate") { -// try await onVoiceChannelStatusUpdate(payload) -// } -// case .sessionsReplace(let payload): -// await withLogging(for: "onSessionsReplace") { -// try await onSessionsReplace(payload) -// } -// case .userApplicationUpdate(let payload): -// await withLogging(for: "onUserApplicationUpdate") { -// try await onUserApplicationUpdate(payload) -// } -// case .userApplicationRemove(let payload): -// await withLogging(for: "onUserApplicationRemove") { -// try await onUserApplicationRemove(payload) -// } -// case .userConnectionsUpdate(let payload): -// await withLogging(for: "onUserConnectionsUpdate") { -// try await onUserConnectionsUpdate(payload) -// } -// case .userGuildSettingsUpdate(let payload): -// await withLogging(for: "onUserGuildSettingsUpdate") { -// try await onUserGuildSettingsUpdate(payload) -// } -// case .userNoteUpdate(let payload): -// await withLogging(for: "onUserNoteUpdate") { -// try await onUserNoteUpdate(payload) -// } -// case .userSettingsUpdate(let payload): -// await withLogging(for: "onUserSettingsUpdate") { -// try await onUserSettingsUpdate(payload) -// } -// case .guildSoundboardSoundCreate(let payload): -// await withLogging(for: "onGuildSoundboardSoundCreate") { -// try await onGuildSoundboardSoundCreate(payload) -// } -// case .guildSoundboardSoundUpdate(let payload): -// await withLogging(for: "onGuildSoundboardSoundUpdate") { -// try await onGuildSoundboardSoundUpdate(payload) -// } -// case .guildSoundboardSoundDelete(let payload): -// await withLogging(for: "onGuildSoundboardSoundDelete") { -// try await onGuildSoundboardSoundDelete(payload) -// } -// case .soundboardSounds(let payload): -// await withLogging(for: "onSoundboardSounds") { -// try await onSoundboardSounds(payload) -// } -// case .channelUnreadUpdate(let payload): -// await withLogging(for: "onChannelUnreadUpdate") { -// try await onChannelUnreadUpdate(payload) -// } -// case .guildMemberListUpdate(let payload): -// await withLogging(for: "guildMemberListUpdate") { -// try await onGuildMemberListUpdate(payload) -// } -// case .__undocumented: break -// } -// -// await withLogging(for: "onEventHandlerEnd") { -// try await onEventHandlerEnd() -// } -// } -// -// @usableFromInline -// func withLogging(for function: String, block: () async throws -> Void) async { -// do { -// try await block() -// } catch { -// logError(function: function, error: error) -// } -// } -// -// @usableFromInline -// func logError(function: String, error: any Error) { -// logger.error( -// "\(Self.self) produced an error", -// metadata: [ -// "event-handler-func": .string(function), -// "error": .string(String(reflecting: error)), -// ] -// ) -// } -//} - -// this has no use in paicord. diff --git a/PaicordLib/Sources/DiscordGateway/GatewayManager.swift b/PaicordLib/Sources/DiscordGateway/GatewayManager.swift deleted file mode 100644 index f20351fd..00000000 --- a/PaicordLib/Sources/DiscordGateway/GatewayManager.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Atomics -import DiscordModels - -import struct NIOCore.ByteBuffer - -#if compiler(>=6.0) - /// A manager of Gateway interactions. - /// - /// Using `AnyObject, Sendable` instead of `AnyActor` in Swift 6. - public protocol GatewayManager: AnyObject, Sendable { - /// The client to send requests to Discord with. - nonisolated var client: any DiscordClient { get } - /// This gateway manager's identifier. - nonisolated var id: UInt { get } - /// The identification payload that is sent to Discord. - nonisolated var identifyPayload: Gateway.Identify { get } - /// An stream of Gateway events. - var events: DiscordAsyncSequence { get async } - /// An stream of Gateway event parse failures. - var eventFailures: DiscordAsyncSequence<(any Error, ByteBuffer)> { - get async - } - /// Connects to Discord. - func connect() async - /// https://discord.com/developers/docs/topics/gateway-events#request-guild-members - func requestGuildMembersChunk(payload: Gateway.RequestGuildMembers) async - /// https://discord.com/developers/docs/topics/gateway-events#update-presence - func updatePresence(payload: Gateway.Identify.Presence) async - /// https://discord.com/developers/docs/topics/gateway-events#update-voice-state - func updateVoiceState(payload: VoiceStateUpdate) async - /// An stream of Gateway events. - @available(*, deprecated, renamed: "events") - func makeEventsStream() async -> AsyncStream - /// Makes an stream of Gateway event parse failures. - @available(*, deprecated, renamed: "eventFailures") - func makeEventsParseFailureStream() async -> AsyncStream< - (any Error, ByteBuffer) - > - /// Disconnects from Discord. - func disconnect() async - } -#else - /// A manager of Gateway interactions. - public protocol GatewayManager: AnyActor { - /// The client to send requests to Discord with. - nonisolated var client: any DiscordClient { get } - /// This gateway manager's identifier. - nonisolated var id: UInt { get } - /// The identification payload that is sent to Discord. - nonisolated var identifyPayload: Gateway.Identify { get } - /// An stream of Gateway events. - var events: DiscordAsyncSequence { get async } - /// An stream of Gateway event parse failures. - var eventFailures: DiscordAsyncSequence<(any Error, ByteBuffer)> { - get async - } - /// Connects to Discord. - func connect() async - /// https://discord.com/developers/docs/topics/gateway-events#request-guild-members - func requestGuildMembersChunk(payload: Gateway.RequestGuildMembers) async - /// https://discord.com/developers/docs/topics/gateway-events#update-presence - func updatePresence(payload: Gateway.Identify.Presence) async - /// https://discord.com/developers/docs/topics/gateway-events#update-voice-state - func updateVoiceState(payload: VoiceStateUpdate) async - /// An stream of Gateway events. - @available(*, deprecated, renamed: "events") - func makeEventsStream() async -> AsyncStream - /// Makes an stream of Gateway event parse failures. - @available(*, deprecated, renamed: "eventFailures") - func makeEventsParseFailureStream() async -> AsyncStream< - (any Error, ByteBuffer) - > - /// Disconnects from Discord. - func disconnect() async - } -#endif - -/// Default implementations to not break people's code. -extension GatewayManager { - public var events: DiscordAsyncSequence { - get async { - await self.events - } - } - - public var eventFailures: DiscordAsyncSequence<(any Error, ByteBuffer)> { - get async { - await self.eventFailures - } - } -} diff --git a/PaicordLib/Sources/DiscordGateway/SerialQueue.swift b/PaicordLib/Sources/DiscordGateway/SerialQueue.swift index b117ffbd..c1880a18 100644 --- a/PaicordLib/Sources/DiscordGateway/SerialQueue.swift +++ b/PaicordLib/Sources/DiscordGateway/SerialQueue.swift @@ -2,24 +2,24 @@ import Foundation import Logging import NIOCore -actor SerialQueue { +package actor SerialQueue { var lastSend: Date let waitTime: Duration - init(waitTime: Duration) { + package init(waitTime: Duration) { /// Setting `lastSend` to sometime in the past that is not way too far. let waitSeconds = waitTime.asTimeInterval self.lastSend = Date().addingTimeInterval(-waitSeconds * 2) self.waitTime = waitTime } - func reset() { + package func reset() { let waitSeconds = waitTime.asTimeInterval self.lastSend = Date().addingTimeInterval(-waitSeconds * 2) } - nonisolated func perform(_ task: @escaping @Sendable () -> Void) { + nonisolated package func perform(_ task: @escaping @Sendable () -> Void) { Task { await self._perform(task) } } diff --git a/PaicordLib/Sources/DiscordGateway/ShardCoordinator.swift b/PaicordLib/Sources/DiscordGateway/ShardCoordinator.swift deleted file mode 100644 index 3637ade3..00000000 --- a/PaicordLib/Sources/DiscordGateway/ShardCoordinator.swift +++ /dev/null @@ -1,35 +0,0 @@ -import DiscordModels - -import struct Foundation.Date - -actor ShardCoordinator { - private var lastConnectionDates = [Date]() - /// Each `maxConcurrency` amount of connections need to wait **5** seconds. - /// https://discord.com/developers/docs/topics/gateway#session-start-limit-object-session-start-limit-structure - let waitTime = 5.0 - - init() {} - - /// Wait until the other required shards have connected. - func waitForOtherShards(shard: IntPair, maxConcurrency: Int) async { - if self.lastConnectionDates.count < maxConcurrency { - self.lastConnectionDates.append(Date()) - return - } else { - let index = self.lastConnectionDates.count - maxConcurrency - /// `index` guaranteed to be valid for `lastConnectionDates`. - let firstDateInMaxConcurrencyLimitBucket = self.lastConnectionDates[index] - let first = firstDateInMaxConcurrencyLimitBucket.timeIntervalSince1970 - let now = Date().timeIntervalSince1970 - let diff = now - first - let diffWithWaitTime = self.waitTime - diff - if diffWithWaitTime > 0 { - self.lastConnectionDates.append(Date().addingTimeInterval(diffWithWaitTime)) - try? await Task.sleep(nanoseconds: UInt64(diffWithWaitTime * 1_000_000_000)) - } else { - self.lastConnectionDates.append(Date()) - return - } - } - } -} diff --git a/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift b/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift index 5cb3812f..fa318282 100644 --- a/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift +++ b/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift @@ -18,7 +18,7 @@ import WSClient import enum NIOWebSocket.WebSocketErrorCode import struct NIOWebSocket.WebSocketOpcode -public actor UserGatewayManager: GatewayManager { +public actor UserGatewayManager { private struct Message { let payload: Gateway.Event @@ -153,6 +153,7 @@ public actor UserGatewayManager: GatewayManager { .userSettingsProto, .debounceMessageReactions, .nonChannelReadStates, + .autoCallConnect ], captchaCallback: CaptchaChallengeHandler? = nil, mfaCallback: MFAVerificationHandler? = nil, @@ -212,51 +213,56 @@ public actor UserGatewayManager: GatewayManager { await self.sendQueue.reset() let gatewayURL = await getGatewayURL() - // #if DEBUGo - let queries: [(String, String)] = [ - ("v", "\(DiscordGlobalConfiguration.apiVersion)"), - ("encoding", "json"), - ("compress", "zstd-stream"), - ] - let decompressorWSExtension: ZstdDecompressorWSExtension - do { - decompressorWSExtension = try ZstdDecompressorWSExtension( - logger: self.logger + #if DEBUG + let queries: [(String, String)] = [ + ("v", "\(DiscordGlobalConfiguration.apiVersion)"), + ("encoding", "json"), + ] + #else + let decompressorWSExtension: ZstdDecompressorWSExtension + do { + decompressorWSExtension = try ZstdDecompressorWSExtension( + logger: self.logger + ) + } catch { + self.logger.critical( + "Will not connect because can't create a decompressor. Something is wrong. Please report this failure at https://github.com/llsc12/Paicord/issues", + metadata: ["error": .string(String(reflecting: error))] + ) + return + } + let queries: [(String, String)] = [ + ("v", "\(DiscordGlobalConfiguration.apiVersion)"), + ("encoding", "json"), + ("compress", "zstd-stream"), + ] + #endif + + #if DEBUG + let configuration = WebSocketClientConfiguration( + maxFrameSize: self.maxFrameSize, + additionalHeaders: [ + .userAgent: SuperProperties.useragent(ws: false)!, + .origin: "https://discord.com", + .cacheControl: "no-cache", + .acceptLanguage: SuperProperties.GenerateLocaleHeader(), + + ], + extensions: [] ) - } catch { - self.logger.critical( - "Will not connect because can't create a decompressor. Something is wrong. Please report this failure at https://github.com/DiscordBM/DiscordBM/issues", - metadata: ["error": .string(String(reflecting: error))] + #else + let configuration = WebSocketClientConfiguration( + maxFrameSize: self.maxFrameSize, + additionalHeaders: [ + .userAgent: SuperProperties.useragent(ws: false)!, + .origin: "https://discord.com", + .cacheControl: "no-cache", + .acceptLanguage: SuperProperties.GenerateLocaleHeader(), + + ], + extensions: [.nonNegotiatedExtension { decompressorWSExtension }] ) - return - } - // #endif - - // #if DEBUG - // let configuration = WebSocketClientConfiguration( - // maxFrameSize: self.maxFrameSize, - // additionalHeaders: [ - // .userAgent: SuperProperties.useragent(ws: false)!, - // .origin: "https://discord.com", - // .cacheControl: "no-cache", - // .acceptLanguage: SuperProperties.GenerateLocaleHeader(), - // - // ], - // extensions: [] - // ) - // #else - let configuration = WebSocketClientConfiguration( - maxFrameSize: self.maxFrameSize, - additionalHeaders: [ - .userAgent: SuperProperties.useragent(ws: false)!, - .origin: "https://discord.com", - .cacheControl: "no-cache", - .acceptLanguage: SuperProperties.GenerateLocaleHeader(), - - ], - extensions: [.nonNegotiatedExtension { decompressorWSExtension }] - ) - // #endif + #endif logger.trace("Will try to connect to Discord through web-socket") let connectionId = self.connectionId.wrappingIncrementThenLoad( @@ -281,7 +287,8 @@ public actor UserGatewayManager: GatewayManager { self.state.store(.configured, ordering: .relaxed) self.stateCallback?(.configured) - for try await message in inbound.messages(maxSize: self.maxFrameSize) { + for try await message in inbound.messages(maxSize: self.maxFrameSize) + { await self.processBinaryData( message, forConnectionWithId: connectionId @@ -601,11 +608,10 @@ extension UserGatewayManager { ) ) ) - let opcode = Gateway.Opcode.identify self.send( message: .init( payload: resume, - opcode: .init(encodedWebSocketOpcode: opcode.rawValue)! + opcode: .text ) ) diff --git a/PaicordLib/Sources/DiscordHTTP/AuthenticationHeader.swift b/PaicordLib/Sources/DiscordHTTP/AuthenticationHeader.swift index 9777c7a8..bc131a46 100644 --- a/PaicordLib/Sources/DiscordHTTP/AuthenticationHeader.swift +++ b/PaicordLib/Sources/DiscordHTTP/AuthenticationHeader.swift @@ -59,7 +59,7 @@ public enum AuthenticationHeader: Sendable { } DiscordGlobalConfiguration.makeLogger("AuthenticationHeader").error( - "Cannot extract app-id from the bot token, please report this at https://github.com/DiscordBM/DiscordBM/issues. It can be an empty issue with a title like 'AuthenticationHeader failed to decode app-id'", + "Cannot extract app-id from the bot token, please report this at https://github.com/llsc12/Paicord/issues. It can be an empty issue with a title like 'AuthenticationHeader failed to decode app-id'", metadata: [ "botTokenSecret": .stringConvertible(token) ] diff --git a/PaicordLib/Sources/DiscordHTTP/CookieStore.swift b/PaicordLib/Sources/DiscordHTTP/CookieStore.swift index c7c2a048..9fac8485 100644 --- a/PaicordLib/Sources/DiscordHTTP/CookieStore.swift +++ b/PaicordLib/Sources/DiscordHTTP/CookieStore.swift @@ -30,7 +30,7 @@ public struct Cookie: Codable, Sendable { @usableFromInline actor CookieStore { - private var cookies: [String: Cookie] = [:] + internal var cookies: [String: Cookie] = [:] // gets cookies @usableFromInline diff --git a/PaicordLib/Sources/DiscordHTTP/DiscordClient+UserAPIEndpoint.swift b/PaicordLib/Sources/DiscordHTTP/DiscordClient+UserAPIEndpoint.swift index ae9bde87..14972669 100644 --- a/PaicordLib/Sources/DiscordHTTP/DiscordClient+UserAPIEndpoint.swift +++ b/PaicordLib/Sources/DiscordHTTP/DiscordClient+UserAPIEndpoint.swift @@ -241,7 +241,57 @@ extension DiscordClient { payload: payload ) } - + + // MARK: - Channels + + /// Checks if the current user is eligible to ring a call in the DM channel. + /// https://docs.discord.food/resources/channel#get-call-eligibility + @inlinable + public func getCallEligibility(channelID: ChannelSnowflake) async throws + -> DiscordClientResponse + { + let endpoint = UserAPIEndpoint.getCallEligibility(channelId: channelID) + return try await self.send(request: .init(to: endpoint)) + } + + /// Modifies the active call in the private channel. Returns a 204 empty response on success. Fires a Call Update Gateway event. + /// https://docs.discord.food/resources/channel#modify-call + @inlinable + public func modifyCall(channelID: ChannelSnowflake, payload: Payloads.ModifyCall) + async throws -> DiscordHTTPResponse { + let endpoint = UserAPIEndpoint.modifyCall(channelId: channelID) + return try await self.send( + request: .init(to: endpoint), + payload: payload + ) + } + + /// Rings the recipients of a private channel to notify them of an active call. Returns a 204 empty response on success. Fires a Call Update Gateway event. + /// https://docs.discord.food/resources/channel#ring-channel-recipients + @inlinable + public func ringChannelRecipients(channelID: ChannelSnowflake, payload: Payloads.RingChannelRecipients) async throws + -> DiscordHTTPResponse + { + let endpoint = UserAPIEndpoint.ringChannelRecipients(channelId: channelID) + return try await self.send( + request: .init(to: endpoint), + payload: payload + ) + } + + /// Stops ringing the recipients of a private channel. Returns a 204 empty response on success. Fires a Call Update Gateway event. + /// https://docs.discord.food/resources/channel#stop-ringing-channel-recipients + @inlinable + public func stopRingingChannelRecipients(channelID: ChannelSnowflake, payload: Payloads.RingChannelRecipients) async throws + -> DiscordHTTPResponse + { + let endpoint = UserAPIEndpoint.stopRingingChannelRecipients(channelId: channelID) + return try await self.send( + request: .init(to: endpoint), + payload: payload + ) + } + // MARK: - Emoji /// Returns the most-used emojis for the given guild. diff --git a/PaicordLib/Sources/DiscordHTTP/Endpoints/UserAPIEndpoint.swift b/PaicordLib/Sources/DiscordHTTP/Endpoints/UserAPIEndpoint.swift index 0b41bfcf..dca68b46 100644 --- a/PaicordLib/Sources/DiscordHTTP/Endpoints/UserAPIEndpoint.swift +++ b/PaicordLib/Sources/DiscordHTTP/Endpoints/UserAPIEndpoint.swift @@ -31,6 +31,10 @@ public enum UserAPIEndpoint: Endpoint { // MARK: - Billing // MARK: - Channels + case getCallEligibility(channelId: ChannelSnowflake) + case modifyCall(channelId: ChannelSnowflake) + case ringChannelRecipients(channelId: ChannelSnowflake) + case stopRingingChannelRecipients(channelId: ChannelSnowflake) // MARK: - Components @@ -188,6 +192,16 @@ public enum UserAPIEndpoint: Endpoint { case .executeAutoModAlertAction(let guildId): suffix = "guilds/\(guildId.rawValue)/auto-moderation/alert-action" + // MARK: - Channels + case .getCallEligibility(let channelId): + suffix = "channels/\(channelId.rawValue)/call" + case .modifyCall(let channelId): + suffix = "channels/\(channelId.rawValue)/call" + case .ringChannelRecipients(let channelId): + suffix = "channels/\(channelId.rawValue)/call/ring" + case .stopRingingChannelRecipients(let channelId): + suffix = "channels/\(channelId.rawValue)/call/stop-ringing" + // MARK: - Emojis case .getGuildTopEmojis(let guildId): suffix = "guilds/\(guildId.rawValue)/top-emojis" @@ -344,6 +358,14 @@ public enum UserAPIEndpoint: Endpoint { suffix = "guilds/\(guildId.rawValue)/auto-moderation/rules/validate" case .executeAutoModAlertAction(let guildId): suffix = "guilds/\(guildId.rawValue)/auto-moderation/alert-action" + case .getCallEligibility(let channelId): + suffix = "channels/\(channelId.rawValue)/call" + case .modifyCall(let channelId): + suffix = "channels/\(channelId.rawValue)/call" + case .ringChannelRecipients(let channelId): + suffix = "channels/\(channelId.rawValue)/call/ring" + case .stopRingingChannelRecipients(let channelId): + suffix = "channels/\(channelId.rawValue)/call/stop-ringing" case .getGuildTopEmojis(let guildId): suffix = "/guilds/\(guildId.rawValue)/top-emojis" case .acceptInvite(let code): @@ -462,6 +484,10 @@ public enum UserAPIEndpoint: Endpoint { case .getDetectableApplications: return .GET case .validateAutoModRule: return .POST case .executeAutoModAlertAction: return .POST + case .getCallEligibility: return .GET + case .modifyCall: return .PATCH + case .ringChannelRecipients: return .POST + case .stopRingingChannelRecipients: return .POST case .getGuildTopEmojis: return .GET case .acceptInvite: return .POST case .getUserInvites: return .GET @@ -521,6 +547,10 @@ public enum UserAPIEndpoint: Endpoint { case .getDetectableApplications: return true case .validateAutoModRule: return true case .executeAutoModAlertAction: return true + case .getCallEligibility: return true + case .modifyCall: return true + case .ringChannelRecipients: return true + case .stopRingingChannelRecipients: return true case .getGuildTopEmojis: return true case .acceptInvite: return true case .getUserInvites: return true @@ -580,6 +610,10 @@ public enum UserAPIEndpoint: Endpoint { case .getDetectableApplications: return true case .validateAutoModRule: return true case .executeAutoModAlertAction: return true + case .getCallEligibility: return true + case .modifyCall: return true + case .ringChannelRecipients: return true + case .stopRingingChannelRecipients: return true case .getGuildTopEmojis: return true case .acceptInvite: return true case .getUserInvites: return true @@ -640,6 +674,11 @@ public enum UserAPIEndpoint: Endpoint { case .getDetectableApplications: return [] case .validateAutoModRule(let guildId): return [guildId.rawValue] case .executeAutoModAlertAction(let guildId): return [guildId.rawValue] + case .getCallEligibility(let channelId): return [channelId.rawValue] + case .modifyCall(let channelId): return [channelId.rawValue] + case .ringChannelRecipients(let channelId): return [channelId.rawValue] + case .stopRingingChannelRecipients(let channelId): + return [channelId.rawValue] case .getGuildTopEmojis(let guildId): return [guildId.rawValue] case .acceptInvite(let code): return [code] case .getUserInvites: return [] @@ -723,6 +762,10 @@ public enum UserAPIEndpoint: Endpoint { case .getDetectableApplications: return 14 case .validateAutoModRule: return 15 case .executeAutoModAlertAction: return 16 + case .getCallEligibility: return 21 + case .modifyCall: return 22 + case .ringChannelRecipients: return 23 + case .stopRingingChannelRecipients: return 24 // ... space for ignored endpoints i didn't implement case .getGuildTopEmojis: return 41 case .acceptInvite: return 51 @@ -796,6 +839,14 @@ public enum UserAPIEndpoint: Endpoint { return "validateAutoModRule(guildId: \(guildId.rawValue), ...)" case .executeAutoModAlertAction(let guildId): return "executeAutoModAlertAction(guildId: \(guildId.rawValue), ..." + case .getCallEligibility(let channelId): + return "getCallEligibility(channelId: \(channelId.rawValue))" + case .modifyCall(let channelId): + return "modifyCall(channelId: \(channelId.rawValue), ...)" + case .ringChannelRecipients(let channelId): + return "ringChannelRecipients(channelId: \(channelId.rawValue))" + case .stopRingingChannelRecipients(let channelId): + return "stopRingingChannelRecipients(channelId: \(channelId.rawValue))" case .getGuildTopEmojis(let guildId): return "getGuildTopEmojis(guildId: \(guildId.rawValue))" case .acceptInvite(let code): diff --git a/PaicordLib/Sources/DiscordHTTP/HTTP Types.swift b/PaicordLib/Sources/DiscordHTTP/HTTP Types.swift index 43ce9fe5..0444bbe3 100644 --- a/PaicordLib/Sources/DiscordHTTP/HTTP Types.swift +++ b/PaicordLib/Sources/DiscordHTTP/HTTP Types.swift @@ -289,7 +289,7 @@ public enum DiscordHTTPError: Error, CustomStringConvertible { case badStatusCode(DiscordHTTPResponse) /// Discord responded with a 200 status code but you requested DiscordBM to decode a `JSONError`. case cantDecodeJSONErrorFromSuccessfulResponse(DiscordHTTPResponse) - /// The response body was unexpectedly empty. If it happens frequently, you should report it to me at https://github.com/DiscordBM/DiscordBM/issues. + /// The response body was unexpectedly empty. If it happens frequently, you should report it to me at https://github.com/llsc12/Paicord/issues. case emptyBody(DiscordHTTPResponse) /// Discord didn't send a Content-Type header. See if they mentions any errors in the response. case noContentTypeHeader(DiscordHTTPResponse) diff --git a/PaicordLib/Sources/DiscordModels/Types/BitField.swift b/PaicordLib/Sources/DiscordModels/Types/BitField.swift index 504da27c..843b530e 100644 --- a/PaicordLib/Sources/DiscordModels/Types/BitField.swift +++ b/PaicordLib/Sources/DiscordModels/Types/BitField.swift @@ -115,7 +115,7 @@ where R: RawRepresentable & LosslessRawRepresentable & Hashable, R.RawValue == U extension StringBitField: Codable { public enum DecodingError: Error, CustomStringConvertible { - /// The string value could not be converted to an integer. This is a library decoding issue, please report this at https://github.com/DiscordBM/DiscordBM/issues. + /// The string value could not be converted to an integer. This is a library decoding issue, please report this at https://github.com/llsc12/Paicord/issues. case notRepresentingUInt(String) public var description: String { diff --git a/PaicordLib/Sources/DiscordModels/Types/Error Codes.swift b/PaicordLib/Sources/DiscordModels/Types/Error Codes.swift index 8d9a0d71..43dbfdb2 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Error Codes.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Error Codes.swift @@ -1,4 +1,5 @@ /// https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes +/// https://docs.discord.food/topics/opcodes-and-status-codes#gateway-close-event-codes public enum GatewayCloseCode: UInt16, Sendable, Codable { case unknownError = 4000 case unknownOpcode = 4001 @@ -6,6 +7,7 @@ public enum GatewayCloseCode: UInt16, Sendable, Codable { case notAuthenticated = 4003 case authenticationFailed = 4004 case alreadyAuthenticated = 4005 + case sessionNoLongerValid = 4006 case invalidSequence = 4007 case rateLimited = 4008 case sessionTimedOut = 4009 @@ -14,6 +16,8 @@ public enum GatewayCloseCode: UInt16, Sendable, Codable { case invalidAPIVersion = 4012 case invalidIntents = 4013 case disallowedIntents = 4014 + case tooManySessions = 4015 + case connectionRequestCancelled = 4016 public var canTryReconnect: Bool { switch self { @@ -23,6 +27,7 @@ public enum GatewayCloseCode: UInt16, Sendable, Codable { case .notAuthenticated: return true case .authenticationFailed: return false case .alreadyAuthenticated: return true + case .sessionNoLongerValid: return true case .invalidSequence: return true case .rateLimited: return true case .sessionTimedOut: return true @@ -31,6 +36,48 @@ public enum GatewayCloseCode: UInt16, Sendable, Codable { case .invalidAPIVersion: return false case .invalidIntents: return false case .disallowedIntents: return false + case .tooManySessions: return false + case .connectionRequestCancelled: return false + } + } +} + +/// https://docs.discord.food/topics/opcodes-and-status-codes#voice-close-event-codes +/// https://docs.discord.com/developers/topics/opcodes-and-status-codes#voice +public enum VoiceGatewayCloseCode: UInt16, Sendable, Codable { + case unknownOpcode = 4001 + case decodeError = 4002 + case notAuthenticated = 4003 + case authenticationFailed = 4004 + case alreadyAuthenticated = 4005 + case sessionNoLongerValid = 4006 + case sessionTimedOut = 4009 + case serverNotFound = 4011 + case unknownProtocol = 4012 + case disconnected = 4014 + case voiceServerCrashed = 4015 + case unknownEncryption = 4016 + case badRequest = 4020 + case rateLimited = 4021 + case callTerminated = 4022 + + public var canTryReconnect: Bool { + switch self { + case .unknownOpcode: return true + case .decodeError: return true + case .notAuthenticated: return true + case .authenticationFailed: return false + case .alreadyAuthenticated: return true + case .rateLimited: return false + case .sessionTimedOut: return true + case .sessionNoLongerValid: return true + case .serverNotFound: return false + case .unknownProtocol: return false + case .disconnected: return false + case .voiceServerCrashed: return true + case .unknownEncryption: return false + case .badRequest: return false + case .callTerminated: return false } } } @@ -157,7 +204,8 @@ public enum JSONErrorCode: Sendable, Codable { case tagRequiredToCreateForumPostInChannel // 40067 case anEntitlementHasAlreadyBeenGrantedForThisResource // 40074 case thisInteractionHasHitTheMaximumNumberOfFollowUpMessage // 40094 - case cloudflareIsBlockingYourRequestThisCanOftenBeResolvedBySettingProperUserAgent // 40333 + case + cloudflareIsBlockingYourRequestThisCanOftenBeResolvedBySettingProperUserAgent // 40333 case missingAccess // 50001 case invalidAccountType // 50002 case cannotExecuteActionOnDMChannel // 50003 diff --git a/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift index 6d58a5e9..25a913aa 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift @@ -1484,8 +1484,8 @@ extension Gateway { /// https://discord.com/developers/docs/topics/gateway-events#voice-server-update-voice-server-update-event-fields public struct VoiceServerUpdate: Sendable, Codable { - public var token: String - public var guild_id: GuildSnowflake + public var token: Secret + public var guild_id: GuildSnowflake? public var endpoint: String? } @@ -1540,6 +1540,7 @@ extension Gateway { public var message_id: MessageSnowflake public var region: String public var ringing: [UserSnowflake] + public var voice_states: [VoiceState]? } /// https://docs.discord.food/topics/gateway-events#call-update @@ -1548,7 +1549,6 @@ extension Gateway { public var message_id: MessageSnowflake public var region: String public var ringing: [UserSnowflake] - public var voice_states: [VoiceState]? } /// https://docs.discord.food/topics/gateway-events#call-delete @@ -1567,6 +1567,11 @@ extension Gateway { /// https://docs.discord.food/topics/gateway-events#request-channel-member-count public struct RequestChannelMemberCount: Sendable, Codable { + public init(guild_id: GuildSnowflake, channel_id: ChannelSnowflake) { + self.guild_id = guild_id + self.channel_id = channel_id + } + public var guild_id: GuildSnowflake public var channel_id: ChannelSnowflake } diff --git a/PaicordLib/Sources/DiscordModels/Types/Gateway.swift b/PaicordLib/Sources/DiscordModels/Types/Gateway.swift index 882e46e0..94d15fe8 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Gateway.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Gateway.swift @@ -1,7 +1,7 @@ import Foundation public struct Gateway: Sendable, Codable { - + /// https://discord.com/developers/docs/topics/opcodes-and-status-codes#opcodes-and-status-codes public enum Opcode: UInt8, Sendable, Codable, CustomStringConvertible { // common @@ -17,7 +17,7 @@ public struct Gateway: Sendable, Codable { case hello = 10 case heartbeatAccepted = 11 case requestSoundboardSounds = 31 - + // user only gateway opcodes ? case voiceServerPing = 5 case callConnect = 13 @@ -41,7 +41,7 @@ public struct Gateway: Sendable, Codable { case requestChannelMemberCounts = 39 case qosHeartbeat = 40 case updateTimeSpentSessionId = 41 - + public var description: String { switch self { case .dispatch: return "dispatch" @@ -81,17 +81,17 @@ public struct Gateway: Sendable, Codable { } } } - + /// The top-level gateway event. /// https://discord.com/developers/docs/topics/gateway#gateway-events public struct Event: Sendable, Codable { - + /// This enum is just for swiftly organizing Discord gateway event's `data`. /// You need to read each case's inner payload's documentation for more info. /// /// `indirect` is used to mitigate this issue: https://github.com/swiftlang/swift/issues/74303 indirect - public enum Payload: Sendable + public enum Payload: Sendable { /// https://discord.com/developers/docs/topics/gateway-events#heartbeat case heartbeat(lastSequenceNumber: Int?) @@ -109,277 +109,277 @@ public struct Gateway: Sendable, Codable { case invalidSession(canResume: Bool) case authSessionChange(AuthSessionChange) case sessionsReplace(SessionsReplace) - + case updateTimeSpentSessionId(UpdateTimeSpentSessionID) - + // case authenticatorCreate // TODO // case authenticatorUpdate // TODO // case authenticatorDelete // TODO - + case channelCreate(DiscordChannel) case channelUpdate(DiscordChannel) case channelDelete(DiscordChannel) - + case callCreate(CallCreate) case callUpdate(CallUpdate) case callDelete(CallDelete) - + case voiceChannelStatuses(VoiceChannelStatuses) case channelPinsUpdate(ChannelPinsUpdate) - + case conversationSummaryUpdate(ConversationSummaryUpdate) - + case channelRecipientAdd(ChannelRecipientAdd) case channelRecipientRemove(ChannelRecipientRemove) - + case channelUnreadUpdate(ChannelUnreadUpdate) - + case consoleCommandUpdate(ConsoleCommandUpdate) // TODO - + case dmSettingsShow(DMSettingsShow) - + case threadCreate(DiscordChannel) case threadUpdate(DiscordChannel) case threadDelete(ThreadDelete) - + case threadSyncList(ThreadListSync) case threadMemberUpdate(ThreadMemberUpdate) case threadMembersUpdate(ThreadMembersUpdate) - + case entitlementCreate(Entitlement) case entitlementUpdate(Entitlement) case entitlementDelete(Entitlement) - + case friendSuggestionCreate(FriendSuggestionCreate) case friendSuggestionDelete(FriendSuggestionDelete) - + // case giftCodeCreate // TODO // case giftCodeUpdate // TODO - + case guildCreate(GuildCreate) case guildUpdate(Guild) case guildDelete(UnavailableGuild) - + case guildApplicationCommandIndexUpdate( GuildApplicationCommandIndexUpdate ) case guildAppliedBoostsUpdate(Guild.PremiumGuildSubscription) case guildAuditLogEntryCreate(AuditLog.Entry) - + case guildBanAdd(GuildBan) case guildBanRemove(GuildBan) - + // case guildDirectoryEntryCreate // TODO // case guildDirectoryEntryUpdate // TODO // case guildDirectoryEntryDelete // TODO - + // case guildJoinRequestCreate // TODO // case guildJoinRequestUpdate // TODO // case guildJoinRequestDelete // TODO - + case guildMemberAdd(GuildMemberAdd) case guildMemberRemove(GuildMemberRemove) case guildMemberUpdate(GuildMemberAdd) - + case guildRoleCreate(GuildRole) case guildRoleUpdate(GuildRole) case guildRoleDelete(GuildRoleDelete) - + case guildMembersChunk(GuildMembersChunk) case requestGuildMembers(RequestGuildMembers) - + case updateGuildSubscriptions(UpdateGuildSubscriptions) case guildMemberListUpdate(GuildMemberListUpdate) case guildJoinRequestUpdate(GuildJoinRequestUpdate) - + // case guildPowerupEntitlementsCreate // TODO // case guildPowerupEntitlementsDelete // TODO - + case guildEmojisUpdate(GuildEmojisUpdate) case guildStickersUpdate(GuildStickersUpdate) - + case guildScheduledEventCreate(GuildScheduledEvent) case guildScheduledEventUpdate(GuildScheduledEvent) case guildScheduledEventDelete(GuildScheduledEvent) - + case guildScheduledEventExceptionCreate(GuildScheduledEventException) case guildScheduledEventExceptionUpdate(GuildScheduledEventException) case guildScheduledEventExceptionDelete(GuildScheduledEventException) case guildScheduledEventExceptionsDelete( GuildScheduledEventExceptionsDelete ) - + case guildScheduledEventUserAdd(GuildScheduledEventUser) case guildScheduledEventUserRemove(GuildScheduledEventUser) - + case guildSoundboardSoundCreate(SoundboardSound) case guildSoundboardSoundUpdate(SoundboardSound) case guildSoundboardSoundDelete(SoundboardSoundDelete) // TODO - + case soundboardSounds(SoundboardSounds) - + case guildIntegrationsUpdate(GuildIntegrationsUpdate) - + case integrationCreate(IntegrationCreate) case integrationUpdate(IntegrationCreate) case integrationDelete(IntegrationDelete) - + // case interactionCreate(Interaction) // bot gets full interaction object case interactionCreate(InteractionCreate) // user gets limited object case interactionFailure(InteractionFailure) case interactionSuccess(InteractionSuccess) - + case applicationCommandAutocompleteResponse( ApplicationCommandAutocomplete ) - + case interactionModalCreate(InteractionModalCreate) case interactionIFrameModalCreate(InteractionIFrameModalCreate) - + case inviteCreate(InviteCreate) case inviteDelete(InviteDelete) - + case messageCreate(MessageCreate) case messageUpdate(DiscordChannel.PartialMessage) case messageDelete(MessageDelete) case messageDeleteBulk(MessageDeleteBulk) - + case messageAcknowledge(MessageAcknowledge) case channelPinsAcknowledge(ChannelPinsAcknowledge) case userNonChannelAcknowledge(UserNonChannelAcknowledge) - + case messagePollVoteAdd(MessagePollVote) case messagePollVoteRemove(MessagePollVote) - + case messageReactionAdd(MessageReactionAdd) case messageReactionAddMany(MessageReactionAddMany) case messageReactionRemove(MessageReactionRemove) case messageReactionRemoveAll(MessageReactionRemoveAll) case messageReactionRemoveEmoji(MessageReactionRemoveEmoji) - + case requestLastMessages(RequestLastMessages) case lastMessages(LastMessages) - + case recentMentionDelete(RecentMentionDelete) - + case notificationSettingsUpdate(NotificationSettings) - + // case oauth2TokenRevoke // TODO - + case presenceUpdate(PresenceUpdate) case requestPresenceUpdate(Identify.Presence) - + // case questsUserStatusUpdate // TODO // case questsUserCompletionUpdate // TODO - + case relationshipAdd(DiscordRelationship) case relationshipUpdate(PartialRelationship) case relationshipRemove(PartialRelationship) - + // case gameRelationshipAdd // TODO // case gameRelationshipRemove // TODO - + case savedMessageCreate(SavedMessageCreate) case savedMessageDelete(SavedMessageDelete) - + case channelMemberCountUpdate(ChannelMemberCountUpdate) case requestChannelMemberCount(RequestChannelMemberCount) - + case autoModerationRuleCreate(AutoModerationRule) case autoModerationRuleUpdate(AutoModerationRule) case autoModerationRuleDelete(AutoModerationRule) - + case autoModerationActionExecution(AutoModerationActionExecution) case autoModerationMentionRaidDetection( AutoModerationMentionRaidDetection ) - + case stageInstanceCreate(StageInstance) case stageInstanceDelete(StageInstance) case stageInstanceUpdate(StageInstance) - + // case streamCreateStream() // TODO // case streamServerUpdate() // TODO // case streamUpdateStream() // TODO // case streamDelete() // TODO - + // case speedTestCreate() // TODO // case speedTestServerUpdate() // TODO // case speedTestUpdate() // TODO // case speedTestDelete() // TODO - + case typingStart(TypingStart) - + case userUpdate(DiscordUser) case userApplicationIdentityUpdate(UserApplicationIdentityUpdate) - + case voiceStateUpdate(VoiceState) case requestVoiceStateUpdate(VoiceStateUpdate) case voiceChannelStatusUpdate(VoiceChannelStatusUpdate) case voiceServerUpdate(VoiceServerUpdate) case voiceChannelStartTimeUpdate(VoiceChannelStartTimeUpdate) // case voiceChannelEffectSend() // TODO - + case webhooksUpdate(WebhooksUpdate) - + case applicationCommandPermissionsUpdate( GuildApplicationCommandPermissions ) - + case userApplicationUpdate(UserApplicationUpdate) case userApplicationRemove(UserApplicationRemove) - + case userConnectionsUpdate(UserConnectionsUpdate) - + case userGuildSettingsUpdate(Guild.UserGuildSettings) - + case userNoteUpdate(UserNote) - + // case userRequiredActionUpdate() // TODO case userSettingsUpdate(UserSettingsProtoUpdate) - + // case audioSettingsUpdate() // TODO - + // case userPremiumGuildSubscriptionSlotCreate() // TODO // case userPremiumGuildSubscriptionSlotUpdate() // TODO // case userPremiumGuildSubscriptionSlotDelete() // TODO - + case embeddedActivityUpdateV2(EmbeddedActivityUpdateV2) case contentInventoryInboxStale(ContentInventoryInboxStale) - + case __undocumented - + // MARK: - End of payloads - + public var correspondingIntents: [Intent] { switch self { case .heartbeat, .identify, .hello, .ready, .resume, .resumed, - .invalidSession, .requestGuildMembers, - .requestPresenceUpdate, .requestVoiceStateUpdate, .interactionCreate, - .entitlementCreate, - .entitlementUpdate, .entitlementDelete, - .applicationCommandPermissionsUpdate, .userUpdate, - .voiceServerUpdate, .updateGuildSubscriptions, .guildMemberListUpdate: + .invalidSession, .requestGuildMembers, + .requestPresenceUpdate, .requestVoiceStateUpdate, .interactionCreate, + .entitlementCreate, + .entitlementUpdate, .entitlementDelete, + .applicationCommandPermissionsUpdate, .userUpdate, + .voiceServerUpdate, .updateGuildSubscriptions, .guildMemberListUpdate: return [] case .guildCreate, .guildUpdate, .guildDelete, .guildMembersChunk, - .guildRoleCreate, .guildRoleUpdate, - .guildRoleDelete, .channelCreate, .channelUpdate, .channelDelete, - .threadCreate, .threadUpdate, - .threadDelete, .threadSyncList, .threadMemberUpdate, - .stageInstanceCreate, .stageInstanceDelete, - .stageInstanceUpdate: + .guildRoleCreate, .guildRoleUpdate, + .guildRoleDelete, .channelCreate, .channelUpdate, .channelDelete, + .threadCreate, .threadUpdate, + .threadDelete, .threadSyncList, .threadMemberUpdate, + .stageInstanceCreate, .stageInstanceDelete, + .stageInstanceUpdate: return [.guilds] case .channelPinsUpdate: return [.guilds, .directMessages] case .threadMembersUpdate, .guildMemberAdd, .guildMemberRemove, - .guildMemberUpdate: + .guildMemberUpdate: return [.guilds, .guildMembers] case .guildAuditLogEntryCreate, .guildBanAdd, .guildBanRemove: return [.guildModeration] case .guildEmojisUpdate, .guildStickersUpdate: return [.guildEmojisAndStickers] case .guildIntegrationsUpdate, .integrationCreate, .integrationUpdate, - .integrationDelete: + .integrationDelete: return [.guildIntegrations] case .webhooksUpdate: return [.guildWebhooks] @@ -390,22 +390,22 @@ public struct Gateway: Sendable, Codable { case .presenceUpdate: return [.guildPresences] case .messageCreate, .messageUpdate, .messageDelete, - .messageAcknowledge: + .messageAcknowledge: return [.guildMessages, .directMessages] case .messageDeleteBulk: return [.guildMessages] case .messageReactionAdd, .messageReactionRemove, - .messageReactionRemoveAll, - .messageReactionRemoveEmoji: + .messageReactionRemoveAll, + .messageReactionRemoveEmoji: return [.guildMessageReactions] case .typingStart: return [.guildMessageTyping] case .guildScheduledEventCreate, .guildScheduledEventUpdate, - .guildScheduledEventDelete, - .guildScheduledEventUserAdd, .guildScheduledEventUserRemove: + .guildScheduledEventDelete, + .guildScheduledEventUserAdd, .guildScheduledEventUserRemove: return [.guildScheduledEvents] case .autoModerationRuleCreate, .autoModerationRuleUpdate, - .autoModerationRuleDelete: + .autoModerationRuleDelete: return [.autoModerationConfiguration] case .autoModerationActionExecution: return [.autoModerationExecution] @@ -419,11 +419,11 @@ public struct Gateway: Sendable, Codable { } } } - + public enum GatewayDecodingError: Error, CustomStringConvertible { /// The dispatch event type '\(type ?? "nil")' is unhandled. This is probably a new Discord event which is not yet officially documented. I actively look for new events, and check Discord docs, so there is nothing to worry about. The library will support this event when it should. case unhandledDispatchEvent(type: String?) - + public var description: String { switch self { case .unhandledDispatchEvent(let type): @@ -432,19 +432,19 @@ public struct Gateway: Sendable, Codable { } } } - + enum CodingKeys: String, CodingKey { case opcode = "op" case data = "d" case sequenceNumber = "s" case type = "t" } - + public var opcode: Opcode public var data: Payload? public var sequenceNumber: Int? public var type: String? - + public init( opcode: Opcode, data: Payload? = nil, @@ -456,7 +456,7 @@ public struct Gateway: Sendable, Codable { self.sequenceNumber = sequenceNumber self.type = type } - + public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.opcode = try container.decode(Opcode.self, forKey: .opcode) @@ -465,11 +465,11 @@ public struct Gateway: Sendable, Codable { forKey: .sequenceNumber ) self.type = try container.decodeIfPresent(String.self, forKey: .type) - + func decodeData(as type: D.Type = D.self) throws -> D { try container.decode(D.self, forKey: .data) } - + switch opcode { case .heartbeat, .heartbeatAccepted, .reconnect: guard try container.decodeNil(forKey: .data) else { @@ -484,14 +484,14 @@ public struct Gateway: Sendable, Codable { } self.data = nil case .identify, .presenceUpdate, .voiceStateUpdate, .resume, - .requestGuildMembers, .requestSoundboardSounds, .voiceServerPing, - .callConnect, - .guildSubscriptions, .lobbyVoiceStates, .streamCreate, .streamDelete, - .streamWatch, .streamPing, .streamSetPaused, .requestForumUnread, - .remoteCommand, .requestDeletedEntityIds, .speedtestCreate, - .speedtestDelete, .requestLastMessages, .searchRecentMembers, - .requestChannelStatuses, .guildSubscriptionsBulk, .guildChannelsResync, - .requestChannelMemberCounts, .qosHeartbeat, .updateTimeSpentSessionId: + .requestGuildMembers, .requestSoundboardSounds, .voiceServerPing, + .callConnect, + .guildSubscriptions, .lobbyVoiceStates, .streamCreate, .streamDelete, + .streamWatch, .streamPing, .streamSetPaused, .requestForumUnread, + .remoteCommand, .requestDeletedEntityIds, .speedtestCreate, + .speedtestDelete, .requestLastMessages, .searchRecentMembers, + .requestChannelStatuses, .guildSubscriptionsBulk, .guildChannelsResync, + .requestChannelMemberCounts, .qosHeartbeat, .updateTimeSpentSessionId: throw DecodingError.dataCorrupted( .init( codingPath: container.codingPath, @@ -759,11 +759,11 @@ public struct Gateway: Sendable, Codable { } } } - + public enum EncodingError: Error, CustomStringConvertible { - /// This event is not supposed to be sent at all. This could be a library issue, please report at https://github.com/DiscordBM/DiscordBM/issues. + /// This event is not supposed to be sent at all. This could be a library issue, please report at https://github.com/llsc12/Paicord/issues. case notSupposedToBeSent(message: String) - + public var description: String { switch self { case .notSupposedToBeSent(let message): @@ -771,10 +771,10 @@ public struct Gateway: Sendable, Codable { } } } - + public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - + switch self.opcode { case .dispatch, .reconnect, .invalidSession, .heartbeatAccepted, .hello: throw EncodingError.notSupposedToBeSent( @@ -784,7 +784,7 @@ public struct Gateway: Sendable, Codable { default: break } try container.encode(self.opcode, forKey: .opcode) - + if self.sequenceNumber != nil { throw EncodingError.notSupposedToBeSent( message: @@ -797,7 +797,7 @@ public struct Gateway: Sendable, Codable { "'type' is supposed to never be sent but wasn't nil (\(String(describing: type))." ) } - + switch self.data { case .none: try container.encodeNil(forKey: .data) @@ -827,41 +827,3 @@ public struct Gateway: Sendable, Codable { } } } - -// MARK: + Gateway.Intent -extension Gateway.Intent { - /// All intents that require no privileges. - /// https://discord.com/developers/docs/topics/gateway#privileged-intents - public static var unprivileged: [Gateway.Intent] { - Gateway.Intent.allCases.filter { !$0.isPrivileged } - } - - /// https://discord.com/developers/docs/topics/gateway#privileged-intents - public var isPrivileged: Bool { - switch self { - case .guilds: return false - case .guildMembers: return true - case .guildModeration: return false - case .guildEmojisAndStickers: return false - case .guildIntegrations: return false - case .guildWebhooks: return false - case .guildInvites: return false - case .guildVoiceStates: return false - case .guildPresences: return true - case .guildMessages: return false - case .guildMessageReactions: return false - case .guildMessageTyping: return false - case .directMessages: return false - case .directMessageReactions: return false - case .directMessageTyping: return false - case .messageContent: return true - case .guildScheduledEvents: return false - case .autoModerationConfiguration: return false - case .autoModerationExecution: return false - case .guildMessagePolls: return false - case .directMessagePolls: return false - /// Undocumented cases are considered privileged just to be safe than sorry - case .__undocumented: return true - } - } -} diff --git a/PaicordLib/Sources/DiscordModels/Types/Guild.swift b/PaicordLib/Sources/DiscordModels/Types/Guild.swift index 5952bedf..13ddca7d 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Guild.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Guild.swift @@ -52,7 +52,8 @@ public struct Guild: Sendable, Codable, Hashable, Equatable, Identifiable { embedded_activities: [Gateway.Activity]? = nil, members: [Guild.Member]? = nil, version: Int? = nil, - guild_id: GuildSnowflake? = nil + guild_id: GuildSnowflake? = nil, + voice_states: [VoiceState]? = nil ) { self.id = id self.name = name @@ -105,6 +106,7 @@ public struct Guild: Sendable, Codable, Hashable, Equatable, Identifiable { self.members = members self.version = version self.guild_id = guild_id + self.voice_states = voice_states } /// https://discord.com/developers/docs/resources/guild#guild-member-object-guild-member-structure @@ -517,6 +519,7 @@ public struct Guild: Sendable, Codable, Hashable, Equatable, Identifiable { public var members: [Guild.Member]? public var version: Int? public var guild_id: GuildSnowflake? + public var voice_states: [VoiceState]? } /// https://discord.com/developers/docs/resources/guild#guild-object-guild-structure @@ -571,6 +574,7 @@ public struct PartialGuild: Sendable, Codable, Equatable, Hashable { public var embedded_activities: [Gateway.Activity]? public var version: Int? public var guild_id: GuildSnowflake? + public var voice_states: [VoiceState]? } extension Guild { diff --git a/PaicordLib/Sources/DiscordModels/Types/Interaction.swift b/PaicordLib/Sources/DiscordModels/Types/Interaction.swift index 5aebacd5..f959a00c 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Interaction.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Interaction.swift @@ -1316,9 +1316,9 @@ extension Interaction { public var components: [Component] public enum CodingError: Swift.Error, CustomStringConvertible { - /// This component kind was not expected here. This is a library decoding issue, please report at: https://github.com/DiscordBM/DiscordBM/issues. + /// This component kind was not expected here. This is a library decoding issue, please report at: https://github.com/llsc12/Paicord/issues. case unexpectedComponentKind(Kind) - /// I thought action-row is supposed to only appear at top-level as a container for other components. This is a library decoding issue, please report at: https://github.com/DiscordBM/DiscordBM/issues. + /// I thought action-row is supposed to only appear at top-level as a container for other components. This is a library decoding issue, please report at: https://github.com/llsc12/Paicord/issues. case actionRowIsSupposedToOnlyAppearAtTopLevel public var description: String { diff --git a/PaicordLib/Sources/DiscordModels/Types/Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/Payloads.swift index 138c4e52..f93f11da 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Payloads.swift @@ -3131,4 +3131,26 @@ public enum Payloads { public func validate() -> [ValidationFailure] {} } + + /// https://docs.discord.food/resources/channel#modify-call + public struct ModifyCall: Sendable, Encodable, ValidatablePayload { + public var region: String? + + public init(region: String? = nil) { + self.region = region + } + + public func validate() -> [ValidationFailure] {} + } + + /// https://docs.discord.food/resources/channel#ring-channel-recipients + public struct RingChannelRecipients: Sendable, Encodable, ValidatablePayload { + public var recipients: [UserSnowflake]? + + public init(recipients: [UserSnowflake]? = nil) { + self.recipients = recipients + } + + public func validate() -> [ValidationFailure] {} + } } diff --git a/PaicordLib/Sources/DiscordModels/Types/RTP/RTCP.swift b/PaicordLib/Sources/DiscordModels/Types/RTP/RTCP.swift new file mode 100644 index 00000000..a9bb923d --- /dev/null +++ b/PaicordLib/Sources/DiscordModels/Types/RTP/RTCP.swift @@ -0,0 +1,44 @@ +/// https://github.com/SwiftDiscordAudio/DiscordAudioKit/blob/main/Sources/DiscordRTP/RTCP.swift + +/// https://www.iana.org/assignments/rtp-parameters/rtp-parameters.xhtml#rtp-parameters-4 +enum RTCPControlPacketType { + case smpteTimeCodeMapping + case extendedInterarrivalJitterReport + case senderReport + case receiverReport + case sourceDescription + case goodbye + case applicationDefined + case genericRTPFeedback + case payloadSpecific + case extendedReport + case avbRTCPPacket + case receiverSummaryInformation + case portMapping + case idmsSettings + case reportingGroupReportingSources + case splicingNotificationMessage + + init?(from: UInt8) { + switch from { + case 194: self = .smpteTimeCodeMapping + case 195: self = .extendedInterarrivalJitterReport + case 200: self = .senderReport + case 201: self = .receiverReport + case 202: self = .sourceDescription + case 203: self = .goodbye + case 204: self = .applicationDefined + case 205: self = .genericRTPFeedback + case 206: self = .payloadSpecific + case 207: self = .extendedReport + case 208: self = .avbRTCPPacket + case 209: self = .receiverSummaryInformation + case 210: self = .portMapping + case 211: self = .idmsSettings + case 212: self = .reportingGroupReportingSources + case 213: self = .splicingNotificationMessage + default: + return nil + } + } +} diff --git a/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift b/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift new file mode 100644 index 00000000..ba117417 --- /dev/null +++ b/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift @@ -0,0 +1,249 @@ +/// https://github.com/SwiftDiscordAudio/DiscordAudioKit/blob/main/Sources/DiscordRTP/RTP.swift + +import NIOCore + +/// https://docs.discord.food/topics/voice-connections#rtp-packet-structure +/// +/// FIELD TYPE DESCRIPTION SIZE +/// Version + Flags 1 Unsigned byte The RTP version and flags (always 0x80 for voice) 1 byte +/// Payload Type 2 Unsigned byte The type of payload (0x78 with the default Opus configuration) 1 byte +/// Sequence Unsigned short (big endian) The sequence number of the packet 2 bytes +/// Timestamp Unsigned integer (big endian) The RTC timestamp of the packet 4 bytes +/// SSRC Unsigned integer (big endian) The SSRC of the user 4 bytes +/// Payload Binary data Encrypted audio/video data n bytes +/// +/// Discord expects a playout delay RTP extension header on every video packet. + +/// Represents a Real-time Transport Protocol (RTP) packet used for audio streaming. +/// https://datatracker.ietf.org/doc/html/rfc3550#section-5.1 +public struct RTPPacket: Sendable, RawRepresentable { + // MARK: - First byte + + /// This field identifies the version of RTP. The version defined by + /// this specification is two (2). (The value 1 is used by the first + /// draft version of RTP and the value 0 is used by the protocol + /// initially implemented in the "vat" audio tool.) + /// + /// 2 bits + public let version: UInt8 + + /// If the padding bit is set, the packet contains one or more + /// additional padding octets at the end which are not part of the + /// payload. The last octet of the padding contains a count of how + /// many padding octets should be ignored, including itself. Padding + /// may be needed by some encryption algorithms with fixed block sizes + /// or for carrying several RTP packets in a lower-layer protocol data + /// unit. + /// + /// 1 bit + public let padding: Bool + + /// If the extension bit is set, the fixed header MUST be followed by + /// exactly one header extension, with a format defined in + /// [Section 5.3.1](https://datatracker.ietf.org/doc/html/rfc3550#section-5.3.1). + /// + /// 1 bit + public let `extension`: Bool + + // MARK: - Second byte + + /// The interpretation of the marker is defined by a profile. It is + /// intended to allow significant events such as frame boundaries to + /// be marked in the packet stream. A profile MAY define additional + /// marker bits or specify that there is no marker bit by changing the + /// number of bits in the payload type field + /// + /// 1 bit + public let marker: Bool + + /// This field identifies the format of the RTP payload and determines + /// its interpretation by the application. A profile MAY specify a + /// default static mapping of payload type codes to payload formats. + /// Additional payload type codes MAY be defined dynamically through + /// non-RTP means (see Section 3). A set of default mappings for + /// audio and video is specified in the companion RFC 3551 [1]. An + /// RTP source MAY change the payload type during a session, but this + /// field SHOULD NOT be used for multiplexing separate media streams + /// (see Section 5.2). + /// + /// A receiver MUST ignore packets with payload types that it does not + /// understand. + /// + /// 7 bits + public let payloadType: RTPType + + // MARK: - Byte-aligned fields + + /// The sequence number increments by one for each RTP data packet + /// sent, and may be used by the receiver to detect packet loss and to + /// restore packet sequence. The initial value of the sequence number + /// SHOULD be random (unpredictable) to make known-plaintext attacks + /// on encryption more difficult, even if the source itself does not + /// encrypt according to the method in Section 9.1, because the + /// packets may flow through a translator that does. + /// + /// 16 bits + public let sequence: UInt16 + + /// The timestamp reflects the sampling instant of the first octet in + /// the RTP data packet. The sampling instant MUST be derived from a + /// clock that increments monotonically and linearly in time to allow + /// synchronization and jitter calculations (see Section 6.4.1). The + /// resolution of the clock MUST be sufficient for the desired + /// synchronization accuracy and for measuring packet arrival jitter + /// (one tick per video frame is typically not sufficient). The clock + /// frequency is dependent on the format of data carried as payload + /// and is specified statically in the profile or payload format + /// specification that defines the format, or MAY be specified + /// dynamically for payload formats defined through non-RTP means. If + /// RTP packets are generated periodically, the nominal sampling + /// instant as determined from the sampling clock is to be used, not a + /// reading of the system clock. As an example, for fixed-rate audio + /// the timestamp clock would likely increment by one for each + /// sampling period. If an audio application reads blocks covering + /// 160 sampling periods from the input device, the timestamp would be + /// increased by 160 for each such block, regardless of whether the + /// block is transmitted in a packet or dropped as silent. + /// + /// The initial value of the timestamp SHOULD be random, as for the + /// sequence number. Several consecutive RTP packets will have equal + /// timestamps if they are (logically) generated at once, e.g., belong + /// to the same video frame. Consecutive RTP packets MAY contain + /// timestamps that are not monotonic if the data is not transmitted + /// in the order it was sampled, as in the case of MPEG interpolated + /// video frames. (The sequence numbers of the packets as transmitted + /// will still be monotonic.) + /// + /// RTP timestamps from different media streams may advance at + /// different rates and usually have independent, random offsets. + /// Therefore, although these timestamps are sufficient to reconstruct + /// the timing of a single stream, directly comparing RTP timestamps + /// from different media is not effective for synchronization. + /// Instead, for each medium the RTP timestamp is related to the + /// sampling instant by pairing it with a timestamp from a reference + /// clock (wallclock) that represents the time when the data + /// corresponding to the RTP timestamp was sampled. The reference + /// clock is shared by all media to be synchronized. The timestamp + /// pairs are not transmitted in every data packet, but at a lower + /// rate in RTCP SR packets as described in Section 6.4. + /// + /// The sampling instant is chosen as the point of reference for the + /// RTP timestamp because it is known to the transmitting endpoint and + /// has a common definition for all media, independent of encoding + /// delays or other processing. The purpose is to allow synchronized + /// presentation of all media sampled at the same time. + /// + /// 32 bits + public let timestamp: UInt32 + + /// The SSRC field identifies the synchronization source. + /// + /// 32 bits + public let ssrc: UInt32 + + /// The CSRC list identifies the contributing sources for the payload + /// contained in this packet. The number of identifiers is given by + /// the CC field. If there are more than 15 contributing sources, + /// only 15 can be identified. + /// + /// 0 to 15 items, 32 bits each + public let csrcs: [UInt32] + + /// Remaining payload data + public var payload: ByteBuffer + + public init( + `extension`: Bool, + payloadType: RTPType, + sequence: UInt16, + timestamp: UInt32, + ssrc: UInt32, + payload: ByteBuffer, + marker: Bool = false + ) { + self.version = 2 + self.padding = false + self.extension = `extension` + self.marker = marker + self.payloadType = payloadType + self.sequence = sequence + self.timestamp = timestamp + self.ssrc = ssrc + self.csrcs = [] + self.payload = payload + } + + public init?(rawValue: ByteBuffer) { + var buffer: ByteBuffer = rawValue + guard let firstByte = buffer.readInteger(as: UInt8.self) else { + return nil + } + self.version = (firstByte & 0b11000000) >> 6 + self.padding = ((firstByte & 0b00100000) >> 5) == 1 + self.extension = ((firstByte & 0b00010000) >> 4) == 1 + + // The CSRC count contains the number of CSRC identifiers that + // follow the fixed header. + // We don't bother storing this value since we can get it from the + // csrcs array. + let csrcCount = firstByte & 0b00001111 + + guard let secondByte = buffer.readInteger(as: UInt8.self), + let rtpType = RTPType(rawValue: secondByte & 0b01111111) + else { + return nil + } + self.marker = ((secondByte & 0b10000000) >> 7) == 1 + self.payloadType = rtpType + + guard let sequence = buffer.readInteger(as: UInt16.self), + let timestamp = buffer.readInteger(as: UInt32.self), + let ssrc = buffer.readInteger(as: UInt32.self) + else { + return nil + } + + self.sequence = sequence + self.timestamp = timestamp + self.ssrc = ssrc + + var csrcs: [UInt32] = [] + for _ in 0.. + public enum Kind: Sendable, Codable { + case audio + case video + case screen + case speedtest // test + case __undocumented(String) + } + + /// https://docs.discord.food/topics/voice-connections#stream-resolution-structure + public struct StreamResolution: Sendable, Codable { + public init(type: Kind, width: Int, height: Int) { + self.type = type + self.width = width + self.height = height + } + + public var type: Kind + public var width: Int + public var height: Int + + @UnstableEnum + public enum Kind: Sendable, Codable { + case fixed + case source + case __undocumented(String) + } + } + } + + /// https://docs.discord.food/topics/voice-connections#ready-structure + public struct Ready: Sendable, Codable { + public var ssrc: UInt32 + public var ip: String + public var port: Int + public var modes: [EncryptionMode] + public var experiments: [String] + public var streams: [Stream] + } + + /// https://docs.discord.food/topics/voice-connections#select-protocol-structure + public struct SelectProtocol: Sendable, Codable { + public init( + protocol: String, + data: ProtocolData, + rtc_connection_id: String? = nil, + codecs: [Codec]? = nil, + experiments: [String]? = nil + ) { + self.protocol = `protocol` + self.data = data + self.rtc_connection_id = rtc_connection_id + self.codecs = codecs + self.experiments = experiments + } + + public var `protocol`: String + public var data: ProtocolData + public var rtc_connection_id: String? + public var codecs: [Codec]? + public var experiments: [String]? + + /// https://docs.discord.food/topics/voice-connections#protocol-data-structure + public struct ProtocolData: Sendable, Codable { + public init(address: String, port: Int, mode: EncryptionMode) { + self.address = address + self.port = port + self.mode = mode + } + + public var address: String + public var port: Int + public var mode: EncryptionMode + } + + // never really used + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let `protocol` = try container.decode(String.self, forKey: .protocol) + let data = try container.decode(ProtocolData.self, forKey: .data) + let rtc_connection_id = try container.decodeIfPresent( + String.self, + forKey: .rtc_connection_id + ) + let codecs = try container.decodeIfPresent([Codec].self, forKey: .codecs) + let experiments = try container.decodeIfPresent( + [String].self, + forKey: .experiments + ) + self.init( + protocol: `protocol`, + data: data, + rtc_connection_id: rtc_connection_id, + codecs: codecs, + experiments: experiments + ) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(`protocol`, forKey: .protocol) + try container.encode(data, forKey: .data) + try container.encode(data.address, forKey: .address) + try container.encode(data.port, forKey: .port) + try container.encode(data.mode, forKey: .mode) + try container.encodeIfPresent( + rtc_connection_id, + forKey: .rtc_connection_id + ) + try container.encodeIfPresent(codecs, forKey: .codecs) + try container.encodeIfPresent(experiments, forKey: .experiments) + } + + enum CodingKeys: String, CodingKey { + case `protocol` = "protocol" + case data + case address, port, mode + case rtc_connection_id + case codecs + case experiments + } + } + + /// https://docs.discord.food/topics/voice-connections#encryption-mode + @UnstableEnum + public enum EncryptionMode: Sendable, Codable { + // preferred + case aead_aes256_gcm_rtpsize + // required + case aead_xchacha20_poly1305_rtpsize + // optional, deprecated + case xsalsa20_poly1305_lite_rtpsize + case aead_aes256_gcm + case xsalsa20_poly1305 + case xsalsa20_poly1305_suffix + case xsalsa20_poly1305_lite + case __undocumented(String) + } + + /// https://docs.discord.food/topics/voice-connections#codec-structure + public struct Codec: Sendable, Codable { + public var name: CodecName + public var type: String + public var priority: Int + public var payload_type: Int + public var rtx_payload_type: Int? + public var encode: Bool? + public var decode: Bool? + + public static let opusCodec = Codec( + name: .opus, + type: "audio", + priority: 1000, + payload_type: 120, + rtx_payload_type: nil, + encode: nil, + decode: nil + ) + + public static let h265Codec = Codec( + name: .h265, + type: "video", + priority: 2000, + payload_type: 103, + rtx_payload_type: 104, + encode: true, + decode: true + ) + + public static let h264Codec = Codec( + name: .h264, + type: "video", + priority: 3000, + payload_type: 105, + rtx_payload_type: 106, + encode: true, + decode: true + ) + + @UnstableEnum + public enum CodecName: Sendable, Codable { + case opus + case av1 // AV1 + case h265 // H265 + case h264 // H264 + case vp8 // VP8 + case vp9 // VP9 + case __undocumented(String) + } + } + + /// https://docs.discord.food/topics/voice-connections#session-description-structure + public struct SessionDescription: Sendable, Codable { + public var audio_codec: Codec.CodecName + public var video_codec: Codec.CodecName + public var media_session_id: String + public var mode: EncryptionMode? + public var secret_key: [UInt8] + public var dave_protocol_version: UInt16 + public var sdp: String? // not applicable to udp + public var keyframe_interval: Int? // not applicable to udp + } + + /// https://docs.discord.food/topics/voice-connections#session-update-structure-(send) + /// https://docs.discord.food/topics/voice-connections#session-update-structure-(receive) + public struct SessionUpdate: Sendable, Codable { + public init( + codecs: [Codec] + ) { + self.codecs = codecs + self.audio_codec = nil + self.video_codec = nil + self.media_session_id = nil + self.keyframe_interval = nil + } + + // send properties + public var codecs: [Codec]? + + // receive properties + public var audio_codec: Codec.CodecName? + public var video_codec: Codec.CodecName? + public var media_session_id: String? + public var keyframe_interval: Int? // not applicable to udp + } + + /// https://docs.discord.food/topics/voice-connections#hello-structure + public struct Hello: Sendable, Codable { + public var heartbeat_interval: Int + public var v: Int + } + + /// https://docs.discord.food/topics/voice-connections#heartbeat-structure + public struct Heartbeat: Sendable, Codable { + public init(seq_ack: Int? = nil) { + self.seq_ack = seq_ack + } + public init(t: UInt, seq_ack: Int? = nil) { + self.t = t + self.seq_ack = seq_ack + } + + public var t: UInt = UInt(Date.now.timeIntervalSince1970) + public var seq_ack: Int? = nil + } + + /// https://docs.discord.food/topics/voice-connections#speaking-structure + public struct Speaking: Sendable, Codable { + public init(speaking: IntBitField, ssrc: UInt, delay: UInt? = nil) { + self.speaking = speaking + self.ssrc = ssrc + self.delay = delay + } + + package init( + speaking: IntBitField, + ssrc: UInt, + user_id: UserSnowflake + ) { + self.speaking = speaking + self.ssrc = ssrc + self.user_id = user_id + } + + public var speaking: IntBitField + public var ssrc: UInt + // present on receive + public var user_id: UserSnowflake? + + // send only + public var delay: UInt? = nil + + #if Non64BitSystemsCompatibility + @UnstableEnum + #else + @UnstableEnum + #endif + public enum Flag: Sendable, Codable { + case voice // 0 + case soundshare // 1 + case priority // 2 + + #if Non64BitSystemsCompatibility + case __undocumented(UInt64) + #else + case __undocumented(UInt) + #endif + } + } + + /// https://docs.discord.food/topics/voice-connections#resume-structure + public struct Resume: Sendable, Codable { + public init( + server_id: GuildSnowflake, + channel_id: ChannelSnowflake, + session_id: String, + token: Secret, + seq_ack: Int? = nil + ) { + self.server_id = server_id + self.channel_id = channel_id + self.session_id = session_id + self.token = token + self.seq_ack = seq_ack + } + + public var server_id: GuildSnowflake + public var channel_id: ChannelSnowflake + public var session_id: String + public var token: Secret + public var seq_ack: Int? + } + + /// https://docs.discord.food/topics/voice-connections#clients-connect-structure + public struct ClientsConnect: Sendable, Codable { + public var user_ids: [UserSnowflake] + } + + /// https://docs.discord.food/topics/voice-connections#client-flags-structure + public struct ClientFlags: Sendable, Codable { + public var user_id: UserSnowflake + public var flags: IntBitField + } + + /// https://docs.discord.food/topics/voice-connections#voice-platform + public struct ClientPlatform: Sendable, Codable { + } + + /// https://docs.discord.food/topics/voice-connections#client-disconnect-structure + public struct ClientDisconnect: Sendable, Codable { + public var user_id: UserSnowflake + } + + /// https://docs.discord.food/topics/voice-connections#video-structure + public struct Video: Sendable, Codable { + public init( + audio_ssrc: UInt, + video_ssrc: UInt, + rtx_ssrc: UInt, + streams: [Stream]? = nil + ) { + self.audio_ssrc = audio_ssrc + self.video_ssrc = video_ssrc + self.rtx_ssrc = rtx_ssrc + self.streams = streams + self.user_id = nil + } + + public var audio_ssrc: UInt + public var video_ssrc: UInt + public var rtx_ssrc: UInt + public var streams: [Stream]? + public var user_id: UserSnowflake? // sent by server only + } + + /// https://docs.discord.food/topics/voice-connections#example-media-sink-wants + public struct MediaSinkWants: Sendable, Codable { + public var pixelCounts: [String: Double]? + public var ssrcs: [String: Int] + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + var pixelCounts: [String: Double] = [:] + var ssrcs: [String: Int] = [:] + for key in container.allKeys { + if key.stringValue == "pixelCounts" { + pixelCounts = try container.decode([String: Double].self, forKey: key) + } else { + let value = try container.decode(Int.self, forKey: key) + ssrcs[key.stringValue] = value + } + } + self.pixelCounts = pixelCounts + self.ssrcs = ssrcs + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: DynamicCodingKeys.self) + try container.encode(pixelCounts, forKey: .pixelCounts) + for (key, value) in ssrcs { + try container.encode(value, forKey: .dynamic(key)) + } + } + + public init( + ssrcs: [String: Int], + pixelCounts: [String: Double]? + ) { + self.pixelCounts = pixelCounts + self.ssrcs = ssrcs + } + + private enum DynamicCodingKeys: CodingKey { + case pixelCounts + case dynamic(String) + + init?(stringValue: String) { + if stringValue == "pixelCounts" { + self = .pixelCounts + } else { + self = .dynamic(stringValue) + } + } + + var stringValue: String { + switch self { + case .pixelCounts: + return "pixelCounts" + case .dynamic(let key): + return key + } + } + + init?(intValue: Int) { + return nil + } + + var intValue: Int? { + return nil + } + } + } + + /// https://docs.discord.food/topics/voice-connections#voice-backend-version-structure + public struct VoiceBackendVersion: Sendable, Codable { + public var voice: String? + public var rtc_worker: String? + + public init() { + self.voice = nil + self.rtc_worker = nil + } + } + + public struct DavePrepareTransition: Sendable, Codable { + public var transition_id: UInt16 + public var protocol_version: UInt16 + } + + public struct DaveCommitTransition: Sendable, Codable { + public var transition_id: UInt16 + } + + public struct DavePrepareEpoch: Sendable, Codable { + public var epoch: UInt32 + public var protocol_version: UInt16 + } + + public struct DaveTransitionReady: Sendable, Codable { + public init(transition_id: UInt16) { + self.transition_id = transition_id + } + + public var transition_id: UInt16 + } + + public struct DaveMLSInvalidCommitWelcome: Sendable, Codable { + public init(transition_id: UInt16) { + self.transition_id = transition_id + } + + public var transition_id: UInt16 + } +} diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift new file mode 100644 index 00000000..976011b5 --- /dev/null +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift @@ -0,0 +1,312 @@ +// +// VoiceGateway.swift +// PaicordLib +// +// Created by Lakhan Lothiyi on 19/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import Foundation +import NIOCore + +public struct VoiceGateway: Sendable, Codable { + + /// https://docs.discord.food/topics/voice-connections + public enum Opcode: UInt8, Sendable, Codable, CustomStringConvertible { + // key: + // r - received by client + // s - sent by client + // b - sent/received as binary + case identify = 0 // s + case selectProtocol = 1 // s + case ready = 2 // r + case heartbeat = 3 // s + case sessionDescription = 4 // r + case speaking = 5 // s r + case heartbeatAck = 6 // r + case resume = 7 // s + case hello = 8 // r + case resumed = 9 // r + // signal opcode deprecated, but its 10 jsyk ykyk + case clientsConnect = 11 // r + case video = 12 // r + case clientDisconnect = 13 // r + case sessionUpdate = 14 // s r + case mediaSinkWants = 15 // s r + case voiceBackendVersion = 16 // s r + case channelOptionsUpdate = 17 // unknown + case clientFlags = 18 + case clientPlatform = 20 + // https://github.com/Snazzah/davey/blob/master/docs/USAGE.md + case davePrepareTransition = 21 // r + case daveExecuteTransition = 22 // r + case daveTransitionReady = 23 // s + case davePrepareEpoch = 24 // r + case mlsExternalSender = 25 // b r + case mlsKeyPackage = 26 // b s + case mlsProposals = 27 // b r + case mlsCommitWelcome = 28 // b s + case mlsAnnounceCommitTransition = 29 // b r + case mlsWelcome = 30 // b r + case mlsInvalidCommitWelcome = 31 // s + + public var description: String { + switch self { + case .identify: return "identify" + case .selectProtocol: return "selectProtocol" + case .ready: return "ready" + case .heartbeat: return "heartbeat" + case .sessionDescription: return "sessionDescription" + case .speaking: return "speaking" + case .heartbeatAck: return "heartbeatAck" + case .resume: return "resume" + case .hello: return "hello" + case .resumed: return "resumed" + case .clientsConnect: return "clientsConnect" + case .video: return "video" + case .clientDisconnect: return "clientDisconnect" + case .sessionUpdate: return "sessionUpdate" + case .mediaSinkWants: return "mediaSinkWants" + case .voiceBackendVersion: return "voiceBackendVersion" + case .channelOptionsUpdate: return "channelOptionsUpdate" + case .clientFlags: return "clientFlags" + case .clientPlatform: return "clientPlatform" + case .davePrepareTransition: return "davePrepareTransition" + case .daveExecuteTransition: return "daveExecuteTransition" + case .daveTransitionReady: return "daveTransitionReady" + case .davePrepareEpoch: return "davePrepareEpoch" + case .mlsExternalSender: return "mlsExternalSender" + case .mlsKeyPackage: return "mlsKeyPackage" + case .mlsProposals: return "mlsProposals" + case .mlsCommitWelcome: return "mlsCommitWelcome" + case .mlsAnnounceCommitTransition: return "mlsAnnounceCommitTransition" + case .mlsWelcome: return "mlsWelcome" + case .mlsInvalidCommitWelcome: return "mlsInvalidCommitWelcome" + } + } + } + + /// The top-level gateway event. + /// https://discord.com/developers/docs/topics/gateway#gateway-events + public struct Event: Sendable, Codable { + + /// This enum is just for swiftly organizing Discord gateway event's `data`. + /// You need to read each case's inner payload's documentation for more info. + /// + /// `indirect` is used to mitigate this issue: https://github.com/swiftlang/swift/issues/74303 + indirect public enum Payload: Sendable { + case identify(Identify) + case ready(Ready) + case selectProtocol(SelectProtocol) + case heartbeat(Heartbeat) + case sessionDescription(SessionDescription) + case speaking(Speaking) + case heartbeatAck(Heartbeat) + case resume(Resume) + case hello(Hello) + case resumed + case clientsConnect(ClientsConnect) + case video(Video) + case clientDisconnect(ClientDisconnect) + case sessionUpdate(SessionUpdate) + case mediaSinkWants(MediaSinkWants) + case voiceBackendVersion(VoiceBackendVersion) + // case channelOptionsUpdate + case clientFlags(ClientFlags) + case clientPlatform(ClientPlatform) + + // dave stuff packages the entire frame. + case davePrepareTransition(DavePrepareTransition) + case daveExecuteTransition(DaveCommitTransition) + case daveTransitionReady(DaveTransitionReady) + case davePrepareEpoch(DavePrepareEpoch) + case mlsExternalSender(Data) + case mlsKeyPackage(Data) + case mlsProposals(Data) + case mlsCommitWelcome(Data) + case mlsAnnounceCommitTransition(transitionId: UInt16, commit: Data) + case mlsWelcome(transitionId: UInt16, welcome: Data) + case mlsInvalidCommitWelcome(DaveMLSInvalidCommitWelcome) + + case __undocumented + } + + public enum GatewayDecodingError: Error, CustomStringConvertible { + case unhandledDispatchEvent(type: String?) + case unexpectedBinaryData(message: String) + + public var description: String { + switch self { + case .unhandledDispatchEvent(let type): + return + "Gateway.Event.GatewayDecodingError.unhandledDispatchEvent(type: \(type ?? "nil"))" + case .unexpectedBinaryData(let message): + return + "Gateway.Event.GatewayDecodingError.unexpectedBinaryData(message: \(message))" + } + } + } + + enum CodingKeys: String, CodingKey { + case opcode = "op" + case data = "d" + case sequenceNumber = "s" + } + + public var opcode: Opcode + public var data: Payload? + public var sequenceNumber: Int? + + public init( + opcode: Opcode, + data: Payload? = nil, + sequenceNumber: Int? = nil, + ) { + self.opcode = opcode + self.data = data + self.sequenceNumber = sequenceNumber + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.opcode = try container.decode(Opcode.self, forKey: .opcode) + self.sequenceNumber = try container.decodeIfPresent( + Int.self, + forKey: .sequenceNumber + ) + + func decodeData(as type: D.Type = D.self) throws -> D { + try container.decode(D.self, forKey: .data) + } + + switch opcode { + case .resumed: + guard try container.decodeNil(forKey: .data) else { + throw DecodingError.typeMismatch( + Optional.self, + .init( + codingPath: container.codingPath, + debugDescription: + "`\(opcode)` opcode is supposed to have no data." + ) + ) + } + self.data = nil + case .identify, .selectProtocol, .resume, .daveTransitionReady, + .mlsKeyPackage, .mlsCommitWelcome, .mlsInvalidCommitWelcome: + throw DecodingError.dataCorrupted( + .init( + codingPath: container.codingPath, + debugDescription: + "'\(opcode)' opcode is supposed to never be received." + ) + ) + case .ready: + self.data = .ready(try decodeData()) + case .sessionDescription: + self.data = .sessionDescription(try decodeData()) + case .sessionUpdate: + self.data = .sessionUpdate(try decodeData()) + case .hello: + self.data = .hello(try decodeData()) + case .heartbeat: + self.data = .heartbeat(try decodeData()) + case .speaking: + self.data = .speaking(try decodeData()) + case .heartbeatAck: + self.data = .heartbeatAck(try decodeData()) + case .clientsConnect: + self.data = .clientsConnect(try decodeData()) + case .video: + self.data = .video(try decodeData()) + case .clientDisconnect: + self.data = .clientDisconnect(try decodeData()) + case .mediaSinkWants: + self.data = .mediaSinkWants(try decodeData()) + case .voiceBackendVersion: + self.data = .voiceBackendVersion(try decodeData()) + case .channelOptionsUpdate: + self.data = .__undocumented + case .clientFlags: + self.data = .clientFlags(try decodeData()) + case .clientPlatform: + self.data = .clientPlatform(try decodeData()) + case .davePrepareTransition: + self.data = .davePrepareTransition(try decodeData()) + case .daveExecuteTransition: + self.data = .daveExecuteTransition(try decodeData()) + case .davePrepareEpoch: + self.data = .davePrepareEpoch(try decodeData()) + case .mlsExternalSender, .mlsProposals, .mlsAnnounceCommitTransition, + .mlsWelcome: + print( + "Received an opcode \(opcode.description) that is supposed to be binary, but it came as JSON." + ) + self.data = .none + break + } + } + + public enum EncodingError: Error, CustomStringConvertible { + /// This event is not supposed to be sent at all. This could be a library issue, please report at https://github.com/llsc12/Paicord/issues. + case notSupposedToBeSent(message: String) + + public var description: String { + switch self { + case .notSupposedToBeSent(let message): + return "Gateway.Event.EncodingError.notSupposedToBeSent(\(message))" + } + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self.opcode { + case .ready, .sessionDescription, .heartbeatAck, .hello, .resumed, + .clientsConnect, .video, .clientDisconnect: + throw EncodingError.notSupposedToBeSent( + message: + "`\(self.opcode.rawValue)` opcode is supposed to never be sent." + ) + default: break + } + try container.encode(self.opcode, forKey: .opcode) + + if self.sequenceNumber != nil { + throw EncodingError.notSupposedToBeSent( + message: + "'sequenceNumber' is supposed to never be sent but wasn't nil (\(String(describing: sequenceNumber))." + ) + } + + switch self.data { + case .none: + try container.encodeNil(forKey: .data) + case .identify(let payload): + try container.encode(payload, forKey: .data) + case .selectProtocol(let payload): + try container.encode(payload, forKey: .data) + case .heartbeat(let payload): + try container.encode(payload, forKey: .data) + case .speaking(let payload): + try container.encode(payload, forKey: .data) + case .resume(let payload): + try container.encode(payload, forKey: .data) + case .sessionUpdate(let payload): + try container.encode(payload, forKey: .data) + case .mediaSinkWants(let payload): + try container.encode(payload, forKey: .data) + case .voiceBackendVersion(let payload): + try container.encode(payload, forKey: .data) + case .daveTransitionReady(let payload): + try container.encode(payload, forKey: .data) + default: + throw EncodingError.notSupposedToBeSent( + message: "'\(self)' data is supposed to never be sent." + ) + } + } + } +} diff --git a/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift new file mode 100644 index 00000000..3810425f --- /dev/null +++ b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift @@ -0,0 +1,178 @@ +// +// CryptoExtensions.swift +// PaicordLib +// +// Created by Lakhan Lothiyi on 23/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import Crypto +import DiscordModels +import Foundation +import NIOCore + +/// https://github.com/SwiftDiscordAudio/DiscordAudioKit/blob/main/Sources/DiscordAudioKit/CryptoMode.swift + +extension VoiceGateway.EncryptionMode { + static var supportedCases: [Self] { + [ + .aead_aes256_gcm_rtpsize, + .aead_xchacha20_poly1305_rtpsize, + ] + } + + func decrypt( + fullPacket: Data, + with key: SymmetricKey, + hasExtension: Bool + ) -> Data? { + let aadSize = hasExtension ? 16 : 12 + let rtpHeaderAAD = fullPacket.prefix(aadSize) + + let rtpNonceSuffix = fullPacket.suffix(4) + let tagSize = 16 + + let ciphertextStart = aadSize + let ciphertextEnd = fullPacket.count - tagSize - 4 + + guard ciphertextEnd > ciphertextStart else { return nil } + + let ciphertext = fullPacket[ciphertextStart.. (ciphertext: Data, tag: Data, nonceSuffix: Data)? { + + let nonceSuffixValue: UInt32 = sequence ?? .random(in: .min ... .max) + var leNonceSuffix = nonceSuffixValue.littleEndian + let nonceSuffix = withUnsafeBytes(of: &leNonceSuffix) { Data($0) } + + var nonceData = Data(repeating: 0, count: self.nonceLength) + nonceData.replaceSubrange(0.. ByteBuffer { + + var buffer = ByteBuffer() + buffer.writeBytes(rtpHeader) + buffer.writeBytes(ciphertext) + buffer.writeBytes(tag) + buffer.writeBytes(nonceSuffix) + + return buffer + } + + /// The length of the nonce as it is stored in the RTP packet + private var rtpNonceLength: Int { + switch self { + case .aead_aes256_gcm_rtpsize: + return 4 + case .aead_xchacha20_poly1305_rtpsize: + return 4 + default: + return 24 + } + } + + /// The length of the nonce as required by the crypto algorithm + private var nonceLength: Int { + switch self { + case .aead_aes256_gcm_rtpsize: + // From `AES.GCM.defaultNonceByteCount` + return 12 + case .xsalsa20_poly1305_lite_rtpsize: + // Other implementations sometimes use 24, but swift-crypto + // requires 12. + // From `ChaChaPoly.nonceByteCount` + return 12 + default: + return 24 + } + } + + /// The length of the authentication tag used by the crypto algorithm + private var tagLength: Int { + switch self { + case .aead_aes256_gcm_rtpsize: + // From `AES.GCM.tagByteCount` + return 16 + case .xsalsa20_poly1305_lite_rtpsize: + // From `ChaChaPoly.tagByteCount` + return 16 + default: + return 16 + } + } +} diff --git a/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift b/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift new file mode 100644 index 00000000..5053c31d --- /dev/null +++ b/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift @@ -0,0 +1,170 @@ +// +// VoiceConnection.swift +// PaicordLib +// +// Created by Lakhan Lothiyi on 22/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import AsyncAlgorithms +import NIO + +/// This contains the UDP connection. ``VoiceGatewayManager`` handles lifecycle and also handles send recv. +internal actor VoiceConnection { + private static let keepaliveInterval: Duration = .seconds(5) + + let address: SocketAddress + let inbound: NIOAsyncChannelInboundStream> + let outbound: NIOAsyncChannelOutboundWriter> + let audioStream: AsyncStream + private let audioContinuation: AsyncStream.Continuation + var discoveryContinuation: + CheckedContinuation<(ip: String, port: UInt16)?, Never>? + + private init( + inbound: NIOAsyncChannelInboundStream>, + outbound: NIOAsyncChannelOutboundWriter>, + socketAddress: SocketAddress + ) { + self.inbound = inbound + self.outbound = outbound + self.address = socketAddress + + var continuation: AsyncStream.Continuation! + self.audioStream = AsyncStream { continuation = $0 } + self.audioContinuation = continuation + + Task { + for try await envelope in inbound { + await handleIncoming(envelope.data) + } + audioContinuation.finish() + } + } + + private func handleIncoming(_ data: ByteBuffer) { + // Check packet type first + if let type: UInt16 = data.getInteger(at: 0, endianness: .big), + type == 2, + let continuation = discoveryContinuation + { + + discoveryContinuation = nil + + if let addressData = data.getData(at: 8, length: 64), + let address = String( + data: addressData.prefix( + upTo: addressData.firstIndex(of: 0) ?? addressData.endIndex + ), + encoding: .utf8 + ), + let port = data.getInteger(at: 72, endianness: .little, as: UInt16.self) + { + + continuation.resume(returning: (address, port)) + return + } + + continuation.resume(returning: nil) + return + } + + audioContinuation.yield(data) + } + + static func connect( + host: String, + port: Int, + onConnect: + @Sendable @escaping (VoiceConnection) async throws -> Void + ) async throws { + let remoteAddress = try SocketAddress(ipAddress: host, port: port) + + let localAddress = try SocketAddress(ipAddress: "0.0.0.0", port: 0) + + let server = try await DatagramBootstrap( + group: NIOSingletons.posixEventLoopGroup + ) + .bind(to: localAddress) + .flatMap { channel in + channel.connect(to: remoteAddress).map { channel } + } + .flatMapThrowing { channel in + return try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: NIOAsyncChannel.Configuration( + inboundType: AddressedEnvelope.self, + outboundType: AddressedEnvelope.self + ) + ) + } + .get() + + try await server.executeThenClose { inbound, outbound in + let connection = VoiceConnection( + inbound: inbound, + outbound: outbound, + socketAddress: remoteAddress + ) + + try await onConnect(connection) + } + } + + /// Asks Discord to give us our IP address and port, punching a hole through our local network's NAT (to the wider internet). + /// We can then send this IP to discord via voice gateway payload selectProtocol so they know where to send us audio data. + /// We also keepalive so the route through NAT doesn't collapse. + /// - Parameter ssrc: The SSRC of our audio stream. + /// - Returns: Tuple of IP and port. + func discoverExternalIP( + ssrc: UInt32, + ) async throws -> (ip: String, port: UInt16)? { + guard self.discoveryContinuation == nil else { return nil } + + var buffer = ByteBufferAllocator().buffer(capacity: 74) + buffer.writeInteger(UInt16(1), endianness: .big) // Type + buffer.writeInteger(UInt16(70), endianness: .big) // Length + buffer.writeInteger(ssrc, endianness: .big) // SSRC + buffer.writeBytes(Array(repeating: 0, count: 66)) // Padding + try await outbound.write( + AddressedEnvelope( + remoteAddress: address, + data: buffer + ) + ) + + return await withCheckedContinuation { continuation in + self.discoveryContinuation = continuation + } + } + + func send(buffer: ByteBuffer) async throws { + try await outbound.write( + AddressedEnvelope( + remoteAddress: address, + data: buffer + ) + ) + } + + /// Start sending keepalive packets at regular intervals, keeping the connection alive. + var keepaliveCounter: UInt32 = 0 + func keepalive(ssrc: UInt32) async throws { + // 13 37 CA FE [keepaliveCounter] + // Discord will reply with: + // 13 37 F0 0D [keepaliveCounter] + // but we dont care lol + for await _ in AsyncTimerSequence( + interval: Self.keepaliveInterval, + clock: .continuous + ) { + var buffer: ByteBuffer = ByteBufferAllocator().buffer(capacity: 8) + buffer.writeInteger(UInt16(0x1337), endianness: .big) + buffer.writeInteger(UInt16(0xCAFE), endianness: .big) + buffer.writeInteger(keepaliveCounter, endianness: .little) + try await send(buffer: buffer) + self.keepaliveCounter += 1 + } + } +} + diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift new file mode 100644 index 00000000..11209722 --- /dev/null +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -0,0 +1,1773 @@ +// +// VoiceGatewayManager.swift +// PaicordLib +// +// Created by Lakhan Lothiyi on 19/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import AsyncAlgorithms +import AsyncHTTPClient +import Atomics +import Crypto +import DaveKit +import DiscordGateway +import DiscordModels +import Foundation +import Logging +import NIO +import WSClient + +import enum NIOWebSocket.WebSocketErrorCode +import struct NIOWebSocket.WebSocketOpcode + +/// https://docs.discord.food/topics/voice-connections#voice-data-interpolation + +/// This actor manages a voice gateway connection, handling the WebSocket communication and +/// UDP audio transmission, as well as the necessary encryption and decryption of audio data. +public actor VoiceGatewayManager { + private struct Message { + let payload: VoiceGateway.Event + let opcode: WebSocketOpcode? + let connectionId: UInt? + var tryCount: Int + + init( + payload: VoiceGateway.Event, + opcode: WebSocketOpcode? = nil, + connectionId: UInt? = nil, + tryCount: Int = 0 + ) { + self.payload = payload + self.opcode = opcode + self.connectionId = connectionId + self.tryCount = tryCount + } + } + + /// Structure used to initialise voice connections + public struct ConnectionData { + public var token: Secret // voice token + public var guildID: GuildSnowflake + public var channelID: ChannelSnowflake + public var userID: UserSnowflake + public var sessionID: String + public var endpoint: String? + + public init( + token: Secret, + guildID: GuildSnowflake, + channelID: ChannelSnowflake, + userID: UserSnowflake, + sessionID: String, + endpoint: String + ) { + self.token = token + self.guildID = guildID + self.channelID = channelID + self.userID = userID + self.sessionID = sessionID + self.endpoint = endpoint + } + } + + var outboundWriter: WebSocketOutboundWriter? + let eventLoopGroup: any EventLoopGroup + + /// Max frame size we accept to receive through the web-socket connection. + let maxFrameSize: Int + /// Generator of `UserGatewayManager` ids. + static let idGenerator = ManagedAtomic(UInt(0)) + /// This gateway manager's identifier. + public nonisolated let id = idGenerator.wrappingIncrementThenLoad( + ordering: .relaxed + ) + let logger: Logger + + private var lastSentPingNonce: Int = 0 + + private var connectionData: ConnectionData + + //MARK: Event streams + var eventsStreamContinuations = [ + AsyncStream.Continuation + ]() + var eventsParseFailureContinuations = [ + AsyncStream<(any Error, ByteBuffer)>.Continuation + ]() + + /// An async sequence of Gateway events. + public var events: DiscordAsyncSequence { + DiscordAsyncSequence( + base: AsyncStream { continuation in + self.eventsStreamContinuations.append(continuation) + } + ) + } + /// An async sequence of Gateway event parse failures. + public var eventFailures: DiscordAsyncSequence<(any Error, ByteBuffer)> { + DiscordAsyncSequence<(any Error, ByteBuffer)>( + base: AsyncStream<(any Error, ByteBuffer)> { continuation in + self.eventsParseFailureContinuations.append(continuation) + } + ) + } + + //MARK: Connection data + // discord uses this for analytics but we'll send it anyways + public nonisolated let rtcConnectionID = UUID().uuidString.lowercased() + + //MARK: Connection state + public nonisolated let state = ManagedAtomic(GatewayState.noConnection) + public nonisolated let stateCallback: (@Sendable (GatewayState) -> Void)? + + //MARK: UDP Connection + /// Created upon ready event receive + private var udpConnection: VoiceConnection? = nil + private var udpConnectionTask: Task? + /// Once the session description event is received, we can listen. + private var udpListeningTask: Task? + private var udpSpeakingTask: Task? + + private var pendingOpusFrames = OpusFrameRing(capacity: 10) + + private var recvBuffer = ReceiveBuffer(targetFrames: 5, maxFrames: 50) + + /// This contains the speaking payload to send next when there is data to send over UDP. + public var nextSpeakingPayload: VoiceGateway.Speaking? = nil + + private lazy var dave: DaveSessionManager = { + return DaveSessionManager( + selfUserId: connectionData.userID.rawValue, + groupId: .init(connectionData.channelID.rawValue) ?? 0, + delegate: self, + ) + }() + + var audioSSRC: UInt { + return self.knownSSRCs.first(where: { + $0.value == self.connectionData.userID + })?.key ?? 0 + } + + /// Incoming channel for you to iterate over. The payload is modified to contain a decrypted Opus frame. + public let incomingAudioChannel = AsyncChannel() + + //MARK: Send queue + + /// 120 per 60 seconds (1 every 500ms), + /// per https://discord.com/developers/docs/topics/gateway#rate-limiting + let sendQueue = SerialQueue(waitTime: .milliseconds(500)) + + //MARK: Current connection properties + + /// An ID to keep track of connection changes. + nonisolated let connectionId = ManagedAtomic(UInt(0)) + + //MARK: Resume-related current-connection properties + + /// The sequence number for the payloads sent to us. + var sequenceNumber: Int? = nil + /// Gateway URL for connecting and resuming connections. + var resumeGatewayURL: String? { + return connectionData.endpoint.map { "wss://\($0)" } + } + + //MARK: Backoff + + /// Discord cares about the identify payload for rate-limiting and if you send + /// more than 1000 identifies in a day, Discord will revoke your bot token + /// (unless your bot is big enough that has a bigger identify-limit than 1000 per day). + /// This does not apply for users, but could be deemed suspicious behaviour. + /// + /// This Backoff does not necessarily prevent your bot token getting revoked, + /// but in the worst case, doesn't let it happen sooner than ~6 hours. + /// This also helps in other situations, for example when there is a Discord outage. + let connectionBackoff = Backoff( + base: 2, + maxExponentiation: 7, + coefficient: 1, + minBackoff: 15 + ) + + //MARK: Ping-pong tracking properties + var unsuccessfulPingsCount = 0 + var lastPongDate = Date() + + public init( + eventLoopGroup: any EventLoopGroup = HTTPClient.shared.eventLoopGroup, + maxFrameSize: Int = 1 << 28, + connectionData: ConnectionData, + stateCallback: (@Sendable (GatewayState) -> Void)? = nil + ) { + self.eventLoopGroup = eventLoopGroup + self.stateCallback = stateCallback + self.maxFrameSize = maxFrameSize + self.connectionData = connectionData + + var logger = DiscordGlobalConfiguration.makeLogger("VoiceGatewayManager") + logger[metadataKey: "gateway-id"] = .string("\(self.id)") + self.logger = logger + } + + /// Connects to Discord. + /// `state` must be set to an appropriate value before triggering this function. + public func connect() async { + logger.debug("Connect method triggered") + /// Guard we're attempting to connect too fast + if let connectIn = connectionBackoff.canPerformIn() { + logger.warning( + "Cannot try to connect immediately due to backoff", + metadata: [ + "wait-time": .stringConvertible(connectIn) + ] + ) + try? await Task.sleep(for: connectIn) + } + /// Guard if other connections are in process + let state = self.state.load(ordering: .relaxed) + guard [.noConnection, .configured, .stopped].contains(state) else { + logger.error( + "Gateway state doesn't allow a new connection", + metadata: [ + "state": .stringConvertible(state) + ] + ) + return + } + self.state.store(.connecting, ordering: .relaxed) + self.stateCallback?(.connecting) + + await self.sendQueue.reset() + let gatewayURL = self.resumeGatewayURL ?? "" + let queries: [(String, String)] = [ + ("v", "\(DiscordGlobalConfiguration.apiVersion)") + ] + let configuration = WebSocketClientConfiguration( + maxFrameSize: self.maxFrameSize, + additionalHeaders: [ + .userAgent: SuperProperties.useragent(ws: false)!, + .origin: "https://discord.com", + .cacheControl: "no-cache", + .acceptLanguage: SuperProperties.GenerateLocaleHeader(), + + ], + extensions: [] + ) + + logger.trace("Will try to connect to Discord through web-socket") + let connectionId = self.connectionId.wrappingIncrementThenLoad( + ordering: .relaxed + ) + /// FIXME: remove this `Task` in a future major version. + /// This is so the `connect()` method does still exit, like it used to. + /// But for proper structured concurrency, this method should never exit (optimally). + Task { + do { + let url = gatewayURL + "/" + queries.makeForURLQuery() + let closeFrame = try await WebSocketClient.connect( + url: url, + configuration: configuration, + eventLoopGroup: self.eventLoopGroup, + logger: self.logger + ) { inbound, outbound, context in + await self.setupOutboundWriter(outbound) + + self.logger.debug( + "Connected to Discord through web-socket. Will configure" + ) + await self.sendResumeOrIdentify() + self.state.store(.configured, ordering: .relaxed) + self.stateCallback?(.configured) + + for try await message in inbound.messages(maxSize: self.maxFrameSize) + { + await self.processBinaryData( + message, + forConnectionWithId: connectionId + ) + } + } + + logger.debug( + "web-socket connection closed", + metadata: [ + "closeCode": .string(String(reflecting: closeFrame?.closeCode)), + "closeReason": .string(String(reflecting: closeFrame?.reason)), + "connectionId": .stringConvertible( + self.connectionId.load(ordering: .relaxed) + ), + ] + ) + await self.onClose( + closeReason: .closeFrame(closeFrame), + forConnectionWithId: connectionId + ) + } catch { + logger.debug( + "web-socket error while connecting to Discord. Will try again", + metadata: [ + "error": .string(String(reflecting: error)), + "connectionId": .stringConvertible( + self.connectionId.load(ordering: .relaxed) + ), + ] + ) + self.state.store(.noConnection, ordering: .relaxed) + self.stateCallback?(.noConnection) + await self.onClose( + closeReason: .error(error), + forConnectionWithId: connectionId + ) + } + } + } + + // MARK: - Internal event handling and connection management + // required to manage connection. library users can watch events to get this instead. + private var knownSSRCs: [UInt: UserSnowflake] = [:] + + private func processEvent(_ event: VoiceGateway.Event) async { + if let sequenceNumber = event.sequenceNumber { + self.sequenceNumber = sequenceNumber + } + + switch event.data { + case .heartbeatAck: + self.lastPongDate = Date() + self.unsuccessfulPingsCount = 0 + logger.trace( + "Received heartbeat ack/pong", + metadata: [ + "opcode": .string(event.opcode.description) + ] + ) + case .hello(let payload): + self.setupPingTask( + forConnectionWithId: self.connectionId.load(ordering: .relaxed), + every: .milliseconds(payload.heartbeat_interval / 2) + ) + case .ready(let payload): + await self.onSuccessfulConnection() + await self.dave.assign(ssrc: payload.ssrc, to: .opus) + self.knownSSRCs[UInt(payload.ssrc)] = self.connectionData.userID + setupUDP(payload) + case .sessionDescription(let payload): + await self.dave.selectProtocol( + protocolVersion: UInt16(payload.dave_protocol_version) + ) + + self.nextSpeakingPayload = .init( + speaking: [.voice], + ssrc: audioSSRC, + delay: 0 + ) + + self.listen(description: payload) + self.speak(description: payload) + + self.requestVoiceBackendVersion() + case .speaking(let payload): + if let id = payload.user_id { + self.didEmitSpeaking[id] = ( + didEmit: !payload.speaking.isEmpty, remainingSilenceFrames: 5, + lastKnownFlags: payload.speaking + ) + } + self.knownSSRCs[payload.ssrc] = payload.user_id + case .clientsConnect(let payload): + for id in payload.user_ids { + await self.dave.addUser(userId: id.rawValue) + } + case .clientDisconnect(let payload): + self.didEmitSpeaking.removeValue(forKey: payload.user_id) + self.knownSSRCs = self.knownSSRCs.filter { $0.value != payload.user_id } + await self.dave.removeUser(userId: payload.user_id.rawValue) + + self.recvBuffer.removeStreamsNotIn( + allowedSSRCs: Set(self.knownSSRCs.keys.map { UInt32($0) }) + ) + + case .davePrepareTransition(let payload): + await self.dave.prepareTransition( + transitionId: payload.transition_id, + protocolVersion: payload.protocol_version + ) + case .daveExecuteTransition(let payload): + await self.dave.executeTransition(transitionId: payload.transition_id) + case .davePrepareEpoch(let payload): + await self.dave.prepareEpoch( + epoch: String(payload.epoch), + protocolVersion: payload.protocol_version + ) + case .mlsExternalSender(let data): + await self.dave.mlsExternalSenderPackage(externalSenderPackage: data) + case .mlsProposals(let data): + await self.dave.mlsProposals(proposals: data) + case .mlsAnnounceCommitTransition(let transitionId, let commit): + await self.dave.mlsPrepareCommitTransition( + transitionId: transitionId, + commit: commit + ) + case .mlsWelcome(let transitionId, let welcome): + await self.dave.mlsWelcome(transitionId: transitionId, welcome: welcome) + default: + break + } + } + + // MARK: - UDP setup + + func setupUDP(_ payload: VoiceGateway.Ready) { + self.udpConnectionTask = Task { + do { + try await VoiceConnection.connect( + host: payload.ip, + port: Int(payload.port) + ) { connection in + guard + let (ip, port) = try await connection.discoverExternalIP( + ssrc: payload.ssrc, + ) + else { + self.logger.error("Failed to discover external IP and port") + return + } + + guard + let mode = VoiceGateway.EncryptionMode.supportedCases.first(where: { + mode in + payload.modes.contains(mode) + }) + else { + self.logger.error("No supported crypto modes found") + return + } + + self.send( + message: .init( + payload: .init( + opcode: .selectProtocol, + data: .selectProtocol( + .init( + protocol: "udp", + data: .init( + address: ip, + port: .init(port), + mode: mode + ), + rtc_connection_id: self.rtcConnectionID, + codecs: [ + .opusCodec, + .h264Codec, + .h265Codec, + ], + experiments: nil + ) + ) + ), + opcode: .text + ) + ) + + await self.storeConnection(connection) + + // When this function returns, the UDP connection will be closed, so we + // need to keep it alive. Other things will be handled in other tasks. + // Luckily, we also need to send keepalive packets to the voice server. + // We can accomplish both requirements by awaiting the keepalive task + // here. + try await connection.keepalive(ssrc: payload.ssrc) + } + } catch { + self.logger.error( + "Failed to establish UDP connection", + metadata: [ + "error": .string(String(reflecting: error)), + "ip": .string(payload.ip), + "port": .stringConvertible(payload.port), + ] + ) + } + } + } + private func storeConnection(_ connection: VoiceConnection) { + self.udpConnection = connection + } + + // MARK: - Speaking + + private struct OpusFrameRing { + private var buf: [Data?] + private var head = 0 + private var tail = 0 + private(set) var count = 0 + let capacity: Int + + init(capacity: Int) { + self.capacity = capacity + self.buf = Array(repeating: nil, count: capacity) + } + + mutating func push(_ frame: Data) { + if count == capacity { + // drop oldest + buf[head] = nil + head = (head + 1) % capacity + count -= 1 + } + buf[tail] = frame + tail = (tail + 1) % capacity + count += 1 + } + + mutating func pop() -> Data? { + guard count > 0 else { return nil } + let v = buf[head] + buf[head] = nil + head = (head + 1) % capacity + count -= 1 + return v + } + } + + /// Writes Opus data out through UDP. + private func speak( + description: VoiceGateway.SessionDescription + ) { + guard let mode = description.mode, + VoiceGateway.EncryptionMode.supportedCases.contains(mode) + else { + logger.error( + "Unsupported crypto mode: \(description.mode?.rawValue ?? "nil")" + ) + return + } + + udpSpeakingTask = Task { + var sequence: UInt16 = 0 + var timestamp: UInt32 = 0 + + let clock = ContinuousClock() + let interval: Duration = .milliseconds(20) + + let key = SymmetricKey(data: description.secret_key) + + let silenceTailCount = 5 + var silenceFramesRemaining = 0 + + // send like 3 silence frames to ensure discord knows we connected. + // otherwise discord wont send us audio data. + for _ in 1...3 { + await sendSilence( + sequence: sequence, + timestamp: timestamp, + ssrc: .init(audioSSRC), + mode: mode, + key: key + ) + timestamp &+= 960 + sequence &+= 1 + try? await clock.sleep(for: interval) + } + + while !Task.isCancelled { + let start = clock.now + + let frame = pendingOpusFrames.pop() + + if let frame { + if let payload = self.nextSpeakingPayload { + self.updateSpeaking(payload: payload) + self.nextSpeakingPayload = nil + + // emit speaking event for ourselves, so application layer can use gateway events for speaking indicators. + self.eventsStreamContinuations.forEach { + $0.yield( + .init( + opcode: .speaking, + data: .speaking( + .init( + speaking: payload.speaking, + ssrc: self.audioSSRC, + user_id: self.connectionData.userID, + ) + ) + ) + ) + } + } + + let sequence = sequence + let timestamp = timestamp + Task { + await sendOpusPacket( + frame: frame, + sequence: sequence, + timestamp: timestamp, + ssrc: .init(audioSSRC), + mode: mode, + key: key + ) + } + silenceFramesRemaining = silenceTailCount + + } else if silenceFramesRemaining > 0 { + let sequence = sequence + let timestamp = timestamp + Task { + await sendSilence( + sequence: sequence, + timestamp: timestamp, + ssrc: .init(audioSSRC), + mode: mode, + key: key + ) + } + silenceFramesRemaining -= 1 + + if silenceFramesRemaining == 0 { + self.eventsStreamContinuations.forEach { + $0.yield( + .init( + opcode: .speaking, + data: .speaking( + .init( + speaking: [], + ssrc: self.audioSSRC, + user_id: self.connectionData.userID, + ) + ) + ) + ) + } + } + + } else { + try? await clock.sleep(until: start + interval) + continue + } + + timestamp &+= 960 + sequence &+= 1 + try? await clock.sleep(until: start + interval) + } + } + } + + private func sendSilence( + sequence: UInt16, + timestamp: UInt32, + ssrc: UInt32, + mode: VoiceGateway.EncryptionMode, + key: SymmetricKey + ) async { + // Discord Opus silence frame + let silence = Data([0xF8, 0xFF, 0xFE]) + + await sendOpusPacket( + frame: silence, + sequence: sequence, + timestamp: timestamp, + ssrc: ssrc, + mode: mode, + key: key + ) + } + + func sendOpusPacket( + frame: Data, + sequence: UInt16, + timestamp: UInt32, + ssrc: UInt32, + mode: VoiceGateway.EncryptionMode, + key: SymmetricKey + ) async { + guard let udpConnection = self.udpConnection else { return } + + let headerPacket = RTPPacket( + extension: false, + payloadType: .dynamic(.init(VoiceGateway.Codec.opusCodec.payload_type)), + sequence: sequence, + timestamp: timestamp, + ssrc: ssrc, + payload: ByteBuffer() + ) + var headerBuffer = headerPacket.rawValue + guard let headerBytes = headerBuffer.readBytes(length: 12) else { + logger.error("Failed to extract RTP header") + return + } + let headerData = Data(headerBytes) + + let daveEncrypted: Data + do { + daveEncrypted = try await self.dave.encrypt( + ssrc: ssrc, + data: frame, + mediaType: .audio + ) + } catch { + logger.error( + "DAVE encryption failed", + metadata: ["error": .string(String(reflecting: error))] + ) + return + } + + guard + let encrypted = mode.encrypt( + buffer: daveEncrypted, + using: key, + additionalData: headerData, + sequence: .init(sequence) + ) + else { + logger.error("Symmetric voice encryption failed") + return + } + + var packet = ByteBuffer() + packet.writeBytes(headerBytes) + packet.writeBytes(encrypted.ciphertext) + packet.writeBytes(encrypted.tag) + packet.writeBytes(encrypted.nonceSuffix) + + do { + try await udpConnection.send(buffer: packet) + } catch { + logger.error( + "Failed to send UDP voice packet", + metadata: ["error": .string(String(reflecting: error))] + ) + } + } + + public func enqueueOpusFrame(_ frame: Data) { + pendingOpusFrames.push(frame) + } + + // MARK: - Listening + + /// Start listening for incoming audio packets on the UDP connection. + private func listen( + description: VoiceGateway.SessionDescription, + ) { + guard let encryption = description.mode, + VoiceGateway.EncryptionMode.supportedCases.contains(encryption) + else { + logger.error( + "Unsupported crypto mode: \(description.mode?.rawValue ?? "nil")" + ) + return + } + + let key = SymmetricKey(data: description.secret_key) + + self.udpListeningTask = Task { + guard let udpConnection = self.udpConnection else { + self.logger.error( + "UDP connection not established when trying to listen" + ) + return + } + + defer { + // When the UDP listening ends, cancel the UDP connection task + self.logger.debug( + "UDP listening task ending, cancelling UDP connection task" + ) + self.udpConnectionTask?.cancel() + } + + print("[VoiceGW] Started listening for voice data packets") + for await data in udpConnection.audioStream { + if let packet = RTPPacket(rawValue: data) { + await self.processIncomingVoicePacket( + packet, + rawPayload: data, + mode: encryption, + key: key + ) + } else { + continue + } + } + } + } + + var didEmitSpeaking: + [UserSnowflake: ( + didEmit: Bool, remainingSilenceFrames: UInt8, + lastKnownFlags: IntBitField + )] = [:] + + /// Process an incoming voice packet. Voice packets are RTP packets that are encrypted + /// using the selected crypto mode and key, E2EE encrypted using Dave, and then encoded + /// using OPUS. + private func processIncomingVoicePacket( + _ packet: RTPPacket, + rawPayload: ByteBuffer, + mode: VoiceGateway.EncryptionMode, + key: SymmetricKey + ) async { + var packet = packet + var buffer = packet.payload // copy for reading + // First, decrypt the RTP packet payload + + var extensionLength: UInt16? + if packet.extension { + // If the packet has an extension, the metadata for the extension is stored + // outside of the encrypted portion of the payload, but the extension data itself + // is encrypted. This is not compliant with the RTP spec, but is how Discord + // implements it. + guard buffer.readInteger(as: UInt16.self) != nil, // extension info + let length = buffer.readInteger(as: UInt16.self) + else { + return + } + + extensionLength = length + } + guard + var data = mode.decrypt( + fullPacket: .init(buffer: rawPayload, byteTransferStrategy: .noCopy), + with: key, + hasExtension: packet.extension + ) + else { + return print("[VoiceGW] Failed to decrypt RTP packet") + } + + if let extensionLength { + data.removeFirst(Int(extensionLength) * 4) + } + + if data.isEmpty { + return + } + + // We've removed the crypto layer, now to remove the Dave E2EE layer + + guard let userId = knownSSRCs[.init(packet.ssrc)] else { + return + } + + guard + let data = try? await dave.decrypt( + userId: userId.rawValue, + data: data, + mediaType: .audio + ) + else { + return + } + + // fake speaking indicator logic based on whether the opus frame is a silence frame or not. + // makes application layer easier to implement speaking indicators. + if let userID = self.knownSSRCs[.init(packet.ssrc)] { + // check if the packet opus frame is silence, if so, set speaking to false after 5 consecutive silence frames. + let current = + didEmitSpeaking[userID] ?? ( + didEmit: false, remainingSilenceFrames: 0, lastKnownFlags: .init() + ) + let silenceFrame = Data([0xF8, 0xFF, 0xFE]) + + if data == silenceFrame { + if !current.didEmit { + didEmitSpeaking[userID] = current + } else { + if current.remainingSilenceFrames > 0 { + didEmitSpeaking[userID] = ( + didEmit: true, + remainingSilenceFrames: current.remainingSilenceFrames - 1, + lastKnownFlags: current.lastKnownFlags + ) + } else { + didEmitSpeaking[userID] = ( + didEmit: false, + remainingSilenceFrames: 0, + lastKnownFlags: current.lastKnownFlags + ) + + self.eventsStreamContinuations.forEach { + $0.yield( + .init( + opcode: .speaking, + data: .speaking( + .init( + speaking: [], + ssrc: .init(packet.ssrc), + user_id: .init(userID) + ) + ) + ) + ) + } + } + } + } else { + didEmitSpeaking[userID] = ( + didEmit: true, + remainingSilenceFrames: 5, + lastKnownFlags: current.lastKnownFlags + ) + + if !current.didEmit { + self.eventsStreamContinuations.forEach { + $0.yield( + .init( + opcode: .speaking, + data: .speaking( + .init( + speaking: current.lastKnownFlags.isEmpty + ? [.voice] : current.lastKnownFlags, + ssrc: .init(packet.ssrc), + user_id: .init(userID) + ) + ) + ) + ) + } + } + } + } + + // modify packet payload to be decrypted frame, easier for application to use with bundled ssrc and other data. + packet.payload = ByteBuffer(data: data) + + await forwardBuffered(packet) + } + + private func forwardBuffered(_ packet: RTPPacket) async { + recvBuffer.push(packet) + + while let next = recvBuffer.popIfReady(ssrc: packet.ssrc) { + await incomingAudioChannel.send(next) + } + } + + private struct ReceiveBuffer { + struct Stream { + var queue: [RTPPacket] = [] + var started: Bool = false + } + + let targetFrames: Int + let maxFrames: Int + var streams: [UInt32: Stream] = [:] + + init(targetFrames: Int, maxFrames: Int) { + self.targetFrames = targetFrames + self.maxFrames = maxFrames + } + + mutating func push(_ packet: RTPPacket) { + var stream = streams[packet.ssrc] ?? Stream() + + stream.queue.append(packet) + + if stream.queue.count > maxFrames { + let overflow = stream.queue.count - maxFrames + stream.queue.removeFirst(overflow) + } + + if !stream.started, stream.queue.count >= targetFrames { + stream.started = true + } + + streams[packet.ssrc] = stream + } + + mutating func popIfReady(ssrc: UInt32) -> RTPPacket? { + guard var stream = streams[ssrc] else { return nil } + guard stream.started else { return nil } + guard !stream.queue.isEmpty else { return nil } + + let pkt = stream.queue.removeFirst() + streams[ssrc] = stream + return pkt + } + + mutating func removeStreamsNotIn(allowedSSRCs: Set) { + streams = streams.filter { allowedSSRCs.contains($0.key) } + } + } + + // MARK: - Gateway actions + + /// https://docs.discord.food/topics/voice-connections#simulcasting + public func mediaSinkWants(payload: VoiceGateway.MediaSinkWants) { + self.send( + message: .init( + payload: .init( + opcode: .mediaSinkWants, + data: .mediaSinkWants(payload) + ), + opcode: .text + ) + ) + } + + /// https://docs.discord.food/topics/voice-connections#video + public func updateVideo(payload: VoiceGateway.Video) { + self.send( + message: .init( + payload: .init( + opcode: .video, + data: .video(payload) + ), + opcode: .text + ) + ) + } + + /// https://docs.discord.food/topics/voice-connections#session-updates + public func updateSession(payload: VoiceGateway.SessionUpdate) { + self.send( + message: .init( + payload: .init( + opcode: .sessionUpdate, + data: .sessionUpdate(payload) + ), + opcode: .text + ) + ) + } + + /// https://docs.discord.food/topics/voice-connections#speaking + public func updateSpeaking(payload: VoiceGateway.Speaking) { + self.send( + message: .init( + payload: .init( + opcode: .speaking, + data: .speaking(payload) + ), + opcode: .text + ) + ) + } + + /// https://docs.discord.food/topics/voice-connections#voice-backend-version + public func requestVoiceBackendVersion() { + self.send( + message: .init( + payload: .init( + opcode: .voiceBackendVersion, + data: .voiceBackendVersion(.init()) + ), + opcode: .text + ) + ) + } + + // MARK: End of Gateway actions - + + /// Makes an stream of Gateway events. + @available(*, deprecated, renamed: "events") + public func makeEventsStream() -> AsyncStream { + self.events.base + } + + /// Makes an stream of Gateway event parse failures. + @available(*, deprecated, renamed: "eventFailures") + public func makeEventsParseFailureStream() -> AsyncStream< + (any Error, ByteBuffer) + > { + self.eventFailures.base + } + + /// Disconnects from Discord. + /// Doesn't end the event streams. + public func disconnect() async { + logger.debug( + "Will disconnect", + metadata: [ + "connectionId": .stringConvertible( + self.connectionId.load(ordering: .relaxed) + ) + ] + ) + if self.state.load(ordering: .relaxed) == .stopped { + logger.debug( + "Already disconnected", + metadata: [ + "connectionId": .stringConvertible( + self.connectionId.load(ordering: .relaxed) + ) + ] + ) + return + } + self.connectionId.wrappingIncrement(ordering: .relaxed) + self.state.store(.noConnection, ordering: .relaxed) + self.stateCallback?(.noConnection) + connectionBackoff.resetTryCount() + await self.sendQueue.reset() + await self.closeWebSocket() + // cancel udp connection tasks + self.udpConnectionTask?.cancel() + self.udpListeningTask?.cancel() + self.udpSpeakingTask?.cancel() + self.udpConnection = nil + self.nextSpeakingPayload = nil + + self.recvBuffer = ReceiveBuffer(targetFrames: 5, maxFrames: 50) + self.knownSSRCs = [:] + self.didEmitSpeaking = [:] + } +} + +extension VoiceGatewayManager { + private func sendResumeOrIdentify() async { + if let lastSequenceNumber = self.sequenceNumber { + self.sendResume(sequenceNumber: lastSequenceNumber) + } else { + logger.debug( + "Can't resume last Discord connection. Will identify", + metadata: [ + "lastSequenceNumber": .stringConvertible(self.sequenceNumber ?? -1) + ] + ) + await self.sendIdentify() + } + } + + private func sendResume(sequenceNumber: Int) { + let resume = VoiceGateway.Event( + opcode: .resume, + data: .resume( + .init( + server_id: connectionData.guildID, + channel_id: connectionData.channelID, + session_id: connectionData.sessionID, + token: connectionData.token, + seq_ack: sequenceNumber + ) + ) + ) + self.send( + message: .init( + payload: resume, + opcode: .text + ) + ) + + /// Invalidate `sequenceNumber` info for the next connection, incase this one fails. + /// This will be a notice for the next connection to + /// not try resuming anymore, if this connection has failed. + self.sequenceNumber = nil + + logger.debug("Sent resume request to Discord") + } + + private func sendIdentify() async { + connectionBackoff.willTry() + let identify = VoiceGateway.Event( + opcode: .identify, + data: .identify(.init( + server_id: connectionData.guildID , + channel_id: connectionData.channelID, + user_id: connectionData.userID, + session_id: connectionData.sessionID, + token: connectionData.token, + video: true, + streams: [ + .init( + type: .video, + rid: "100", + quality: 100 + ), + .init( + type: .video, + rid: "50", + quality: 50 + ), + ] + )) + ) + self.send(message: .init(payload: identify, opcode: .text)) + } + + private func processBinaryData( + _ message: WebSocketMessage, + forConnectionWithId connectionId: UInt + ) async { + guard self.connectionId.load(ordering: .relaxed) == connectionId else { + return + } + + var buffer: ByteBuffer + let isBinary: Bool + switch message { + case .text(let string): + self.logger.debug( + "Got text from websocket", + metadata: [ + "text": .string(string) + ] + ) + isBinary = false + buffer = ByteBuffer(string: string) + case .binary(let _buffer): + self.logger.debug( + "Got binary from websocket", + metadata: [ + "text": .string(String(buffer: _buffer)) + ] + ) + isBinary = true + buffer = _buffer + } + + // check if the raw data is a binary message with valid opcode or json message. + do { + let event = try self.tryDecodeBufferAsEvent(&buffer, binary: isBinary) + await self.processEvent(event) + for continuation in self.eventsStreamContinuations { + continuation.yield(event) + } + } catch { + self.logger.debug( + "Failed to decode event", + metadata: [ + "error": .string("\(error)") + ] + ) + for continuation in self.eventsParseFailureContinuations { + continuation.yield((error, buffer)) + } + } + } + + func tryDecodeBufferAsEvent(_ buffer: inout ByteBuffer, binary: Bool) throws + -> VoiceGateway.Event + { + if binary { + // https://github.com/Snazzah/davey/blob/master/docs/USAGE.md + guard let seq = buffer.readInteger(as: UInt16.self) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: [], + debugDescription: + "Expected the first 2 bytes of the binary data to be the sequence number, but it couldn't be read as UInt16." + ) + ) + } + self.sequenceNumber = .init(seq) + + guard let opcode = buffer.readInteger(as: UInt8.self), + let opcode = VoiceGateway.Opcode(rawValue: opcode) + else { + throw DecodingError.dataCorrupted( + .init( + codingPath: [], + debugDescription: + "Expected the 3rd byte of the binary data to be the opcode, but it couldn't be read as UInt8 or didn't match any known opcode." + ) + ) + } + + let data: VoiceGateway.Event.Payload? + switch opcode { + case .mlsExternalSender: + data = .mlsExternalSender(Data(buffer: buffer)) + case .mlsProposals: + data = .mlsProposals(Data(buffer: buffer)) + case .mlsAnnounceCommitTransition: + guard let transitionId = buffer.readInteger(as: UInt16.self) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: [], + debugDescription: + "Expected the first 2 bytes of the binary data after the mlsAnnounceCommitTransition opcode to be the transition ID, but it couldn't be read as UInt16." + ) + ) + } + let commit = Data(buffer: buffer) + data = .mlsAnnounceCommitTransition( + transitionId: transitionId, + commit: commit + ) + case .mlsWelcome: + guard let transitionId = buffer.readInteger(as: UInt16.self) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: [], + debugDescription: + "Expected the first 2 bytes of the binary data after the mlsWelcome opcode to be the transition ID, but it couldn't be read as UInt16." + ) + ) + } + let welcome = Data(buffer: buffer) + data = .mlsWelcome( + transitionId: transitionId, + welcome: welcome + ) + default: + throw DecodingError.dataCorrupted( + .init( + codingPath: [], + debugDescription: + "Received an opcode \(opcode.description) that is not expected to be binary, but it came as binary." + ) + ) + } + let event = VoiceGateway.Event( + opcode: opcode, + data: data + ) + self.logger.debug( + "Decoded binary event", + metadata: [ + "event": .string("\(event)"), + "opcode": .string(event.opcode.description), + ] + ) + return event + } else { + let event = try DiscordGlobalConfiguration.decoder.decode( + VoiceGateway.Event.self, + from: Data(buffer: buffer, byteTransferStrategy: .noCopy) + ) + self.logger.debug( + "Decoded event", + metadata: [ + "event": .string("\(event)"), + "opcode": .string(event.opcode.description), + ] + ) + return event + } + } + + private enum CloseReason { + case closeFrame(WebSocketCloseFrame?) + case error(any Error) + } + + private func onClose( + closeReason: CloseReason, + forConnectionWithId connectionId: UInt + ) async { + self.logger.debug("Received connection close notification for a web-socket") + guard self.connectionId.load(ordering: .relaxed) == connectionId else { + return + } + let (code, codeDesc) = self.getCloseCodeAndDescription(of: closeReason) + let isDebugLevelCode = [nil, .goingAway, .unexpectedServerError].contains( + code + ) + self.logger.log( + level: isDebugLevelCode ? .debug : .warning, + "Received connection close notification. Will try to reconnect", + metadata: [ + "code": .string(codeDesc), + "closedConnectionId": .stringConvertible( + self.connectionId.load(ordering: .relaxed) + ), + ] + ) + if self.canTryReconnect(code: code) { + self.state.store(.noConnection, ordering: .relaxed) + self.stateCallback?(.noConnection) + self.logger.trace( + "Will try reconnect since Discord does allow it.", + metadata: [ + "code": .string(codeDesc), + "closedConnectionId": .stringConvertible( + self.connectionId.load(ordering: .relaxed) + ), + ] + ) + await self.connect() + } else { + self.state.store(.stopped, ordering: .relaxed) + self.stateCallback?(.stopped) + self.connectionId.wrappingIncrement(ordering: .relaxed) + self.logger.critical( + "Will not reconnect because Discord does not allow it. Something is wrong. Your close code is '\(codeDesc)'." + ) + + /// Don't remove/end the event streams just to stop apps from crashing/restarting + /// which could result in bot-token revocations or even temporary ip bans. + } + } + + private nonisolated func getCloseCodeAndDescription( + of closeReason: CloseReason + ) -> (WebSocketErrorCode?, String) { + switch closeReason { + case .error(let error): + return (nil, String(reflecting: error)) + case .closeFrame(let closeFrame): + guard let closeFrame else { + return (nil, "nil") + } + let code = closeFrame.closeCode + let description: String + switch code { + case .unknown(let codeNumber): + switch VoiceGatewayCloseCode(rawValue: codeNumber) { + case .some(let discordCode): + description = "\(discordCode)" + case .none: + description = "\(codeNumber)" + } + default: + description = closeFrame.reason ?? "\(code)" + } + return (code, description) + } + } + + private nonisolated func canTryReconnect(code: WebSocketErrorCode?) -> Bool { + switch code { + case .unknown(let codeNumber): + guard let discordCode = VoiceGatewayCloseCode(rawValue: codeNumber) else { + return true + } + return discordCode.canTryReconnect + default: return true + } + } + + private func setupPingTask( + forConnectionWithId connectionId: UInt, + every duration: Duration + ) { + Task { + // Send the first ping immediately, then loop sleeping between sends. + while self.connectionId.load(ordering: .relaxed) == connectionId { + self.logger.debug( + "Will send automatic ping", + metadata: [ + "connectionId": .stringConvertible(connectionId) + ] + ) + self.sendPing(forConnectionWithId: connectionId) + + try? await Task.sleep(for: duration) + } + + self.logger.trace( + "Canceled a ping task", + metadata: [ + "connectionId": .stringConvertible(connectionId) + ] + ) + } + } + + private func sendPing(forConnectionWithId connectionId: UInt) { + logger.trace( + "Will ping", + metadata: [ + "connectionId": .stringConvertible(connectionId) + ] + ) + + // last sent ping nonce is usually the current unix timestamp: + // https://docs.discord.food/topics/voice-connections#heartbeat-structure + self.lastSentPingNonce = Int(Date().timeIntervalSince1970) + self.send( + message: .init( + payload: .init( + opcode: .heartbeat, + data: .heartbeat( + .init(seq_ack: self.sequenceNumber) + ) + ), + opcode: .text + ) + ) + Task { + try? await Task.sleep(for: .seconds(10)) + guard self.connectionId.load(ordering: .relaxed) == connectionId else { + return + } + /// 15 == 10 + 5. 10 seconds that we slept, + 5 seconds tolerance. + /// The tolerance being too long should not matter as pings usually happen + /// only once in ~45 seconds, and a successful ping will reset the counter anyway. + if self.lastPongDate.addingTimeInterval(15) > Date() { + logger.trace("Successful ping") + self.unsuccessfulPingsCount = 0 + } else { + logger.trace("Unsuccessful ping") + self.unsuccessfulPingsCount += 1 + } + if unsuccessfulPingsCount > 2 { + logger.debug( + "Too many unsuccessful pings. Will try to reconnect", + metadata: [ + "connectionId": .stringConvertible( + self.connectionId.load(ordering: .relaxed) + ) + ] + ) + self.state.store(.noConnection, ordering: .relaxed) + self.stateCallback?(.noConnection) + await self.connect() + } + } + } + + private nonisolated func send(message: Message) { + self.sendQueue.perform { [weak self] in + guard let self = self else { return } + let state = self.state.load(ordering: .relaxed) + switch state { + case .connected: + break + case .stopped: + logger.warning( + "Will not send message because bot is stopped", + metadata: [ + "message": .string("\(message)") + ] + ) + return + case .noConnection, .connecting, .configured: + switch message.payload.opcode.isSentForConnectionEstablishment { + case true: + break + case false: + /// Recursively try to send through the queue. + /// The send queue has slowdown mechanisms so it's fine. + self.send(message: message) + return + } + } + if let connectionId = message.connectionId, + self.connectionId.load(ordering: .relaxed) != connectionId + { + return + } + Task { + let opcode: WebSocketOpcode = + message.opcode ?? .text + + let data: Data + do { + // switch opcodes bc some are sent as binary. + switch message.payload.opcode { + case .mlsKeyPackage, .mlsCommitWelcome: + // get payload + switch message.payload.data { + case .mlsKeyPackage(let payload), + .mlsCommitWelcome(let payload): + // opcode + payload + var frame = Data([message.payload.opcode.rawValue]) + frame.append(payload) + data = frame + default: + /// never called, here to initialise data for compile time checks. + data = Data() + } + default: + // json payload, encode + data = try DiscordGlobalConfiguration.encoder.encode( + message.payload + ) + } + } catch { + self.logger.error( + "Could not encode payload, \(error)", + metadata: [ + "payload": .string("\(message.payload)"), + "opcode": .stringConvertible(opcode), + "connectionId": .stringConvertible( + self.connectionId.load(ordering: .relaxed) + ), + ] + ) + return + } + + if let outboundWriter = await self.outboundWriter { + do { + self.logger.debug( + "Will send a payload with opcode", + metadata: [ + "opcode": .string(message.payload.opcode.description) + ] + ) + self.logger.trace( + "Will send a payload", + metadata: [ + "payload": .string("\(message.payload)"), + "opcode": .stringConvertible(opcode), + ] + ) + try await outboundWriter.write( + .custom( + .init( + fin: true, + opcode: opcode, + data: ByteBuffer(data: data) + ) + ) + ) + } catch { + if let channelError = error as? ChannelError, + case .ioOnClosedChannel = channelError + { + self.logger.error( + "Received 'ChannelError.ioOnClosedChannel' error while sending payload through web-socket. Will fully disconnect and reconnect again" + ) + await self.disconnect() + await self.connect() + } else if message.payload.opcode == .heartbeat, + let writerError = error as? NIOAsyncWriterError, + writerError == .alreadyFinished() + { + self.logger.debug( + "Received 'NIOAsyncWriterError.alreadyFinished' error while sending heartbeat through web-socket. Will ignore" + ) + } else { + self.logger.error( + "Could not send payload through web-socket", + metadata: [ + "error": .string(String(reflecting: error)), + "payload": .string("\(message.payload)"), + "opcode": .stringConvertible(opcode), + "state": .stringConvertible( + self.state.load(ordering: .relaxed) + ), + "connectionId": .stringConvertible( + self.connectionId.load(ordering: .relaxed) + ), + ] + ) + } + } + } else { + /// Pings aka `heartbeat`s are fine if they are sent when a ws connection + /// is not established. Pings are not disabled after a connection goes down + /// so long story short, the gateway manager never gets stuck in a bad + /// cycle of no-connection. + self.logger.log( + level: (message.payload.opcode == .heartbeat) ? .debug : .warning, + "Trying to send through ws when a connection is not established", + metadata: [ + "payload": .string("\(message.payload)"), + "state": .stringConvertible(self.state.load(ordering: .relaxed)), + "connectionId": .stringConvertible( + self.connectionId.load(ordering: .relaxed) + ), + ] + ) + } + } + } + } + + private func onSuccessfulConnection() async { + self.state.store(.connected, ordering: .relaxed) + self.stateCallback?(.connected) + connectionBackoff.resetTryCount() + self.unsuccessfulPingsCount = 0 + await self.sendQueue.reset() + } + + func setupOutboundWriter(_ outboundWriter: WebSocketOutboundWriter) { + self.outboundWriter = outboundWriter + } + + private func closeWebSocket() async { + logger.debug("Will possibly close a web-socket") + do { + try await self.outboundWriter?.close(.goingAway, reason: nil) + } catch { + logger.warning( + "Will ignore WS closure failure", + metadata: [ + "error": .string(String(reflecting: error)) + ] + ) + } + self.outboundWriter = nil + } + + public func getSessionID() -> String? { + return self.connectionData.sessionID + } +} + +extension VoiceGatewayManager: DaveSessionDelegate { + public func mlsKeyPackage(keyPackage: Data) async { + let event = VoiceGateway.Event( + opcode: .mlsKeyPackage, + data: .mlsKeyPackage(keyPackage) + ) + self.send( + message: .init( + payload: event, + opcode: .binary + ) + ) + } + + public func mlsCommitWelcome(welcome: Data) async { + let event = VoiceGateway.Event( + opcode: .mlsCommitWelcome, + data: .mlsCommitWelcome(welcome) + ) + self.send( + message: .init( + payload: event, + opcode: .binary + ) + ) + } + + public func mlsInvalidCommitWelcome(transitionId: UInt16) async { + let event = VoiceGateway.Event( + opcode: .mlsInvalidCommitWelcome, + data: .mlsInvalidCommitWelcome(.init(transition_id: transitionId)) + ) + self.send( + message: .init( + payload: event, + opcode: .text + ) + ) + } + + public func readyForTransition(transitionId: UInt16) async { + let event = VoiceGateway.Event( + opcode: .daveTransitionReady, + data: .daveTransitionReady(.init(transition_id: transitionId)) + ) + self.send( + message: .init( + payload: event, + opcode: .text + ) + ) + } + +} + +extension VoiceGatewayManager { + func addEventsContinuation( + _ continuation: AsyncStream.Continuation + ) { + self.eventsStreamContinuations.append(continuation) + } + + func addEventsParseFailureContinuation( + _ continuation: AsyncStream<(any Error, ByteBuffer)>.Continuation + ) { + self.eventsParseFailureContinuations.append(continuation) + } +} + +extension VoiceGateway.Opcode { + var isSentForConnectionEstablishment: Bool { + switch self { + case .identify, .resume: true + default: false + } + } +} diff --git a/PaicordLib/Sources/PaicordLib/exports.swift b/PaicordLib/Sources/PaicordLib/exports.swift index 9b9aa91f..6fb01f3a 100644 --- a/PaicordLib/Sources/PaicordLib/exports.swift +++ b/PaicordLib/Sources/PaicordLib/exports.swift @@ -1,6 +1,7 @@ @_exported import DiscordAuth @_exported import DiscordCore @_exported import DiscordGateway +@_exported import DiscordVoice @_exported import DiscordHTTP @_exported import DiscordModels @_exported import DiscordUtilities diff --git a/PaicordLib/Tests/DiscordBMTests/BotAuthManager.swift b/PaicordLib/Tests/DiscordBMTests/BotAuthManager.swift deleted file mode 100644 index 22fbb568..00000000 --- a/PaicordLib/Tests/DiscordBMTests/BotAuthManager.swift +++ /dev/null @@ -1,63 +0,0 @@ -import PaicordLib -import XCTest - -class BotAuthManagerTests: XCTestCase { - - let clientId = "121232141392410" - lazy var manager = BotAuthManager(clientId: clientId) - - func testBotAuthURLPlain() throws { - let url1 = manager.makeBotAuthorizationURL() - - XCTAssertEqual( - url1, - "https://discord.com/api/oauth2/authorize?client_id=121232141392410&permissions=0&scope=bot" - ) - - let url2 = manager.makeBotAuthorizationURL( - permissions: [.addReactions, .changeNickname] - ) - - XCTAssertEqual( - url2, - "https://discord.com/api/oauth2/authorize?client_id=121232141392410&permissions=67108928&scope=bot" - ) - } - - @available(*, deprecated) - func testBotAuthURLDeprecated() throws { - let url1 = manager.makeBotAuthorizationURL( - withApplicationCommands: false, - permissions: [.addReactions, .changeNickname] - ) - - XCTAssertEqual( - url1, - "https://discord.com/api/oauth2/authorize?client_id=121232141392410&permissions=67108928&scope=bot" - ) - - let url2 = manager.makeBotAuthorizationURL( - withApplicationCommands: false, - permissions: [.manageRoles, .manageGuild, .createInstantInvite], - guildId: "123456789123456789", - disableGuildSelect: true - ) - - XCTAssertEqual( - url2, - "https://discord.com/api/oauth2/authorize?client_id=121232141392410&permissions=268435489&scope=bot&guild_id=123456789123456789&disable_guild_select=true" - ) - - let url3 = manager.makeBotAuthorizationURL( - withApplicationCommands: true, - permissions: [.manageRoles, .manageGuild, .createInstantInvite], - guildId: "123456789123456789", - disableGuildSelect: true - ) - - XCTAssertEqual( - url3, - "https://discord.com/api/oauth2/authorize?client_id=121232141392410&permissions=268435489&scope=bot%20applications.commands&guild_id=123456789123456789&disable_guild_select=true" - ) - } -} diff --git a/PaicordLib/Tests/DiscordBMTests/DecompressionTests.swift b/PaicordLib/Tests/DiscordBMTests/DecompressionTests.swift deleted file mode 100644 index 2804cab0..00000000 --- a/PaicordLib/Tests/DiscordBMTests/DecompressionTests.swift +++ /dev/null @@ -1,272 +0,0 @@ -import Logging -import NIOCore -import NIOWebSocket -import XCTest - -@testable import DiscordGateway -@testable import WSCore - -class DecompressionTests: XCTestCase { - let logger = Logger(label: "TestDecompression") - - func testDeflateDecompression() throws { - let decompressor = try ZlibDecompressorWSExtension(logger: logger) - - for (data, decodedString) in zip(deflatedData, deflatedDataDecodedStrings) { - let frame = WebSocketFrame(fin: true, data: ByteBuffer(data: data)) - let decodedFrame = try decompressor.processReceivedFrame( - frame, - context: .init(logger: logger) - ) - - XCTAssertEqual(String(buffer: decodedFrame.data).count, decodedString.count) - } - } -} - -let deflatedData: [Data] = [ - Data([ - 120, 156, 52, 201, 77, 10, 131, 48, 16, 6, 208, 187, 124, 235, 164, 36, 253, 91, 204, 85, 140, - 200, 24, 7, 43, - 164, 42, 201, 216, 82, 66, 238, 222, 110, 186, 123, 240, 42, 20, 180, 30, 41, 25, 148, 63, 182, - 29, 228, 157, - 193, 4, 170, 120, 8, 103, 29, 133, 117, 88, 86, 149, 252, 226, 4, 186, 250, 243, 237, 247, 131, - 102, 142, 2, - 234, 208, 5, 204, 172, 242, 230, 143, 221, 243, 100, 143, 98, 133, 139, 122, 27, 237, 229, 62, - 206, 1, 166, 6, - 60, 151, 152, 183, 18, 64, 238, 228, 90, 143, 190, 181, 47, 0, 0, 0, 255, 255, - ]), - Data([ - 156, 85, 77, 111, 156, 48, 16, 253, 47, 156, 179, 13, 54, 224, 181, 123, 107, 149, 222, 218, 75, - 213, 75, 213, - 70, 150, 129, 97, 215, 141, 49, 212, 54, 169, 146, 104, 255, 123, 199, 124, 52, 93, 200, 42, 85, - 181, 210, 10, - 204, 155, 153, 55, 111, 222, 192, 83, 172, 157, 124, 254, 240, 238, 230, 107, 50, 150, 39, 83, - 237, 165, 244, - 253, 68, 99, 240, 224, 164, 135, 16, 180, 61, 32, 232, 233, 52, 29, 141, 8, 112, 186, 209, 128, - 232, 224, 6, - 152, 206, 173, 106, 145, 83, 114, 163, 253, 251, 79, 31, 117, 249, 5, 124, 120, 223, 5, 44, 208, - 54, 74, 130, - 85, 165, 121, 198, 107, 188, 74, 72, 154, 165, 132, 240, 61, 221, 231, 132, 179, 156, 49, 42, - 16, 221, 24, 21, - 171, 97, 125, 104, 149, 54, 139, 54, 181, 246, 149, 211, 173, 182, 42, 116, 72, 33, 73, 57, 35, - 136, 46, 177, - 194, 156, 83, 221, 171, 160, 220, 132, 71, 170, 30, 188, 215, 157, 149, 225, 161, 143, 180, 108, - 231, 90, 148, - 240, 249, 124, 164, 80, 54, 28, 74, 206, 33, 23, 153, 128, 130, 214, 77, 42, 50, 202, 72, 147, - 169, 178, 172, - 88, 198, 17, 239, 192, 15, 45, 200, 89, 102, 57, 56, 164, 148, 252, 242, 254, 237, 245, 245, 34, - 253, 179, 236, - 111, 34, 205, 206, 213, 111, 14, 135, 49, 212, 168, 128, 181, 252, 81, 247, 216, 210, 183, 219, - 171, 164, 119, - 26, 105, 130, 172, 142, 202, 90, 48, 127, 78, 193, 131, 173, 96, 190, 61, 12, 218, 212, 241, - 250, 41, 25, 44, - 182, 165, 77, 20, 239, 76, 59, 193, 246, 69, 154, 226, 63, 45, 246, 130, 113, 198, 211, 228, - 116, 117, 25, 142, - 82, 51, 206, 137, 40, 82, 38, 24, 229, 156, 238, 247, 201, 105, 169, 36, 127, 116, 218, 74, 7, - 63, 7, 28, 217, - 66, 1, 58, 137, 125, 128, 131, 90, 186, 80, 225, 211, 67, 108, 36, 122, 174, 197, 252, 54, 118, - 215, 5, 244, - 101, 173, 218, 56, 98, 85, 59, 44, 132, 243, 24, 176, 51, 236, 38, 206, 221, 135, 174, 186, 59, - 118, 166, 77, - 48, 161, 234, 123, 163, 171, 81, 142, 104, 160, 215, 13, 80, 176, 130, 230, 252, 244, 223, 110, - 23, 44, 43, 178, - 171, 239, 73, 165, 140, 137, 247, 24, 172, 107, 89, 57, 220, 40, 168, 87, 88, 146, 114, 241, 55, - 244, 246, 132, - 119, 139, 81, 76, 215, 221, 13, 189, 12, 186, 133, 85, 24, 229, 249, 107, 81, 141, 182, 218, 31, - 183, 5, 249, - 38, 112, 118, 206, 110, 78, 224, 119, 165, 25, 96, 236, 147, 238, 8, 97, 235, 238, 114, 28, 250, - 121, 119, 30, - 221, 31, 228, 28, 190, 130, 231, 140, 166, 244, 28, 190, 212, 83, 189, 222, 137, 166, 44, 27, - 209, 176, 124, - 231, 31, 91, 42, 214, 193, 132, 103, 252, 60, 248, 0, 65, 198, 157, 95, 33, 121, 193, 105, 236, - 37, 62, 158, 76, - 188, 2, 20, 148, 138, 73, 37, 91, 75, 95, 161, 48, 3, 190, 21, 100, 13, 6, 162, 51, 100, 139, - 236, 213, 97, 173, - 51, 201, 198, 164, 91, 175, 174, 231, 17, 97, 106, 8, 199, 206, 233, 71, 76, 171, 123, 137, 45, - 118, 235, 108, - 249, 233, 246, 52, 205, 42, 42, 134, 47, 183, 137, 43, 98, 113, 37, 171, 176, 78, 74, 139, 205, - 172, 150, 125, - 149, 99, 134, 205, 116, 115, 70, 178, 253, 38, 104, 18, 228, 66, 8, 201, 182, 6, 156, 3, 94, - 166, 69, 46, 147, - 186, 16, 128, 203, 191, 137, 153, 161, 23, 109, 42, 138, 109, 243, 229, 40, 22, 238, 81, 253, - 240, 186, 171, - 187, 30, 247, 6, 135, 241, 34, 126, 203, 199, 227, 75, 34, 252, 35, 182, 50, 160, 236, 203, 60, - 206, 177, 227, - 47, 126, 247, 126, 3, 0, 0, 255, 255, - ]), - Data([ - 188, 91, 93, 111, 219, 70, 22, 253, 43, 134, 94, 23, 14, 230, 123, 56, 122, 218, 198, 113, 221, - 5, 106, 236, - 182, 113, 90, 236, 67, 33, 140, 68, 202, 226, 134, 34, 85, 138, 178, 227, 20, 249, 239, 123, 46, - 63, 68, 43, - 145, 52, 110, 144, 201, 67, 28, 67, 20, 201, 51, 119, 238, 61, 247, 92, 242, 184, 235, 123, 55, - 239, 254, 245, - 243, 155, 217, 21, 218, 223, 221, 117, 215, 254, 196, 97, 251, 163, 126, 85, 100, 15, 89, 209, - 126, 84, 110, - 151, 143, 147, 233, 210, 23, 91, 144, 233, 67, 149, 119, 187, 222, 12, 92, 221, 17, 35, 46, 155, - 102, 216, 128, - 5, 145, 203, 164, 37, 244, 117, 190, 91, 131, 52, 168, 95, 182, 189, 172, 250, 95, 222, 81, 58, - 122, 231, 182, - 61, 133, 17, 135, 22, 227, 133, 254, 220, 229, 53, 237, 94, 209, 18, 109, 199, 222, 125, 83, - 253, 101, 151, 101, - 101, 203, 179, 37, 202, 35, 221, 227, 25, 122, 129, 77, 156, 100, 138, 97, 139, 45, 186, 150, - 152, 180, 221, - 240, 176, 15, 248, 50, 95, 183, 232, 186, 115, 169, 91, 124, 13, 146, 31, 106, 84, 109, 189, 13, - 96, 17, 86, 42, - 197, 172, 2, 183, 71, 197, 82, 87, 143, 33, 40, 202, 9, 110, 165, 99, 134, 197, 132, 242, 218, - 207, 159, 222, 4, - 144, 128, 177, 185, 97, 212, 130, 93, 68, 36, 63, 87, 85, 32, 83, 148, 2, 61, 161, 35, 128, 172, - 227, 134, 164, - 76, 243, 38, 0, 69, 39, 70, 42, 193, 32, 1, 226, 66, 169, 231, 248, 7, 61, 22, 128, 147, 48, - 225, 164, 54, 137, - 142, 14, 231, 167, 93, 32, 52, 154, 9, 173, 148, 212, 90, 186, 36, 50, 150, 64, 9, 105, 129, - 124, 17, 218, 65, - 171, 177, 168, 72, 154, 0, 16, 103, 37, 115, 142, 227, 71, 84, 32, 63, 101, 190, 0, 109, 159, - 135, 226, 64, 110, - 204, 72, 1, 102, 137, 27, 147, 166, 200, 126, 237, 53, 246, 9, 52, 142, 49, 161, 140, 178, 92, - 89, 173, 163, - 162, 169, 214, 243, 187, 234, 241, 124, 108, 128, 70, 105, 158, 8, 252, 176, 58, 106, 21, 1, 77, - 16, 138, 101, - 138, 107, 105, 20, 55, 38, 42, 148, 199, 64, 198, 0, 138, 179, 206, 114, 103, 12, 99, 58, 34, - 148, 43, 204, 149, - 85, 121, 5, 141, 121, 30, 14, 70, 10, 48, 140, 76, 148, 141, 218, 23, 59, 56, 1, 40, 24, 185, - 53, 18, 24, 29, - 50, 102, 100, 22, 248, 44, 11, 32, 209, 10, 52, 151, 88, 109, 77, 204, 22, 157, 250, 250, 253, - 166, 206, 161, - 213, 3, 112, 172, 4, 249, 115, 153, 36, 81, 73, 6, 112, 154, 251, 106, 94, 228, 161, 125, 34, - 121, 153, 168, 68, - 49, 22, 179, 35, 209, 115, 175, 213, 159, 59, 255, 62, 16, 29, 116, 37, 166, 57, 51, 74, 58, 30, - 19, 78, 129, 1, - 169, 174, 210, 218, 223, 135, 18, 25, 195, 45, 244, 157, 115, 12, 138, 38, 34, 162, 235, 155, - 220, 151, 129, - 242, 22, 90, 90, 11, 241, 96, 173, 137, 10, 229, 237, 38, 175, 207, 139, 60, 96, 73, 172, 18, - 74, 105, 101, 141, - 141, 137, 229, 247, 252, 227, 121, 32, 146, 65, 241, 82, 246, 10, 21, 147, 243, 174, 131, 154, - 10, 80, 132, 106, - 165, 21, 170, 41, 42, 253, 86, 5, 165, 111, 21, 232, 77, 82, 161, 31, 72, 201, 173, 19, 49, 43, - 251, 250, 6, 95, - 12, 72, 25, 169, 173, 230, 244, 116, 19, 115, 91, 76, 40, 31, 2, 236, 34, 193, 189, 137, 161, - 71, 147, 70, 69, - 196, 241, 35, 142, 188, 164, 134, 176, 65, 152, 146, 180, 1, 197, 196, 172, 103, 130, 51, 247, - 197, 217, 49, - 169, 165, 55, 5, 229, 64, 19, 74, 76, 85, 69, 96, 22, 181, 95, 188, 15, 73, 43, 101, 180, 3, 20, - 195, 226, 10, - 206, 31, 233, 17, 89, 189, 246, 129, 46, 128, 146, 150, 9, 218, 64, 34, 121, 84, 52, 197, 211, - 173, 95, 172, - 242, 144, 166, 209, 160, 61, 145, 40, 3, 249, 25, 53, 115, 234, 44, 251, 24, 130, 34, 48, 51, - 113, 206, 21, 143, - 27, 153, 93, 93, 250, 144, 182, 210, 74, 24, 135, 94, 96, 208, 156, 34, 98, 185, 121, 27, 128, - 97, 48, 88, 83, - 174, 0, 74, 68, 24, 111, 203, 234, 49, 92, 215, 244, 48, 134, 51, 109, 19, 33, 99, 214, 245, 11, - 228, 11, 74, - 153, 37, 202, 1, 9, 139, 138, 164, 122, 193, 99, 33, 160, 145, 204, 104, 97, 45, 198, 37, 25, - 23, 205, 21, 32, - 4, 176, 88, 170, 102, 8, 42, 19, 117, 162, 125, 83, 231, 129, 108, 193, 212, 230, 148, 99, 14, - 83, 129, 136, 90, - 65, 213, 252, 198, 151, 247, 1, 44, 96, 57, 141, 134, 45, 161, 50, 35, 99, 9, 38, 47, 61, 92, - 181, 24, 218, 156, - 136, 76, 45, 85, 232, 33, 162, 227, 74, 75, 7, 154, 51, 152, 6, 34, 215, 17, 166, 199, 243, 146, - 151, 43, 75, 2, - 83, 26, 167, 117, 220, 42, 42, 210, 172, 124, 93, 61, 5, 208, 56, 97, 185, 16, 152, 29, 101, - 220, 77, 10, 41, - 94, 174, 153, 131, 94, 80, 204, 74, 21, 117, 143, 254, 27, 128, 33, 32, 88, 164, 114, 32, 221, - 23, 141, 211, - 127, 208, 11, 254, 156, 196, 89, 127, 247, 117, 70, 207, 227, 122, 219, 66, 239, 19, 121, 230, - 11, 185, 245, - 171, 52, 191, 120, 125, 75, 111, 199, 118, 72, 151, 197, 172, 127, 209, 175, 184, 83, 146, 169, - 30, 140, 32, 1, - 133, 108, 229, 26, 154, 78, 209, 243, 187, 201, 17, 243, 135, 230, 118, 48, 127, 244, 43, 25, - 220, 31, 19, 50, - 113, 120, 198, 93, 146, 49, 54, 247, 66, 206, 151, 34, 93, 88, 145, 44, 84, 58, 119, 42, 117, - 147, 79, 7, 49, - 27, 222, 212, 109, 219, 199, 49, 189, 217, 100, 147, 149, 105, 14, 206, 25, 46, 94, 98, 157, - 195, 177, 245, 174, - 201, 246, 7, 232, 109, 116, 150, 206, 60, 189, 87, 20, 152, 255, 47, 153, 186, 20, 242, 142, - 187, 41, 147, 83, - 161, 95, 33, 201, 24, 99, 255, 96, 108, 218, 182, 247, 209, 219, 146, 102, 126, 185, 191, 202, - 162, 90, 175, - 119, 101, 239, 142, 152, 97, 177, 173, 87, 102, 182, 43, 155, 209, 255, 114, 232, 110, 57, 26, - 225, 95, 171, 39, - 95, 100, 23, 63, 20, 139, 21, 214, 212, 90, 48, 14, 35, 205, 246, 27, 206, 209, 96, 33, 195, - 104, 202, 17, 6, - 115, 206, 145, 24, 107, 163, 147, 227, 6, 155, 201, 130, 47, 156, 97, 203, 84, 75, 159, 241, 68, - 167, 204, 42, - 238, 93, 202, 209, 16, 192, 55, 243, 249, 119, 10, 177, 96, 83, 174, 94, 105, 253, 29, 67, 220, - 37, 241, 237, - 237, 145, 52, 30, 131, 107, 36, 233, 5, 122, 207, 135, 73, 64, 105, 123, 36, 184, 40, 122, 117, - 34, 129, 123, - 251, 82, 196, 248, 37, 151, 92, 222, 49, 55, 229, 114, 202, 196, 171, 4, 154, 252, 187, 197, - 239, 75, 115, 216, - 241, 32, 30, 119, 6, 253, 109, 15, 88, 188, 32, 114, 70, 65, 228, 106, 42, 245, 84, 177, 87, - 150, 70, 137, 111, - 31, 68, 224, 206, 62, 144, 111, 42, 111, 200, 213, 209, 100, 37, 89, 53, 138, 166, 55, 21, 172, - 253, 135, 217, - 67, 158, 102, 213, 96, 39, 107, 61, 57, 100, 104, 208, 136, 10, 125, 82, 239, 61, 134, 155, 194, - 111, 87, 251, - 155, 140, 102, 172, 214, 7, 215, 125, 74, 78, 135, 103, 198, 135, 194, 215, 247, 99, 4, 158, - 159, 66, 235, 240, - 37, 25, 102, 0, 157, 156, 129, 19, 222, 218, 106, 176, 194, 209, 214, 118, 216, 165, 58, 23, 30, - 72, 126, 83, - 109, 243, 102, 248, 116, 131, 113, 58, 239, 252, 82, 21, 190, 253, 88, 231, 123, 95, 69, 159, - 48, 119, 217, 135, - 230, 226, 106, 184, 234, 231, 230, 55, 103, 57, 212, 176, 107, 159, 20, 13, 49, 255, 188, 63, - 126, 237, 157, - 127, 35, 171, 71, 248, 214, 50, 120, 107, 250, 165, 218, 228, 139, 33, 204, 53, 249, 255, 10, - 164, 113, 51, 3, - 138, 89, 87, 37, 236, 229, 248, 54, 190, 166, 60, 56, 29, 137, 126, 1, 247, 25, 246, 191, 181, - 60, 98, 235, 155, - 193, 90, 53, 27, 235, 11, 35, 68, 130, 185, 6, 39, 105, 217, 190, 4, 58, 126, 73, 117, 102, 133, - 173, 69, 180, - 93, 203, 179, 5, 11, 172, 113, 111, 25, 252, 46, 171, 150, 227, 170, 111, 78, 175, 186, 3, 114, - 252, 10, 250, - 160, 106, 231, 121, 67, 120, 39, 83, 67, 101, 253, 173, 182, 149, 127, 147, 109, 133, 10, 187, - 207, 182, 39, - 118, 21, 194, 8, 67, 60, 13, 172, 232, 63, 78, 181, 130, 242, 249, 33, 173, 141, 224, 164, 123, - 33, 76, 78, 109, - 43, 55, 70, 27, 203, 173, 118, 206, 37, 95, 191, 90, 241, 77, 86, 91, 228, 243, 75, 156, 212, - 92, 246, 204, 114, - 42, 155, 201, 221, 42, 104, 204, 17, 60, 65, 203, 125, 182, 238, 54, 209, 45, 166, 101, 137, - 102, 34, 156, 57, - 224, 138, 63, 136, 161, 151, 126, 87, 140, 87, 44, 171, 38, 95, 246, 76, 215, 101, 131, 95, 190, - 111, 221, 159, - 21, 166, 173, 169, 100, 108, 108, 38, 155, 186, 186, 175, 113, 222, 108, 238, 235, 209, 226, - 220, 51, 38, 177, - 243, 182, 161, 75, 30, 229, 104, 70, 250, 185, 93, 66, 137, 255, 71, 243, 239, 41, 123, 111, - 119, 193, 189, 200, - 198, 97, 70, 80, 86, 187, 121, 111, 113, 238, 91, 218, 161, 149, 248, 111, 54, 46, 156, 189, - 204, 106, 50, 253, - 22, 213, 194, 23, 237, 251, 175, 242, 242, 29, 61, 196, 202, 23, 99, 49, 55, 43, 178, 221, 245, - 183, 32, 68, - 105, 74, 55, 89, 52, 249, 3, 118, 127, 184, 119, 135, 181, 235, 18, 29, 3, 15, 61, 120, 55, 167, - 70, 190, 233, - 155, 73, 123, 152, 237, 165, 192, 110, 147, 146, 189, 110, 31, 175, 177, 116, 211, 108, 127, - 218, 240, 81, 225, - 63, 62, 13, 205, 191, 115, 113, 142, 174, 82, 116, 178, 114, 240, 51, 87, 143, 160, 133, 217, - 233, 9, 227, 192, - 56, 221, 111, 224, 160, 30, 62, 163, 60, 52, 240, 42, 205, 102, 173, 163, 111, 31, 16, 63, 56, - 228, 79, 240, - 217, 182, 205, 69, 203, 161, 9, 141, 97, 174, 149, 51, 125, 138, 255, 19, 56, 235, 167, 254, - 221, 52, 16, 227, - 219, 7, 48, 78, 205, 108, 95, 38, 200, 243, 45, 90, 85, 52, 1, 12, 231, 140, 212, 70, 179, 97, - 221, 167, 126, - 23, 176, 21, 132, 65, 182, 237, 93, 253, 219, 129, 169, 250, 197, 255, 53, 169, 80, 227, 205, - 179, 219, 175, 90, - 29, 49, 249, 247, 245, 218, 166, 191, 144, 210, 95, 103, 141, 199, 142, 249, 51, 95, 94, 220, - 253, 39, 173, 30, - 39, 7, 2, 225, 228, 151, 229, 253, 245, 59, 137, 47, 127, 234, 42, 239, 203, 52, 232, 254, 248, - 160, 215, 34, - 163, 94, 121, 192, 132, 218, 180, 6, 253, 25, 109, 208, 94, 213, 244, 47, 124, 104, 187, 46, - 222, 102, 53, 206, - 190, 224, 189, 152, 36, 86, 122, 154, 29, 106, 163, 237, 211, 182, 201, 214, 251, 219, 142, 145, - 251, 236, 192, - 233, 102, 89, 35, 251, 142, 165, 239, 50, 243, 205, 174, 238, 170, 227, 211, 167, 255, 3, 0, 0, - 255, 255, - ]), -] - -let deflatedDataDecodedStrings: [String] = [ - #"{"t":null,"s":null,"op":10,"d":{"heartbeat_interval":41250,"_trace":["[\"gateway-prd-us-east1-c-36bg\",{\"micros\":0.0}]"]}}"#, - #"{"t":"READY","s":1,"op":0,"d":{"v":10,"user_settings":{},"user":{"verified":true,"username":"DisBMLibTestBot","mfa_enabled":true,"id":"1030118727418646629","flags":0,"email":null,"discriminator":"0861","bot":true,"avatar":null},"session_type":"normal","session_id":"bf8eb88e4939e52df093261f3abbc638","resume_gateway_url":"wss://gateway-us-east1-c.discord.gg","relationships":[],"private_channels":[],"presences":[],"guilds":[{"unavailable":true,"id":"967500967257968680"},{"unavailable":true,"id":"1036881950696288277"}],"guild_join_requests":[],"geo_ordered_rtc_regions":["milan","rotterdam","madrid","bucharest","stockholm"],"application":{"id":"1030118727418646629","flags":565248},"_trace":["[\"gateway-prd-us-east1-c-36bg\",{\"micros\":96353,\"calls\":[\"id_created\",{\"micros\":1089,\"calls\":[]},\"session_lookup_time\",{\"micros\":284,\"calls\":[]},\"session_lookup_finished\",{\"micros\":18,\"calls\":[]},\"discord-sessions-blue-prd-2-116\",{\"micros\":94686,\"calls\":[\"start_session\",{\"micros\":46202,\"calls\":[\"discord-api-9fbbf9f64-szm29\",{\"micros\":41838,\"calls\":[\"get_user\",{\"micros\":8582},\"get_guilds\",{\"micros\":5229},\"send_scheduled_deletion_message\",{\"micros\":13},\"guild_join_requests\",{\"micros\":2},\"authorized_ip_coro\",{\"micros\":14}]}]},\"starting_guild_connect\",{\"micros\":225,\"calls\":[]},\"presence_started\",{\"micros\":46137,\"calls\":[]},\"guilds_started\",{\"micros\":139,\"calls\":[]},\"guilds_connect\",{\"micros\":1,\"calls\":[]},\"presence_connect\",{\"micros\":1950,\"calls\":[]},\"connect_finished\",{\"micros\":1955,\"calls\":[]},\"build_ready\",{\"micros\":18,\"calls\":[]},\"optimize_ready\",{\"micros\":0,\"calls\":[]},\"split_ready\",{\"micros\":0,\"calls\":[]},\"clean_ready\",{\"micros\":1,\"calls\":[]}]}]}]"]}}"#, - #"{"t":"GUILD_CREATE","s":2,"op":0,"d":{"mfa_level":0,"nsfw":false,"voice_states":[],"region":"deprecated","premium_tier":0,"emojis":[{"version":0,"roles":[],"require_colons":true,"name":"Queen","managed":false,"id":"967789304095076382","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Archers","managed":false,"id":"967789327344074872","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Arrows","managed":false,"id":"967789349217390602","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"BabyD","managed":false,"id":"967789368616050699","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Loon","managed":false,"id":"967789441374625822","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Bandit","managed":false,"id":"967789458634207272","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"BarbBarrel","managed":false,"id":"967789480293568572","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"BarbHut","managed":false,"id":"967789502544355398","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Barbs","managed":false,"id":"967789521372590110","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Bats","managed":false,"id":"967789973099130910","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Healer","managed":false,"id":"967789997480632390","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"BattleRam","managed":false,"id":"967790024647147550","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"BombTower","managed":false,"id":"967790045182451752","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Bomber","managed":false,"id":"967790070415364166","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Bowler","managed":false,"id":"967790097971966005","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"CannonCart","managed":false,"id":"967790116502384702","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Cannon","managed":false,"id":"967790132654649365","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"clone","managed":false,"id":"967790154590875769","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"darkprince","managed":false,"id":"967790173398138890","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"dartgoblin","managed":false,"id":"967790195078484008","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"earthquake","managed":false,"id":"967790213051064391","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"electrodragon","managed":false,"id":"967790229605990420","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"EGiant","managed":false,"id":"967790253773557760","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"ESpirit","managed":false,"id":"967790287424454767","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"EWiz","managed":false,"id":"967790308224008242","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"EBarbs","managed":false,"id":"967790324778954842","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Collector","managed":false,"id":"967790340113317928","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"EGolem","managed":false,"id":"967790357519675492","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Exe","managed":false,"id":"967790373386727464","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"FireSpirit","managed":false,"id":"967790401207562290","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Fireball","managed":false,"id":"967790420438450256","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Firecracker","managed":false,"id":"967790465925660752","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Fisherman","managed":false,"id":"967790484380598312","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"FlyMachine","managed":false,"id":"967790503028469790","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Freeze","managed":false,"id":"967790524801114112","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Furnace","managed":false,"id":"967790542694006874","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"GS","managed":false,"id":"967790562595983400","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Snowball","managed":false,"id":"967790586310578236","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Giant","managed":false,"id":"967790607084978206","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"GobBarrel","managed":false,"id":"967790630652772383","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"GobCage","managed":false,"id":"967790670284742666","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Drill","managed":false,"id":"967791329490919524","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"GobGang","managed":false,"id":"967791350353383454","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"GobGiant","managed":false,"id":"967791374713892874","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"GobHut","managed":false,"id":"967791453969465376","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Goblins","managed":false,"id":"967791473179369553","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"GoldenBoy","managed":false,"id":"967791492712239134","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"Golem","managed":false,"id":"967791509380407346","available":true,"animated":false},{"version":0,"roles":[],"require_colons":true,"name":"GY","managed":false,"id":"967791528313499781","available":true,"animated":false}],"stickers":[],"members":[{"user":{"username":"Mahdi BM","public_flags":4194304,"id":"290483761559240704","discriminator":"0517","bot":false,"avatar":"2df0a0198e00ba23bf2dc728c4db94d9"},"roles":[],"premium_since":null,"pending":false,"nick":null,"mute":false,"joined_at":"2022-04-23T19:03:25.927000+00:00","flags":0,"deaf":false,"communication_disabled_until":null,"avatar":null},{"user":{"username":"Royale Alchemist","public_flags":0,"id":"961607141037326386","discriminator":"5658","bot":true,"avatar":"c1c960fd53ae185d0741a9d1294539bb"},"roles":[],"premium_since":null,"pending":false,"nick":null,"mute":false,"joined_at":"2022-04-23T19:20:14.557000+00:00","flags":0,"deaf":false,"communication_disabled_until":null,"avatar":null},{"user":{"username":"Mahdi MMBM","public_flags":0,"id":"966330655069843457","discriminator":"1504","bot":false,"avatar":null},"roles":[],"premium_since":null,"pending":false,"nick":null,"mute":false,"joined_at":"2022-08-13T09:13:02.848000+00:00","flags":0,"deaf":false,"communication_disabled_until":null,"avatar":null},{"user":{"username":"DisBMLibTestBot","public_flags":0,"id":"1030118727418646629","discriminator":"0861","bot":true,"avatar":null},"roles":[],"premium_since":null,"pending":false,"nick":null,"mute":false,"joined_at":"2022-10-13T14:35:40.794000+00:00","flags":0,"deaf":false,"communication_disabled_until":null,"avatar":null}],"explicit_content_filter":0,"max_video_channel_users":25,"banner":null,"splash":null,"application_id":null,"nsfw_level":0,"large":false,"application_command_counts":{"1":14},"channels":[{"version":0,"type":4,"position":0,"permission_overwrites":[],"name":"Text Channels","id":"967500967971028992","flags":0},{"version":0,"type":4,"position":0,"permission_overwrites":[],"name":"Voice Channels","id":"967500967971028993","flags":0},{"version":0,"type":0,"topic":null,"rate_limit_per_user":0,"position":0,"permission_overwrites":[],"parent_id":"967500967971028992","name":"general","last_message_id":"1030126686529925302","id":"967500967971028994","flags":0},{"version":0,"user_limit":0,"type":2,"rtc_region":null,"rate_limit_per_user":0,"position":0,"permission_overwrites":[],"parent_id":"967500967971028993","name":"General","last_message_id":null,"id":"967500967971028995","flags":0,"bitrate":64000},{"version":0,"type":0,"topic":null,"rate_limit_per_user":0,"position":1,"permission_overwrites":[],"parent_id":"967500967971028992","name":"images","last_message_id":"1005158606305509446","id":"1005158556217122927","flags":0},{"version":1665671759998,"type":0,"topic":null,"rate_limit_per_user":0,"position":2,"permission_overwrites":[],"parent_id":"967500967971028992","name":"lib-test-channel","last_message_id":"1036881265372184576","id":"1030126766632742962","flags":0}],"default_message_notifications":0,"afk_timeout":300,"premium_progress_bar_enabled":false,"max_stage_video_channel_users":0,"stage_instances":[],"id":"967500967257968680","max_members":500000,"hub_type":null,"presences":[],"joined_at":"2022-10-13T14:35:40.794000+00:00","preferred_locale":"en-US","icon":null,"threads":[],"embedded_activities":[],"member_count":4,"premium_subscription_count":0,"public_updates_channel_id":null,"description":null,"lazy":true,"guild_scheduled_events":[],"owner_id":"290483761559240704","unavailable":false,"roles":[{"version":0,"unicode_emoji":null,"tags":{},"position":0,"permissions":"1071698660929","name":"@everyone","mentionable":false,"managed":false,"id":"967500967257968680","icon":null,"hoist":false,"flags":0,"color":0}],"guild_hashes":{"version":1,"roles":{"omitted":false,"hash":"OEm7dQ"},"metadata":{"omitted":false,"hash":"cTPdow"},"channels":{"omitted":false,"hash":"3gEU3w"}},"afk_channel_id":null,"verification_level":0,"vanity_url_code":null,"name":"Emoji Server 1","discovery_splash":null,"system_channel_flags":0,"system_channel_id":"967500967971028994","rules_channel_id":null,"features":[]}}"#, -] diff --git a/PaicordLib/Tests/DiscordBMTests/DiscordCache.swift b/PaicordLib/Tests/DiscordBMTests/DiscordCache.swift deleted file mode 100644 index d2034812..00000000 --- a/PaicordLib/Tests/DiscordBMTests/DiscordCache.swift +++ /dev/null @@ -1,84 +0,0 @@ -import XCTest - -import struct NIOCore.ByteBuffer - -@testable import DiscordGateway - -class DiscordCacheTests: XCTestCase { - - func testItemsLimitPolicy() async throws { - let storage = DiscordCache.Storage(auditLogs: ["1": []]) - let cache = await DiscordCache( - gatewayManager: FakeGatewayManager(), - intents: .all, - requestAllMembers: .enabledWithPresences, - itemsLimit: .constant(10), - storage: storage - ) - - /// First 10 items must be kept like normal. - for idx in 2...10 { - await cache._tests_modifyStorage { storage in - storage.auditLogs[AnySnowflake("\(idx)")] = [] - } - } - - do { - let auditLogs = await cache.storage.auditLogs - XCTAssertEqual(auditLogs.keys.map(\.rawValue.description), (1...10).map(\.description)) - } - - /// The 11th item will trigger a check, and the first item will be removed. - for idx in 11...11 { - await cache._tests_modifyStorage { storage in - storage.auditLogs[AnySnowflake("\(idx)")] = [] - } - } - - do { - let auditLogs = await cache.storage.auditLogs - XCTAssertEqual(auditLogs.keys.map(\.rawValue.description), (2...11).map(\.description)) - } - - /// The 12-19th mutations won't trigger a check. - for idx in 12...19 { - await cache._tests_modifyStorage { storage in - storage.auditLogs[AnySnowflake("\(idx)")] = [] - } - } - - do { - let auditLogs = await cache.storage.auditLogs - XCTAssertEqual(auditLogs.keys.map(\.rawValue.description), (2...19).map(\.description)) - } - - /// The 20th mutation will trigger a check, and older items will be removed. - for idx in 20...20 { - await cache._tests_modifyStorage { storage in - storage.auditLogs[AnySnowflake("\(idx)")] = [] - } - } - - do { - let auditLogs = await cache.storage.auditLogs - XCTAssertEqual(auditLogs.keys.map(\.rawValue.description), (11...20).map(\.description)) - } - } -} - -private actor FakeGatewayManager: GatewayManager { - nonisolated var client: any DiscordClient { fatalError() } - nonisolated let id: UInt = 0 - nonisolated let identifyPayload: Gateway.Identify = .init(token: "", intents: []) - func connect() async {} - func requestGuildMembersChunk(payload: Gateway.RequestGuildMembers) async {} - func updatePresence(payload: Gateway.Identify.Presence) async {} - func updateVoiceState(payload: VoiceStateUpdate) async {} - func makeEventsStream() async -> AsyncStream { - AsyncStream { _ in } - } - func makeEventsParseFailureStream() async -> AsyncStream<(any Error, ByteBuffer)> { - AsyncStream<(any Error, ByteBuffer)> { _ in } - } - func disconnect() async {} -} diff --git a/PaicordLib/Tests/DiscordBMTests/VoiceGatewayTests.swift b/PaicordLib/Tests/DiscordBMTests/VoiceGatewayTests.swift new file mode 100644 index 00000000..54efad0d --- /dev/null +++ b/PaicordLib/Tests/DiscordBMTests/VoiceGatewayTests.swift @@ -0,0 +1,38 @@ +// +// VoiceGatewayTests.swift +// PaicordLib +// +// Created by Lakhan Lothiyi on 22/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import Foundation +import NIOCore +import XCTest + +@testable import DiscordVoice + +class VoiceGatewayTests: XCTestCase { + func testBinaryDataDecode() async throws { + let gw = VoiceGatewayManager.init( + connectionData: .init( + token: "", + guildID: try! .makeFake(), + channelID: try! .makeFake(), + userID: try! .makeFake(), + sessionID: "", + endpoint: "" + ) + ) + let b64encoded = [ + "AAEZQEEEoNRlRXBSfG9sxtr5jJxzVcUuTfJslWTSDfqcEtPF8pnyvue1yLTrW24vmka5hnjp67V0c+0wPu5jYTTrJWEmAQABAQA=", + "AAUbAEHwAAEAAQgUFa0c+EQQpQAAAAAAAAAAAgAAAAAAAgABAAEAAkBBBA6ffGO2L8WWMuAQ++A1Guoy24snd0uvi1PRIuJx+4dH6PMtRIfTH+ziN72vzIxctvdpozvYNHu67LyCakgni09AQQTWjD7BWWBExpHTTyoBuq1CF6wIIvSPKBMCYZoepq6kqKubOdIN/wLSlkZd0U118EVDVOMkt7+he3037GO6L0GjQEEEHjylB9FDUAK/ne9bxgQmSS8NdZy59gA4XrjkF4n1BQvbzepPUPln+KlzPUSJ9HazjqDXl19lUWG8YIxtS80K9wABCAVLf4aFQAAAAgABAgACAAACAAEBAAAAAAAAAAD//////////wBARzBFAiEAgAIMGdDd9QJBg489IMOK5grxwnrufTKc2kxwjPx+cgMCIAyukS8MJ9ifqTghaV6WPWTNenyK7W26KIbwpKu9RZhjAEBHMEUCIQCZpnz5onf5GTSD9EiQ4BU0iqZ5+017ntaXxZABk8f20AIgLkH7plqgAhSMUj3CWi7LM1wQJGAywVGlbJpV80In4GBASDBGAiEAz5mrzAcKQDdqgVEMNkUr53PM+Iki2TxZzsars+cAMwoCIQDjN91qFSm7pO8rbHiBLeod/tpwpKpsWk0QbzHpGHw/fw==", + "AAYdAAAAAQABCBQVrRz4RBClAAAAAAAAAAABAAAAAAADIgIg+vGT+WKMUdMhzDRQuUmb7m8ucQBmF7Q8BhrtHiHlhiEAQEYwRAIgQXho5VoNJ9QZOPlcrr1cxQMUNEaUwgYyoUEyaJjqKh0CICWeFzuXJyFWqcJCs7rK21oz9Vu4zLKh8gFtQa0jYke/IGXRtM8sLvaABUM6F7GckyemC6gvCXr+0pfHdaE5EAF+IFehvFBjnOgQyENbJbqs/fHjXOTDSgJg+EMKijIYlUQv" + ] + for encoded in b64encoded { + var data = ByteBuffer.init(data: Data(base64Encoded: encoded)!) + let decoded = try await gw.tryDecodeBufferAsEvent(&data, binary: true) + print(decoded) + } + } +} diff --git a/PaicordLib/Tests/IntegrationTests/GatwayConnection.swift b/PaicordLib/Tests/IntegrationTests/GatwayConnection.swift index c8b5fbfc..d18a48a3 100644 --- a/PaicordLib/Tests/IntegrationTests/GatwayConnection.swift +++ b/PaicordLib/Tests/IntegrationTests/GatwayConnection.swift @@ -226,7 +226,7 @@ class GatewayConnectionTests: XCTestCase, @unchecked Sendable { let first = try XCTUnwrap(messages.first) XCTAssertEqual( first, - #"Will not reconnect because Discord does not allow it. Something is wrong. Your close code is 'authenticationFailed', check Discord docs at https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes and see what it means. Report at https://github.com/DiscordBM/DiscordBM/issues if you think this is a library issue"# + #"Will not reconnect because Discord does not allow it. Something is wrong. Your close code is 'authenticationFailed', check Discord docs at https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes and see what it means. Report at https://github.com/llsc12/Paicord/issues if you think this is a library issue"# ) /// Wait 1s just incase. diff --git a/README.md b/README.md index ddf3e181..a639d4ee 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,16 @@ A native Discord client written in Swift using SwiftUI, with a goal of feature --- -Paicord currently supports sending messages, replying to messages, uploading files and photos, it has partial Discord-flavoured markdown support, partial reactions support, partial embeds support etc. +## Progress -This list is not exhaustive but the goal for Paicord is to have parity with the official Discord client, excluding unfavourable things like upselling of services. A real feature/todo list will be made eventually. +Paicord has support for core chat features, like partial markdown, attachments and embeds with support for file uploads, editing, replying and deleting messages, partial voice support and more! + +Paicord aims for feature parity! By default, the more difficult features are targeted first. Whilst this leaves many smaller features unimplemented at first, it helps keep momentum going! [Click here for a rough feature list!](Feature Checklist.md) > [!WARNING] > > As all third-party clients and client mods do, using Paicord is a violation of Discord ToS! Whilst Paicord ensures to pretend to be Discord as close as possible, the risk of account bans is ever-present. Beware! -## Cross-platform -We have subprojects in the works to port the client to other platforms natively too! Keep an eye out on the repo or join the [Discord Server](https://discord.gg/fqhPGHPyaK)! - ## Installing There nightly releases of Paicord built from source when source successfully compiles. @@ -47,6 +46,45 @@ If you've enjoyed using Paicord, I would apprecate a [sponsor](https://github.co +## FAQ + +
+Is this client allowed by Discord? +Third-party clients are not officially supported by Discord. +Use at your own risk. +
+ +
+Does this support plugins? +No, but plugin-like functionality will eventually make it into Paicord cleanly. Extra features must be implemented in a minimalistic way as to not cause clutter. As of writing, Paicord is still early in the works and focus is only on feature parity, not extras. +
+ +
+Will this be maintained? +I mean I use Discord a lot, plus this is quite a lot of fun thus far. +It really depends on motivation and community support. Paicord is still a hobby project and I balance it with my education. +At a minimum, Paicord shouldn't break easily even with inconsistent maintainence. +
+ +
+Where's token login support? +Will never be implemented, using the same token on two clients like the one you took the token from is more dangerous. I think it's using them both at the same time that creates the risk of bans. Discord could also compare super-properties against prior sessions I guess. Use the normal login methods, they're much safer. +
+ +
+What about theming support? +This information only applies to the SwiftUI application.
+That's in the works! Paicord will let you set custom colors or materials on various interface elements. There will also be pre-made alternative interface layouts. It won't be as flexible as CSS, but it should hopefully allow for some tasteful customisation! +
+
+I want to help translate the app! +Awesome! To translate the macOS/iOS app, you'll need Xcode installed or xcstring-tool if not on macOS (linux CLI tool, run under WSL if on Windows).
+If using Xcode, open the Localizable.xcstrings file in `Paicord/Resources`. If using xcstring-tool, use the file browser with arrow keys and enter, with tab for autocompletions to navigate to the file, or pass the filepath into the command.
+Add your language, then begin localising! You will need to handle different substitutions, use this document to learn more. +
+ +Any other questions? Join the [Discord server]()! + ## References Paicord uses modified versions of [DiscordBM](https://github.com/DiscordBM/DiscordBM) and [SwiftMarkdownParser](https://github.com/sciasxp/SwiftMarkdownParser). These other references are mentioned since I read their code to learn from others. @@ -56,3 +94,6 @@ Paicord uses modified versions of [DiscordBM](https://github.com/DiscordBM/Disco - [Cyclone](https://github.com/slice/cyclone) And of course, [Discord Userdoccers and its maintainers](https://docs.discord.food) helped massively with their unofficial documentation and direct help. + +For voice, work from [SwiftDiscordAudio/DiscordAudioKit](https://github.com/SwiftDiscordAudio/DiscordAudioKit) was used for reference a lot, and also uses [these RTP packet models etc.](https://github.com/SwiftDiscordAudio/DiscordAudioKit/tree/main/Sources/DiscordRTP) too. Paicord relies on a fork of [DaveKit](https://github.com/SwiftDiscordAudio/DaveKit) for voice too! +