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!
+