From 4b66a39e189b48ab1eda5e0cf37024ee577afbbc Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Thu, 19 Feb 2026 11:28:12 +0000 Subject: [PATCH 01/66] add voice module, deps, wip models --- .../xcshareddata/swiftpm/Package.resolved | 20 +- .../xcschemes/xcschememanagement.plist | 5 + PaicordLib/Package.swift | 28 +- .../Types/VoiceGateway+Payloads.swift | 206 +++++++++++++++ .../DiscordModels/Types/VoiceGateway.swift | 250 ++++++++++++++++++ .../Sources/DiscordVoice/DiscordVoice.swift | 10 + PaicordLib/Sources/PaicordLib/exports.swift | 1 + 7 files changed, 518 insertions(+), 2 deletions(-) create mode 100644 PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift create mode 100644 PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift create mode 100644 PaicordLib/Sources/DiscordVoice/DiscordVoice.swift diff --git a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved index d59d98c4..36bdd800 100644 --- a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "bfacdd34b4927d43359347cb7517b46411822ef5371dc63508a5a8ed06405338", + "originHash" : "b27bf575182b512b6c2e0bcf8d0980f3c2abf425f317f9801c6eeaa2d2fcca11", "pins" : [ { "identity" : "async-http-client", @@ -343,6 +343,15 @@ "version" : "1.1.0" } }, + { + "identity" : "swift-opus", + "kind" : "remoteSourceControl", + "location" : "https://github.com/alta/swift-opus.git", + "state" : { + "branch" : "main", + "revision" : "6f3cb6bd3ffed1fe5f06d00a962d5c191a50daf8" + } + }, { "identity" : "swift-protobuf", "kind" : "remoteSourceControl", @@ -361,6 +370,15 @@ "version" : "2.8.0" } }, + { + "identity" : "swift-sodium", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jedisct1/swift-sodium.git", + "state" : { + "revision" : "e7e799cd1eaa4d0f6d3eab56832e7f4b377f4a4f", + "version" : "0.10.0" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist b/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist index b096bf4f..28027995 100644 --- a/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist @@ -39,6 +39,11 @@ orderHint 6 + DiscordVoice.xcscheme_^#shared#^_ + + orderHint + 8 + GenerateAPIEndpointsExec.xcscheme_^#shared#^_ orderHint diff --git a/PaicordLib/Package.swift b/PaicordLib/Package.swift index 7a61f577..dd06ee53 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,14 @@ let package = Package( url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"5.0.0" ), + .package( + url: "https://github.com/jedisct1/swift-sodium.git", + from: "0.10.0" + ), + .package( + url: "https://github.com/alta/swift-opus.git", + branch: "main" + ) ], targets: [ .target( @@ -82,6 +94,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 +124,22 @@ 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: "Opus", package: "swift-opus"), + .product(name: "Sodium", package: "swift-sodium"), + .target(name: "DiscordHTTP"), ], swiftSettings: swiftSettings ), diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift new file mode 100644 index 00000000..6a77918d --- /dev/null +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift @@ -0,0 +1,206 @@ +// +// VoiceGateway+Payloads.swift +// PaicordLib +// +// Created by Lakhan Lothiyi on 19/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import Foundation + +extension VoiceGateway { + + /// https://docs.discord.food/topics/voice-connections#identify-structure + public struct Identify: Sendable, Codable { + public init( + server_id: GuildSnowflake, + channel_id: ChannelSnowflake, + user_id: UserSnowflake, + session_id: String, + token: Secret, + video: Bool? = nil, + streams: [Stream]? = nil + ) { + self.server_id = server_id + self.channel_id = channel_id + self.user_id = user_id + self.session_id = session_id + self.token = token + self.video = video + self.streams = streams + } + + public var server_id: GuildSnowflake + public var channel_id: ChannelSnowflake + public var user_id: UserSnowflake + public var session_id: String + public var token: Secret + public var video: Bool? + public var streams: [Stream]? + } + + /// https://docs.discord.food/topics/voice-connections#stream-structure + public struct Stream: Sendable, Codable { + public init( + type: Kind, + rid: String, + quality: Int? = nil, + active: Bool? = nil, + max_bitrate: Int? = nil, + max_framerate: Int? = nil, + max_resolution: StreamResolution? = nil + ) { + self.type = type + self.rid = rid + self.quality = quality + self.active = active + self.max_bitrate = max_bitrate + self.max_framerate = max_framerate + self.max_resolution = max_resolution + } + + public var type: Kind + public var rid: String + public var quality: Int? + public var active: Bool? + public var max_bitrate: Int? + public var max_framerate: Int? + public var max_resolution: StreamResolution? + + @UnstableEnum + 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: Int + 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 var address: String + public var port: Int + public var mode: EncryptionMode + } + } + + @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_lit + 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) + } + } +} diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift new file mode 100644 index 00000000..b34c8be8 --- /dev/null +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift @@ -0,0 +1,250 @@ +// +// VoiceGateway.swift +// PaicordLib +// +// Created by Lakhan Lothiyi on 19/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import Foundation + +public struct VoiceGateway: Sendable, Codable { + + /// https://docs.discord.food/topics/voice-connections + public enum Opcode: UInt8, Sendable, Codable, CustomStringConvertible { + case identify = 0 // s + case selectProtocol = 1 // s + case clientPlatform = 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 + case clientConnect = 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, not docced too. + + public var description: String { + switch self { + case .identify: return "identify" + case .selectProtocol: return "selectProtocol" + case .clientPlatform: return "clientPlatform" + 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 .clientConnect: return "clientConnect" + case .video: return "video" + case .clientDisconnect: return "clientDisconnect" + case .sessionUpdate: return "sessionUpdate" + case .mediaSinkWants: return "mediaSinkWants" + case .voiceBackendVersion: return "voiceBackendVersion" + case .channelOptionsUpdate: return "channelOptionsUpdate" + } + } + } + + /// 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 + + case __undocumented + + } + + public enum GatewayDecodingError: Error, CustomStringConvertible { + case unhandledDispatchEvent(type: String?) + + public var description: String { + switch self { + case .unhandledDispatchEvent(let type): + return + "Gateway.Event.GatewayDecodingError.unhandledDispatchEvent(type: \(type ?? "nil"))" + } + } + } + + 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, + type: String? = 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 .none: + // 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, .heartbeat, .resume: + throw DecodingError.dataCorrupted( + .init( + codingPath: container.codingPath, + debugDescription: + "'\(opcode)' opcode is supposed to never be received." + ) + ) + case .invalidSession: + self.data = try .invalidSession(canResume: decodeData()) + case .hello: + } + } + + 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. + 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 .dispatch, .reconnect, .invalidSession, .heartbeatAccepted, .hello: + 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))." + ) + } + if self.type != nil { + throw EncodingError.notSupposedToBeSent( + message: + "'type' is supposed to never be sent but wasn't nil (\(String(describing: type))." + ) + } + + switch self.data { + case .none: + try container.encodeNil(forKey: .data) + case .heartbeat(let lastSequenceNumber): + try container.encode(lastSequenceNumber, forKey: .data) + case .qosHeartbeat(let payload): + try container.encode(payload, forKey: .data) + case .identify(let payload): + try container.encode(payload, forKey: .data) + case .resume(let payload): + try container.encode(payload, forKey: .data) + case .requestGuildMembers(let payload): + try container.encode(payload, forKey: .data) + case .requestPresenceUpdate(let payload): + try container.encode(payload, forKey: .data) + case .requestVoiceStateUpdate(let payload): + try container.encode(payload, forKey: .data) + case .updateGuildSubscriptions(let payload): + try container.encode(payload, forKey: .data) + case .updateTimeSpentSessionId(let payload): + try container.encode(payload, forKey: .data) + default: + throw EncodingError.notSupposedToBeSent( + message: "'\(self)' data is supposed to never be sent." + ) + } + } + } +} + +// 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/DiscordVoice/DiscordVoice.swift b/PaicordLib/Sources/DiscordVoice/DiscordVoice.swift new file mode 100644 index 00000000..86a4040d --- /dev/null +++ b/PaicordLib/Sources/DiscordVoice/DiscordVoice.swift @@ -0,0 +1,10 @@ +// +// DiscordVoice.swift +// PaicordLib +// +// Created by Lakhan Lothiyi on 19/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import Sodium +import Opus 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 From d49fcc1bd38b2a9c57be5ba173a8db0e711a8a92 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Thu, 19 Feb 2026 14:15:49 +0000 Subject: [PATCH 02/66] more voice models --- .../xcschemes/xcschememanagement.plist | 2 +- .../xcschemes/xcschememanagement.plist | 2 +- .../Protocols/UDPEncodable.swift | 14 + .../Sources/DiscordModels/Types/Gateway.swift | 280 ++++++++---------- .../Types/VoiceGateway+Payloads.swift | 181 ++++++++++- .../DiscordModels/Types/VoiceGateway.swift | 114 ++++--- .../DiscordModels/Types/VoiceUDP.swift | 109 +++++++ .../Sources/DiscordVoice/DiscordVoice.swift | 2 + 8 files changed, 503 insertions(+), 201 deletions(-) create mode 100644 PaicordLib/Sources/DiscordModels/Protocols/UDPEncodable.swift create mode 100644 PaicordLib/Sources/DiscordModels/Types/VoiceUDP.swift 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/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist b/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist index 28027995..4c0532e1 100644 --- a/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist @@ -42,7 +42,7 @@ DiscordVoice.xcscheme_^#shared#^_ orderHint - 8 + 3 GenerateAPIEndpointsExec.xcscheme_^#shared#^_ diff --git a/PaicordLib/Sources/DiscordModels/Protocols/UDPEncodable.swift b/PaicordLib/Sources/DiscordModels/Protocols/UDPEncodable.swift new file mode 100644 index 00000000..1433f059 --- /dev/null +++ b/PaicordLib/Sources/DiscordModels/Protocols/UDPEncodable.swift @@ -0,0 +1,14 @@ +// +// UDPEncodable.swift +// PaicordLib +// +// Created by Lakhan Lothiyi on 19/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import Foundation + +public protocol UDPEncodable { + /// The binary representation of the RTP packet, ready to be sent over UDP. + func encode() -> Data +} diff --git a/PaicordLib/Sources/DiscordModels/Types/Gateway.swift b/PaicordLib/Sources/DiscordModels/Types/Gateway.swift index 882e46e0..d9357dc7 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,32 +419,32 @@ 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): return - "Gateway.Event.GatewayDecodingError.unhandledDispatchEvent(type: \(type ?? "nil"))" + "Gateway.Event.GatewayDecodingError.unhandledDispatchEvent(type: \(type ?? "nil"))" } } } - + 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. 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/VoiceGateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift index 6a77918d..dc7aa6c0 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift @@ -137,6 +137,7 @@ extension VoiceGateway { } } + /// https://docs.discord.food/topics/voice-connections#encryption-mode @UnstableEnum public enum EncryptionMode: Sendable, Codable { // preferred @@ -161,7 +162,7 @@ extension VoiceGateway { public var rtx_payload_type: Int? public var encode: Bool? public var decode: Bool? - + public static let opusCodec = Codec( name: .opus, type: "audio", @@ -171,7 +172,7 @@ extension VoiceGateway { encode: nil, decode: nil ) - + public static let h265Codec = Codec( name: .h265, type: "video", @@ -181,7 +182,7 @@ extension VoiceGateway { encode: true, decode: true ) - + public static let h264Codec = Codec( name: .h264, type: "video", @@ -203,4 +204,178 @@ extension VoiceGateway { 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: [Int]? + 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 { + // 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 var t: Int /* current unix timestamp */ = Int( + Date().timeIntervalSince1970 + ) + public var seq_ack: Int? = nil + } + + /// https://docs.discord.food/topics/voice-connections#speaking-structure + public struct Speaking: Sendable, Codable { + public var speaking: IntBitField + public var delay: Int? = nil + public var ssrc: Int? = 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 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#example-client-connect + public struct ClientConnect: 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 var audio_ssrc: Int + public var video_ssrc: Int + public var rtx_ssrc: Int + public var streams: [Stream]? // sent by client only + 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( + pixelCounts: [String: Double], + ssrcs: [String: Int] + ) { + 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 + } } diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift index b34c8be8..8e96e4bd 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift @@ -14,14 +14,14 @@ public struct VoiceGateway: Sendable, Codable { public enum Opcode: UInt8, Sendable, Codable, CustomStringConvertible { case identify = 0 // s case selectProtocol = 1 // s - case clientPlatform = 2 // r + 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 + case resumed = 9 // r // signal opcode deprecated case clientConnect = 11 // r case video = 12 // r @@ -29,13 +29,15 @@ public struct VoiceGateway: Sendable, Codable { case sessionUpdate = 14 // s r case mediaSinkWants = 15 // s r case voiceBackendVersion = 16 // s r - case channelOptionsUpdate = 17 // unknown, not docced too. + case channelOptionsUpdate = 17 // unknown + case clientFlags = 18 + case clientPlatform = 20 public var description: String { switch self { case .identify: return "identify" case .selectProtocol: return "selectProtocol" - case .clientPlatform: return "clientPlatform" + case .ready: return "ready" case .heartbeat: return "heartbeat" case .sessionDescription: return "sessionDescription" case .speaking: return "speaking" @@ -50,6 +52,8 @@ public struct VoiceGateway: Sendable, Codable { case .mediaSinkWants: return "mediaSinkWants" case .voiceBackendVersion: return "voiceBackendVersion" case .channelOptionsUpdate: return "channelOptionsUpdate" + case .clientFlags: return "clientFlags" + case .clientPlatform: return "clientPlatform" } } } @@ -65,7 +69,23 @@ public struct VoiceGateway: Sendable, Codable { indirect public enum Payload: Sendable { case identify(Identify) case ready(Ready) - case + case selectProtocol(SelectProtocol) + case heartbeat(Heartbeat) + case sessionDescription(SessionDescription) + case speaking(Speaking) + case heartbeatAck(Heartbeat) + case resume(Resume) + case hello(Hello) + case resumed + case clientConnect(ClientConnect) + case video(Video) + case clientDisconnect(ClientDisconnect) + case sessionUpdate(SessionUpdate) + case mediaSinkWants(MediaSinkWants) + case voiceBackendVersion(VoiceBackendVersion) +// case channelOptionsUpdate + case clientFlags(ClientFlags) + case clientPlatform(ClientPlatform) case __undocumented @@ -117,19 +137,19 @@ public struct VoiceGateway: Sendable, Codable { } switch opcode { - // case .none: - // 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, .heartbeat, .resume: + 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: throw DecodingError.dataCorrupted( .init( codingPath: container.codingPath, @@ -137,9 +157,36 @@ public struct VoiceGateway: Sendable, Codable { "'\(opcode)' opcode is supposed to never be received." ) ) - case .invalidSession: - self.data = try .invalidSession(canResume: decodeData()) + 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 .clientConnect: + self.data = .clientConnect(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()) } } @@ -159,7 +206,8 @@ public struct VoiceGateway: Sendable, Codable { var container = encoder.container(keyedBy: CodingKeys.self) switch self.opcode { - case .dispatch, .reconnect, .invalidSession, .heartbeatAccepted, .hello: + case .ready, .sessionDescription, .heartbeatAck, .hello, .resumed, + .clientConnect, .video, .clientDisconnect: throw EncodingError.notSupposedToBeSent( message: "`\(self.opcode.rawValue)` opcode is supposed to never be sent." @@ -174,33 +222,25 @@ public struct VoiceGateway: Sendable, Codable { "'sequenceNumber' is supposed to never be sent but wasn't nil (\(String(describing: sequenceNumber))." ) } - if self.type != nil { - throw EncodingError.notSupposedToBeSent( - message: - "'type' is supposed to never be sent but wasn't nil (\(String(describing: type))." - ) - } switch self.data { case .none: try container.encodeNil(forKey: .data) - case .heartbeat(let lastSequenceNumber): - try container.encode(lastSequenceNumber, forKey: .data) - case .qosHeartbeat(let payload): - try container.encode(payload, forKey: .data) case .identify(let payload): try container.encode(payload, forKey: .data) - case .resume(let payload): + case .selectProtocol(let payload): try container.encode(payload, forKey: .data) - case .requestGuildMembers(let payload): + case .heartbeat(let payload): try container.encode(payload, forKey: .data) - case .requestPresenceUpdate(let payload): + case .speaking(let payload): + try container.encode(payload, forKey: .data) + case .resume(let payload): try container.encode(payload, forKey: .data) - case .requestVoiceStateUpdate(let payload): + case .sessionUpdate(let payload): try container.encode(payload, forKey: .data) - case .updateGuildSubscriptions(let payload): + case .mediaSinkWants(let payload): try container.encode(payload, forKey: .data) - case .updateTimeSpentSessionId(let payload): + case .voiceBackendVersion(let payload): try container.encode(payload, forKey: .data) default: throw EncodingError.notSupposedToBeSent( diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceUDP.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceUDP.swift new file mode 100644 index 00000000..c6dd2f89 --- /dev/null +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceUDP.swift @@ -0,0 +1,109 @@ +// +// VoiceUDP.swift +// PaicordLib +// +// Created by Lakhan Lothiyi on 19/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import Foundation + +/// 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. +public struct RTPPacket: Sendable { + public init( + payloadType: UInt8, + sequence: UInt16, + timestamp: UInt32, + ssrc: UInt32, + payload: Data, + playoutDelay: (min: UInt16, max: UInt16)? = nil + ) { + self.payloadType = payloadType + self.sequence = sequence + self.timestamp = timestamp + self.ssrc = ssrc + self.payload = payload + self.playoutDelay = playoutDelay + } + + public var payloadType: UInt8 + public var sequence: UInt16 + public var timestamp: UInt32 + public var ssrc: UInt32 + public var payload: Data + public var playoutDelay: (min: UInt16, max: UInt16)? + + public func encode() -> Data { + var data = Data() + + // 1 byte version and flags + let version: UInt8 = 0b10 << 6 + let extensionBit: UInt8 = (playoutDelay != nil) ? 0b00010000 : 0 + let firstByte = version | extensionBit + data.append(firstByte) + + // 1 byte payload type + data.append(payloadType) + + // 2 byte sequence + withUnsafeBytes(of: sequence.bigEndian) { data.append(contentsOf: $0) } + + // 4 byte timestamp + withUnsafeBytes(of: timestamp.bigEndian) { data.append(contentsOf: $0) } + + // 4 byte ssrc + withUnsafeBytes(of: ssrc.bigEndian) { data.append(contentsOf: $0) } + + // extension + if let delay = playoutDelay { + data.append(playoutDelayExtension(min: delay.min, max: delay.max)) + } + + // payload + data.append(payload) + + return data + } + + private func playoutDelayExtension(min: UInt16, max: UInt16) -> Data { + var ext = Data(capacity: 8) + + // RFC5285 header + let profile = UInt16(0xBEDE).bigEndian + let lengthWords = UInt16(1).bigEndian + + withUnsafeBytes(of: profile) { ext.append(contentsOf: $0) } + withUnsafeBytes(of: lengthWords) { ext.append(contentsOf: $0) } + + // extension entry (id = 5, len = 2, 3 byte payload) + let id: UInt8 = 5 + let len: UInt8 = 2 // encoded as len-1 in RFC5285 + let headerByte: UInt8 = (id << 4) | len + ext.append(headerByte) + + // pack 12-bit min max + let min12 = UInt32(min & 0x0FFF) + let max12 = UInt32(max & 0x0FFF) + let packed: UInt32 = (min12 << 12) | max12 + + ext.append(UInt8((packed >> 16) & 0xFF)) + ext.append(UInt8((packed >> 8) & 0xFF)) + ext.append(UInt8(packed & 0xFF)) + + // padding to 32 bit boundary + ext.append(UInt8(0)) + + return ext + } + +} diff --git a/PaicordLib/Sources/DiscordVoice/DiscordVoice.swift b/PaicordLib/Sources/DiscordVoice/DiscordVoice.swift index 86a4040d..4e95b551 100644 --- a/PaicordLib/Sources/DiscordVoice/DiscordVoice.swift +++ b/PaicordLib/Sources/DiscordVoice/DiscordVoice.swift @@ -8,3 +8,5 @@ import Sodium import Opus + +/// https://docs.discord.food/topics/voice-connections#voice-data-interpolation From f942d4d86101170c69f362a716df367b7b2e32cf Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Thu, 19 Feb 2026 15:14:07 +0000 Subject: [PATCH 03/66] clean up --- PaicordLib/Package.swift | 9 +- .../Sources/DiscordAuth/BotAuthManager.swift | 65 - .../RemoteAuthGatewayManager.swift | 12 +- .../Sources/DiscordGateway/Backoff.swift | 8 +- .../DiscordGateway/DiscordAsyncStream.swift | 8 +- .../Sources/DiscordGateway/DiscordCache.swift | 1188 ----------------- .../DiscordGateway/GatewayEventHandler.swift | 1039 -------------- .../DiscordGateway/GatewayManager.swift | 91 -- .../Sources/DiscordGateway/SerialQueue.swift | 8 +- .../DiscordGateway/ShardCoordinator.swift | 35 - .../DiscordGateway/UserGatewayManager.swift | 5 +- ...dVoice.swift => VoiceGatewayManager.swift} | 4 +- 12 files changed, 35 insertions(+), 2437 deletions(-) delete mode 100644 PaicordLib/Sources/DiscordAuth/BotAuthManager.swift rename PaicordLib/Sources/{DiscordGateway => DiscordAuth}/RemoteAuthGatewayManager.swift (99%) delete mode 100644 PaicordLib/Sources/DiscordGateway/DiscordCache.swift delete mode 100644 PaicordLib/Sources/DiscordGateway/GatewayEventHandler.swift delete mode 100644 PaicordLib/Sources/DiscordGateway/GatewayManager.swift delete mode 100644 PaicordLib/Sources/DiscordGateway/ShardCoordinator.swift rename PaicordLib/Sources/DiscordVoice/{DiscordVoice.swift => VoiceGatewayManager.swift} (84%) diff --git a/PaicordLib/Package.swift b/PaicordLib/Package.swift index dd06ee53..31af8055 100644 --- a/PaicordLib/Package.swift +++ b/PaicordLib/Package.swift @@ -85,7 +85,7 @@ let package = Package( .package( url: "https://github.com/alta/swift-opus.git", branch: "main" - ) + ), ], targets: [ .target( @@ -165,7 +165,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 ), 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/DiscordGateway/Backoff.swift b/PaicordLib/Sources/DiscordGateway/Backoff.swift index ddec223d..35f2a927 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,7 +57,7 @@ final class Backoff { } } - func resetTryCount() { + package func resetTryCount() { self.tryCount = 0 } 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..95db491f 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 @@ -281,7 +281,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 diff --git a/PaicordLib/Sources/DiscordVoice/DiscordVoice.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift similarity index 84% rename from PaicordLib/Sources/DiscordVoice/DiscordVoice.swift rename to PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index 4e95b551..dca9838a 100644 --- a/PaicordLib/Sources/DiscordVoice/DiscordVoice.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -1,5 +1,5 @@ // -// DiscordVoice.swift +// VoiceGatewayManager.swift // PaicordLib // // Created by Lakhan Lothiyi on 19/02/2026. @@ -10,3 +10,5 @@ import Sodium import Opus /// https://docs.discord.food/topics/voice-connections#voice-data-interpolation + +/// This From a820da19bb02f867019bf7ea7b61d90ff1f105b6 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Fri, 20 Feb 2026 11:40:42 +0000 Subject: [PATCH 04/66] added gateway actor, not ready yet tho --- .../Sources/DiscordGateway/Backoff.swift | 2 +- .../DiscordModels/Types/Error Codes.swift | 64 +- .../Types/VoiceGateway+Payloads.swift | 22 +- .../DiscordVoice/VoiceGatewayManager.swift | 915 +++++++++++++++++- 4 files changed, 997 insertions(+), 6 deletions(-) diff --git a/PaicordLib/Sources/DiscordGateway/Backoff.swift b/PaicordLib/Sources/DiscordGateway/Backoff.swift index 35f2a927..2cec2bd5 100644 --- a/PaicordLib/Sources/DiscordGateway/Backoff.swift +++ b/PaicordLib/Sources/DiscordGateway/Backoff.swift @@ -61,7 +61,7 @@ final package class Backoff { self.tryCount = 0 } - func willTry() { + package func willTry() { self.previousTry = Date().timeIntervalSince1970 } diff --git a/PaicordLib/Sources/DiscordModels/Types/Error Codes.swift b/PaicordLib/Sources/DiscordModels/Types/Error Codes.swift index 8d9a0d71..04f9bc6e 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,62 @@ 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 + } + } +} + +// CODE DESCRIPTION +// 4001 Unknown opcode +// 4002 Failed to decode +// 4003 Not authenticated +// 4004 Authentication failed +// 4005 Already authenticated +// 4006 Session no longer valid +// 4009 Session timeout +// 4011 Server not found +// 4012 Unknown protocol +// 4014 Disconnected +// 4015 Voice server crashed +// 4016 Unknown encryption +// 4020 Bad request +// 4021 Rate limited +// 4022 Disconnected +/// https://docs.discord.food/topics/opcodes-and-status-codes#voice-close-event-codes +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 disconnected1 = 4014 + case voiceServerCrashed = 4015 + case unknownEncryption = 4016 + case badRequest = 4020 + case rateLimited = 4021 + case disconnected2 = 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 true + case .sessionTimedOut: return true + case .sessionNoLongerValid: return true + case .serverNotFound: return false + case .unknownProtocol: return false + case .disconnected1, .disconnected2: return true + case .voiceServerCrashed: return true + case .unknownEncryption: return false + case .badRequest: return false } } } @@ -157,7 +218,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/VoiceGateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift index dc7aa6c0..78fd2e9b 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift @@ -30,6 +30,7 @@ extension VoiceGateway { self.streams = streams } + public var max_dave_protocol_version: Int = 1 public var server_id: GuildSnowflake public var channel_id: ChannelSnowflake public var user_id: UserSnowflake @@ -237,6 +238,11 @@ extension VoiceGateway { /// https://docs.discord.food/topics/voice-connections#heartbeat-structure public struct Heartbeat: Sendable, Codable { + public init(t: Int, seq_ack: Int? = nil) { + self.t = t + self.seq_ack = seq_ack + } + public var t: Int /* current unix timestamp */ = Int( Date().timeIntervalSince1970 ) @@ -269,6 +275,20 @@ extension VoiceGateway { /// 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 @@ -372,7 +392,7 @@ extension VoiceGateway { } } } - + /// https://docs.discord.food/topics/voice-connections#voice-backend-version-structure public struct VoiceBackendVersion: Sendable, Codable { public var voice: String diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index dca9838a..4f6f0f2d 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -4,11 +4,920 @@ // // Created by Lakhan Lothiyi on 19/02/2026. // Copyright © 2026 Lakhan Lothiyi. -// +// -import Sodium +import AsyncHTTPClient +import Atomics +import DiscordGateway +import DiscordHTTP +import DiscordModels +import Foundation +import Logging +import NIO import Opus +import Sodium +import WSClient + +import enum NIOWebSocket.WebSocketErrorCode +import struct NIOWebSocket.WebSocketOpcode /// https://docs.discord.food/topics/voice-connections#voice-data-interpolation -/// This +/// 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 + } + } + + var outboundWriter: WebSocketOutboundWriter? + let eventLoopGroup: any EventLoopGroup + /// A client to send requests to Discord. + public nonisolated let client: any DiscordClient + /// 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: Gateway.VoiceServerUpdate + + //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 + public nonisolated let identifyPayload: VoiceGateway.Identify + + //MARK: Connection state + public nonisolated let state = ManagedAtomic(GatewayState.noConnection) + public nonisolated let stateCallback: (@Sendable (GatewayState) -> Void)? + + //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 + /// The ID of the current Discord-related session. + var sessionId: String? = nil + /// Gateway URL for resuming the connection, so we don't need to make an api call. + var resumeGatewayURL: String? = nil + + //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 + ) + // TODO: - Reconfigure the backoff to be more suitable for users. + + //MARK: Ping-pong tracking properties + var unsuccessfulPingsCount = 0 + var lastPongDate = Date() + + + public init( + eventLoopGroup: any EventLoopGroup = HTTPClient.shared.eventLoopGroup, + maxFrameSize: Int = 1 << 28, + voiceServerUpdatePayload: Gateway.VoiceServerUpdate, + stateCallback: (@Sendable (GatewayState) -> Void)? = nil + ) async { + self.eventLoopGroup = eventLoopGroup + self.stateCallback = stateCallback + self.maxFrameSize = maxFrameSize + self.connectionData = voiceServerUpdatePayload + self.identifyPayload = .init( + server_id: connectionData.guild_id, + channel_id: <#T##ChannelSnowflake#>, + user_id: <#T##UserSnowflake#>, + session_id: <#T##String#>, + token: <#T##Secret#>, + video: <#T##Bool?#>, + streams: nil + ) + + var logger = DiscordGlobalConfiguration.makeLogger("GatewayManager") + 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 = await getGatewayURL() + // #if DEBUGo + 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: [] + // ) + // #else + let configuration = WebSocketClientConfiguration( + maxFrameSize: self.maxFrameSize, + additionalHeaders: [ + .userAgent: SuperProperties.useragent(ws: false)!, + .origin: "https://discord.com", + .cacheControl: "no-cache", + .acceptLanguage: SuperProperties.GenerateLocaleHeader(), + + ], + extensions: [] + ) + // #endif + + 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 closeFrame = try await WebSocketClient.connect( + url: gatewayURL + queries.makeForURLQuery(), + 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" + ) + 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: - Gateway actions + + // /// https://discord.com/developers/docs/topics/gateway-events#update-presence + // public func updatePresence(payload: Gateway.Identify.Presence) { + // self.send( + // message: .init( + // payload: .init( + // opcode: .presenceUpdate, + // data: .requestPresenceUpdate(payload) + // ), + // opcode: .text + // ) + // ) + // } + // + // /// https://discord.com/developers/docs/topics/gateway-events#update-voice-state + // public func updateVoiceState(payload: VoiceStateUpdate) { + // self.send( + // message: .init( + // payload: .init( + // opcode: .voiceStateUpdate, + // data: .requestVoiceStateUpdate(payload) + // ), + // 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() + } +} + +extension VoiceGatewayManager { + private func processEvent(_ event: Gateway.Event) async { + if let sequenceNumber = event.sequenceNumber { + self.sequenceNumber = sequenceNumber + } + + switch event.opcode { + case .heartbeat: + self.sendPing( + forConnectionWithId: self.connectionId.load(ordering: .relaxed) + ) + case .heartbeatAccepted: + self.lastPongDate = Date() + case .reconnect: + logger.debug( + "Received reconnect request. Will reconnect after connection closure" + ) + default: + break + } + + switch event.data { + case .invalidSession(let canResume): + logger.warning( + "Got invalid session. Will try to reconnect or resume", + metadata: [ + "canResume": .stringConvertible(canResume) + ] + ) + if !canResume { + self.sequenceNumber = nil + self.resumeGatewayURL = nil + self.sessionId = nil + } + self.state.store(.noConnection, ordering: .relaxed) + self.stateCallback?(.noConnection) + await self.connect() + case .hello(let hello): + logger.debug("Received 'hello'") + /// Start heart-beating right-away. + self.setupPingTask( + forConnectionWithId: self.connectionId.load(ordering: .relaxed), + every: .milliseconds(Int64(hello.heartbeat_interval)) + ) + logger.trace("Will resume or identify") + await self.sendResumeOrIdentify() + case .ready(let payload): + logger.notice( + "Received ready notice. The connection is fully established", + metadata: [ + "connectionId": .stringConvertible( + self.connectionId.load(ordering: .relaxed) + ) + ] + ) + await self.onSuccessfulConnection() + self.sessionId = payload.session_id + self.resumeGatewayURL = payload.resume_gateway_url + case .resumed: + logger.debug( + "Received resume notice. The connection is fully established", + metadata: [ + "connectionId": .stringConvertible( + self.connectionId.load(ordering: .relaxed) + ) + ] + ) + await self.onSuccessfulConnection() + default: + break + } + } + + private func getGatewayURL() async -> String { + logger.debug("Will try to get Discord gateway url") + if self.sequenceNumber != nil, + /// If can resume at all + let gatewayURL = self.resumeGatewayURL + { + logger.trace("Got Discord gateway url from 'resumeGatewayURL'") + return gatewayURL + } else { + return DiscordGlobalConfiguration.gatewayURL + } + } + + private func sendResumeOrIdentify() async { + if let sessionId = self.sessionId, + let lastSequenceNumber = self.sequenceNumber + { + self.sendResume(sessionId: sessionId, sequenceNumber: lastSequenceNumber) + } else { + logger.debug( + "Can't resume last Discord connection. Will identify", + metadata: [ + "sessionId": .stringConvertible(self.sessionId ?? "nil"), + "lastSequenceNumber": .stringConvertible(self.sequenceNumber ?? -1), + ] + ) + await self.sendIdentify() + } + } + + private func sendResume(sessionId: String, sequenceNumber: Int) { + let resume = VoiceGateway.Event( + opcode: .resume, + data: .resume( + .init( + token: identifyPayload.token, + session_id: sessionId, + sequence: sequenceNumber + ) + ) + ) + let opcode = Gateway.Opcode.identify + self.send( + message: .init( + payload: resume, + opcode: .init(encodedWebSocketOpcode: opcode.rawValue)! + ) + ) + + /// 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(identifyPayload) + ) + self.send(message: .init(payload: identify, opcode: .text)) + } + + private func processBinaryData( + _ message: WebSocketMessage, + forConnectionWithId connectionId: UInt + ) { + guard self.connectionId.load(ordering: .relaxed) == connectionId else { + return + } + + let buffer: ByteBuffer + switch message { + case .text(let string): + self.logger.debug( + "Got text from websocket", + metadata: [ + "text": .string(string) + ] + ) + buffer = ByteBuffer(string: string) + case .binary(let _buffer): + self.logger.debug( + "Got binary from websocket", + metadata: [ + "text": .string(String(buffer: _buffer)) + ] + ) + buffer = _buffer + } + + do { + let event = try DiscordGlobalConfiguration.decoder.decode( + Gateway.Event.self, + from: Data(buffer: buffer, byteTransferStrategy: .noCopy) + ) + self.logger.debug( + "Decoded event", + metadata: [ + "event": .string("\(event)"), + "opcode": .string(event.opcode.description), + ] + ) + Task { 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)) + } + } + } + + 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 GatewayCloseCode(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 = GatewayCloseCode(rawValue: codeNumber) else { + return true + } + return discordCode.canTryReconnect + default: return true + } + } + + private func setupPingTask( + forConnectionWithId connectionId: UInt, + every duration: Duration + ) { + Task { + try? await Task.sleep(for: duration) + guard self.connectionId.load(ordering: .relaxed) == connectionId else { + self.logger.trace( + "Canceled a ping task", + metadata: [ + "connectionId": .stringConvertible(connectionId) + ] + ) + return/// cancel + } + self.logger.debug( + "Will send automatic ping", + metadata: [ + "connectionId": .stringConvertible(connectionId) + ] + ) + self.sendPing(forConnectionWithId: connectionId) + self.setupPingTask(forConnectionWithId: connectionId, every: duration) + } + } + + 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(t: lastSentPingNonce, seq_ack: self.sequenceNumber) + ) + ), + ) + ) + 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 { + // discordbm tried to turn discord's opcodes into a ws opcode. this only work for heartbeats. + // this is really jank. fall back to .text opcode instead + // let opcode: WebSocketOpcode = + // message.opcode ?? .init( + // encodedWebSocketOpcode: message.payload.opcode.rawValue + // )! + let opcode: WebSocketOpcode = + message.opcode ?? .text + + let data: Data + do { + 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.sessionId + } +} + +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 + } + } +} From 4cf68cbe882e9f194d32e6cd34105316e4a22b37 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sat, 21 Feb 2026 17:46:58 +0000 Subject: [PATCH 05/66] more work --- .../xcshareddata/swiftpm/Package.resolved | 24 +- PaicordLib/Package.swift | 10 +- .../Protocols/UDPEncodable.swift | 14 - .../DiscordModels/Types/Error Codes.swift | 26 +- .../Types/Gateway+Payloads.swift | 2 +- .../DiscordModels/Types/RTP/RTCP.swift | 44 +++ .../Sources/DiscordModels/Types/RTP/RTP.swift | 228 ++++++++++++++ .../DiscordModels/Types/RTP/RTPType.swift | 100 ++++++ .../Types/VoiceGateway+Payloads.swift | 54 +++- .../DiscordModels/Types/VoiceGateway.swift | 290 ++++++++++++------ .../DiscordModels/Types/VoiceUDP.swift | 109 ------- .../DiscordVoice/VoiceGatewayManager.swift | 54 ++-- README.md | 3 + 13 files changed, 670 insertions(+), 288 deletions(-) delete mode 100644 PaicordLib/Sources/DiscordModels/Protocols/UDPEncodable.swift create mode 100644 PaicordLib/Sources/DiscordModels/Types/RTP/RTCP.swift create mode 100644 PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift create mode 100644 PaicordLib/Sources/DiscordModels/Types/RTP/RTPType.swift delete mode 100644 PaicordLib/Sources/DiscordModels/Types/VoiceUDP.swift diff --git a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved index 36bdd800..b3325185 100644 --- a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b27bf575182b512b6c2e0bcf8d0980f3c2abf425f317f9801c6eeaa2d2fcca11", + "originHash" : "991f119a6548b62f1958bda935b935816c57860692d86d336c2f87c3654bc16f", "pins" : [ { "identity" : "async-http-client", @@ -73,6 +73,15 @@ "version" : "4.0.2" } }, + { + "identity" : "davekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/llsc12/DaveKit.git", + "state" : { + "branch" : "main", + "revision" : "7dc9a2ff50d58d14f358c45b1e5090d1e6cd56ae" + } + }, { "identity" : "flatten", "kind" : "remoteSourceControl", @@ -285,8 +294,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version" : "1.6.4" + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" } }, { @@ -370,15 +379,6 @@ "version" : "2.8.0" } }, - { - "identity" : "swift-sodium", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jedisct1/swift-sodium.git", - "state" : { - "revision" : "e7e799cd1eaa4d0f6d3eab56832e7f4b377f4a4f", - "version" : "0.10.0" - } - }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/PaicordLib/Package.swift b/PaicordLib/Package.swift index 31af8055..98bff9e0 100644 --- a/PaicordLib/Package.swift +++ b/PaicordLib/Package.swift @@ -79,11 +79,11 @@ let package = Package( "1.0.0"..<"5.0.0" ), .package( - url: "https://github.com/jedisct1/swift-sodium.git", - from: "0.10.0" + url: "https://github.com/alta/swift-opus.git", + branch: "main" ), .package( - url: "https://github.com/alta/swift-opus.git", + url: "https://github.com/llsc12/DaveKit.git", branch: "main" ), ], @@ -138,8 +138,8 @@ let package = Package( .product(name: "WSClient", package: "swift-websocket"), .product(name: "libzstd", package: "zstd"), .product(name: "Opus", package: "swift-opus"), - .product(name: "Sodium", package: "swift-sodium"), - .target(name: "DiscordHTTP"), + .product(name: "DaveKit", package: "DaveKit"), + .target(name: "DiscordGateway"), ], swiftSettings: swiftSettings ), diff --git a/PaicordLib/Sources/DiscordModels/Protocols/UDPEncodable.swift b/PaicordLib/Sources/DiscordModels/Protocols/UDPEncodable.swift deleted file mode 100644 index 1433f059..00000000 --- a/PaicordLib/Sources/DiscordModels/Protocols/UDPEncodable.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// UDPEncodable.swift -// PaicordLib -// -// Created by Lakhan Lothiyi on 19/02/2026. -// Copyright © 2026 Lakhan Lothiyi. -// - -import Foundation - -public protocol UDPEncodable { - /// The binary representation of the RTP packet, ready to be sent over UDP. - func encode() -> Data -} diff --git a/PaicordLib/Sources/DiscordModels/Types/Error Codes.swift b/PaicordLib/Sources/DiscordModels/Types/Error Codes.swift index 04f9bc6e..43dbfdb2 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Error Codes.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Error Codes.swift @@ -42,23 +42,8 @@ public enum GatewayCloseCode: UInt16, Sendable, Codable { } } -// CODE DESCRIPTION -// 4001 Unknown opcode -// 4002 Failed to decode -// 4003 Not authenticated -// 4004 Authentication failed -// 4005 Already authenticated -// 4006 Session no longer valid -// 4009 Session timeout -// 4011 Server not found -// 4012 Unknown protocol -// 4014 Disconnected -// 4015 Voice server crashed -// 4016 Unknown encryption -// 4020 Bad request -// 4021 Rate limited -// 4022 Disconnected /// 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 @@ -69,12 +54,12 @@ public enum VoiceGatewayCloseCode: UInt16, Sendable, Codable { case sessionTimedOut = 4009 case serverNotFound = 4011 case unknownProtocol = 4012 - case disconnected1 = 4014 + case disconnected = 4014 case voiceServerCrashed = 4015 case unknownEncryption = 4016 case badRequest = 4020 case rateLimited = 4021 - case disconnected2 = 4022 + case callTerminated = 4022 public var canTryReconnect: Bool { switch self { @@ -83,15 +68,16 @@ public enum VoiceGatewayCloseCode: UInt16, Sendable, Codable { case .notAuthenticated: return true case .authenticationFailed: return false case .alreadyAuthenticated: return true - case .rateLimited: return true + case .rateLimited: return false case .sessionTimedOut: return true case .sessionNoLongerValid: return true case .serverNotFound: return false case .unknownProtocol: return false - case .disconnected1, .disconnected2: return true + case .disconnected: return false case .voiceServerCrashed: return true case .unknownEncryption: return false case .badRequest: return false + case .callTerminated: return false } } } diff --git a/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift index 6d58a5e9..bc739a67 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift @@ -1484,7 +1484,7 @@ 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 token: Secret public var guild_id: GuildSnowflake public var endpoint: String? } 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..ffb35976 --- /dev/null +++ b/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift @@ -0,0 +1,228 @@ +/// 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: 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 let payload: ByteBuffer + + 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 var delay: Int? = nil - public var ssrc: Int? = nil + public var delay: UInt? = nil + public var ssrc: UInt? = nil #if Non64BitSystemsCompatibility @UnstableEnum @@ -318,9 +320,9 @@ extension VoiceGateway { /// https://docs.discord.food/topics/voice-connections#video-structure public struct Video: Sendable, Codable { - public var audio_ssrc: Int - public var video_ssrc: Int - public var rtx_ssrc: Int + public var audio_ssrc: UInt + public var video_ssrc: UInt + public var rtx_ssrc: UInt public var streams: [Stream]? // sent by client only public var user_id: UserSnowflake? // sent by server only } @@ -398,4 +400,34 @@ extension VoiceGateway { public var voice: String public var rtc_worker: String } + + public struct DavePrepareTransition: Sendable, Codable { + public var transitionId: UInt16 + public var protocolVersion: UInt16 + } + + public struct DaveCommitTransition: Sendable, Codable { + public var transitionId: UInt16 + } + + public struct DavePrepareEpoch: Sendable, Codable { + public var epoch: UInt32 + public var protocolVersion: UInt16 + } + + public struct DaveTransitionReady: Sendable, Codable { + public init(transitionId: UInt16) { + self.transitionId = transitionId + } + + public var transitionId: UInt16 + } + + public struct DaveMLSInvalidCommitWelcome: Sendable, Codable { + public init(transitionId: UInt16) { + self.transitionId = transitionId + } + + public var transitionId: UInt16 + } } diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift index 8e96e4bd..c17965da 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift @@ -7,11 +7,16 @@ // 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 @@ -22,7 +27,7 @@ public struct VoiceGateway: Sendable, Codable { case resume = 7 // s case hello = 8 // r case resumed = 9 // r - // signal opcode deprecated + // signal opcode deprecated, but its 10 jsyk ykyk case clientConnect = 11 // r case video = 12 // r case clientDisconnect = 13 // r @@ -32,6 +37,18 @@ public struct VoiceGateway: Sendable, Codable { 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 // b s public var description: String { switch self { @@ -52,8 +69,19 @@ public struct VoiceGateway: Sendable, Codable { case .mediaSinkWants: return "mediaSinkWants" case .voiceBackendVersion: return "voiceBackendVersion" case .channelOptionsUpdate: return "channelOptionsUpdate" - case .clientFlags: return "clientFlags" - case .clientPlatform: return "clientPlatform" + 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" } } } @@ -83,22 +111,38 @@ public struct VoiceGateway: Sendable, Codable { case sessionUpdate(SessionUpdate) case mediaSinkWants(MediaSinkWants) case voiceBackendVersion(VoiceBackendVersion) -// case channelOptionsUpdate + // case channelOptionsUpdate case clientFlags(ClientFlags) case clientPlatform(ClientPlatform) - case __undocumented + // 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))" } } } @@ -125,68 +169,158 @@ public struct VoiceGateway: Sendable, Codable { } 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 - ) + // the data could be binary or json. + do { + let container = try decoder.container(keyedBy: CodingKeys.self) - func decodeData(as type: D.Type = D.self) throws -> D { - try container.decode(D.self, forKey: .data) - } + 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, + 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 have no data." + "'\(opcode)' opcode is supposed to never be received." ) ) - } - self.data = nil - case .identify, .selectProtocol, .resume: - 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 .clientConnect: + self.data = .clientConnect(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." ) - ) - 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 .clientConnect: - self.data = .clientConnect(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()) + self.data = .none + break + } + } catch let decodingError { + // try to decode entire thing as a ByteBuffer, then try to get binary out. + // https://github.com/Snazzah/davey/blob/master/docs/USAGE.md + if var buffer = try? ByteBuffer(from: decoder) { + 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 = 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." + ) + ) + } + self.opcode = opcode + + switch opcode { + case .mlsExternalSender: + self.data = .mlsExternalSender(Data(buffer: buffer)) + case .mlsProposals: + self.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) + self.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) + self.data = .mlsWelcome( + transitionId: transitionId, + welcome: welcome + ) + default: + print( + "Received an opcode \(opcode.description) that is not expected to be binary, but it came as binary." + ) + self.data = .none + } + } else { + throw decodingError + } } } @@ -250,41 +384,3 @@ public struct VoiceGateway: 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/VoiceUDP.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceUDP.swift deleted file mode 100644 index c6dd2f89..00000000 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceUDP.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// VoiceUDP.swift -// PaicordLib -// -// Created by Lakhan Lothiyi on 19/02/2026. -// Copyright © 2026 Lakhan Lothiyi. -// - -import Foundation - -/// 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. -public struct RTPPacket: Sendable { - public init( - payloadType: UInt8, - sequence: UInt16, - timestamp: UInt32, - ssrc: UInt32, - payload: Data, - playoutDelay: (min: UInt16, max: UInt16)? = nil - ) { - self.payloadType = payloadType - self.sequence = sequence - self.timestamp = timestamp - self.ssrc = ssrc - self.payload = payload - self.playoutDelay = playoutDelay - } - - public var payloadType: UInt8 - public var sequence: UInt16 - public var timestamp: UInt32 - public var ssrc: UInt32 - public var payload: Data - public var playoutDelay: (min: UInt16, max: UInt16)? - - public func encode() -> Data { - var data = Data() - - // 1 byte version and flags - let version: UInt8 = 0b10 << 6 - let extensionBit: UInt8 = (playoutDelay != nil) ? 0b00010000 : 0 - let firstByte = version | extensionBit - data.append(firstByte) - - // 1 byte payload type - data.append(payloadType) - - // 2 byte sequence - withUnsafeBytes(of: sequence.bigEndian) { data.append(contentsOf: $0) } - - // 4 byte timestamp - withUnsafeBytes(of: timestamp.bigEndian) { data.append(contentsOf: $0) } - - // 4 byte ssrc - withUnsafeBytes(of: ssrc.bigEndian) { data.append(contentsOf: $0) } - - // extension - if let delay = playoutDelay { - data.append(playoutDelayExtension(min: delay.min, max: delay.max)) - } - - // payload - data.append(payload) - - return data - } - - private func playoutDelayExtension(min: UInt16, max: UInt16) -> Data { - var ext = Data(capacity: 8) - - // RFC5285 header - let profile = UInt16(0xBEDE).bigEndian - let lengthWords = UInt16(1).bigEndian - - withUnsafeBytes(of: profile) { ext.append(contentsOf: $0) } - withUnsafeBytes(of: lengthWords) { ext.append(contentsOf: $0) } - - // extension entry (id = 5, len = 2, 3 byte payload) - let id: UInt8 = 5 - let len: UInt8 = 2 // encoded as len-1 in RFC5285 - let headerByte: UInt8 = (id << 4) | len - ext.append(headerByte) - - // pack 12-bit min max - let min12 = UInt32(min & 0x0FFF) - let max12 = UInt32(max & 0x0FFF) - let packed: UInt32 = (min12 << 12) | max12 - - ext.append(UInt8((packed >> 16) & 0xFF)) - ext.append(UInt8((packed >> 8) & 0xFF)) - ext.append(UInt8(packed & 0xFF)) - - // padding to 32 bit boundary - ext.append(UInt8(0)) - - return ext - } - -} diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index 4f6f0f2d..e1aa4f01 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -9,13 +9,11 @@ import AsyncHTTPClient import Atomics import DiscordGateway -import DiscordHTTP import DiscordModels import Foundation import Logging import NIO import Opus -import Sodium import WSClient import enum NIOWebSocket.WebSocketErrorCode @@ -45,10 +43,17 @@ public actor VoiceGatewayManager { } } + struct ConnectionData { + var token: Secret // voice token + var guildID: GuildSnowflake + var channelID: ChannelSnowflake + var userID: UserSnowflake + var sessionID: String + } + var outboundWriter: WebSocketOutboundWriter? let eventLoopGroup: any EventLoopGroup - /// A client to send requests to Discord. - public nonisolated let client: any DiscordClient + /// Max frame size we accept to receive through the web-socket connection. let maxFrameSize: Int /// Generator of `UserGatewayManager` ids. @@ -60,8 +65,8 @@ public actor VoiceGatewayManager { let logger: Logger private var lastSentPingNonce: Int = 0 - - private var connectionData: Gateway.VoiceServerUpdate + + private var connectionData: ConnectionData //MARK: Event streams var eventsStreamContinuations = [AsyncStream.Continuation]() @@ -135,24 +140,33 @@ public actor VoiceGatewayManager { var unsuccessfulPingsCount = 0 var lastPongDate = Date() - public init( eventLoopGroup: any EventLoopGroup = HTTPClient.shared.eventLoopGroup, maxFrameSize: Int = 1 << 28, - voiceServerUpdatePayload: Gateway.VoiceServerUpdate, + token: Secret, + session: Gateway.Session, + userID: UserSnowflake, + guildID: GuildSnowflake, // or init with channel id if in dms + channelID: ChannelSnowflake, stateCallback: (@Sendable (GatewayState) -> Void)? = nil ) async { self.eventLoopGroup = eventLoopGroup self.stateCallback = stateCallback self.maxFrameSize = maxFrameSize - self.connectionData = voiceServerUpdatePayload + self.connectionData = .init( + token: token, + guildID: guildID, + channelID: channelID, + userID: userID, + sessionID: session.id + ) self.identifyPayload = .init( - server_id: connectionData.guild_id, - channel_id: <#T##ChannelSnowflake#>, - user_id: <#T##UserSnowflake#>, - session_id: <#T##String#>, - token: <#T##Secret#>, - video: <#T##Bool?#>, + server_id: connectionData.guildID, + channel_id: connectionData.channelID, + user_id: connectionData.userID, + session_id: connectionData.sessionID, + token: connectionData.token, + video: false, streams: nil ) @@ -474,9 +488,11 @@ extension VoiceGatewayManager { opcode: .resume, data: .resume( .init( - token: identifyPayload.token, - session_id: sessionId, - sequence: sequenceNumber + server_id: connectionData.guildID, + channel_id: connectionData.channelID, + session_id: connectionData.sessionID, + token: connectionData.token, + seq_ack: sequenceNumber ) ) ) @@ -695,7 +711,7 @@ extension VoiceGatewayManager { payload: .init( opcode: .heartbeat, data: .heartbeat( - .init(t: lastSentPingNonce, seq_ack: self.sequenceNumber) + .init(seq_ack: self.sequenceNumber) ) ), ) diff --git a/README.md b/README.md index ddf3e181..1eeefebb 100644 --- a/README.md +++ b/README.md @@ -56,3 +56,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 [DaveKit](https://github.com/SwiftDiscordAudio/DaveKit) for voice too! + From 8d412d37768770c9be2659831367c251bac62968 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sun, 22 Feb 2026 10:53:58 +0000 Subject: [PATCH 06/66] test --- Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- .../DiscordModels/Types/VoiceGateway+Payloads.swift | 2 +- PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift | 1 + PaicordLib/Sources/DiscordVoice/VoiceUDPConnection.swift | 8 ++++++++ 4 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 PaicordLib/Sources/DiscordVoice/VoiceUDPConnection.swift diff --git a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved index b3325185..b178d057 100644 --- a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "991f119a6548b62f1958bda935b935816c57860692d86d336c2f87c3654bc16f", + "originHash" : "0fec6dffd3755fefeb06c808d171eaa14e9b282a409e1ea7091a5c9795cb4f75", "pins" : [ { "identity" : "async-http-client", @@ -79,7 +79,7 @@ "location" : "https://github.com/llsc12/DaveKit.git", "state" : { "branch" : "main", - "revision" : "7dc9a2ff50d58d14f358c45b1e5090d1e6cd56ae" + "revision" : "41756edece55a9dd0d45eb87c3f9a3c2c3f25a4e" } }, { diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift index b2a45b28..73b897aa 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift @@ -150,7 +150,7 @@ extension VoiceGateway { case aead_aes256_gcm case xsalsa20_poly1305 case xsalsa20_poly1305_suffix - case xsalsa20_poly1305_lit + case xsalsa20_poly1305_lite case __undocumented(String) } diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index e1aa4f01..832d369b 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -14,6 +14,7 @@ import Foundation import Logging import NIO import Opus +import DaveKit import WSClient import enum NIOWebSocket.WebSocketErrorCode diff --git a/PaicordLib/Sources/DiscordVoice/VoiceUDPConnection.swift b/PaicordLib/Sources/DiscordVoice/VoiceUDPConnection.swift new file mode 100644 index 00000000..2c455b2c --- /dev/null +++ b/PaicordLib/Sources/DiscordVoice/VoiceUDPConnection.swift @@ -0,0 +1,8 @@ +// +// VoiceUDPConnection.swift +// PaicordLib +// +// Created by Lakhan Lothiyi on 22/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + From c9e96aeaa6c2a84ab75caa198ccff467c7d3f316 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sun, 22 Feb 2026 11:41:10 +0000 Subject: [PATCH 07/66] fix builds --- DiscordMarkdownParser/Package.swift | 15 ++++++++++++--- Paicord.xcodeproj/project.pbxproj | 2 ++ PaicordLib/Package.swift | 7 ++++--- .../Sources/DiscordVoice/VoiceConnection.swift | 14 ++++++++++++++ .../Sources/DiscordVoice/VoiceUDPConnection.swift | 8 -------- 5 files changed, 32 insertions(+), 14 deletions(-) create mode 100644 PaicordLib/Sources/DiscordVoice/VoiceConnection.swift delete mode 100644 PaicordLib/Sources/DiscordVoice/VoiceUDPConnection.swift 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/Paicord.xcodeproj/project.pbxproj b/Paicord.xcodeproj/project.pbxproj index a2c6107d..45a31895 100644 --- a/Paicord.xcodeproj/project.pbxproj +++ b/Paicord.xcodeproj/project.pbxproj @@ -1425,6 +1425,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"; @@ -1488,6 +1489,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; diff --git a/PaicordLib/Package.swift b/PaicordLib/Package.swift index 98bff9e0..8d494657 100644 --- a/PaicordLib/Package.swift +++ b/PaicordLib/Package.swift @@ -148,10 +148,10 @@ 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"), + .target(name: "DiscordCore"), + .target(name: "UnstableEnumMacro"), ], swiftSettings: swiftSettings ), @@ -242,9 +242,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/DiscordVoice/VoiceConnection.swift b/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift new file mode 100644 index 00000000..f378239f --- /dev/null +++ b/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift @@ -0,0 +1,14 @@ +// +// VoiceConnection.swift +// PaicordLib +// +// Created by Lakhan Lothiyi on 22/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import DaveKit + +/// This contains and manages the UDP connection +public actor VoiceConnection { + +} diff --git a/PaicordLib/Sources/DiscordVoice/VoiceUDPConnection.swift b/PaicordLib/Sources/DiscordVoice/VoiceUDPConnection.swift deleted file mode 100644 index 2c455b2c..00000000 --- a/PaicordLib/Sources/DiscordVoice/VoiceUDPConnection.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// VoiceUDPConnection.swift -// PaicordLib -// -// Created by Lakhan Lothiyi on 22/02/2026. -// Copyright © 2026 Lakhan Lothiyi. -// - From 15d959a3818853703df8b15bd166a82db86c9c35 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sun, 22 Feb 2026 11:54:08 +0000 Subject: [PATCH 08/66] Squashed commit of the following: commit b51568df3f387dbf2ca53661162f6c6efe96e17f Author: Lakhan Lothiyi Date: Sun Feb 22 11:53:44 2026 +0000 workflow updates --- .github/workflows/build.yml | 14 ++++++++++++++ .github/workflows/build_pr.yml | 14 ++++++++++++++ .github/workflows/release_pr.yml | 14 ++++++++++++++ 3 files changed, 42 insertions(+) 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 From ef52d9652ea598f82d4a6693c2322161d7cc6ebe Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sun, 22 Feb 2026 13:15:46 +0000 Subject: [PATCH 09/66] fix ios builds --- .../xcshareddata/swiftpm/Package.resolved | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved index b178d057..495debf5 100644 --- a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0fec6dffd3755fefeb06c808d171eaa14e9b282a409e1ea7091a5c9795cb4f75", + "originHash" : "87c1c63e097acb72b6a9235c44f2ace2180cf8cb96ab1d19a0d9e6a811371624", "pins" : [ { "identity" : "async-http-client", @@ -79,7 +79,7 @@ "location" : "https://github.com/llsc12/DaveKit.git", "state" : { "branch" : "main", - "revision" : "41756edece55a9dd0d45eb87c3f9a3c2c3f25a4e" + "revision" : "967172de5c8ea2cf1958a6e916704a4c3bdcb9b8" } }, { @@ -163,6 +163,15 @@ "version" : "4.7.1" } }, + { + "identity" : "openssl-package", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/OpenSSL-Package.git", + "state" : { + "revision" : "4db40e8f0bac4edd7b605cc692b924b558f0d125", + "version" : "3.6.1" + } + }, { "identity" : "sdwebimage", "kind" : "remoteSourceControl", From 753c916e1bd47eec39aed84964c51493ad9af975 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Mon, 23 Feb 2026 02:57:02 +0000 Subject: [PATCH 10/66] clean up, add dave work, tests --- PaicordLib/Package.swift | 1 + .../DiscordGlobalConfiguration.swift | 3 +- .../Sources/DiscordHTTP/CookieStore.swift | 2 +- .../DiscordModels/Types/VoiceGateway.swift | 214 +++------ .../DiscordVoice/CryptoExtensions.swift | 111 +++++ .../DiscordVoice/VoiceGatewayManager.swift | 421 +++++++++++------- .../Tests/DiscordBMTests/BotAuthManager.swift | 63 --- .../DiscordBMTests/DecompressionTests.swift | 272 ----------- .../Tests/DiscordBMTests/DiscordCache.swift | 84 ---- .../DiscordBMTests/VoiceGatewayTests.swift | 38 ++ 10 files changed, 483 insertions(+), 726 deletions(-) create mode 100644 PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift delete mode 100644 PaicordLib/Tests/DiscordBMTests/BotAuthManager.swift delete mode 100644 PaicordLib/Tests/DiscordBMTests/DecompressionTests.swift delete mode 100644 PaicordLib/Tests/DiscordBMTests/DiscordCache.swift create mode 100644 PaicordLib/Tests/DiscordBMTests/VoiceGatewayTests.swift diff --git a/PaicordLib/Package.swift b/PaicordLib/Package.swift index 8d494657..f1264920 100644 --- a/PaicordLib/Package.swift +++ b/PaicordLib/Package.swift @@ -150,6 +150,7 @@ let package = Package( .product(name: "MultipartKit", package: "multipart-kit"), .product(name: "SwiftProtobuf", package: "swift-protobuf"), .product(name: "UInt128", package: "UInt128"), + .product(name: "DaveKit", package: "DaveKit"), .target(name: "DiscordCore"), .target(name: "UnstableEnumMacro"), ], 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/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/DiscordModels/Types/VoiceGateway.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift index c17965da..d0e6b19b 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift @@ -48,7 +48,7 @@ public struct VoiceGateway: Sendable, Codable { case mlsCommitWelcome = 28 // b s case mlsAnnounceCommitTransition = 29 // b r case mlsWelcome = 30 // b r - case mlsInvalidCommitWelcome = 31 // b s + case mlsInvalidCommitWelcome = 31 // s public var description: String { switch self { @@ -161,7 +161,6 @@ public struct VoiceGateway: Sendable, Codable { opcode: Opcode, data: Payload? = nil, sequenceNumber: Int? = nil, - type: String? = nil ) { self.opcode = opcode self.data = data @@ -169,158 +168,83 @@ public struct VoiceGateway: Sendable, Codable { } public init(from decoder: any Decoder) throws { - // the data could be binary or json. - do { - let container = try decoder.container(keyedBy: CodingKeys.self) + 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 - ) + 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) - } + 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( + 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 never be received." + "`\(opcode)` opcode is supposed to have no data." ) ) - 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 .clientConnect: - self.data = .clientConnect(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 - } - } catch let decodingError { - // try to decode entire thing as a ByteBuffer, then try to get binary out. - // https://github.com/Snazzah/davey/blob/master/docs/USAGE.md - if var buffer = try? ByteBuffer(from: decoder) { - 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 = 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." - ) - ) - } - self.opcode = opcode - - switch opcode { - case .mlsExternalSender: - self.data = .mlsExternalSender(Data(buffer: buffer)) - case .mlsProposals: - self.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) - self.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) - self.data = .mlsWelcome( - transitionId: transitionId, - welcome: welcome - ) - default: - print( - "Received an opcode \(opcode.description) that is not expected to be binary, but it came as binary." - ) - self.data = .none - } - } else { - throw decodingError } + 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 .clientConnect: + self.data = .clientConnect(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 } } diff --git a/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift new file mode 100644 index 00000000..9b031397 --- /dev/null +++ b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift @@ -0,0 +1,111 @@ +// +// 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 { + func decrypt( + buffer: consuming ByteBuffer, + with key: SymmetricKey, + ) -> Data? { + guard + let rtpNonce = buffer.readBytes(length: rtpNonceLength), + let ciphertext = buffer.readData( + length: buffer.readableBytes - tagLength + ), + let tag = buffer.readData(length: tagLength) + else { + return nil + } + + var nonce = Data(repeating: 0, count: nonceLength) + nonce.replaceSubrange( + nonce.count - rtpNonce.count...Continuation]() + 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 + public var events: DiscordAsyncSequence { + DiscordAsyncSequence( + base: AsyncStream { continuation in self.eventsStreamContinuations.append(continuation) } ) @@ -99,6 +119,16 @@ public actor VoiceGatewayManager { public nonisolated let state = ManagedAtomic(GatewayState.noConnection) public nonisolated let stateCallback: (@Sendable (GatewayState) -> Void)? + //MARK: UDP Connection + var udpConnection: VoiceConnection? = nil + private lazy var dave: DaveSessionManager = { + return DaveSessionManager( + selfUserId: "", + groupId: 0, + delegate: self, + ) + }() + //MARK: Send queue /// 120 per 60 seconds (1 every 500ms), @@ -114,10 +144,10 @@ public actor VoiceGatewayManager { /// The sequence number for the payloads sent to us. var sequenceNumber: Int? = nil - /// The ID of the current Discord-related session. - var sessionId: String? = nil - /// Gateway URL for resuming the connection, so we don't need to make an api call. - var resumeGatewayURL: String? = nil + /// Gateway URL for connecting and resuming connections. + var resumeGatewayURL: String? { + return connectionData.endpoint.map { "wss://\($0)" } + } //MARK: Backoff @@ -135,7 +165,6 @@ public actor VoiceGatewayManager { coefficient: 1, minBackoff: 15 ) - // TODO: - Reconfigure the backoff to be more suitable for users. //MARK: Ping-pong tracking properties var unsuccessfulPingsCount = 0 @@ -144,34 +173,35 @@ public actor VoiceGatewayManager { public init( eventLoopGroup: any EventLoopGroup = HTTPClient.shared.eventLoopGroup, maxFrameSize: Int = 1 << 28, - token: Secret, - session: Gateway.Session, - userID: UserSnowflake, - guildID: GuildSnowflake, // or init with channel id if in dms - channelID: ChannelSnowflake, + connectionData: ConnectionData, stateCallback: (@Sendable (GatewayState) -> Void)? = nil - ) async { + ) { self.eventLoopGroup = eventLoopGroup self.stateCallback = stateCallback self.maxFrameSize = maxFrameSize - self.connectionData = .init( - token: token, - guildID: guildID, - channelID: channelID, - userID: userID, - sessionID: session.id - ) + self.connectionData = connectionData self.identifyPayload = .init( server_id: connectionData.guildID, channel_id: connectionData.channelID, user_id: connectionData.userID, session_id: connectionData.sessionID, token: connectionData.token, - video: false, - streams: nil + video: true, + streams: [ + .init( + type: .video, + rid: "100", + quality: 100 + ), + .init( + type: .video, + rid: "50", + quality: 50 + ), + ] ) - var logger = DiscordGlobalConfiguration.makeLogger("GatewayManager") + var logger = DiscordGlobalConfiguration.makeLogger("VoiceGatewayManager") logger[metadataKey: "gateway-id"] = .string("\(self.id)") self.logger = logger } @@ -205,28 +235,11 @@ public actor VoiceGatewayManager { self.stateCallback?(.connecting) await self.sendQueue.reset() - let gatewayURL = await getGatewayURL() - // #if DEBUGo + let gatewayURL = self.resumeGatewayURL ?? "" let queries: [(String, String)] = [ - ("v", "\(DiscordGlobalConfiguration.apiVersion)"), - ("encoding", "json"), - ("compress", "zstd-stream"), + ("v", "\(DiscordGlobalConfiguration.apiVersion)") ] - // #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: [ @@ -238,7 +251,6 @@ public actor VoiceGatewayManager { ], extensions: [] ) - // #endif logger.trace("Will try to connect to Discord through web-socket") let connectionId = self.connectionId.wrappingIncrementThenLoad( @@ -338,7 +350,7 @@ public actor VoiceGatewayManager { /// Makes an stream of Gateway events. @available(*, deprecated, renamed: "events") - public func makeEventsStream() -> AsyncStream { + public func makeEventsStream() -> AsyncStream { self.events.base } @@ -382,109 +394,40 @@ public actor VoiceGatewayManager { } extension VoiceGatewayManager { - private func processEvent(_ event: Gateway.Event) async { + private func processEvent(_ event: VoiceGateway.Event) async { if let sequenceNumber = event.sequenceNumber { self.sequenceNumber = sequenceNumber } switch event.opcode { - case .heartbeat: - self.sendPing( - forConnectionWithId: self.connectionId.load(ordering: .relaxed) - ) - case .heartbeatAccepted: - self.lastPongDate = Date() - case .reconnect: - logger.debug( - "Received reconnect request. Will reconnect after connection closure" - ) - default: - break - } - - switch event.data { - case .invalidSession(let canResume): - logger.warning( - "Got invalid session. Will try to reconnect or resume", - metadata: [ - "canResume": .stringConvertible(canResume) - ] - ) - if !canResume { - self.sequenceNumber = nil - self.resumeGatewayURL = nil - self.sessionId = nil + case .hello: + // get heartbeat interval and setup ping task + if case .hello(let payload) = event.data { + self.setupPingTask( + forConnectionWithId: self.connectionId.load(ordering: .relaxed), + every: .milliseconds(payload.heartbeat_interval) + ) } - self.state.store(.noConnection, ordering: .relaxed) - self.stateCallback?(.noConnection) - await self.connect() - case .hello(let hello): - logger.debug("Received 'hello'") - /// Start heart-beating right-away. - self.setupPingTask( - forConnectionWithId: self.connectionId.load(ordering: .relaxed), - every: .milliseconds(Int64(hello.heartbeat_interval)) - ) - logger.trace("Will resume or identify") - await self.sendResumeOrIdentify() - case .ready(let payload): - logger.notice( - "Received ready notice. The connection is fully established", - metadata: [ - "connectionId": .stringConvertible( - self.connectionId.load(ordering: .relaxed) - ) - ] - ) - await self.onSuccessfulConnection() - self.sessionId = payload.session_id - self.resumeGatewayURL = payload.resume_gateway_url - case .resumed: - logger.debug( - "Received resume notice. The connection is fully established", - metadata: [ - "connectionId": .stringConvertible( - self.connectionId.load(ordering: .relaxed) - ) - ] - ) - await self.onSuccessfulConnection() default: break } } - private func getGatewayURL() async -> String { - logger.debug("Will try to get Discord gateway url") - if self.sequenceNumber != nil, - /// If can resume at all - let gatewayURL = self.resumeGatewayURL - { - logger.trace("Got Discord gateway url from 'resumeGatewayURL'") - return gatewayURL - } else { - return DiscordGlobalConfiguration.gatewayURL - } - } - private func sendResumeOrIdentify() async { - if let sessionId = self.sessionId, - let lastSequenceNumber = self.sequenceNumber - { - self.sendResume(sessionId: sessionId, sequenceNumber: lastSequenceNumber) + if let lastSequenceNumber = self.sequenceNumber { + self.sendResume(sequenceNumber: lastSequenceNumber) } else { logger.debug( "Can't resume last Discord connection. Will identify", metadata: [ - "sessionId": .stringConvertible(self.sessionId ?? "nil"), - "lastSequenceNumber": .stringConvertible(self.sequenceNumber ?? -1), + "lastSequenceNumber": .stringConvertible(self.sequenceNumber ?? -1) ] ) await self.sendIdentify() } } - private func sendResume(sessionId: String, sequenceNumber: Int) { + private func sendResume(sequenceNumber: Int) { let resume = VoiceGateway.Event( opcode: .resume, data: .resume( @@ -530,7 +473,8 @@ extension VoiceGatewayManager { return } - let buffer: ByteBuffer + var buffer: ByteBuffer + let isBinary: Bool switch message { case .text(let string): self.logger.debug( @@ -539,6 +483,7 @@ extension VoiceGatewayManager { "text": .string(string) ] ) + isBinary = false buffer = ByteBuffer(string: string) case .binary(let _buffer): self.logger.debug( @@ -547,21 +492,13 @@ extension VoiceGatewayManager { "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 DiscordGlobalConfiguration.decoder.decode( - Gateway.Event.self, - from: Data(buffer: buffer, byteTransferStrategy: .noCopy) - ) - self.logger.debug( - "Decoded event", - metadata: [ - "event": .string("\(event)"), - "opcode": .string(event.opcode.description), - ] - ) + let event = try self.tryDecodeBufferAsEvent(&buffer, binary: isBinary) Task { await self.processEvent(event) } for continuation in self.eventsStreamContinuations { continuation.yield(event) @@ -579,6 +516,107 @@ extension VoiceGatewayManager { } } + 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) @@ -646,7 +684,7 @@ extension VoiceGatewayManager { let description: String switch code { case .unknown(let codeNumber): - switch GatewayCloseCode(rawValue: codeNumber) { + switch VoiceGatewayCloseCode(rawValue: codeNumber) { case .some(let discordCode): description = "\(discordCode)" case .none: @@ -662,7 +700,7 @@ extension VoiceGatewayManager { private nonisolated func canTryReconnect(code: WebSocketErrorCode?) -> Bool { switch code { case .unknown(let codeNumber): - guard let discordCode = GatewayCloseCode(rawValue: codeNumber) else { + guard let discordCode = VoiceGatewayCloseCode(rawValue: codeNumber) else { return true } return discordCode.canTryReconnect @@ -780,18 +818,28 @@ extension VoiceGatewayManager { return } Task { - // discordbm tried to turn discord's opcodes into a ws opcode. this only work for heartbeats. - // this is really jank. fall back to .text opcode instead - // let opcode: WebSocketOpcode = - // message.opcode ?? .init( - // encodedWebSocketOpcode: message.payload.opcode.rawValue - // )! let opcode: WebSocketOpcode = message.opcode ?? .text let data: Data do { - data = try DiscordGlobalConfiguration.encoder.encode(message.payload) + // switch opcodes bc some are sent as binary. + switch message.payload.opcode { + case .mlsKeyPackage, .mlsCommitWelcome: + switch message.payload.data { + case .mlsKeyPackage(let payload): + data = payload + case .mlsCommitWelcome(let payload): + data = payload + default: + /// never happens, here to initialise data for compile time checks. + data = Data() + } + default: + data = try DiscordGlobalConfiguration.encoder.encode( + message.payload + ) + } } catch { self.logger.error( "Could not encode payload, \(error)", @@ -912,13 +960,68 @@ extension VoiceGatewayManager { } public func getSessionID() -> String? { - return self.sessionId + 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(transitionId: transitionId)) + ) + self.send( + message: .init( + payload: event, + opcode: .text + ) + ) + } + + public func readyForTransition(transitionId: UInt16) async { + let event = VoiceGateway.Event( + opcode: .daveTransitionReady, + data: .daveTransitionReady(.init(transitionId: transitionId)) + ) + self.send( + message: .init( + payload: event, + opcode: .text + ) + ) + } + +} + extension VoiceGatewayManager { func addEventsContinuation( - _ continuation: AsyncStream.Continuation + _ continuation: AsyncStream.Continuation ) { self.eventsStreamContinuations.append(continuation) } 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) + } + } +} From 5075107e702de3effbe60beae37414f0393b4772 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Mon, 23 Feb 2026 10:43:57 +0000 Subject: [PATCH 11/66] max dave version --- .../Sources/DiscordModels/Types/VoiceGateway+Payloads.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift index 73b897aa..2d75d554 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift @@ -7,6 +7,7 @@ // import Foundation +import DaveKit extension VoiceGateway { @@ -30,7 +31,7 @@ extension VoiceGateway { self.streams = streams } - public var max_dave_protocol_version: Int = 1 + public var max_dave_protocol_version: Int = .init(DaveSessionManager.maxSupportedProtocolVersion()) public var server_id: GuildSnowflake public var channel_id: ChannelSnowflake public var user_id: UserSnowflake From 1cf1c9a3a934eac7141acecd2add3a3a1a75582b Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Mon, 23 Feb 2026 14:42:30 +0000 Subject: [PATCH 12/66] more udp work --- Paicord.xcodeproj/project.pbxproj | 4 + Paicord/Stores/VoiceConnectionStore.swift | 39 ++++++ .../Types/VoiceGateway+Payloads.swift | 14 +- .../DiscordVoice/CryptoExtensions.swift | 7 + .../DiscordVoice/VoiceConnection.swift | 115 ++++++++++++++++- .../DiscordVoice/VoiceGatewayManager.swift | 121 ++++++++++++++---- 6 files changed, 271 insertions(+), 29 deletions(-) create mode 100644 Paicord/Stores/VoiceConnectionStore.swift diff --git a/Paicord.xcodeproj/project.pbxproj b/Paicord.xcodeproj/project.pbxproj index 45a31895..6c59a0dd 100644 --- a/Paicord.xcodeproj/project.pbxproj +++ b/Paicord.xcodeproj/project.pbxproj @@ -159,6 +159,7 @@ 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 */; }; 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 */ @@ -297,6 +298,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 */ @@ -978,6 +980,7 @@ AABED5A32E7F4DAE005BDD63 /* GuildStore.swift */, AABED59D2E7F4637005BDD63 /* ChannelStore.swift */, AA7B38F32EB50EFD00CA4A3C /* MessageDrainStore.swift */, + AAFBC52D2F4C945F00C5B644 /* VoiceConnectionStore.swift */, AABED5A52E7F5148005BDD63 /* SettingsStore.swift */, AAAF797F2ED1E9ED004B5B3F /* ExternalBadgeStore.swift */, AA79479D2EDC7B9400B7A1EE /* PresenceStore.swift */, @@ -1175,6 +1178,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 */, diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift new file mode 100644 index 00000000..768a782e --- /dev/null +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -0,0 +1,39 @@ +// +// VoiceConnectionStore.swift +// Paicord +// +// Created by Lakhan Lothiyi on 23/02/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import PaicordLib + +final class VoiceConnectionStore: DiscordDataStore { + var gateway: GatewayStore? + var voiceGateway: VoiceGatewayManager? + + var eventTask: Task? + + func setupEventHandling() { + guard let gateway = gateway?.gateway else { return } + + eventTask = Task { @MainActor in + for await event in await gateway.events { + switch event.data { + // capture and store voice events + default: + break + } + } + } + } + + + func cancelEventHandling() { + // overrides default impl of protocol + eventTask?.cancel() + eventTask = nil + + // end networking session etc. + } +} diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift index 2d75d554..c30be70c 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift @@ -101,7 +101,7 @@ extension VoiceGateway { /// https://docs.discord.food/topics/voice-connections#ready-structure public struct Ready: Sendable, Codable { - public var ssrc: UInt + public var ssrc: UInt32 public var ip: String public var port: Int public var modes: [EncryptionMode] @@ -133,6 +133,12 @@ extension VoiceGateway { /// 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 @@ -330,7 +336,7 @@ extension VoiceGateway { /// https://docs.discord.food/topics/voice-connections#example-media-sink-wants public struct MediaSinkWants: Sendable, Codable { - public var pixelCounts: [String: Double] + public var pixelCounts: [String: Double]? public var ssrcs: [String: Int] public init(from decoder: any Decoder) throws { @@ -358,8 +364,8 @@ extension VoiceGateway { } public init( - pixelCounts: [String: Double], - ssrcs: [String: Int] + ssrcs: [String: Int], + pixelCounts: [String: Double]? ) { self.pixelCounts = pixelCounts self.ssrcs = ssrcs diff --git a/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift index 9b031397..bbae5bff 100644 --- a/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift +++ b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift @@ -14,6 +14,13 @@ 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( buffer: consuming ByteBuffer, with key: SymmetricKey, diff --git a/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift b/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift index f378239f..47b9cb45 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift @@ -6,9 +6,118 @@ // Copyright © 2026 Lakhan Lothiyi. // -import DaveKit +import AsyncAlgorithms +import NIO -/// This contains and manages the UDP connection -public actor VoiceConnection { +/// 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> + + private init( + inbound: NIOAsyncChannelInboundStream>, + outbound: NIOAsyncChannelOutboundWriter>, + socketAddress: SocketAddress + ) { + self.inbound = inbound + self.outbound = outbound + self.address = socketAddress + } + + static func connect( + host: String, + port: Int, + onConnect: + @Sendable @escaping (VoiceConnection) async throws -> Void + ) async throws { + let socketAddress = try SocketAddress(ipAddress: host, port: port) + let server = try await DatagramBootstrap( + group: NIOSingletons.posixEventLoopGroup + ) + .bind(to: socketAddress) + .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: socketAddress + ) + + 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)? { + var buffer = ByteBufferAllocator().buffer(capacity: 74) + buffer.writeInteger(UInt16(0x1)) // Type (Send) + buffer.writeInteger(UInt16(70)) // Length + buffer.writeInteger(ssrc) + try await outbound.write( + AddressedEnvelope( + remoteAddress: address, + data: buffer + ) + ) + + var iterator = inbound.makeAsyncIterator() + guard let discoveryResponse = try await iterator.next() else { + return nil + } + + let data = discoveryResponse.data + guard + let address = data.getData(at: 6, length: 64), + let address = String( + data: address.prefix( + upTo: address.firstIndex(of: 0) ?? address.endIndex + ), + encoding: .utf8, + ), + let port = data.getInteger(at: 70, as: UInt16.self) + else { + return nil + } + return (ip: address, port: port) + } + + 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. + func keepalive(ssrc: UInt32) async throws { + for await _ in AsyncTimerSequence( + interval: Self.keepaliveInterval, + clock: .continuous + ) { + var buffer: ByteBuffer = ByteBufferAllocator().buffer(capacity: 4) + buffer.writeInteger(ssrc, endianness: .big) + try await send(buffer: buffer) + } + } } diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index 78265b1b..0bd962a5 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -114,17 +114,24 @@ public actor VoiceGatewayManager { //MARK: Connection data public nonisolated let identifyPayload: VoiceGateway.Identify + // 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 var udpConnection: VoiceConnection? = nil + var udpConnectionTask: Task? + /// Once the session description event is received, we can listen. + var udpListeningTask: Task? + private lazy var dave: DaveSessionManager = { return DaveSessionManager( - selfUserId: "", - groupId: 0, + selfUserId: connectionData.userID.rawValue, + groupId: .init(connectionData.channelID.rawValue) ?? 0, delegate: self, ) }() @@ -318,6 +325,95 @@ public actor VoiceGatewayManager { } } + // MARK: - Internal event handling and connection management + // required to manage connection. library users can watch events to get this instead. + private var knownSSRCs: [UInt32: UserSnowflake] = [:] + + private func processEvent(_ event: VoiceGateway.Event) async { + if let sequenceNumber = event.sequenceNumber { + self.sequenceNumber = sequenceNumber + } + + switch event.data { + case .hello(let payload): + self.setupPingTask( + forConnectionWithId: self.connectionId.load(ordering: .relaxed), + every: .milliseconds(payload.heartbeat_interval) + ) + case .ready(let payload): + setupUDP(payload) + default: + break + } + } + + func setupUDP(_ payload: VoiceGateway.Ready) { + self.udpConnectionTask = Task { + 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) + } + } + } + + private func storeConnection(_ connection: VoiceConnection) { + self.udpConnection = connection + } + // MARK: - Gateway actions // /// https://discord.com/developers/docs/topics/gateway-events#update-presence @@ -394,25 +490,6 @@ public actor VoiceGatewayManager { } extension VoiceGatewayManager { - private func processEvent(_ event: VoiceGateway.Event) async { - if let sequenceNumber = event.sequenceNumber { - self.sequenceNumber = sequenceNumber - } - - switch event.opcode { - case .hello: - // get heartbeat interval and setup ping task - if case .hello(let payload) = event.data { - self.setupPingTask( - forConnectionWithId: self.connectionId.load(ordering: .relaxed), - every: .milliseconds(payload.heartbeat_interval) - ) - } - default: - break - } - } - private func sendResumeOrIdentify() async { if let lastSequenceNumber = self.sequenceNumber { self.sendResume(sequenceNumber: lastSequenceNumber) @@ -1003,7 +1080,7 @@ extension VoiceGatewayManager: DaveSessionDelegate { ) ) } - + public func readyForTransition(transitionId: UInt16) async { let event = VoiceGateway.Event( opcode: .daveTransitionReady, From 2917dc610527737572fdc741eb8342d608275f4a Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Mon, 23 Feb 2026 16:54:10 +0000 Subject: [PATCH 13/66] add dave session handling --- Paicord.xcodeproj/project.pbxproj | 25 ++++++++++ .../xcshareddata/swiftpm/Package.resolved | 2 +- PaicordLib/Package.swift | 5 -- .../Types/VoiceGateway+Payloads.swift | 14 +++++- .../DiscordVoice/VoiceGatewayManager.swift | 48 ++++++++++++++++--- 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/Paicord.xcodeproj/project.pbxproj b/Paicord.xcodeproj/project.pbxproj index 6c59a0dd..9d6c759d 100644 --- a/Paicord.xcodeproj/project.pbxproj +++ b/Paicord.xcodeproj/project.pbxproj @@ -160,6 +160,8 @@ 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 */ @@ -318,6 +320,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 */, @@ -1037,6 +1041,8 @@ AA8ABACB2F2A794E00557278 /* SwiftEmojiIndex */, AA8ABACE2F2A79B500557278 /* Loupe */, AA8ABAD12F2A7A0800557278 /* MijickCamera */, + AAFBC5302F4CA9C000C5B644 /* Copus */, + AAFBC5322F4CA9C000C5B644 /* Opus */, ); productName = PaiCord; productReference = AA1096DB2E63BE84005BC3D2 /* Paicord.app */; @@ -1086,6 +1092,7 @@ AA8ABAC82F2A794E00557278 /* XCRemoteSwiftPackageReference "SwiftEmoji" */, AA8ABACD2F2A79B500557278 /* XCRemoteSwiftPackageReference "Loupe" */, AA8ABAD02F2A7A0800557278 /* XCRemoteSwiftPackageReference "Camera" */, + AAFBC52F2F4CA9C000C5B644 /* XCRemoteSwiftPackageReference "swift-opus" */, ); preferredProjectObjectVersion = 77; productRefGroup = AA1096DC2E63BE84005BC3D2 /* Products */; @@ -1668,6 +1675,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 */ @@ -1779,6 +1794,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.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved index 495debf5..e265ffcb 100644 --- a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "87c1c63e097acb72b6a9235c44f2ace2180cf8cb96ab1d19a0d9e6a811371624", + "originHash" : "1e84c1ae19d6c4c9b1505df266f944c611d8b1021f959593f963fb47406e7b2f", "pins" : [ { "identity" : "async-http-client", diff --git a/PaicordLib/Package.swift b/PaicordLib/Package.swift index f1264920..bb646d96 100644 --- a/PaicordLib/Package.swift +++ b/PaicordLib/Package.swift @@ -78,10 +78,6 @@ let package = Package( url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"5.0.0" ), - .package( - url: "https://github.com/alta/swift-opus.git", - branch: "main" - ), .package( url: "https://github.com/llsc12/DaveKit.git", branch: "main" @@ -137,7 +133,6 @@ let package = Package( .product(name: "AsyncHTTPClient", package: "async-http-client"), .product(name: "WSClient", package: "swift-websocket"), .product(name: "libzstd", package: "zstd"), - .product(name: "Opus", package: "swift-opus"), .product(name: "DaveKit", package: "DaveKit"), .target(name: "DiscordGateway"), ], diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift index c30be70c..f40522c8 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift @@ -220,7 +220,7 @@ extension VoiceGateway { public var media_session_id: String public var mode: EncryptionMode? public var secretKey: [UInt8] - public var daveProtocolVersion: UInt8 + public var daveProtocolVersion: UInt16 public var sdp: String? // not applicable to udp public var keyframe_interval: Int? // not applicable to udp } @@ -260,9 +260,19 @@ extension VoiceGateway { /// 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 + } + public var speaking: IntBitField + public var ssrc: UInt + // present on receive + public var user_id: UserSnowflake? + + // send only public var delay: UInt? = nil - public var ssrc: UInt? = nil #if Non64BitSystemsCompatibility @UnstableEnum diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index 0bd962a5..54fb7e70 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -6,15 +6,16 @@ // 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 Opus import WSClient import enum NIOWebSocket.WebSocketErrorCode @@ -123,8 +124,8 @@ public actor VoiceGatewayManager { //MARK: UDP Connection /// Created upon ready event receive - var udpConnection: VoiceConnection? = nil - var udpConnectionTask: Task? + private var udpConnection: VoiceConnection? = nil + private var udpConnectionTask: Task? /// Once the session description event is received, we can listen. var udpListeningTask: Task? @@ -327,7 +328,7 @@ public actor VoiceGatewayManager { // MARK: - Internal event handling and connection management // required to manage connection. library users can watch events to get this instead. - private var knownSSRCs: [UInt32: UserSnowflake] = [:] + private var knownSSRCs: [UInt: UserSnowflake] = [:] private func processEvent(_ event: VoiceGateway.Event) async { if let sequenceNumber = event.sequenceNumber { @@ -342,6 +343,42 @@ public actor VoiceGatewayManager { ) case .ready(let payload): setupUDP(payload) + case .sessionDescription(let payload): + await self.dave.selectProtocol( + protocolVersion: payload.daveProtocolVersion + ) + // listen on udp + case .speaking(let payload): + self.knownSSRCs[payload.ssrc] = payload.user_id + case .clientConnect(let payload): + for id in payload.user_ids { + await self.dave.addUser(userId: id.rawValue) + } + case .clientDisconnect(let payload): + await self.dave.removeUser(userId: payload.user_id.rawValue) + case .davePrepareTransition(let payload): + await self.dave.prepareTransition( + transitionId: payload.transitionId, + protocolVersion: payload.protocolVersion + ) + case .daveExecuteTransition(let payload): + await self.dave.executeTransition(transitionId: payload.transitionId) + case .davePrepareEpoch(let payload): + await self.dave.prepareEpoch( + epoch: String(payload.epoch), + protocolVersion: payload.protocolVersion + ) + 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 } @@ -388,7 +425,7 @@ public actor VoiceGatewayManager { codecs: [ .opusCodec, .h264Codec, - .h265Codec + .h265Codec, ], experiments: nil ) @@ -409,7 +446,6 @@ public actor VoiceGatewayManager { } } } - private func storeConnection(_ connection: VoiceConnection) { self.udpConnection = connection } From 8a767b540995c710968af22f758ccb6a3fffa64c Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Mon, 23 Feb 2026 18:10:02 +0000 Subject: [PATCH 14/66] i think my outbound code is wrong --- .../Sources/DiscordModels/Types/RTP/RTP.swift | 20 ++ .../DiscordVoice/CryptoExtensions.swift | 70 ++++ .../DiscordVoice/VoiceGatewayManager.swift | 321 +++++++++++++++++- 3 files changed, 409 insertions(+), 2 deletions(-) diff --git a/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift b/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift index ffb35976..ddb410df 100644 --- a/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift +++ b/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift @@ -151,6 +151,26 @@ public struct RTPPacket: RawRepresentable { /// Remaining payload data public let payload: ByteBuffer + + public init( + payloadType: RTPType, + sequence: UInt16, + timestamp: UInt32, + ssrc: UInt32, + payload: ByteBuffer, + marker: Bool = false + ) { + self.version = 2 + self.padding = false + self.extension = false + 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 diff --git a/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift index bbae5bff..e32e628a 100644 --- a/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift +++ b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift @@ -74,6 +74,76 @@ extension VoiceGateway.EncryptionMode { } } + func encrypt( + buffer: consuming Data, + using key: SymmetricKey + ) -> (ciphertext: Data, tag: Data, nonceSuffix: Data)? { + + var nonceSuffixValue = UInt32.random(in: .min ... .max) + var beNonceSuffix = nonceSuffixValue.bigEndian + let nonceSuffix = withUnsafeBytes(of: &beNonceSuffix) { + Data($0) + } + + var nonce = Data(repeating: 0, count: nonceLength) + nonce.replaceSubrange( + nonce.count - nonceSuffix.count.. 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 { diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index 54fb7e70..af7171fe 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -127,7 +127,14 @@ public actor VoiceGatewayManager { private var udpConnection: VoiceConnection? = nil private var udpConnectionTask: Task? /// Once the session description event is received, we can listen. - var udpListeningTask: Task? + private var udpListeningTask: Task? + private var udpSpeakingTask: Task? + + private var pendingOpusFrames: [Data] = [] + private var channelDrainTask: Task? + + /// 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( @@ -137,6 +144,19 @@ public actor VoiceGatewayManager { ) }() + var audioSSRC: UInt { + return self.knownSSRCs.first(where: { + $0.value == self.connectionData.userID + })?.key ?? 0 + } + + private let outgoingOpusChannel = AsyncChannel() + private let incomingOpusChannel = AsyncChannel() + + public var incomingOpusPackets: AsyncChannel { + incomingOpusChannel + } + //MARK: Send queue /// 120 per 60 seconds (1 every 500ms), @@ -342,12 +362,44 @@ public actor VoiceGatewayManager { every: .milliseconds(payload.heartbeat_interval) ) case .ready(let payload): + self.knownSSRCs[UInt(payload.ssrc)] = self.connectionData.userID setupUDP(payload) case .sessionDescription(let payload): await self.dave.selectProtocol( protocolVersion: payload.daveProtocolVersion ) - // listen on udp + + self.nextSpeakingPayload = .init( + speaking: [.voice], + ssrc: audioSSRC, + delay: 0 + ) + + guard + let udpConnection, + let mode = payload.mode + else { return } + + let key = SymmetricKey(data: payload.secretKey) + + // find ssrc for current user id in connectionData.userID + guard + let ssrc = self.knownSSRCs.first(where: { + $0.value == self.connectionData.userID + })?.key + else { + self.logger.error( + "Failed to find SSRC for current user ID when trying to start speaking", + ) + return + } + + self.listen(description: payload) + self.speak( + ssrc: .init(ssrc), + mode: mode, + key: key + ) case .speaking(let payload): self.knownSSRCs[payload.ssrc] = payload.user_id case .clientConnect(let payload): @@ -450,6 +502,271 @@ public actor VoiceGatewayManager { self.udpConnection = connection } + /// 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.secretKey) + + self.udpListeningTask = Task { + guard let udpConnection = self.udpConnection else { + return + } + + defer { + // When the UDP listening ends, cancel the UDP connection task + self.udpConnectionTask?.cancel() + } + + for try await envelope in udpConnection.inbound { + guard let packet = RTPPacket(rawValue: envelope.data) else { + continue + } + + await self.processIncomingVoicePacket( + packet, + mode: encryption, + key: key + ) + } + } + } + + /// Writes Opus data out through UDP. + private func speak( + ssrc: UInt32, + mode: VoiceGateway.EncryptionMode, + key: SymmetricKey + ) { + // Start draining channel first + startDrainingOutgoingChannel() + + udpSpeakingTask = Task { + var sequence: UInt16 = 0 + var timestamp: UInt32 = 0 + + let clock = ContinuousClock() + let interval: Duration = .milliseconds(20) + + while !Task.isCancelled { + let start = clock.now + + let frame: Data? + if pendingOpusFrames.isEmpty { + frame = nil + } else { + frame = pendingOpusFrames.removeFirst() + } + + if frame != nil, let payload = self.nextSpeakingPayload { + // we're going to start talking, send any pending speaking payloads first. + self.send( + message: .init( + payload: .init(opcode: .speaking, data: .speaking(payload)), + opcode: .text + ) + ) + self.nextSpeakingPayload = nil + } + + if let frame { + await sendPacket( + frame: frame, + sequence: sequence, + timestamp: timestamp, + ssrc: ssrc, + mode: mode, + key: key + ) + } else { + await sendSilence( + sequence: sequence, + timestamp: timestamp, + ssrc: ssrc, + mode: mode, + key: key + ) + } + + 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 sendPacket( + frame: silence, + sequence: sequence, + timestamp: timestamp, + ssrc: ssrc, + mode: mode, + key: key + ) + } + + private func startDrainingOutgoingChannel() { + channelDrainTask = Task { + for await frame in outgoingOpusChannel { + pendingOpusFrames.append(frame) + + if pendingOpusFrames.count > 5 { + pendingOpusFrames.removeFirst() + } + } + } + } + + func sendPacket( + frame: Data, + sequence: UInt16, + timestamp: UInt32, + ssrc: UInt32, + mode: VoiceGateway.EncryptionMode, + key: SymmetricKey + ) async { + + guard let udpConnection = self.udpConnection else { + return + } + + guard + let encrypted = mode.encrypt( + buffer: frame, + using: key + ) + else { + logger.error("Voice encryption failed") + return + } + + let headerPacket = RTPPacket( + payloadType: .dynamic(.init(VoiceGateway.Codec.opusCodec.payload_type)), + sequence: sequence, + timestamp: timestamp, + ssrc: ssrc, + payload: ByteBuffer() // empty for now + ) + + var headerBuffer = headerPacket.rawValue + + guard let headerBytes = headerBuffer.readBytes(length: 12) else { + logger.error("Failed to extract RTP header") + return + } + + var packet = ByteBuffer() + + packet.writeBytes(headerBytes) + packet.writeBytes(encrypted.ciphertext) + packet.writeBytes(encrypted.tag) + packet.writeBytes(encrypted.nonceSuffix) + + // dave encrypt + + do { + let daveEncrypted = try await self.dave.encrypt( + ssrc: ssrc, + data: .init(buffer: packet, byteTransferStrategy: .noCopy), + mediaType: .audio + ) + try await udpConnection.send(buffer: .init(data: daveEncrypted)) + } catch { + logger.error( + "Failed to send voice packet", + metadata: [ + "error": .string(String(reflecting: error)) + ] + ) + } + } + + /// 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, + mode: VoiceGateway.EncryptionMode, + key: SymmetricKey + ) async { + var buffer = packet.payload + // 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( + buffer: packet.payload, + with: key, + ) + else { + return + } + + 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 + } + + await incomingOpusChannel.send(data) + } + + public func sendOpusFrame(_ frame: Data) { + + } + // MARK: - Gateway actions // /// https://discord.com/developers/docs/topics/gateway-events#update-presence From 0e6698252dd0d0f2c98bcb6a798edb826abc30ad Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Tue, 24 Feb 2026 01:50:09 +0000 Subject: [PATCH 15/66] wip app side voice stuff --- .../xcschemes/xcschememanagement.plist | 2 +- Paicord/Common/Guilds/ChannelButton.swift | 73 ++++++ Paicord/Common/Guilds/GuildButton.swift | 10 +- Paicord/Stores/GatewayStore.swift | 18 +- Paicord/Stores/VoiceConnectionStore.swift | 230 +++++++++++++++++- .../xcschemes/xcschememanagement.plist | 2 +- .../DiscordVoice/VoiceConnection.swift | 2 +- .../DiscordVoice/VoiceGatewayManager.swift | 8 +- 8 files changed, 319 insertions(+), 26 deletions(-) diff --git a/DiscordMarkdownParser/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist b/DiscordMarkdownParser/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist index 306f0f0c..4ec1b314 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 - 4 + 3 diff --git a/Paicord/Common/Guilds/ChannelButton.swift b/Paicord/Common/Guilds/ChannelButton.swift index ad63caa3..9b9caaf3 100644 --- a/Paicord/Common/Guilds/ChannelButton.swift +++ b/Paicord/Common/Guilds/ChannelButton.swift @@ -258,6 +258,79 @@ 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 body: some View { + if !shouldHide { + Button { + Task { + await gw.voice.updateVoiceConnection( + .join( + channelId: channel.id, + guildId: channel.guild_id! + ) + ) + } + } label: { + content(isHovered) + } + .onHover { isHovered = $0 } + .buttonStyle(.borderless) + } + } + } + + /// Button that triggers voice channel actions. + @ViewBuilder + func voiceChannelButton( + @ViewBuilder label: @escaping (_ hovered: Bool) -> Content + ) + -> some View + { + 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 == channel.id { + Color.gray.opacity(0.13) + } else { + Color.clear + } + } + .clipShape(.rounded) + ) + } + } + 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..c135d89d 100644 --- a/Paicord/Common/Guilds/GuildButton.swift +++ b/Paicord/Common/Guilds/GuildButton.swift @@ -260,8 +260,14 @@ 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 == guild?.id { + #if os(iOS) + appState.chatOpen = true + #endif + } else { + ImpactGenerator.impact(style: .light) + appState.selectedGuild = guild?.id + } } label: { let isSelected = appState.selectedGuild == guild?.id Group { diff --git a/Paicord/Stores/GatewayStore.swift b/Paicord/Stores/GatewayStore.swift index d86e2557..ffede04b 100644 --- a/Paicord/Stores/GatewayStore.swift +++ b/Paicord/Stores/GatewayStore.swift @@ -133,6 +133,8 @@ final class GatewayStore { presence = .init() messageDrain = .init() switcher = .init() + voice.cancelEventHandling() // cancel any ongoing voice stuff + voice = .init() channels = [:] guilds = [:] subscribedGuilds = [] @@ -149,6 +151,7 @@ final class GatewayStore { var presence = PresenceStore() var messageDrain = MessageDrainStore() var switcher = QuickSwitcherProviderStore() + var voice = VoiceConnectionStore() private var channels: [ChannelSnowflake: ChannelStore] = [:] func getChannelStore(for id: ChannelSnowflake, from guild: GuildStore? = nil) @@ -206,21 +209,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) diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 768a782e..08d8d631 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -4,22 +4,44 @@ // // Created by Lakhan Lothiyi on 23/02/2026. // Copyright © 2026 Lakhan Lothiyi. -// +// +import AVFoundation +import Opus import PaicordLib final class VoiceConnectionStore: DiscordDataStore { + init() { + self.opusEncoder = try! Opus.Encoder(format: Self.opusFormat, application: .voip) + self.opusDecoder = try! Opus.Decoder(format: Self.opusFormat, application: .voip) + } + var gateway: GatewayStore? - var voiceGateway: VoiceGatewayManager? + var voiceGateway: VoiceGatewayManager? { + didSet { + if let voiceGateway { + // trigger audio engine setup + audioEngineSetup() + } else { + // shutdown audio engine and release resources, also set status to stopped + voiceStatus = .stopped + audioEngineCleanup() + } + } + } var eventTask: 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 .voiceServerUpdate(let payload): + handleVoiceServerUpdate(payload) // capture and store voice events default: break @@ -27,13 +49,211 @@ final class VoiceConnectionStore: DiscordDataStore { } } } - + + // state + private var channelId: ChannelSnowflake? + private var guildId: GuildSnowflake? + private var isMuted: Bool = false + private var isDeafened: Bool = false + private var isVideoEnabled: Bool = false + private var preferredRegion: String? + private var flags: IntBitField = [] + + private var voiceStatus: GatewayState = .stopped + // MARK: - Public methods + + func updateVoiceConnection(_ update: VoiceConnectionUpdate) async { + // 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 + 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 + + 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 { self.isMuted = isMuted } + if let isDeafened = isDeafened { self.isDeafened = isDeafened } + if let isVideoEnabled = isVideoEnabled { + self.isVideoEnabled = isVideoEnabled + } + + 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) { + // send voice states, temporary until paicord has proper voice handling + 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: [] + ) + ) + } + } + + 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 guildId, let channelId, + let sessionId = await gateway?.gateway?.getSessionID(), + let userId = gateway?.user.currentUser?.id + else { + Task { await voiceGateway?.disconnect() } + voiceGateway = nil + return + } + + self.voiceGateway = VoiceGatewayManager.init( + connectionData: .init( + token: payload.token, + guildID: guildId, + channelID: channelId, + userID: userId, + sessionID: sessionId, + endpoint: endpoint + ), + stateCallback: { state in + Task { @MainActor in + self.voiceStatus = state + } + } + ) + } + } func cancelEventHandling() { // overrides default impl of protocol eventTask?.cancel() eventTask = nil + Task { await voiceGateway?.disconnect() } + } + + // MARK: - Audio Engine implementation + private static let opusFormat = AVAudioFormat( + opusPCMFormat: .int16, + sampleRate: .opus48khz, + channels: 2 + )! + private static let pcmFormat = AVAudioFormat( + commonFormat: .pcmFormatInt16, + sampleRate: .opus48khz, + channels: 2, + interleaved: true + )! + private let opusEncoder: Opus.Encoder + private let opusDecoder: Opus.Decoder + + private let audioEngine = AVAudioEngine() + private lazy var inputNode: AVAudioInputNode = { + return audioEngine.inputNode + }() + private lazy var outputNode: AVAudioOutputNode = { + return audioEngine.outputNode + }() + + // audio player node for playing incoming audio frames + private let playerNode: AVAudioPlayerNode = AVAudioPlayerNode() + + private var incomingAudioTask: Task? = nil - // end networking session etc. + private func audioEngineSetup() { + self.audioEngineCleanup() // cleanup any existing engine before setting up a new one + + // attach player node to engine + audioEngine.attach(playerNode) + + // connect player node to output + audioEngine.connect(playerNode, to: outputNode, format: Self.pcmFormat) + + // start the engine and player node + do { + try audioEngine.start() + playerNode.play() + } catch { + print("[Voice] Failed to start audio engine: \(error)") + return + } + + // make player node schedule buffers as they come in from the gateway in task + self.incomingAudioTask = Task { + guard let voiceGateway else { return } + + for await opusFrame in await voiceGateway.incomingOpusPackets { + if Task.isCancelled { break } + + guard let buffer = try? opusDecoder.decode(opusFrame) else { + continue + } + + await MainActor.run { + self.playerNode.scheduleBuffer(buffer, completionHandler: nil) + } + } + } + } + + private func audioEngineCleanup() { + // cancel incoming audio task + incomingAudioTask?.cancel() + + // stop the engine and reset everything + audioEngine.stop() + playerNode.stop() + audioEngine.reset() + // remove player node from engine + audioEngine.detach(playerNode) } } diff --git a/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist b/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist index 4c0532e1..fb93a8d4 100644 --- a/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist @@ -42,7 +42,7 @@ DiscordVoice.xcscheme_^#shared#^_ orderHint - 3 + 4 GenerateAPIEndpointsExec.xcscheme_^#shared#^_ diff --git a/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift b/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift index 47b9cb45..d2d6c54f 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift @@ -59,7 +59,7 @@ internal actor VoiceConnection { 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. diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index af7171fe..bbc3312c 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -547,7 +547,6 @@ public actor VoiceGatewayManager { mode: VoiceGateway.EncryptionMode, key: SymmetricKey ) { - // Start draining channel first startDrainingOutgoingChannel() udpSpeakingTask = Task { @@ -839,6 +838,13 @@ public actor VoiceGatewayManager { connectionBackoff.resetTryCount() await self.sendQueue.reset() await self.closeWebSocket() + // cancel udp connection tasks + self.udpConnectionTask?.cancel() + self.udpListeningTask?.cancel() + self.udpSpeakingTask?.cancel() + self.channelDrainTask?.cancel() + self.udpConnection = nil + self.nextSpeakingPayload = nil } } From 5645ece4076c6484bb21cb6ff014bcd46bea6df1 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Tue, 24 Feb 2026 02:09:45 +0000 Subject: [PATCH 16/66] dont forget to identify --- PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index bbc3312c..2331f274 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -300,6 +300,7 @@ public actor VoiceGatewayManager { self.logger.debug( "Connected to Discord through web-socket. Will configure" ) + await self.sendResumeOrIdentify() self.state.store(.configured, ordering: .relaxed) self.stateCallback?(.configured) @@ -376,7 +377,6 @@ public actor VoiceGatewayManager { ) guard - let udpConnection, let mode = payload.mode else { return } From d4da406dc829d9713c6f014f19a8b2b479a2a200 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Tue, 24 Feb 2026 14:02:56 +0000 Subject: [PATCH 17/66] connection doesnt always work --- .../xcschemes/xcschememanagement.plist | 2 +- .../xcdebugger/Breakpoints_v2.xcbkptlist | 20 +-- Paicord/App/PaicordApp.swift | 17 +-- Paicord/Common/Guilds/ChannelButton.swift | 5 +- Paicord/Stores/GatewayStore.swift | 1 + Paicord/Stores/VoiceConnectionStore.swift | 114 +++++++++++------- .../xcschemes/xcschememanagement.plist | 2 +- .../DiscordGateway/UserGatewayManager.swift | 81 +++++++------ .../Types/VoiceGateway+Payloads.swift | 66 +++++++++- .../DiscordVoice/VoiceGatewayManager.swift | 55 ++++++++- 10 files changed, 254 insertions(+), 109 deletions(-) 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/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index a608acf3..7cf7d406 100644 --- a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -7,32 +7,32 @@ diff --git a/Paicord/App/PaicordApp.swift b/Paicord/App/PaicordApp.swift index 1db90f95..638295a6 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/Common/Guilds/ChannelButton.swift b/Paicord/Common/Guilds/ChannelButton.swift index 9b9caaf3..09ee47f6 100644 --- a/Paicord/Common/Guilds/ChannelButton.swift +++ b/Paicord/Common/Guilds/ChannelButton.swift @@ -156,7 +156,7 @@ struct ChannelButton: View { } .tint(.primary) case .guildVoice: - textChannelButton { _ in + voiceChannelButton { _ in HStack { Image(systemName: "speaker.wave.2.fill") .imageScale(.medium) @@ -167,7 +167,6 @@ struct ChannelButton: View { .padding(.horizontal, 12) } .tint(.primary) - .disabled(true) default: textChannelButton { _ in HStack { @@ -281,7 +280,7 @@ struct ChannelButton: View { await gw.voice.updateVoiceConnection( .join( channelId: channel.id, - guildId: channel.guild_id! + guildId: appState.selectedGuild, ) ) } diff --git a/Paicord/Stores/GatewayStore.swift b/Paicord/Stores/GatewayStore.swift index ffede04b..015284ce 100644 --- a/Paicord/Stores/GatewayStore.swift +++ b/Paicord/Stores/GatewayStore.swift @@ -113,6 +113,7 @@ final class GatewayStore { presence.setGateway(self) messageDrain.setGateway(self) switcher.setGateway(self) + voice.setGateway(self) // Update existing channel stores for channelStore in channels.values { diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 08d8d631..7da7108a 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -9,9 +9,12 @@ import AVFoundation import Opus import PaicordLib +import Foundation +@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) self.opusDecoder = try! Opus.Decoder(format: Self.opusFormat, application: .voip) } @@ -19,7 +22,7 @@ final class VoiceConnectionStore: DiscordDataStore { var gateway: GatewayStore? var voiceGateway: VoiceGatewayManager? { didSet { - if let voiceGateway { + if voiceGateway != nil { // trigger audio engine setup audioEngineSetup() } else { @@ -59,7 +62,11 @@ final class VoiceConnectionStore: DiscordDataStore { private var preferredRegion: String? private var flags: IntBitField = [] - private var voiceStatus: GatewayState = .stopped + private var voiceStatus: GatewayState = .stopped { + didSet { + print("[Voice] Voice connection status changed to \(voiceStatus)") + } + } // MARK: - Public methods func updateVoiceConnection(_ update: VoiceConnectionUpdate) async { @@ -70,6 +77,7 @@ final class VoiceConnectionStore: DiscordDataStore { if case .disconnect = update { self.channelId = nil self.guildId = nil + print("[Voice] Disconnected from voice channel") return } // well at this point we know we want to connect to a new channel. @@ -79,6 +87,7 @@ final class VoiceConnectionStore: DiscordDataStore { 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, @@ -151,11 +160,13 @@ final class VoiceConnectionStore: DiscordDataStore { 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 return } + print("[Voice] Received voice server update, connecting to voice gateway at endpoint \(endpoint) for guild \(guildId.rawValue) and channel \(channelId.rawValue)") self.voiceGateway = VoiceGatewayManager.init( connectionData: .init( token: payload.token, @@ -171,6 +182,7 @@ final class VoiceConnectionStore: DiscordDataStore { } } ) + await self.voiceGateway?.connect() } } @@ -183,77 +195,97 @@ final class VoiceConnectionStore: DiscordDataStore { // MARK: - Audio Engine implementation private static let opusFormat = AVAudioFormat( - opusPCMFormat: .int16, + opusPCMFormat: .float32, sampleRate: .opus48khz, channels: 2 )! private static let pcmFormat = AVAudioFormat( - commonFormat: .pcmFormatInt16, + commonFormat: .pcmFormatFloat32, sampleRate: .opus48khz, channels: 2, - interleaved: true + interleaved: false )! + @ObservationIgnored private let opusEncoder: Opus.Encoder + @ObservationIgnored private let opusDecoder: Opus.Decoder + @ObservationIgnored private let audioEngine = AVAudioEngine() + @ObservationIgnored private lazy var inputNode: AVAudioInputNode = { return audioEngine.inputNode }() + @ObservationIgnored private lazy var outputNode: AVAudioOutputNode = { return audioEngine.outputNode }() // audio player node for playing incoming audio frames + @ObservationIgnored private let playerNode: AVAudioPlayerNode = AVAudioPlayerNode() + @ObservationIgnored private var incomingAudioTask: Task? = nil + private func audioEngineSetup() { - self.audioEngineCleanup() // cleanup any existing engine before setting up a new one + self.audioEngineCleanup() + Task { @MainActor in + - // attach player node to engine - audioEngine.attach(playerNode) - - // connect player node to output - audioEngine.connect(playerNode, to: outputNode, format: Self.pcmFormat) - - // start the engine and player node - do { - try audioEngine.start() - playerNode.play() - } catch { - print("[Voice] Failed to start audio engine: \(error)") - return - } - - // make player node schedule buffers as they come in from the gateway in task - self.incomingAudioTask = Task { - guard let voiceGateway else { return } +// if !audioEngine.attachedNodes.contains(playerNode) { +// audioEngine.attach(playerNode) +// } +// +// let engineOutputFormat = audioEngine.outputNode.inputFormat(forBus: 0) +// +// audioEngine.connect(playerNode, to: audioEngine.mainMixerNode, format: engineOutputFormat) +// +// audioEngine.prepare() +// do { +// try audioEngine.start() +// playerNode.play() +// } catch { +// print("[Voice] Failed to start audio engine: \(error)") +// return +// } - for await opusFrame in await voiceGateway.incomingOpusPackets { - if Task.isCancelled { break } + incomingAudioTask = Task { [weak self] in + guard let self = self, let voiceGateway = self.voiceGateway else { return } - guard let buffer = try? opusDecoder.decode(opusFrame) else { - continue - } + for await opusFrame in await voiceGateway.incomingOpusPackets { + if Task.isCancelled { break } + + guard let decoded = try? self.opusDecoder.decode(opusFrame) else { + continue + } + + print(decoded.format.debugDescription) - await MainActor.run { - self.playerNode.scheduleBuffer(buffer, completionHandler: nil) +// await MainActor.run { +// self.playerNode.scheduleBuffer(bufferToPlay, at: nil, options: [], completionHandler: nil) +// } } } + + print("[Voice] Audio engine started") } } - + private func audioEngineCleanup() { - // cancel incoming audio task - incomingAudioTask?.cancel() - - // stop the engine and reset everything - audioEngine.stop() - playerNode.stop() - audioEngine.reset() - // remove player node from engine - audioEngine.detach(playerNode) + Task { @MainActor in + // cancel incoming audio task + self.incomingAudioTask?.cancel() + + // stop the engine and reset everything + self.audioEngine.stop() + self.playerNode.stop() + self.audioEngine.reset() + // remove player node from engine + self.audioEngine.detach(self.playerNode) + + print("[Voice] Audio engine stopped") + } } } diff --git a/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist b/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist index fb93a8d4..4c0532e1 100644 --- a/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist @@ -42,7 +42,7 @@ DiscordVoice.xcscheme_^#shared#^_ orderHint - 4 + 3 GenerateAPIEndpointsExec.xcscheme_^#shared#^_ diff --git a/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift b/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift index 95db491f..7e6b0df0 100644 --- a/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift +++ b/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift @@ -212,51 +212,56 @@ public actor UserGatewayManager { await self.sendQueue.reset() let gatewayURL = await getGatewayURL() - // #if DEBUGo + #if DEBUG + let queries: [(String, String)] = [ + ("v", "\(DiscordGlobalConfiguration.apiVersion)"), + ("encoding", "json") + ] + 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/DiscordBM/DiscordBM/issues", + metadata: ["error": .string(String(reflecting: error))] + ) + return + } + #else let queries: [(String, String)] = [ ("v", "\(DiscordGlobalConfiguration.apiVersion)"), ("encoding", "json"), ("compress", "zstd-stream"), ] - let decompressorWSExtension: ZstdDecompressorWSExtension - do { - decompressorWSExtension = try ZstdDecompressorWSExtension( - logger: self.logger + #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( diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift index f40522c8..9fcea80d 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift @@ -6,8 +6,8 @@ // Copyright © 2026 Lakhan Lothiyi. // -import Foundation import DaveKit +import Foundation extension VoiceGateway { @@ -31,7 +31,9 @@ extension VoiceGateway { self.streams = streams } - public var max_dave_protocol_version: Int = .init(DaveSessionManager.maxSupportedProtocolVersion()) + public var max_dave_protocol_version: Int = .init( + DaveSessionManager.maxSupportedProtocolVersion() + ) public var server_id: GuildSnowflake public var channel_id: ChannelSnowflake public var user_id: UserSnowflake @@ -138,11 +140,58 @@ extension VoiceGateway { 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 @@ -265,7 +314,7 @@ extension VoiceGateway { self.ssrc = ssrc self.delay = delay } - + public var speaking: IntBitField public var ssrc: UInt // present on receive @@ -414,8 +463,13 @@ extension VoiceGateway { /// 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 var voice: String? + public var rtc_worker: String? + + public init() { + self.voice = nil + self.rtc_worker = nil + } } public struct DavePrepareTransition: Sendable, Codable { diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index 2331f274..cd05dafb 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -289,8 +289,9 @@ public actor VoiceGatewayManager { /// 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: gatewayURL + queries.makeForURLQuery(), + url: url, configuration: configuration, eventLoopGroup: self.eventLoopGroup, logger: self.logger @@ -400,6 +401,58 @@ public actor VoiceGatewayManager { mode: mode, key: key ) + + self.send( + message: .init( + payload: .init( + opcode: .voiceBackendVersion, + data: .voiceBackendVersion(.init()), + ), + opcode: .text + ) + ) + + guard + let discovery = try? await self.udpConnection?.discoverExternalIP( + ssrc: .init(self.audioSSRC) + ) + else { + // udp discovery failed, disconnect and set state to stopped + logger.error( + "Failed to discover external IP and port during session description handling" + ) + await self.disconnect() + return + } + + self.send( + message: .init( + payload: .init( + opcode: .selectProtocol, + data: .selectProtocol( + .init( + protocol: "udp", + data: .init( + address: discovery.ip, + port: .init(discovery.port), + mode: payload.mode ?? .aead_aes256_gcm_rtpsize + ), + rtc_connection_id: self.rtcConnectionID, + codecs: [ + .opusCodec, + .h264Codec, + .h265Codec, + ], + experiments: [ + "fixed_keyframe_interval", + "keyframe_on_join", + ] + ) + ) + ), + opcode: .text + ) + ) case .speaking(let payload): self.knownSSRCs[payload.ssrc] = payload.user_id case .clientConnect(let payload): From 368e7e4df946898ae3a122088eb8ddfc224268bd Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Tue, 24 Feb 2026 23:58:01 +0000 Subject: [PATCH 18/66] voice gateway working --- Paicord/App/PaicordApp.swift | 14 ++-- Paicord/Stores/VoiceConnectionStore.swift | 5 +- .../xcshareddata/xcschemes/TestCode.xcscheme | 78 +++++++++++++++++++ .../xcschemes/xcschememanagement.plist | 12 +-- .../DiscordVoice/VoiceGatewayManager.swift | 37 ++++++--- 5 files changed, 117 insertions(+), 29 deletions(-) create mode 100644 PaicordLib/.swiftpm/xcode/xcshareddata/xcschemes/TestCode.xcscheme diff --git a/Paicord/App/PaicordApp.swift b/Paicord/App/PaicordApp.swift index 638295a6..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, diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 7da7108a..33de2c23 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -262,10 +262,7 @@ final class VoiceConnectionStore: DiscordDataStore { } print(decoded.format.debugDescription) - -// await MainActor.run { -// self.playerNode.scheduleBuffer(bufferToPlay, at: nil, options: [], completionHandler: nil) -// } + } } 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 4c0532e1..44d5ff57 100644 --- a/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist @@ -39,11 +39,6 @@ orderHint 6 - DiscordVoice.xcscheme_^#shared#^_ - - orderHint - 3 - GenerateAPIEndpointsExec.xcscheme_^#shared#^_ orderHint @@ -77,7 +72,7 @@ TestCode.xcscheme_^#shared#^_ orderHint - 9 + 3 SuppressBuildableAutocreation @@ -117,6 +112,11 @@ primary + DiscordVoice + + primary + + GenerateAPIEndpointsExec primary diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index cd05dafb..f243de73 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -267,7 +267,6 @@ public actor VoiceGatewayManager { let queries: [(String, String)] = [ ("v", "\(DiscordGlobalConfiguration.apiVersion)") ] - let configuration = WebSocketClientConfiguration( maxFrameSize: self.maxFrameSize, additionalHeaders: [ @@ -289,7 +288,7 @@ public actor VoiceGatewayManager { /// But for proper structured concurrency, this method should never exit (optimally). Task { do { - let url = gatewayURL + queries.makeForURLQuery() + let url = gatewayURL + "/" + queries.makeForURLQuery() let closeFrame = try await WebSocketClient.connect( url: url, configuration: configuration, @@ -358,12 +357,24 @@ public actor VoiceGatewayManager { } 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) + every: .milliseconds(payload.heartbeat_interval / 2) ) case .ready(let payload): + self.state.store(.connected, ordering: .relaxed) + self.stateCallback?(.connected) + self.knownSSRCs[UInt(payload.ssrc)] = self.connectionData.userID setupUDP(payload) case .sessionDescription(let payload): @@ -1202,24 +1213,25 @@ extension VoiceGatewayManager { every duration: Duration ) { Task { - try? await Task.sleep(for: duration) - guard self.connectionId.load(ordering: .relaxed) == connectionId else { - self.logger.trace( - "Canceled a ping 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) ] ) - return/// cancel + self.sendPing(forConnectionWithId: connectionId) + + try? await Task.sleep(for: duration) } - self.logger.debug( - "Will send automatic ping", + + self.logger.trace( + "Canceled a ping task", metadata: [ "connectionId": .stringConvertible(connectionId) ] ) - self.sendPing(forConnectionWithId: connectionId) - self.setupPingTask(forConnectionWithId: connectionId, every: duration) } } @@ -1242,6 +1254,7 @@ extension VoiceGatewayManager { .init(seq_ack: self.sequenceNumber) ) ), + opcode: .text ) ) Task { From d5aea9d79048d15881a6ee290a5a00eb282691d0 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 25 Feb 2026 00:40:23 +0000 Subject: [PATCH 19/66] temporary leave button --- .../xcdebugger/Breakpoints_v2.xcbkptlist | 54 ++++++++++++------- Paicord/Stores/VoiceConnectionStore.swift | 12 +++++ Paicord/macOS/Sidebar/ProfileBar.swift | 19 ++++++- 3 files changed, 63 insertions(+), 22 deletions(-) diff --git a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 7cf7d406..715c630e 100644 --- a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -7,33 +7,47 @@ - - - - + + + + + + diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 33de2c23..5099a30f 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -77,6 +77,18 @@ final class VoiceConnectionStore: DiscordDataStore { 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 } diff --git a/Paicord/macOS/Sidebar/ProfileBar.swift b/Paicord/macOS/Sidebar/ProfileBar.swift index 7da2acf6..1e01d885 100644 --- a/Paicord/macOS/Sidebar/ProfileBar.swift +++ b/Paicord/macOS/Sidebar/ProfileBar.swift @@ -32,7 +32,7 @@ struct ProfileBar: View { member: nil, user: user ) - .maxHeight(40) + .maxHeight(30) .profileAnimated(barHovered) .profileShowsAvatarDecoration() } @@ -80,6 +80,21 @@ struct ProfileBar: View { } Spacer() + + if gw.voice.voiceGateway != nil { + Button { + Task { + await gw.voice.updateVoiceConnection(.disconnect) + } + } label: { + // hang up call + Image(systemName: "phone.down.fill") + .font(.title2) + .padding(5) + .background(.ultraThinMaterial) + .clipShape(.circle) + } + } #if os(macOS) Button { @@ -97,7 +112,7 @@ struct ProfileBar: View { // do something #endif } - .padding(10) + .padding(8) .background { if let nameplate = gw.user.currentUser?.collectibles?.nameplate { Profile.NameplateView(nameplate: nameplate) From 5be2f7116c31883bd86631086cbbbfbec4afdb01 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 25 Feb 2026 12:28:56 +0000 Subject: [PATCH 20/66] fix udp stuff, fix ip discovery --- .../xcdebugger/Breakpoints_v2.xcbkptlist | 48 ------- Paicord/Stores/VoiceConnectionStore.swift | 23 ++++ .../Types/VoiceGateway+Payloads.swift | 2 +- .../DiscordVoice/VoiceConnection.swift | 23 ++-- .../DiscordVoice/VoiceGatewayManager.swift | 123 ++++++++++-------- 5 files changed, 109 insertions(+), 110 deletions(-) diff --git a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 715c630e..36b89fc6 100644 --- a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,52 +3,4 @@ uuid = "3C51FC77-CE2B-4F32-B3F3-96CDC8C1DACC" type = "0" version = "2.0"> - - - - - - - - - - - - diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 5099a30f..debafd52 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -23,9 +23,11 @@ final class VoiceConnectionStore: DiscordDataStore { var voiceGateway: VoiceGatewayManager? { didSet { if voiceGateway != nil { + setupVoiceEventHandling() // trigger audio engine setup audioEngineSetup() } else { + self.voiceEventTask?.cancel() // shutdown audio engine and release resources, also set status to stopped voiceStatus = .stopped audioEngineCleanup() @@ -34,6 +36,8 @@ final class VoiceConnectionStore: DiscordDataStore { } var eventTask: Task? + var voiceEventTask: Task? + var voiceErrorEventTask: Task? func setupEventHandling() { guard let gateway = gateway?.gateway else { return } @@ -52,6 +56,25 @@ final class VoiceConnectionStore: DiscordDataStore { } } } + + func setupVoiceEventHandling() { + guard let voiceGateway = voiceGateway else { return } + + voiceEventTask = Task { @MainActor in + for await event in await voiceGateway.events { + switch event.data { + case .ready(let payload): + print("we in yk") + default: break + } + } + } + voiceErrorEventTask = Task { @MainActor in + for await (error, buffer) in await voiceGateway.eventFailures { + print("[Voice Error] \(error), \(String(buffer: buffer))") + } + } + } // state private var channelId: ChannelSnowflake? diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift index 9fcea80d..bda42a68 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift @@ -268,7 +268,7 @@ extension VoiceGateway { public var video_codec: Codec.CodecName public var media_session_id: String public var mode: EncryptionMode? - public var secretKey: [UInt8] + public var secret_key: [UInt8] public var daveProtocolVersion: UInt16 public var sdp: String? // not applicable to udp public var keyframe_interval: Int? // not applicable to udp diff --git a/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift b/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift index d2d6c54f..69d1248c 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift @@ -33,11 +33,17 @@ internal actor VoiceConnection { onConnect: @Sendable @escaping (VoiceConnection) async throws -> Void ) async throws { - let socketAddress = try SocketAddress(ipAddress: host, port: port) + 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: socketAddress) + .bind(to: localAddress) + .flatMap { channel in + channel.connect(to: remoteAddress).map { channel } + } .flatMapThrowing { channel in return try NIOAsyncChannel( wrappingChannelSynchronously: channel, @@ -53,7 +59,7 @@ internal actor VoiceConnection { let connection = VoiceConnection( inbound: inbound, outbound: outbound, - socketAddress: socketAddress + socketAddress: remoteAddress ) try await onConnect(connection) @@ -69,9 +75,10 @@ internal actor VoiceConnection { ssrc: UInt32, ) async throws -> (ip: String, port: UInt16)? { var buffer = ByteBufferAllocator().buffer(capacity: 74) - buffer.writeInteger(UInt16(0x1)) // Type (Send) - buffer.writeInteger(UInt16(70)) // Length - buffer.writeInteger(ssrc) + 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, @@ -86,14 +93,14 @@ internal actor VoiceConnection { let data = discoveryResponse.data guard - let address = data.getData(at: 6, length: 64), + let address = data.getData(at: 8, length: 64), let address = String( data: address.prefix( upTo: address.firstIndex(of: 0) ?? address.endIndex ), encoding: .utf8, ), - let port = data.getInteger(at: 70, as: UInt16.self) + let port = data.getInteger(at: 72, endianness: .little, as: UInt16.self) else { return nil } diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index f243de73..8d810f14 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -392,7 +392,7 @@ public actor VoiceGatewayManager { let mode = payload.mode else { return } - let key = SymmetricKey(data: payload.secretKey) + let key = SymmetricKey(data: payload.secret_key) // find ssrc for current user id in connectionData.userID guard @@ -502,63 +502,74 @@ public actor VoiceGatewayManager { func setupUDP(_ payload: VoiceGateway.Ready) { self.udpConnectionTask = Task { - 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 - } + 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 - } + 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 + 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 + ), + opcode: .text + ) ) - ) - await self.storeConnection(connection) + 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) + // 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), + ] + ) } } } @@ -579,15 +590,21 @@ public actor VoiceGatewayManager { return } - let key = SymmetricKey(data: description.secretKey) + 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() } From bfe23ed5800c057ee7e3107bb829ee5397ce8964 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 25 Feb 2026 15:14:34 +0000 Subject: [PATCH 21/66] weird crash with libdave --- .../Types/VoiceGateway+Payloads.swift | 2 +- .../DiscordVoice/VoiceGatewayManager.swift | 16 ++-------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift index bda42a68..626c9ae0 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift @@ -269,7 +269,7 @@ extension VoiceGateway { public var media_session_id: String public var mode: EncryptionMode? public var secret_key: [UInt8] - public var daveProtocolVersion: UInt16 + public var dave_protocol_version: UInt16 public var sdp: String? // not applicable to udp public var keyframe_interval: Int? // not applicable to udp } diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index 8d810f14..e12f2359 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -379,7 +379,7 @@ public actor VoiceGatewayManager { setupUDP(payload) case .sessionDescription(let payload): await self.dave.selectProtocol( - protocolVersion: payload.daveProtocolVersion + protocolVersion: UInt16(payload.dave_protocol_version) ) self.nextSpeakingPayload = .init( @@ -394,21 +394,9 @@ public actor VoiceGatewayManager { let key = SymmetricKey(data: payload.secret_key) - // find ssrc for current user id in connectionData.userID - guard - let ssrc = self.knownSSRCs.first(where: { - $0.value == self.connectionData.userID - })?.key - else { - self.logger.error( - "Failed to find SSRC for current user ID when trying to start speaking", - ) - return - } - self.listen(description: payload) self.speak( - ssrc: .init(ssrc), + ssrc: .init(audioSSRC), mode: mode, key: key ) From bf98f17f5dfebef5d62cf668fefe4833bea1ed2e Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Tue, 3 Mar 2026 14:44:49 +0000 Subject: [PATCH 22/66] the crypto stuff is fixed --- .../xcschemes/Paicord Release.xcscheme | 7 ++ .../xcshareddata/xcschemes/Paicord.xcscheme | 5 ++ .../xcshareddata/swiftpm/Package.resolved | 4 +- .../xcdebugger/Breakpoints_v2.xcbkptlist | 27 +++++++ Paicord/App/Commands.swift | 2 +- Paicord/App/PaicordApp.swift | 14 ++-- .../LocalConsoleManager.swift | 4 + .../DiscordVoice/VoiceConnection.swift | 19 +++-- .../DiscordVoice/VoiceGatewayManager.swift | 80 +++++-------------- 9 files changed, 85 insertions(+), 77 deletions(-) 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 35c6bfbf..638295a6 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, 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/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift b/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift index 69d1248c..d5e26606 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceConnection.swift @@ -16,7 +16,17 @@ internal actor VoiceConnection { let address: SocketAddress let inbound: NIOAsyncChannelInboundStream> let outbound: NIOAsyncChannelOutboundWriter> - + lazy var inboundStream: AsyncStream = { + AsyncStream { continuation in + Task { + for try await envelope in inbound { + continuation.yield(envelope.data) + } + continuation.finish() + } + } + }() + private init( inbound: NIOAsyncChannelInboundStream>, outbound: NIOAsyncChannelOutboundWriter>, @@ -86,12 +96,11 @@ internal actor VoiceConnection { ) ) - var iterator = inbound.makeAsyncIterator() - guard let discoveryResponse = try await iterator.next() else { + var iterator = inboundStream.makeAsyncIterator() + guard let data = await iterator.next() else { return nil } - - let data = discoveryResponse.data + guard let address = data.getData(at: 8, length: 64), let address = String( diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index e12f2359..cc1c5b2b 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -388,18 +388,8 @@ public actor VoiceGatewayManager { delay: 0 ) - guard - let mode = payload.mode - else { return } - - let key = SymmetricKey(data: payload.secret_key) - self.listen(description: payload) - self.speak( - ssrc: .init(audioSSRC), - mode: mode, - key: key - ) + self.speak(description: payload) self.send( message: .init( @@ -410,48 +400,6 @@ public actor VoiceGatewayManager { opcode: .text ) ) - - guard - let discovery = try? await self.udpConnection?.discoverExternalIP( - ssrc: .init(self.audioSSRC) - ) - else { - // udp discovery failed, disconnect and set state to stopped - logger.error( - "Failed to discover external IP and port during session description handling" - ) - await self.disconnect() - return - } - - self.send( - message: .init( - payload: .init( - opcode: .selectProtocol, - data: .selectProtocol( - .init( - protocol: "udp", - data: .init( - address: discovery.ip, - port: .init(discovery.port), - mode: payload.mode ?? .aead_aes256_gcm_rtpsize - ), - rtc_connection_id: self.rtcConnectionID, - codecs: [ - .opusCodec, - .h264Codec, - .h265Codec, - ], - experiments: [ - "fixed_keyframe_interval", - "keyframe_on_join", - ] - ) - ) - ), - opcode: .text - ) - ) case .speaking(let payload): self.knownSSRCs[payload.ssrc] = payload.user_id case .clientConnect(let payload): @@ -596,8 +544,8 @@ public actor VoiceGatewayManager { self.udpConnectionTask?.cancel() } - for try await envelope in udpConnection.inbound { - guard let packet = RTPPacket(rawValue: envelope.data) else { + for try await data in await udpConnection.inboundStream { + guard let packet = RTPPacket(rawValue: data) else { continue } @@ -612,11 +560,17 @@ public actor VoiceGatewayManager { /// Writes Opus data out through UDP. private func speak( - ssrc: UInt32, - mode: VoiceGateway.EncryptionMode, - key: SymmetricKey + description: VoiceGateway.SessionDescription ) { startDrainingOutgoingChannel() + 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 @@ -646,12 +600,14 @@ public actor VoiceGatewayManager { self.nextSpeakingPayload = nil } + let key = SymmetricKey(data: description.secret_key) + if let frame { await sendPacket( frame: frame, sequence: sequence, timestamp: timestamp, - ssrc: ssrc, + ssrc: .init(audioSSRC), mode: mode, key: key ) @@ -659,7 +615,7 @@ public actor VoiceGatewayManager { await sendSilence( sequence: sequence, timestamp: timestamp, - ssrc: ssrc, + ssrc: .init(audioSSRC), mode: mode, key: key ) @@ -973,7 +929,7 @@ extension VoiceGatewayManager { private func processBinaryData( _ message: WebSocketMessage, forConnectionWithId connectionId: UInt - ) { + ) async { guard self.connectionId.load(ordering: .relaxed) == connectionId else { return } @@ -1004,7 +960,7 @@ extension VoiceGatewayManager { // check if the raw data is a binary message with valid opcode or json message. do { let event = try self.tryDecodeBufferAsEvent(&buffer, binary: isBinary) - Task { await self.processEvent(event) } + await self.processEvent(event) for continuation in self.eventsStreamContinuations { continuation.yield(event) } From 9e6664f22f9b66a1be3c8d73248052aeb6aa1516 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Tue, 3 Mar 2026 17:26:47 +0000 Subject: [PATCH 23/66] Update VoiceGatewayManager.swift --- .../DiscordVoice/VoiceGatewayManager.swift | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index cc1c5b2b..70e46c2e 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -372,8 +372,7 @@ public actor VoiceGatewayManager { every: .milliseconds(payload.heartbeat_interval / 2) ) case .ready(let payload): - self.state.store(.connected, ordering: .relaxed) - self.stateCallback?(.connected) + await self.onSuccessfulConnection() self.knownSSRCs[UInt(payload.ssrc)] = self.connectionData.userID setupUDP(payload) @@ -391,15 +390,15 @@ public actor VoiceGatewayManager { self.listen(description: payload) self.speak(description: payload) - self.send( - message: .init( - payload: .init( - opcode: .voiceBackendVersion, - data: .voiceBackendVersion(.init()), - ), - opcode: .text - ) - ) +// self.send( +// message: .init( +// payload: .init( +// opcode: .voiceBackendVersion, +// data: .voiceBackendVersion(.init()), +// ), +// opcode: .text +// ) +// ) case .speaking(let payload): self.knownSSRCs[payload.ssrc] = payload.user_id case .clientConnect(let payload): From 35a559976becd2fa99df5ff771e8da7ebc7e625a Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Tue, 3 Mar 2026 17:58:24 +0000 Subject: [PATCH 24/66] forgot to encode opcode, works now --- .../xcdebugger/Breakpoints_v2.xcbkptlist | 4 ++-- .../Sources/DiscordVoice/VoiceGatewayManager.swift | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 10bcf3c1..e56e7f01 100644 --- a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -14,8 +14,8 @@ filePath = "PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift" startingColumnNumber = "9223372036854775807" endingColumnNumber = "9223372036854775807" - startingLineNumber = "879" - endingLineNumber = "879" + startingLineNumber = "878" + endingLineNumber = "878" landmarkName = "sendResumeOrIdentify()" landmarkType = "7"> diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index 70e46c2e..91d63594 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -1288,16 +1288,20 @@ extension VoiceGatewayManager { // 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): - data = payload - case .mlsCommitWelcome(let payload): - data = payload + case .mlsKeyPackage(let payload), + .mlsCommitWelcome(let payload): + // opcode + payload + var frame = Data([message.payload.opcode.rawValue]) + frame.append(payload) + data = frame default: - /// never happens, here to initialise data for compile time checks. + /// never called, here to initialise data for compile time checks. data = Data() } default: + // json payload, encode data = try DiscordGlobalConfiguration.encoder.encode( message.payload ) From 6299376e0bb4a7152f5446dfeb8322692d901f22 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 4 Mar 2026 13:55:38 +0000 Subject: [PATCH 25/66] fix a bunch of logical issues, fix sending audio packets, send silent frames at join --- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../xcdebugger/Breakpoints_v2.xcbkptlist | 27 --- Paicord/App/PaicordApp.swift | 14 +- Paicord/Stores/VoiceConnectionStore.swift | 113 ++++++----- .../Types/VoiceGateway+Payloads.swift | 20 +- .../DiscordVoice/CryptoExtensions.swift | 54 +++--- .../DiscordVoice/VoiceConnection.swift | 98 ++++++---- .../DiscordVoice/VoiceGatewayManager.swift | 178 +++++++++++------- 8 files changed, 285 insertions(+), 221 deletions(-) diff --git a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved index c7a5d9da..244c3d43 100644 --- a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -79,7 +79,7 @@ "location" : "https://github.com/llsc12/DaveKit.git", "state" : { "branch" : "main", - "revision" : "0a901b6cabbd13894b58356b4266d0473af39469" + "revision" : "65bb91b796ab6f0edf12331a158dd3fb9af44c8d" } }, { diff --git a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index e56e7f01..36b89fc6 100644 --- a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,31 +3,4 @@ uuid = "3C51FC77-CE2B-4F32-B3F3-96CDC8C1DACC" type = "0" version = "2.0"> - - - - - - - - - - - - diff --git a/Paicord/App/PaicordApp.swift b/Paicord/App/PaicordApp.swift index 638295a6..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, diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index debafd52..3728bb7b 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -7,18 +7,24 @@ // import AVFoundation +import Foundation import Opus import PaicordLib -import Foundation @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) - self.opusDecoder = try! Opus.Decoder(format: Self.opusFormat, application: .voip) + self.opusEncoder = try! Opus.Encoder( + format: Self.opusFormat, + application: .voip + ) + self.opusDecoder = try! Opus.Decoder( + format: Self.opusFormat, + application: .voip + ) } - + var gateway: GatewayStore? var voiceGateway: VoiceGatewayManager? { didSet { @@ -56,7 +62,7 @@ final class VoiceConnectionStore: DiscordDataStore { } } } - + func setupVoiceEventHandling() { guard let voiceGateway = voiceGateway else { return } @@ -122,7 +128,9 @@ final class VoiceConnectionStore: DiscordDataStore { self.channelId = channelId self.guildId = guildId - print("[Voice] Attempting to connect to voice channel \(channelId.rawValue) in guild \(guildId?.rawValue ?? "DMs")") + print( + "[Voice] Attempting to connect to voice channel \(channelId.rawValue) in guild \(guildId?.rawValue ?? "DMs")" + ) await gateway?.gateway?.updateVoiceState( payload: .init( guild_id: guildId, @@ -195,13 +203,17 @@ final class VoiceConnectionStore: DiscordDataStore { 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") + print( + "[Voice] Received voice server update with empty endpoint, disconnecting from voice" + ) Task { await voiceGateway?.disconnect() } voiceGateway = nil return } - print("[Voice] Received voice server update, connecting to voice gateway at endpoint \(endpoint) for guild \(guildId.rawValue) and channel \(channelId.rawValue)") + print( + "[Voice] Received voice server update, connecting to voice gateway at endpoint \(endpoint) for guild \(guildId.rawValue) and channel \(channelId.rawValue)" + ) self.voiceGateway = VoiceGatewayManager.init( connectionData: .init( token: payload.token, @@ -259,64 +271,71 @@ final class VoiceConnectionStore: DiscordDataStore { // audio player node for playing incoming audio frames @ObservationIgnored private let playerNode: AVAudioPlayerNode = AVAudioPlayerNode() - + @ObservationIgnored private var incomingAudioTask: Task? = nil - private func audioEngineSetup() { self.audioEngineCleanup() + Task { @MainActor in - + if !audioEngine.attachedNodes.contains(playerNode) { + audioEngine.attach(playerNode) + } -// if !audioEngine.attachedNodes.contains(playerNode) { -// audioEngine.attach(playerNode) -// } -// -// let engineOutputFormat = audioEngine.outputNode.inputFormat(forBus: 0) -// -// audioEngine.connect(playerNode, to: audioEngine.mainMixerNode, format: engineOutputFormat) -// -// audioEngine.prepare() -// do { -// try audioEngine.start() -// playerNode.play() -// } catch { -// print("[Voice] Failed to start audio engine: \(error)") -// return -// } + audioEngine.connect( + playerNode, + to: audioEngine.mainMixerNode, + format: Self.pcmFormat + ) - incomingAudioTask = Task { [weak self] in - guard let self = self, let voiceGateway = self.voiceGateway else { return } + audioEngine.prepare() + do { + try audioEngine.start() + playerNode.play() + } catch { + print("[Voice] Failed to start audio engine: \(error)") + return + } + + print("[Voice] Audio engine started") + print("[Voice] Player node format: \(Self.pcmFormat)") + print( + "[Voice] Engine output format: \(audioEngine.outputNode.outputFormat(forBus: 0))" + ) + + guard let voiceGateway = self.voiceGateway else { + print("[Voice] No voice gateway when starting incoming audio task") + return + } - for await opusFrame in await voiceGateway.incomingOpusPackets { + incomingAudioTask = Task { [weak self] in + for await opusFrame in await voiceGateway.incomingOpusChannel { + guard let self else { break } if Task.isCancelled { break } - guard let decoded = try? self.opusDecoder.decode(opusFrame) else { - continue + do { + let decoded = try self.opusDecoder.decode(opusFrame) + self.playerNode.scheduleBuffer(decoded, completionHandler: nil) + } catch { + print("[Voice OPUS] Frame decode error: \(error)") } - - print(decoded.format.debugDescription) - } } - - print("[Voice] Audio engine started") } } private func audioEngineCleanup() { + incomingAudioTask?.cancel() + incomingAudioTask = nil + Task { @MainActor in - // cancel incoming audio task - self.incomingAudioTask?.cancel() - - // stop the engine and reset everything - self.audioEngine.stop() - self.playerNode.stop() - self.audioEngine.reset() - // remove player node from engine - self.audioEngine.detach(self.playerNode) - + playerNode.stop() + audioEngine.stop() + audioEngine.reset() + if audioEngine.attachedNodes.contains(playerNode) { + audioEngine.detach(playerNode) + } print("[Voice] Audio engine stopped") } } diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift index 626c9ae0..2f7a7924 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift @@ -473,32 +473,32 @@ extension VoiceGateway { } public struct DavePrepareTransition: Sendable, Codable { - public var transitionId: UInt16 - public var protocolVersion: UInt16 + public var transition_id: UInt16 + public var protocol_version: UInt16 } public struct DaveCommitTransition: Sendable, Codable { - public var transitionId: UInt16 + public var transition_id: UInt16 } public struct DavePrepareEpoch: Sendable, Codable { public var epoch: UInt32 - public var protocolVersion: UInt16 + public var protocol_version: UInt16 } public struct DaveTransitionReady: Sendable, Codable { - public init(transitionId: UInt16) { - self.transitionId = transitionId + public init(transition_id: UInt16) { + self.transition_id = transition_id } - public var transitionId: UInt16 + public var transition_id: UInt16 } public struct DaveMLSInvalidCommitWelcome: Sendable, Codable { - public init(transitionId: UInt16) { - self.transitionId = transitionId + public init(transition_id: UInt16) { + self.transition_id = transition_id } - public var transitionId: UInt16 + public var transition_id: UInt16 } } diff --git a/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift index e32e628a..a46b07fe 100644 --- a/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift +++ b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift @@ -76,51 +76,47 @@ extension VoiceGateway.EncryptionMode { func encrypt( buffer: consuming Data, - using key: SymmetricKey + using key: SymmetricKey, + additionalData: Data, + sequence: UInt32? = nil ) -> (ciphertext: Data, tag: Data, nonceSuffix: Data)? { - var nonceSuffixValue = UInt32.random(in: .min ... .max) + let nonceSuffixValue: UInt32 = sequence ?? .random(in: .min ... .max) var beNonceSuffix = nonceSuffixValue.bigEndian - let nonceSuffix = withUnsafeBytes(of: &beNonceSuffix) { - Data($0) - } + let nonceSuffix = withUnsafeBytes(of: &beNonceSuffix) { Data($0) } - var nonce = Data(repeating: 0, count: nonceLength) - nonce.replaceSubrange( - nonce.count - nonceSuffix.count..> let outbound: NIOAsyncChannelOutboundWriter> - lazy var inboundStream: AsyncStream = { - AsyncStream { continuation in - Task { - for try await envelope in inbound { - continuation.yield(envelope.data) - } - continuation.finish() - } - } - }() - + let audioStream: AsyncStream + private let audioContinuation: AsyncStream.Continuation + var discoveryContinuation: + CheckedContinuation<(ip: String, port: UInt16)?, Never>? + private init( inbound: NIOAsyncChannelInboundStream>, outbound: NIOAsyncChannelOutboundWriter>, @@ -35,6 +29,47 @@ internal actor VoiceConnection { 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( @@ -84,11 +119,13 @@ internal actor VoiceConnection { 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 + 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, @@ -96,24 +133,9 @@ internal actor VoiceConnection { ) ) - var iterator = inboundStream.makeAsyncIterator() - guard let data = await iterator.next() else { - return nil - } - - guard - let address = data.getData(at: 8, length: 64), - let address = String( - data: address.prefix( - upTo: address.firstIndex(of: 0) ?? address.endIndex - ), - encoding: .utf8, - ), - let port = data.getInteger(at: 72, endianness: .little, as: UInt16.self) - else { - return nil + return await withCheckedContinuation { continuation in + self.discoveryContinuation = continuation } - return (ip: address, port: port) } func send(buffer: ByteBuffer) async throws { @@ -126,14 +148,24 @@ internal actor VoiceConnection { } /// 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: 4) - buffer.writeInteger(ssrc, endianness: .big) + self.keepaliveCounter += 1 + + 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) } } } + diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index 91d63594..2b19d9c8 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -151,11 +151,7 @@ public actor VoiceGatewayManager { } private let outgoingOpusChannel = AsyncChannel() - private let incomingOpusChannel = AsyncChannel() - - public var incomingOpusPackets: AsyncChannel { - incomingOpusChannel - } + public let incomingOpusChannel = AsyncChannel() //MARK: Send queue @@ -390,15 +386,15 @@ public actor VoiceGatewayManager { self.listen(description: payload) self.speak(description: payload) -// self.send( -// message: .init( -// payload: .init( -// opcode: .voiceBackendVersion, -// data: .voiceBackendVersion(.init()), -// ), -// opcode: .text -// ) -// ) + self.send( + message: .init( + payload: .init( + opcode: .voiceBackendVersion, + data: .voiceBackendVersion(.init()), + ), + opcode: .text + ) + ) case .speaking(let payload): self.knownSSRCs[payload.ssrc] = payload.user_id case .clientConnect(let payload): @@ -409,15 +405,15 @@ public actor VoiceGatewayManager { await self.dave.removeUser(userId: payload.user_id.rawValue) case .davePrepareTransition(let payload): await self.dave.prepareTransition( - transitionId: payload.transitionId, - protocolVersion: payload.protocolVersion + transitionId: payload.transition_id, + protocolVersion: payload.protocol_version ) case .daveExecuteTransition(let payload): - await self.dave.executeTransition(transitionId: payload.transitionId) + await self.dave.executeTransition(transitionId: payload.transition_id) case .davePrepareEpoch(let payload): await self.dave.prepareEpoch( epoch: String(payload.epoch), - protocolVersion: payload.protocolVersion + protocolVersion: payload.protocol_version ) case .mlsExternalSender(let data): await self.dave.mlsExternalSenderPackage(externalSenderPackage: data) @@ -543,10 +539,13 @@ public actor VoiceGatewayManager { self.udpConnectionTask?.cancel() } - for try await data in await udpConnection.inboundStream { + print("[VoiceGW] Started listening for voice data packets") + for await data in udpConnection.audioStream { guard let packet = RTPPacket(rawValue: data) else { + print("[VoiceGW] Packet decode failed") continue } + print("[VoiceGW] Voice data packet received") await self.processIncomingVoicePacket( packet, @@ -578,6 +577,24 @@ public actor VoiceGatewayManager { let clock = ContinuousClock() let interval: Duration = .milliseconds(20) + let key = SymmetricKey(data: description.secret_key) + + let silenceTailCount = 5 + var silenceFramesRemaining = 0 + + 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 @@ -588,20 +605,17 @@ public actor VoiceGatewayManager { frame = pendingOpusFrames.removeFirst() } - if frame != nil, let payload = self.nextSpeakingPayload { - // we're going to start talking, send any pending speaking payloads first. - self.send( - message: .init( - payload: .init(opcode: .speaking, data: .speaking(payload)), - opcode: .text + if let frame { + if let payload = self.nextSpeakingPayload { + self.send( + message: .init( + payload: .init(opcode: .speaking, data: .speaking(payload)), + opcode: .text + ) ) - ) - self.nextSpeakingPayload = nil - } - - let key = SymmetricKey(data: description.secret_key) + self.nextSpeakingPayload = nil + } - if let frame { await sendPacket( frame: frame, sequence: sequence, @@ -610,7 +624,9 @@ public actor VoiceGatewayManager { mode: mode, key: key ) - } else { + silenceFramesRemaining = silenceTailCount + + } else if silenceFramesRemaining > 0 { await sendSilence( sequence: sequence, timestamp: timestamp, @@ -618,11 +634,35 @@ public actor VoiceGatewayManager { mode: mode, key: key ) + silenceFramesRemaining -= 1 + + if silenceFramesRemaining == 0 { + let ssrc = audioSSRC + self.send( + message: .init( + payload: .init( + opcode: .speaking, + data: .speaking(.init(speaking: [], ssrc: ssrc, delay: 0)) + ), + opcode: .text + ) + ) + self.nextSpeakingPayload = .init( + speaking: [.voice], + ssrc: ssrc, + delay: 0 + ) + } + + } else { + timestamp &+= 960 + sequence &+= 1 + try? await clock.sleep(until: start + interval) + continue } timestamp &+= 960 sequence &+= 1 - try? await clock.sleep(until: start + interval) } } @@ -668,58 +708,61 @@ public actor VoiceGatewayManager { mode: VoiceGateway.EncryptionMode, key: SymmetricKey ) async { - - guard let udpConnection = self.udpConnection else { - return - } - - guard - let encrypted = mode.encrypt( - buffer: frame, - using: key - ) - else { - logger.error("Voice encryption failed") - return - } + guard let udpConnection = self.udpConnection else { return } let headerPacket = RTPPacket( payloadType: .dynamic(.init(VoiceGateway.Codec.opusCodec.payload_type)), sequence: sequence, timestamp: timestamp, ssrc: ssrc, - payload: ByteBuffer() // empty for now + 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) - var packet = ByteBuffer() + 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) - // dave encrypt - do { - let daveEncrypted = try await self.dave.encrypt( - ssrc: ssrc, - data: .init(buffer: packet, byteTransferStrategy: .noCopy), - mediaType: .audio - ) - try await udpConnection.send(buffer: .init(data: daveEncrypted)) + try await udpConnection.send(buffer: packet) } catch { logger.error( - "Failed to send voice packet", - metadata: [ - "error": .string(String(reflecting: error)) - ] + "Failed to send UDP voice packet", + metadata: ["error": .string(String(reflecting: error))] ) } } @@ -785,9 +828,10 @@ public actor VoiceGatewayManager { await incomingOpusChannel.send(data) } - public func sendOpusFrame(_ frame: Data) { - + Task { + await outgoingOpusChannel.send(frame) + } } // MARK: - Gateway actions @@ -1460,7 +1504,7 @@ extension VoiceGatewayManager: DaveSessionDelegate { public func mlsInvalidCommitWelcome(transitionId: UInt16) async { let event = VoiceGateway.Event( opcode: .mlsInvalidCommitWelcome, - data: .mlsInvalidCommitWelcome(.init(transitionId: transitionId)) + data: .mlsInvalidCommitWelcome(.init(transition_id: transitionId)) ) self.send( message: .init( @@ -1473,7 +1517,7 @@ extension VoiceGatewayManager: DaveSessionDelegate { public func readyForTransition(transitionId: UInt16) async { let event = VoiceGateway.Event( opcode: .daveTransitionReady, - data: .daveTransitionReady(.init(transitionId: transitionId)) + data: .daveTransitionReady(.init(transition_id: transitionId)) ) self.send( message: .init( From b8bcdf246d46d5c3588c4204bd766fc3091075dd Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Thu, 5 Mar 2026 20:03:10 +0000 Subject: [PATCH 26/66] fix crypto --- .../DiscordVoice/CryptoExtensions.swift | 57 ++++----- .../DiscordVoice/VoiceConnection.swift | 3 +- .../DiscordVoice/VoiceGatewayManager.swift | 120 +++++++++--------- 3 files changed, 91 insertions(+), 89 deletions(-) diff --git a/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift index a46b07fe..de144106 100644 --- a/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift +++ b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift @@ -22,55 +22,52 @@ extension VoiceGateway.EncryptionMode { } func decrypt( - buffer: consuming ByteBuffer, + fullPacket: Data, with key: SymmetricKey, + hasExtension: Bool ) -> Data? { - guard - let rtpNonce = buffer.readBytes(length: rtpNonceLength), - let ciphertext = buffer.readData( - length: buffer.readableBytes - tagLength - ), - let tag = buffer.readData(length: tagLength) - else { - return nil - } + let aadSize = hasExtension ? 16 : 12 + let rtpHeaderAAD = fullPacket.prefix(aadSize) - var nonce = Data(repeating: 0, count: nonceLength) - nonce.replaceSubrange( - nonce.count - rtpNonce.count.. ciphertextStart else { return nil } + + let ciphertext = fullPacket[ciphertextStart.. Date: Thu, 5 Mar 2026 21:31:45 +0000 Subject: [PATCH 27/66] wip voice working --- .../xcdebugger/Breakpoints_v2.xcbkptlist | 18 ++++++ Paicord/Stores/VoiceConnectionStore.swift | 60 +++++++++++-------- .../DiscordVoice/VoiceGatewayManager.swift | 4 -- 3 files changed, 52 insertions(+), 30 deletions(-) diff --git a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 36b89fc6..d704362a 100644 --- a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,4 +3,22 @@ uuid = "3C51FC77-CE2B-4F32-B3F3-96CDC8C1DACC" type = "0" version = "2.0"> + + + + + + diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 3728bb7b..e0dc60b3 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -252,6 +252,7 @@ final class VoiceConnectionStore: DiscordDataStore { channels: 2, interleaved: false )! + @ObservationIgnored private let opusEncoder: Opus.Encoder @ObservationIgnored @@ -279,9 +280,7 @@ final class VoiceConnectionStore: DiscordDataStore { self.audioEngineCleanup() Task { @MainActor in - if !audioEngine.attachedNodes.contains(playerNode) { - audioEngine.attach(playerNode) - } + audioEngine.attach(playerNode) audioEngine.connect( playerNode, @@ -289,42 +288,51 @@ final class VoiceConnectionStore: DiscordDataStore { format: Self.pcmFormat ) - audioEngine.prepare() do { + audioEngine.prepare() try audioEngine.start() playerNode.play() } catch { - print("[Voice] Failed to start audio engine: \(error)") - return - } - - print("[Voice] Audio engine started") - print("[Voice] Player node format: \(Self.pcmFormat)") - print( - "[Voice] Engine output format: \(audioEngine.outputNode.outputFormat(forBus: 0))" - ) - - guard let voiceGateway = self.voiceGateway else { - print("[Voice] No voice gateway when starting incoming audio task") + print("[Voice] Failed to start audio engine:", error) return } + } - incomingAudioTask = Task { [weak self] in - for await opusFrame in await voiceGateway.incomingOpusChannel { - guard let self else { break } - if Task.isCancelled { break } + guard let voiceGateway = self.voiceGateway else { return } + + incomingAudioTask = Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } + + for await opusFrame in await voiceGateway.incomingOpusChannel { + if Task.isCancelled { break } + do { + let decoded = try self.opusDecoder.decode(opusFrame) + // 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.. Date: Sat, 7 Mar 2026 10:41:54 +0000 Subject: [PATCH 28/66] dont schedule buffers on main thread --- Feature Checklist.md | 327 ++++++++++++++++++ .../xcdebugger/Breakpoints_v2.xcbkptlist | 18 - Paicord/Stores/VoiceConnectionStore.swift | 5 +- README.md | 44 ++- 4 files changed, 367 insertions(+), 27 deletions(-) create mode 100644 Feature Checklist.md 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.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index d704362a..36b89fc6 100644 --- a/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Paicord.xcworkspace/xcuserdata/llsc12.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -3,22 +3,4 @@ uuid = "3C51FC77-CE2B-4F32-B3F3-96CDC8C1DACC" type = "0" version = "2.0"> - - - - - - diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index e0dc60b3..6ad5a921 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -302,6 +302,7 @@ final class VoiceConnectionStore: DiscordDataStore { incomingAudioTask = Task.detached(priority: .userInitiated) { [weak self] in guard let self else { return } + let player = self.playerNode for await opusFrame in await voiceGateway.incomingOpusChannel { if Task.isCancelled { break } @@ -324,9 +325,7 @@ final class VoiceConnectionStore: DiscordDataStore { dstR[i] = src[i * 2 + 1] } - await MainActor.run { - self.playerNode.scheduleBuffer(converted) - } + player.scheduleBuffer(converted, completionHandler: nil) } catch { print("[Voice OPUS] Frame decode error:", error) } diff --git a/README.md b/README.md index 1eeefebb..8a3f3716 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, 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,39 @@ 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! +
+ +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. @@ -57,5 +89,5 @@ Paicord uses modified versions of [DiscordBM](https://github.com/DiscordBM/Disco 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 [DaveKit](https://github.com/SwiftDiscordAudio/DaveKit) for voice too! +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! From e3fade7e7e6dbd2f0ea4e4c1563c91189087c2eb Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sat, 7 Mar 2026 12:08:32 +0000 Subject: [PATCH 29/66] emit opus packets directly, emit fake speaking events --- Paicord/Stores/VoiceConnectionStore.swift | 17 +- .../DiscordGateway/UserGatewayManager.swift | 3 +- .../Sources/DiscordModels/Types/RTP/RTP.swift | 4 +- .../DiscordModels/Types/RTP/RTPType.swift | 6 +- .../Types/VoiceGateway+Payloads.swift | 35 ++- .../DiscordVoice/VoiceGatewayManager.swift | 262 +++++++++++++----- 6 files changed, 247 insertions(+), 80 deletions(-) diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 6ad5a921..39ea8b2c 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -71,6 +71,14 @@ final class VoiceConnectionStore: DiscordDataStore { switch event.data { case .ready(let payload): print("we in yk") + case .speaking(let payload): + if payload.speaking.isEmpty { + print("[Voice] \(payload.user_id?.rawValue ?? "Unknown User") stopped speaking") + } else { + print( + "[Voice] \(payload.user_id?.rawValue ?? "Unknown User") started speaking" + ) + } default: break } } @@ -304,11 +312,14 @@ final class VoiceConnectionStore: DiscordDataStore { guard let self else { return } let player = self.playerNode - for await opusFrame in await voiceGateway.incomingOpusChannel { + for await rtpPacket in await voiceGateway.incomingAudioChannel { if Task.isCancelled { break } do { - let decoded = try self.opusDecoder.decode(opusFrame) - // manually de-interleave + let opusFrame = rtpPacket.payload + let decoded = try self.opusDecoder.decode( + .init(buffer: opusFrame, byteTransferStrategy: .noCopy) + ) + // manually de-interleave. guard let converted = AVAudioPCMBuffer( pcmFormat: Self.pcmFormat, diff --git a/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift b/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift index 7e6b0df0..ec908aca 100644 --- a/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift +++ b/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift @@ -607,11 +607,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/DiscordModels/Types/RTP/RTP.swift b/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift index ddb410df..68b04d62 100644 --- a/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift +++ b/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift @@ -16,7 +16,7 @@ import NIOCore /// 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: RawRepresentable { +public struct RTPPacket: Sendable, RawRepresentable { // MARK: - First byte /// This field identifies the version of RTP. The version defined by @@ -150,7 +150,7 @@ public struct RTPPacket: RawRepresentable { public let csrcs: [UInt32] /// Remaining payload data - public let payload: ByteBuffer + public var payload: ByteBuffer public init( payloadType: RTPType, diff --git a/PaicordLib/Sources/DiscordModels/Types/RTP/RTPType.swift b/PaicordLib/Sources/DiscordModels/Types/RTP/RTPType.swift index 6c69dd9f..155327d6 100644 --- a/PaicordLib/Sources/DiscordModels/Types/RTP/RTPType.swift +++ b/PaicordLib/Sources/DiscordModels/Types/RTP/RTPType.swift @@ -1,7 +1,7 @@ /// https://github.com/SwiftDiscordAudio/DiscordAudioKit/blob/main/Sources/DiscordRTP/RTPType.swift /// https://www.iana.org/assignments/rtp-parameters/rtp-parameters.xhtml#rtp-parameters-1 -public enum RTPType: RawRepresentable { +public enum RTPType: Sendable, RawRepresentable { case pcmu case gsm case g723 @@ -24,14 +24,14 @@ public enum RTPType: RawRepresentable { case h263 case dynamic(UInt8) - public enum DVI4SampleRate { + public enum DVI4SampleRate: Sendable { case `8000` case `16000` case `11025` case `22050` } - public enum l16Channels: Int { + public enum l16Channels: Int, Sendable { case mono = 1 case stereo = 2 } diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift index 2f7a7924..890d2d63 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift @@ -277,6 +277,16 @@ extension VoiceGateway { /// 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]? @@ -314,6 +324,16 @@ extension VoiceGateway { 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 @@ -386,10 +406,23 @@ extension VoiceGateway { /// 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]? // sent by client only + public var streams: [Stream]? public var user_id: UserSnowflake? // sent by server only } diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index 271d8a17..e9716c78 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -150,8 +150,10 @@ public actor VoiceGatewayManager { })?.key ?? 0 } - private let outgoingOpusChannel = AsyncChannel() - public let incomingOpusChannel = AsyncChannel() + /// Outgoing channel only takes Opus frames, rtp packet forming and encryption is handled automatically + private let outgoingAudioChannel = AsyncChannel() + /// Incoming channel for you to iterate over. The payload is modified to contain a decrypted Opus frame. + public let incomingAudioChannel = AsyncChannel() //MARK: Send queue @@ -386,22 +388,21 @@ public actor VoiceGatewayManager { self.listen(description: payload) self.speak(description: payload) - self.send( - message: .init( - payload: .init( - opcode: .voiceBackendVersion, - data: .voiceBackendVersion(.init()), - ), - opcode: .text - ) - ) + 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 .clientConnect(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) case .davePrepareTransition(let payload): @@ -535,6 +536,8 @@ public actor VoiceGatewayManager { 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, @@ -560,16 +563,27 @@ public actor VoiceGatewayManager { if let frame { if let payload = self.nextSpeakingPayload { - self.send( - message: .init( - payload: .init(opcode: .speaking, data: .speaking(payload)), - opcode: .text - ) - ) + 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, + ) + ) + ) + ) + } } - await sendPacket( + await sendOpusPacket( frame: frame, sequence: sequence, timestamp: timestamp, @@ -590,21 +604,20 @@ public actor VoiceGatewayManager { silenceFramesRemaining -= 1 if silenceFramesRemaining == 0 { - let ssrc = audioSSRC - self.send( - message: .init( - payload: .init( + self.eventsStreamContinuations.forEach { + $0.yield( + .init( opcode: .speaking, - data: .speaking(.init(speaking: [], ssrc: ssrc, delay: 0)) - ), - opcode: .text + data: .speaking( + .init( + speaking: [], + ssrc: self.audioSSRC, + user_id: self.connectionData.userID, + ) + ) + ) ) - ) - self.nextSpeakingPayload = .init( - speaking: [.voice], - ssrc: ssrc, - delay: 0 - ) + } } } else { @@ -631,7 +644,7 @@ public actor VoiceGatewayManager { // Discord Opus silence frame let silence = Data([0xF8, 0xFF, 0xFE]) - await sendPacket( + await sendOpusPacket( frame: silence, sequence: sequence, timestamp: timestamp, @@ -643,7 +656,7 @@ public actor VoiceGatewayManager { private func startDrainingOutgoingChannel() { channelDrainTask = Task { - for await frame in outgoingOpusChannel { + for await frame in outgoingAudioChannel { pendingOpusFrames.append(frame) if pendingOpusFrames.count > 5 { @@ -653,7 +666,7 @@ public actor VoiceGatewayManager { } } - func sendPacket( + func sendOpusPacket( frame: Data, sequence: UInt16, timestamp: UInt32, @@ -720,12 +733,6 @@ public actor VoiceGatewayManager { } } - public func sendOpusFrame(_ frame: Data) { - Task { - await outgoingOpusChannel.send(frame) - } - } - /// Start listening for incoming audio packets on the UDP connection. private func listen( description: VoiceGateway.SessionDescription, @@ -773,6 +780,12 @@ public actor VoiceGatewayManager { } } + 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. @@ -782,7 +795,8 @@ public actor VoiceGatewayManager { mode: VoiceGateway.EncryptionMode, key: SymmetricKey ) async { - var buffer = packet.payload + var packet = packet + var buffer = packet.payload // copy for reading // First, decrypt the RTP packet payload var extensionLength: UInt16? @@ -833,36 +847,147 @@ public actor VoiceGatewayManager { return } - await incomingOpusChannel.send(data) + // 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 incomingAudioChannel.send(packet) } // MARK: - Gateway actions - // /// https://discord.com/developers/docs/topics/gateway-events#update-presence - // public func updatePresence(payload: Gateway.Identify.Presence) { - // self.send( - // message: .init( - // payload: .init( - // opcode: .presenceUpdate, - // data: .requestPresenceUpdate(payload) - // ), - // opcode: .text - // ) - // ) - // } - // - // /// https://discord.com/developers/docs/topics/gateway-events#update-voice-state - // public func updateVoiceState(payload: VoiceStateUpdate) { - // self.send( - // message: .init( - // payload: .init( - // opcode: .voiceStateUpdate, - // data: .requestVoiceStateUpdate(payload) - // ), - // opcode: .text - // ) - // ) - // } + /// 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 - @@ -946,11 +1071,10 @@ extension VoiceGatewayManager { ) ) ) - let opcode = Gateway.Opcode.identify self.send( message: .init( payload: resume, - opcode: .init(encodedWebSocketOpcode: opcode.rawValue)! + opcode: .text ) ) From 56d79fab60934fbea50d16df0e792b4e281a4fc1 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sat, 7 Mar 2026 12:32:38 +0000 Subject: [PATCH 30/66] voice states --- Paicord/Stores/VoiceConnectionStore.swift | 43 ++++++++++++++----- .../Types/VoiceGateway+Payloads.swift | 4 +- .../DiscordModels/Types/VoiceGateway.swift | 12 +++--- 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 39ea8b2c..7f020caf 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -69,16 +69,10 @@ final class VoiceConnectionStore: DiscordDataStore { voiceEventTask = Task { @MainActor in for await event in await voiceGateway.events { switch event.data { - case .ready(let payload): - print("we in yk") + case .clientDisconnect(let payload): + handleClientDisconnect(payload) case .speaking(let payload): - if payload.speaking.isEmpty { - print("[Voice] \(payload.user_id?.rawValue ?? "Unknown User") stopped speaking") - } else { - print( - "[Voice] \(payload.user_id?.rawValue ?? "Unknown User") started speaking" - ) - } + default: break } } @@ -90,7 +84,8 @@ final class VoiceConnectionStore: DiscordDataStore { } } - // state + // MARK: - State + // our own voice state stuff private var channelId: ChannelSnowflake? private var guildId: GuildSnowflake? private var isMuted: Bool = false @@ -98,6 +93,9 @@ final class VoiceConnectionStore: DiscordDataStore { private var isVideoEnabled: Bool = false private var preferredRegion: String? private var flags: IntBitField = [] + + // if in a vc, this contains our speaking state and other ppl's speaking state. + private var usersSpeakingState: [UserSnowflake: IntBitField] = [:] private var voiceStatus: GatewayState = .stopped { didSet { @@ -240,6 +238,27 @@ final class VoiceConnectionStore: DiscordDataStore { await self.voiceGateway?.connect() } } + + private func handleClientDisconnect(_ payload: VoiceGateway.ClientDisconnect) { + // someone other than us left the voice channel. + } + + private func handleSpeaking(_ payload: VoiceGateway.Speaking) { + // someone started or stopped speaking, we can use this to show speaking indicators. + let ssrc = payload.ssrc + + if let id = payload.user_id { + self.usersSpeakingState[id] = payload.speaking + } + + if payload.speaking.isEmpty { + print("[Voice] \(payload.user_id?.rawValue ?? "Unknown User") stopped speaking") + } else { + print( + "[Voice] \(payload.user_id?.rawValue ?? "Unknown User") started speaking" + ) + } + } func cancelEventHandling() { // overrides default impl of protocol @@ -336,7 +355,9 @@ final class VoiceConnectionStore: DiscordDataStore { dstR[i] = src[i * 2 + 1] } - player.scheduleBuffer(converted, completionHandler: nil) + Task { + await player.scheduleBuffer(converted) + } } catch { print("[Voice OPUS] Frame decode error:", error) } diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift index 890d2d63..7973adbe 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway+Payloads.swift @@ -384,8 +384,8 @@ extension VoiceGateway { public var seq_ack: Int? } - /// https://docs.discord.food/topics/voice-connections#example-client-connect - public struct ClientConnect: Sendable, Codable { + /// https://docs.discord.food/topics/voice-connections#clients-connect-structure + public struct ClientsConnect: Sendable, Codable { public var user_ids: [UserSnowflake] } diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift index d0e6b19b..b9513b34 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift @@ -28,7 +28,7 @@ public struct VoiceGateway: Sendable, Codable { case hello = 8 // r case resumed = 9 // r // signal opcode deprecated, but its 10 jsyk ykyk - case clientConnect = 11 // r + case clientsConnect = 11 // r case video = 12 // r case clientDisconnect = 13 // r case sessionUpdate = 14 // s r @@ -62,7 +62,7 @@ public struct VoiceGateway: Sendable, Codable { case .resume: return "resume" case .hello: return "hello" case .resumed: return "resumed" - case .clientConnect: return "clientConnect" + case .clientsConnect: return "clientsConnect" case .video: return "video" case .clientDisconnect: return "clientDisconnect" case .sessionUpdate: return "sessionUpdate" @@ -105,7 +105,7 @@ public struct VoiceGateway: Sendable, Codable { case resume(Resume) case hello(Hello) case resumed - case clientConnect(ClientConnect) + case clientsConnect(ClientsConnect) case video(Video) case clientDisconnect(ClientDisconnect) case sessionUpdate(SessionUpdate) @@ -216,8 +216,8 @@ public struct VoiceGateway: Sendable, Codable { self.data = .speaking(try decodeData()) case .heartbeatAck: self.data = .heartbeatAck(try decodeData()) - case .clientConnect: - self.data = .clientConnect(try decodeData()) + case .clientsConnect: + self.data = .clientsConnect(try decodeData()) case .video: self.data = .video(try decodeData()) case .clientDisconnect: @@ -265,7 +265,7 @@ public struct VoiceGateway: Sendable, Codable { switch self.opcode { case .ready, .sessionDescription, .heartbeatAck, .hello, .resumed, - .clientConnect, .video, .clientDisconnect: + .clientsConnect, .video, .clientDisconnect: throw EncodingError.notSupposedToBeSent( message: "`\(self.opcode.rawValue)` opcode is supposed to never be sent." From 88aa05d17412505976faf566d3547d27152eff95 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sat, 7 Mar 2026 13:19:46 +0000 Subject: [PATCH 31/66] Update VoiceGatewayManager.swift --- PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index e9716c78..168f4311 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -397,7 +397,7 @@ public actor VoiceGatewayManager { ) } self.knownSSRCs[payload.ssrc] = payload.user_id - case .clientConnect(let payload): + case .clientsConnect(let payload): for id in payload.user_ids { await self.dave.addUser(userId: id.rawValue) } From 6001c74e2ead38f1045d4a1aca05cb8da07f39eb Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sat, 7 Mar 2026 13:42:46 +0000 Subject: [PATCH 32/66] init decoder and player node per ssrc --- Paicord/Stores/VoiceConnectionStore.swift | 173 ++++++++++++++++------ 1 file changed, 131 insertions(+), 42 deletions(-) diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 7f020caf..0f6de48b 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -19,10 +19,6 @@ final class VoiceConnectionStore: DiscordDataStore { format: Self.opusFormat, application: .voip ) - self.opusDecoder = try! Opus.Decoder( - format: Self.opusFormat, - application: .voip - ) } var gateway: GatewayStore? @@ -34,9 +30,15 @@ final class VoiceConnectionStore: DiscordDataStore { audioEngineSetup() } else { self.voiceEventTask?.cancel() + self.voiceErrorEventTask?.cancel() // shutdown audio engine and release resources, also set status to stopped voiceStatus = .stopped audioEngineCleanup() + + // clear up state, reinit encoder. + try? self.opusEncoder.reset() + self.usersSpeakingState.removeAll() + self.knownSSRCs.removeAll() } } } @@ -70,9 +72,9 @@ final class VoiceConnectionStore: DiscordDataStore { for await event in await voiceGateway.events { switch event.data { case .clientDisconnect(let payload): - handleClientDisconnect(payload) + await handleClientDisconnect(payload) case .speaking(let payload): - + await handleSpeaking(payload) default: break } } @@ -93,15 +95,18 @@ final class VoiceConnectionStore: DiscordDataStore { private var isVideoEnabled: Bool = false private var preferredRegion: String? private var flags: IntBitField = [] - + // if in a vc, this contains our speaking state and other ppl's speaking state. - private var usersSpeakingState: [UserSnowflake: IntBitField] = [:] + var usersSpeakingState: + [UserSnowflake: IntBitField] = [:] + private var knownSSRCs: [UInt: UserSnowflake] = [:] private var voiceStatus: GatewayState = .stopped { didSet { print("[Voice] Voice connection status changed to \(voiceStatus)") } } + // MARK: - Public methods func updateVoiceConnection(_ update: VoiceConnectionUpdate) async { @@ -238,33 +243,37 @@ final class VoiceConnectionStore: DiscordDataStore { await self.voiceGateway?.connect() } } - - private func handleClientDisconnect(_ payload: VoiceGateway.ClientDisconnect) { + + 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 { + await removeIncomingStreamIfPresent(ssrc: .init(ssrc)) + } } - - private func handleSpeaking(_ payload: VoiceGateway.Speaking) { + + 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 } - - if payload.speaking.isEmpty { - print("[Voice] \(payload.user_id?.rawValue ?? "Unknown User") stopped speaking") - } else { - print( - "[Voice] \(payload.user_id?.rawValue ?? "Unknown User") started speaking" - ) - } + await ensureIncomingStreamExists(ssrc: .init(ssrc)) } func cancelEventHandling() { // overrides default impl of protocol eventTask?.cancel() eventTask = nil - Task { await voiceGateway?.disconnect() } + Task { + await voiceGateway?.disconnect() + voiceGateway = nil + } } // MARK: - Audio Engine implementation @@ -282,8 +291,7 @@ final class VoiceConnectionStore: DiscordDataStore { @ObservationIgnored private let opusEncoder: Opus.Encoder - @ObservationIgnored - private let opusDecoder: Opus.Decoder + // NOTE: decoder is now created per-SSRC @ObservationIgnored private let audioEngine = AVAudioEngine() @@ -296,29 +304,44 @@ final class VoiceConnectionStore: DiscordDataStore { return audioEngine.outputNode }() - // audio player node for playing incoming audio frames + // 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 + + 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() + } + } + @ObservationIgnored - private let playerNode: AVAudioPlayerNode = AVAudioPlayerNode() + private var incomingStreamsBySSRC: [UInt32: IncomingStream] = [:] @ObservationIgnored private var incomingAudioTask: Task? = nil + @ObservationIgnored + private let dummyPlayerNode = AVAudioPlayerNode() + + @ObservationIgnored + private var dummyNodeAttached: Bool = false + private func audioEngineSetup() { self.audioEngineCleanup() Task { @MainActor in - audioEngine.attach(playerNode) - - audioEngine.connect( - playerNode, - to: audioEngine.mainMixerNode, - format: Self.pcmFormat - ) + self.ensureDummyNodeAttached() do { audioEngine.prepare() try audioEngine.start() - playerNode.play() } catch { print("[Voice] Failed to start audio engine:", error) return @@ -329,15 +352,25 @@ final class VoiceConnectionStore: DiscordDataStore { incomingAudioTask = Task.detached(priority: .userInitiated) { [weak self] in guard let self else { return } - let player = self.playerNode 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 self.opusDecoder.decode( + let decoded = try stream.decoder.decode( .init(buffer: opusFrame, byteTransferStrategy: .noCopy) ) + // manually de-interleave. guard let converted = AVAudioPCMBuffer( @@ -356,25 +389,81 @@ final class VoiceConnectionStore: DiscordDataStore { } Task { - await player.scheduleBuffer(converted) + await stream.playerNode.scheduleBuffer(converted) } } catch { - print("[Voice OPUS] Frame decode error:", error) + print("[Voice OPUS] Frame decode error for ssrc \(ssrc):", error) } } } } + + private func ensureDummyNodeAttached() { + guard !dummyNodeAttached else { return } + + audioEngine.attach(dummyPlayerNode) + audioEngine.connect( + dummyPlayerNode, + to: audioEngine.mainMixerNode, + format: Self.pcmFormat + ) + + dummyNodeAttached = true + } + + private func ensureIncomingStreamExists(ssrc: UInt32) { + if incomingStreamsBySSRC[ssrc] != nil { return } + + let stream = IncomingStream(ssrc: ssrc) + incomingStreamsBySSRC[ssrc] = stream + + audioEngine.attach(stream.playerNode) + audioEngine.connect( + stream.playerNode, + to: audioEngine.mainMixerNode, + format: Self.pcmFormat + ) + stream.playerNode.play() + + print("[Voice] Created incoming stream for ssrc \(ssrc)") + } + + private func removeIncomingStreamIfPresent(ssrc: UInt32) { + guard let stream = incomingStreamsBySSRC.removeValue(forKey: ssrc) else { + return + } + + stream.playerNode.stop() + + if audioEngine.attachedNodes.contains(stream.playerNode) { + audioEngine.detach(stream.playerNode) + } + + print("[Voice] Removed incoming stream for ssrc \(ssrc)") + } + private func audioEngineCleanup() { incomingAudioTask?.cancel() incomingAudioTask = nil Task { @MainActor in - playerNode.stop() + for (ssrc, stream) in incomingStreamsBySSRC { + stream.playerNode.stop() + if audioEngine.attachedNodes.contains(stream.playerNode) { + audioEngine.detach(stream.playerNode) + } + print("[Voice] Removed incoming stream for ssrc=\(ssrc) (cleanup)") + } + incomingStreamsBySSRC.removeAll() + + dummyPlayerNode.stop() + if audioEngine.attachedNodes.contains(dummyPlayerNode) { + audioEngine.detach(dummyPlayerNode) + } + dummyNodeAttached = false + audioEngine.stop() audioEngine.reset() - if audioEngine.attachedNodes.contains(playerNode) { - audioEngine.detach(playerNode) - } print("[Voice] Audio engine stopped") } } From 12ab4dee15e6565ac095fe033dacfd7e64b9e66b Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sat, 7 Mar 2026 14:52:47 +0000 Subject: [PATCH 33/66] fix missing sendable payload --- PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift index b9513b34..9bad4c30 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift @@ -300,6 +300,8 @@ public struct VoiceGateway: Sendable, Codable { 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." From 3b6303f471691ff8346caca9b024e48f5b1fd233 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sat, 7 Mar 2026 15:43:39 +0000 Subject: [PATCH 34/66] add audio receive buffer --- .../DiscordVoice/VoiceGatewayManager.swift | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index 168f4311..e19b33af 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -133,6 +133,8 @@ public actor VoiceGatewayManager { private var pendingOpusFrames: [Data] = [] private var channelDrainTask: Task? + 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 @@ -405,6 +407,11 @@ public actor VoiceGatewayManager { 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, @@ -919,7 +926,63 @@ public actor VoiceGatewayManager { // modify packet payload to be decrypted frame, easier for application to use with bundled ssrc and other data. packet.payload = ByteBuffer(data: data) - await incomingAudioChannel.send(packet) + + 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 @@ -1040,6 +1103,10 @@ public actor VoiceGatewayManager { self.channelDrainTask?.cancel() self.udpConnection = nil self.nextSpeakingPayload = nil + + self.recvBuffer = ReceiveBuffer(targetFrames: 5, maxFrames: 50) + self.knownSSRCs = [:] + self.didEmitSpeaking = [:] } } From e99099796c679777e727ee151d475a69db1a7aa2 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 11 Mar 2026 00:22:49 +0000 Subject: [PATCH 35/66] fix encryption nonce, add additional data to chacha --- .../Sources/DiscordModels/Types/RTP/RTP.swift | 3 ++- .../Sources/DiscordVoice/CryptoExtensions.swift | 15 ++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift b/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift index 68b04d62..ba117417 100644 --- a/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift +++ b/PaicordLib/Sources/DiscordModels/Types/RTP/RTP.swift @@ -153,6 +153,7 @@ public struct RTPPacket: Sendable, RawRepresentable { public var payload: ByteBuffer public init( + `extension`: Bool, payloadType: RTPType, sequence: UInt16, timestamp: UInt32, @@ -162,7 +163,7 @@ public struct RTPPacket: Sendable, RawRepresentable { ) { self.version = 2 self.padding = false - self.extension = false + self.extension = `extension` self.marker = marker self.payloadType = payloadType self.sequence = sequence diff --git a/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift index de144106..3810425f 100644 --- a/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift +++ b/PaicordLib/Sources/DiscordVoice/CryptoExtensions.swift @@ -79,14 +79,11 @@ extension VoiceGateway.EncryptionMode { ) -> (ciphertext: Data, tag: Data, nonceSuffix: Data)? { let nonceSuffixValue: UInt32 = sequence ?? .random(in: .min ... .max) - var beNonceSuffix = nonceSuffixValue.bigEndian - let nonceSuffix = withUnsafeBytes(of: &beNonceSuffix) { Data($0) } + var leNonceSuffix = nonceSuffixValue.littleEndian + let nonceSuffix = withUnsafeBytes(of: &leNonceSuffix) { Data($0) } - var nonceData = Data(repeating: 0, count: nonceLength) - nonceData.replaceSubrange( - nonceData.count - nonceSuffix.count.. Date: Wed, 11 Mar 2026 15:15:59 +0000 Subject: [PATCH 36/66] paicordlib add dave ssrc codec info, better buffering impl, get rid of bad concurrent code --- .../Utilities/Audio/PCMFloatRingBuffer.swift | 84 +++++++++++++ .../DiscordVoice/VoiceGatewayManager.swift | 111 +++++++++++------- 2 files changed, 152 insertions(+), 43 deletions(-) create mode 100644 Paicord/Utilities/Audio/PCMFloatRingBuffer.swift diff --git a/Paicord/Utilities/Audio/PCMFloatRingBuffer.swift b/Paicord/Utilities/Audio/PCMFloatRingBuffer.swift new file mode 100644 index 00000000..43becc5a --- /dev/null +++ b/Paicord/Utilities/Audio/PCMFloatRingBuffer.swift @@ -0,0 +1,84 @@ +// +// PCMFloatRingBuffer.swift +// Paicord +// +// Created by Lakhan Lothiyi on 11/03/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import AVFoundation + +/// Efficient ring buffer for PCM float data from microphone. +struct PCMFloatRingBuffer { + let channels: Int + let capacityFrames: Int + private var storage: [[Float]] + private var readIndex: Int = 0 + private var writeIndex: Int = 0 + private(set) var availableFrames: Int = 0 + + init(channels: Int, capacityFrames: Int) { + self.channels = channels + self.capacityFrames = capacityFrames + self.storage = (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..? private var udpSpeakingTask: Task? - private var pendingOpusFrames: [Data] = [] - private var channelDrainTask: Task? + private var pendingOpusFrames = OpusFrameRing(capacity: 5) private var recvBuffer = ReceiveBuffer(targetFrames: 5, maxFrames: 50) @@ -152,8 +151,6 @@ public actor VoiceGatewayManager { })?.key ?? 0 } - /// Outgoing channel only takes Opus frames, rtp packet forming and encryption is handled automatically - private let outgoingAudioChannel = AsyncChannel() /// Incoming channel for you to iterate over. The payload is modified to contain a decrypted Opus frame. public let incomingAudioChannel = AsyncChannel() @@ -373,7 +370,7 @@ public actor VoiceGatewayManager { ) 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): @@ -440,6 +437,8 @@ public actor VoiceGatewayManager { } } + // MARK: - UDP setup + func setupUDP(_ payload: VoiceGateway.Ready) { self.udpConnectionTask = Task { do { @@ -516,12 +515,47 @@ public actor VoiceGatewayManager { 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 ) { - startDrainingOutgoingChannel() guard let mode = description.mode, VoiceGateway.EncryptionMode.supportedCases.contains(mode) else { @@ -561,12 +595,7 @@ public actor VoiceGatewayManager { while !Task.isCancelled { let start = clock.now - let frame: Data? - if pendingOpusFrames.isEmpty { - frame = nil - } else { - frame = pendingOpusFrames.removeFirst() - } + let frame = pendingOpusFrames.pop() if let frame { if let payload = self.nextSpeakingPayload { @@ -590,24 +619,28 @@ public actor VoiceGatewayManager { } } - await sendOpusPacket( - frame: frame, - sequence: sequence, - timestamp: timestamp, - ssrc: .init(audioSSRC), - mode: mode, - key: key - ) + Task { + await sendOpusPacket( + frame: frame, + sequence: sequence, + timestamp: timestamp, + ssrc: .init(audioSSRC), + mode: mode, + key: key + ) + } silenceFramesRemaining = silenceTailCount } else if silenceFramesRemaining > 0 { - await sendSilence( - sequence: sequence, - timestamp: timestamp, - ssrc: .init(audioSSRC), - mode: mode, - key: key - ) + Task { + await sendSilence( + sequence: sequence, + timestamp: timestamp, + ssrc: .init(audioSSRC), + mode: mode, + key: key + ) + } silenceFramesRemaining -= 1 if silenceFramesRemaining == 0 { @@ -628,8 +661,6 @@ public actor VoiceGatewayManager { } } else { - timestamp &+= 960 - sequence &+= 1 try? await clock.sleep(until: start + interval) continue } @@ -650,7 +681,7 @@ public actor VoiceGatewayManager { ) async { // Discord Opus silence frame let silence = Data([0xF8, 0xFF, 0xFE]) - + await sendOpusPacket( frame: silence, sequence: sequence, @@ -661,18 +692,6 @@ public actor VoiceGatewayManager { ) } - private func startDrainingOutgoingChannel() { - channelDrainTask = Task { - for await frame in outgoingAudioChannel { - pendingOpusFrames.append(frame) - - if pendingOpusFrames.count > 5 { - pendingOpusFrames.removeFirst() - } - } - } - } - func sendOpusPacket( frame: Data, sequence: UInt16, @@ -684,6 +703,7 @@ public actor VoiceGatewayManager { guard let udpConnection = self.udpConnection else { return } let headerPacket = RTPPacket( + extension: false, payloadType: .dynamic(.init(VoiceGateway.Codec.opusCodec.payload_type)), sequence: sequence, timestamp: timestamp, @@ -739,6 +759,12 @@ public actor VoiceGatewayManager { ) } } + + public func enqueueOpusFrame(_ frame: Data) { + pendingOpusFrames.push(frame) + } + + // MARK: - Listening /// Start listening for incoming audio packets on the UDP connection. private func listen( @@ -1100,7 +1126,6 @@ public actor VoiceGatewayManager { self.udpConnectionTask?.cancel() self.udpListeningTask?.cancel() self.udpSpeakingTask?.cancel() - self.channelDrainTask?.cancel() self.udpConnection = nil self.nextSpeakingPayload = nil From c182f07091ade24f7d08c80ce09e1dff4730f3ec Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 11 Mar 2026 15:42:56 +0000 Subject: [PATCH 37/66] working bidirectional audio --- Paicord.xcodeproj/project.pbxproj | 20 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- Paicord/Resources/Localizable.xcstrings | 6 + Paicord/Stores/VoiceConnectionStore.swift | 423 ++++++++++++++---- Paicord/macOS/Sidebar/ProfileBar.swift | 60 ++- .../DiscordVoice/VoiceGatewayManager.swift | 2 +- 6 files changed, 417 insertions(+), 96 deletions(-) diff --git a/Paicord.xcodeproj/project.pbxproj b/Paicord.xcodeproj/project.pbxproj index 9d6c759d..d9b64c98 100644 --- a/Paicord.xcodeproj/project.pbxproj +++ b/Paicord.xcodeproj/project.pbxproj @@ -63,6 +63,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 */; }; @@ -216,6 +217,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 = ""; }; @@ -371,6 +373,7 @@ isa = PBXGroup; children = ( AA2F51C22EE4ABCA00F18DB7 /* Storage.swift */, + AA2610F82F6191300078A870 /* Audio */, AA078FC52EC80D2100EDFFA8 /* LocalConsoleManager */, AA409ABD2EC74F3100848045 /* Theming */, AABED5A72E7FF970005BDD63 /* PaicordLib++ */, @@ -641,6 +644,14 @@ path = Profiles; sourceTree = ""; }; + AA2610F82F6191300078A870 /* Audio */ = { + isa = PBXGroup; + children = ( + AA2610F62F6191280078A870 /* PCMFloatRingBuffer.swift */, + ); + path = Audio; + sourceTree = ""; + }; AA36D5A52EB60F23006612F8 /* Input */ = { isa = PBXGroup; children = ( @@ -1132,6 +1143,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 */, @@ -1398,10 +1410,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; @@ -1462,10 +1474,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; diff --git a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved index 244c3d43..0f0a56a1 100644 --- a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -79,7 +79,7 @@ "location" : "https://github.com/llsc12/DaveKit.git", "state" : { "branch" : "main", - "revision" : "65bb91b796ab6f0edf12331a158dd3fb9af44c8d" + "revision" : "3ec5186503c2b92b19d850705e1386ae49588ed1" } }, { diff --git a/Paicord/Resources/Localizable.xcstrings b/Paicord/Resources/Localizable.xcstrings index 2ffebe0f..365d2ec3 100644 --- a/Paicord/Resources/Localizable.xcstrings +++ b/Paicord/Resources/Localizable.xcstrings @@ -226,6 +226,9 @@ }, "Mention" : { + }, + "Microphone Unavailable" : { + }, "Multi-Factor Authentication" : { @@ -259,6 +262,9 @@ }, "Playgrounds" : { + }, + "Please allow microphone access in your system settings to unmute yourself in voice channels." : { + }, "Profile" : { diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 0f6de48b..a5b59b11 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -7,6 +7,7 @@ // import AVFoundation +import AsyncAlgorithms import Foundation import Opus import PaicordLib @@ -19,6 +20,12 @@ final class VoiceConnectionStore: DiscordDataStore { format: Self.opusFormat, application: .voip ) + + if AVAudioApplication.shared.recordPermission == .granted { + self.isMuted = false + } else { + self.isMuted = true + } } var gateway: GatewayStore? @@ -29,16 +36,10 @@ final class VoiceConnectionStore: DiscordDataStore { // trigger audio engine setup audioEngineSetup() } else { - self.voiceEventTask?.cancel() - self.voiceErrorEventTask?.cancel() - // shutdown audio engine and release resources, also set status to stopped - voiceStatus = .stopped - audioEngineCleanup() + cancelVoiceEventHandling() - // clear up state, reinit encoder. - try? self.opusEncoder.reset() - self.usersSpeakingState.removeAll() - self.knownSSRCs.removeAll() + // shutdown audio engine and release resources + audioEngineCleanup() } } } @@ -55,6 +56,8 @@ final class VoiceConnectionStore: DiscordDataStore { switch event.data { case .ready(let payload): handleReady(payload) + case .resume(let payload): + handleResume(payload) case .voiceServerUpdate(let payload): handleVoiceServerUpdate(payload) // capture and store voice events @@ -88,18 +91,13 @@ final class VoiceConnectionStore: DiscordDataStore { // MARK: - State // our own voice state stuff - private var channelId: ChannelSnowflake? - private var guildId: GuildSnowflake? - private var isMuted: Bool = false - private var isDeafened: Bool = false - private var isVideoEnabled: Bool = false - private var preferredRegion: String? - private var flags: IntBitField = [] - - // 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 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 var voiceStatus: GatewayState = .stopped { didSet { @@ -165,12 +163,20 @@ final class VoiceConnectionStore: DiscordDataStore { isDeafened: Bool? = nil, isVideoEnabled: Bool? = nil ) async { - if let isMuted = isMuted { self.isMuted = isMuted } + 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, @@ -188,7 +194,6 @@ final class VoiceConnectionStore: DiscordDataStore { // MARK: - Event handling private func handleReady(_ payload: Gateway.Ready) { - // send voice states, temporary until paicord has proper voice handling Task { await gateway?.gateway?.updateVoiceState( payload: .init( @@ -199,7 +204,24 @@ final class VoiceConnectionStore: DiscordDataStore { self_video: false, preferred_region: self.preferredRegion, preferred_regions: nil, - flags: [] + 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 ) ) } @@ -217,8 +239,10 @@ final class VoiceConnectionStore: DiscordDataStore { print( "[Voice] Received voice server update with empty endpoint, disconnecting from voice" ) - Task { await voiceGateway?.disconnect() } - voiceGateway = nil + Task { + await voiceGateway?.disconnect() + voiceGateway = nil + } return } @@ -241,6 +265,10 @@ final class VoiceConnectionStore: DiscordDataStore { } ) await self.voiceGateway?.connect() + + if AVAudioApplication.shared.recordPermission != .granted { + await AVAudioApplication.requestRecordPermission() + } } } @@ -251,7 +279,7 @@ final class VoiceConnectionStore: DiscordDataStore { let id = payload.user_id let ssrc = self.knownSSRCs.first(where: { $0.value == id })?.key if let ssrc { - await removeIncomingStreamIfPresent(ssrc: .init(ssrc)) + removeIncomingStreamIfPresent(ssrc: .init(ssrc)) } } @@ -263,19 +291,28 @@ final class VoiceConnectionStore: DiscordDataStore { self.knownSSRCs[ssrc] = id self.usersSpeakingState[id] = payload.speaking } - await ensureIncomingStreamExists(ssrc: .init(ssrc)) + 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, @@ -291,7 +328,6 @@ final class VoiceConnectionStore: DiscordDataStore { @ObservationIgnored private let opusEncoder: Opus.Encoder - // NOTE: decoder is now created per-SSRC @ObservationIgnored private let audioEngine = AVAudioEngine() @@ -328,17 +364,43 @@ final class VoiceConnectionStore: DiscordDataStore { private var incomingAudioTask: Task? = nil @ObservationIgnored - private let dummyPlayerNode = AVAudioPlayerNode() + private var outgoingAudioTask: Task? = nil @ObservationIgnored - private var dummyNodeAttached: Bool = false + 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 private func audioEngineSetup() { - self.audioEngineCleanup() + Task { + self.audioEngineCleanup() - Task { @MainActor in + /// avaudioengine will throw a c++ exception if you start it when `inputNode == nullptr || outputNode == nullptr`. self.ensureDummyNodeAttached() + /// microphone tap + self.setupTap() + do { audioEngine.prepare() try audioEngine.start() @@ -346,60 +408,238 @@ final class VoiceConnectionStore: DiscordDataStore { 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..() - guard let voiceGateway = self.voiceGateway else { return } + private func setupTap() { + let targetFormat = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: .opus48khz, + channels: 2, + interleaved: false + )! - incomingAudioTask = Task.detached(priority: .userInitiated) { [weak self] in - guard let self else { return } + let opusFrameCount: AVAudioFrameCount = 960 + let opusFrameSize = Int(opusFrameCount) - for await rtpPacket in await voiceGateway.incomingAudioChannel { - if Task.isCancelled { break } + let inputFormat = inputNode.inputFormat(forBus: 0) + let converter = AVAudioConverter(from: inputFormat, to: targetFormat)! + + var ring = PCMFloatRingBuffer( + channels: Int(targetFormat.channelCount), + capacityFrames: opusFrameSize * 8 + ) - let ssrc = rtpPacket.ssrc - self.ensureIncomingStreamExists(ssrc: ssrc) + let tapFormat = inputNode.inputFormat(forBus: 0) + print("[Voice] Installing tap with format:", tapFormat) + + inputNode.installTap( + onBus: 0, + bufferSize: opusFrameCount, + format: tapFormat + ) { + buffer, + _ in + guard buffer.frameLength > 0 else { return } + + let outCapacity = max( + opusFrameCount * 4, + AVAudioFrameCount(Double(buffer.frameLength) * 2.0) + ) + guard + let converted = AVAudioPCMBuffer( + pcmFormat: targetFormat, + frameCapacity: outCapacity + ) + else { + return + } + var error: NSError? = nil + var fedInput = false + let status = converter.convert(to: converted, error: &error) { + _, + outStatus in + if fedInput { + outStatus.pointee = .noDataNow + return nil + } else { + fedInput = true + outStatus.pointee = .haveData + return buffer + } + } + + guard error == nil else { return } + guard status == .haveData || status == .inputRanDry else { return } + guard converted.frameLength > 0 else { return } + guard let src = converted.floatChannelData else { return } + + ring.write( + from: src, + frames: Int(converted.frameLength), + srcChannels: Int(converted.format.channelCount) + ) + + while ring.availableFrames >= opusFrameSize { guard - let stream = self.incomingStreamsBySSRC[ssrc] + let chunk = AVAudioPCMBuffer( + pcmFormat: Self.pcmFormat, + frameCapacity: opusFrameCount + ) else { - continue + break } + chunk.frameLength = opusFrameCount + guard let dst = chunk.floatChannelData else { break } - do { - let opusFrame = rtpPacket.payload - let decoded = try stream.decoder.decode( - .init(buffer: opusFrame, byteTransferStrategy: .noCopy) - ) + guard ring.read(into: dst, frames: opusFrameSize) else { break } + Task { await self.micChannel.send(chunk) } + } + } - // 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.. 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..? private var udpSpeakingTask: Task? - private var pendingOpusFrames = OpusFrameRing(capacity: 5) + private var pendingOpusFrames = OpusFrameRing(capacity: 10) private var recvBuffer = ReceiveBuffer(targetFrames: 5, maxFrames: 50) From bde09a457fb0ffa67950c9c450cc0853faed980c Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 11 Mar 2026 21:41:31 +0000 Subject: [PATCH 38/66] buffer per incoming stream idk it helps --- Paicord/Stores/VoiceConnectionStore.swift | 32 ++++++++++++++++++- .../DiscordVoice/VoiceGatewayManager.swift | 14 +++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index a5b59b11..37230a45 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -345,6 +345,7 @@ final class VoiceConnectionStore: DiscordDataStore { let ssrc: UInt32 let decoder: Opus.Decoder let playerNode: AVAudioPlayerNode + let scheduler: PlayerScheduler init(ssrc: UInt32) { self.ssrc = ssrc @@ -354,6 +355,35 @@ final class VoiceConnectionStore: DiscordDataStore { application: .voip ) self.playerNode = AVAudioPlayerNode() + self.scheduler = PlayerScheduler(node: self.playerNode) + } + } + + 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() + } } } @@ -457,7 +487,7 @@ final class VoiceConnectionStore: DiscordDataStore { // awaiting causes stuttering bc callback happens when buffer finishes // playing and the node runs dry. Task { - await stream.playerNode.scheduleBuffer(converted) + await stream.scheduler.enqueue(converted) } } catch { print("[Voice OPUS] Frame decode error for ssrc \(ssrc):", error) diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index d69988d2..835b9640 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -438,7 +438,7 @@ public actor VoiceGatewayManager { } // MARK: - UDP setup - + func setupUDP(_ payload: VoiceGateway.Ready) { self.udpConnectionTask = Task { do { @@ -515,9 +515,9 @@ public actor VoiceGatewayManager { private func storeConnection(_ connection: VoiceConnection) { self.udpConnection = connection } - + // MARK: - Speaking - + private struct OpusFrameRing { private var buf: [Data?] private var head = 0 @@ -619,6 +619,8 @@ public actor VoiceGatewayManager { } } + let sequence = sequence + let timestamp = timestamp Task { await sendOpusPacket( frame: frame, @@ -632,6 +634,8 @@ public actor VoiceGatewayManager { silenceFramesRemaining = silenceTailCount } else if silenceFramesRemaining > 0 { + let sequence = sequence + let timestamp = timestamp Task { await sendSilence( sequence: sequence, @@ -681,7 +685,7 @@ public actor VoiceGatewayManager { ) async { // Discord Opus silence frame let silence = Data([0xF8, 0xFF, 0xFE]) - + await sendOpusPacket( frame: silence, sequence: sequence, @@ -759,7 +763,7 @@ public actor VoiceGatewayManager { ) } } - + public func enqueueOpusFrame(_ frame: Data) { pendingOpusFrames.push(frame) } From 5fe71f938c365a7506ec30d5c0f11d9ae9549a8b Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 11 Mar 2026 22:19:41 +0000 Subject: [PATCH 39/66] fix changing channels whilst in a channel --- Paicord/Stores/VoiceConnectionStore.swift | 10 ++++++++++ Paicord/Utilities/PaicordLib++/Conformances.swift | 2 -- PaicordLib/Sources/DiscordModels/Types/Gateway.swift | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 37230a45..3fd33550 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -108,6 +108,11 @@ final class VoiceConnectionStore: DiscordDataStore { // 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 @@ -357,6 +362,11 @@ final class VoiceConnectionStore: DiscordDataStore { self.playerNode = AVAudioPlayerNode() self.scheduler = PlayerScheduler(node: self.playerNode) } + + deinit { + playerNode.stop() + try? decoder.reset() + } } private actor PlayerScheduler { diff --git a/Paicord/Utilities/PaicordLib++/Conformances.swift b/Paicord/Utilities/PaicordLib++/Conformances.swift index d5990026..8d4324a9 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 { diff --git a/PaicordLib/Sources/DiscordModels/Types/Gateway.swift b/PaicordLib/Sources/DiscordModels/Types/Gateway.swift index d9357dc7..628d3269 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Gateway.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Gateway.swift @@ -428,7 +428,7 @@ public struct Gateway: Sendable, Codable { switch self { case .unhandledDispatchEvent(let type): return - "Gateway.Event.GatewayDecodingError.unhandledDispatchEvent(type: \(type ?? "nil"))" + "Gateway.Event.GatewayDecodingError.unhandledDispatchEvent(type: \(type ?? "nil"))" } } } From 7f7623f21cfe3a9a977060ba5add8e6792f0ed85 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Thu, 12 Mar 2026 13:11:31 +0000 Subject: [PATCH 40/66] Update VoiceConnectionStore.swift --- Paicord/Stores/VoiceConnectionStore.swift | 81 ++++++++++++----------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 3fd33550..fea7b440 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -26,6 +26,21 @@ final class VoiceConnectionStore: DiscordDataStore { } 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? @@ -431,18 +446,37 @@ final class VoiceConnectionStore: DiscordDataStore { // 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.voiceProcessingOtherAudioDuckingConfiguration = .init( + enableAdvancedDucking: true, + duckingLevel: .max + ) + inputNode.isVoiceProcessingAGCEnabled = true + inputNode.isVoiceProcessingBypassed = false + inputNode.isVoiceProcessingInputMuted = false + + } catch { + print("[Voice] Failed to enable voice processing mode:", error) + } + do { - audioEngine.prepare() try audioEngine.start() } catch { print("[Voice] Failed to start audio engine:", error) @@ -521,9 +555,6 @@ final class VoiceConnectionStore: DiscordDataStore { let opusFrameCount: AVAudioFrameCount = 960 let opusFrameSize = Int(opusFrameCount) - let inputFormat = inputNode.inputFormat(forBus: 0) - let converter = AVAudioConverter(from: inputFormat, to: targetFormat)! - var ring = PCMFloatRingBuffer( channels: Int(targetFormat.channelCount), capacityFrames: opusFrameSize * 8 @@ -532,6 +563,7 @@ final class VoiceConnectionStore: DiscordDataStore { let tapFormat = inputNode.inputFormat(forBus: 0) print("[Voice] Installing tap with format:", tapFormat) + // print graph description inputNode.installTap( onBus: 0, bufferSize: opusFrameCount, @@ -539,45 +571,14 @@ final class VoiceConnectionStore: DiscordDataStore { ) { buffer, _ in + print("received mic buffer") guard buffer.frameLength > 0 else { return } - - let outCapacity = max( - opusFrameCount * 4, - AVAudioFrameCount(Double(buffer.frameLength) * 2.0) - ) - guard - let converted = AVAudioPCMBuffer( - pcmFormat: targetFormat, - frameCapacity: outCapacity - ) - else { - return - } - - var error: NSError? = nil - var fedInput = false - let status = converter.convert(to: converted, error: &error) { - _, - outStatus in - if fedInput { - outStatus.pointee = .noDataNow - return nil - } else { - fedInput = true - outStatus.pointee = .haveData - return buffer - } - } - - guard error == nil else { return } - guard status == .haveData || status == .inputRanDry else { return } - guard converted.frameLength > 0 else { return } - guard let src = converted.floatChannelData else { return } + guard let src = buffer.floatChannelData else { return } ring.write( from: src, - frames: Int(converted.frameLength), - srcChannels: Int(converted.format.channelCount) + frames: Int(buffer.frameLength), + srcChannels: Int(buffer.format.channelCount) ) while ring.availableFrames >= opusFrameSize { From 42ca30e245bd38ad70225782152c5d5525586b18 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Thu, 12 Mar 2026 20:01:50 +0000 Subject: [PATCH 41/66] disconnect on another client connecting --- Paicord/Stores/VoiceConnectionStore.swift | 26 +++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index fea7b440..084bfaef 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -75,7 +75,8 @@ final class VoiceConnectionStore: DiscordDataStore { handleResume(payload) case .voiceServerUpdate(let payload): handleVoiceServerUpdate(payload) - // capture and store voice events + case .voiceStateUpdate(let payload): + handleVoiceStateUpdate(payload) default: break } @@ -114,7 +115,7 @@ final class VoiceConnectionStore: DiscordDataStore { private(set) var preferredRegion: String? private(set) var flags: IntBitField = [] - private var voiceStatus: GatewayState = .stopped { + private(set) var voiceStatus: GatewayState = .stopped { didSet { print("[Voice] Voice connection status changed to \(voiceStatus)") } @@ -291,6 +292,27 @@ final class VoiceConnectionStore: DiscordDataStore { } } } + + private func handleVoiceStateUpdate(_ payload: VoiceState) { + // if we receie 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: + // - session id isnt ours + // - guild id matches the guild id of current voice connection + + Task { + let vSessionID = payload.session_id + let vGuildID = payload.guild_id + + if self.guildId == vGuildID, await self.gateway?.gateway?.getSessionID() != vSessionID { + print("[Voice] Another client made this clientth disconnect") + await voiceGateway?.disconnect() + voiceGateway = nil + } + } + } private func handleClientDisconnect(_ payload: VoiceGateway.ClientDisconnect) async From 8b1e6ff2f85b0c3091155e706f8b2f00b6963b3c Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Thu, 12 Mar 2026 20:02:16 +0000 Subject: [PATCH 42/66] fix member row popout --- .../Common/Member Sidebar/MemberRowView.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) 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() ) } } From 6f758a5d4b66ef4fe6081c5ad7a2a67020430c73 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Thu, 12 Mar 2026 20:03:35 +0000 Subject: [PATCH 43/66] add voice states to guild --- Paicord/Utilities/PaicordLib++/Conformances.swift | 7 +++++++ PaicordLib/Sources/DiscordModels/Types/Guild.swift | 6 +++++- PaicordLib/Sources/DiscordModels/Types/Voice.swift | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Paicord/Utilities/PaicordLib++/Conformances.swift b/Paicord/Utilities/PaicordLib++/Conformances.swift index 8d4324a9..4c81a053 100644 --- a/Paicord/Utilities/PaicordLib++/Conformances.swift +++ b/Paicord/Utilities/PaicordLib++/Conformances.swift @@ -63,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/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/Voice.swift b/PaicordLib/Sources/DiscordModels/Types/Voice.swift index e2e11124..b2e506a0 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Voice.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Voice.swift @@ -1,5 +1,5 @@ /// https://discord.com/developers/docs/resources/voice#voice-state-object-voice-state-structure -public struct VoiceState: Sendable, Codable { +public struct VoiceState: Sendable, Codable, Equatable, Hashable { public var guild_id: GuildSnowflake? public var channel_id: ChannelSnowflake? public var user_id: UserSnowflake From 7cdc42625631e5f18f524f14d4360f722b8eaa39 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Thu, 12 Mar 2026 20:04:45 +0000 Subject: [PATCH 44/66] expose guild and channel stores, voice channel members, call bar --- Paicord.xcodeproj/project.pbxproj | 4 + Paicord/Common/Guilds/ChannelButton.swift | 132 ++++- Paicord/Stores/GatewayStore.swift | 35 +- Paicord/Stores/VoiceChannelsStore.swift | 116 ++++ Paicord/macOS/Sidebar/ProfileBar.swift | 629 ++++++++++++---------- 5 files changed, 591 insertions(+), 325 deletions(-) create mode 100644 Paicord/Stores/VoiceChannelsStore.swift diff --git a/Paicord.xcodeproj/project.pbxproj b/Paicord.xcodeproj/project.pbxproj index d9b64c98..0b7d5f5e 100644 --- a/Paicord.xcodeproj/project.pbxproj +++ b/Paicord.xcodeproj/project.pbxproj @@ -113,6 +113,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 */; }; @@ -260,6 +261,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 = ""; }; @@ -995,6 +997,7 @@ AABED5A32E7F4DAE005BDD63 /* GuildStore.swift */, AABED59D2E7F4637005BDD63 /* ChannelStore.swift */, AA7B38F32EB50EFD00CA4A3C /* MessageDrainStore.swift */, + AA8CC3D02F62F5E800BFB9B2 /* VoiceChannelsStore.swift */, AAFBC52D2F4C945F00C5B644 /* VoiceConnectionStore.swift */, AABED5A52E7F5148005BDD63 /* SettingsStore.swift */, AAAF797F2ED1E9ED004B5B3F /* ExternalBadgeStore.swift */, @@ -1245,6 +1248,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 */, diff --git a/Paicord/Common/Guilds/ChannelButton.swift b/Paicord/Common/Guilds/ChannelButton.swift index 09ee47f6..c5b7445d 100644 --- a/Paicord/Common/Guilds/ChannelButton.swift +++ b/Paicord/Common/Guilds/ChannelButton.swift @@ -293,6 +293,82 @@ struct ChannelButton: View { } } + struct VoiceChannelUsers: View { + @Environment(\.gateway) var gw + @Environment(\.appState) var appState + var channel: DiscordChannel + + var body: some View { + let voiceChannels = gw.voiceChannels + if let voiceStates = voiceChannels.voiceStates[appState.selectedGuild]?[ + channel.id + ] { + ForEach(voiceStates.values) { state in + UserButton(state: state) + } + } + } + + struct UserButton: View { + var state: VoiceState + @Environment(\.guildStore) var guildStore + @Environment(\.gateway) var gw + + @State var isHovered = false + @State var showPopover = false + var body: some View { + let member = state.member ?? guildStore?.members[state.user_id] + let user = + state.member?.user?.toPartialUser() ?? gw.user.users[state.user_id] + Button { + if user != nil { + showPopover.toggle() + } + } label: { + HStack { + Profile.AvatarWithPresence( + member: member, + user: user + ) + .profileShowsAvatarDecoration() + .frame(maxWidth: 20, maxHeight: 20) + + Text( + state.member?.nick ?? user?.global_name ?? user?.username + ?? "Unknown User" + ) + .lineLimit(1) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background( + Group { + if isHovered { + Color.gray.opacity(0.2) + } else { + Color.clear + } + } + .clipShape(.rounded) + ) + } + .buttonStyle(.borderless) + .onHover { isHovered = $0 } + .popover(isPresented: $showPopover) { + if let user { + ProfilePopoutView( + guild: guildStore, + member: member, + user: user + ) + } + } + } + } + } + /// Button that triggers voice channel actions. @ViewBuilder func voiceChannelButton( @@ -300,33 +376,39 @@ struct ChannelButton: View { ) -> some View { - 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 + VStack(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 == channel.id { - Color.gray.opacity(0.13) - } else { - Color.clear + .clipShape(.rounded) + ) + .background( + Group { + if appState.selectedChannel == channel.id { + Color.gray.opacity(0.13) + } else { + Color.clear + } } - } - .clipShape(.rounded) - ) + .clipShape(.rounded) + ) + } + + VoiceChannelUsers(channel: channel) + .padding(.leading, 32) + .padding(.bottom, 4) } } diff --git a/Paicord/Stores/GatewayStore.swift b/Paicord/Stores/GatewayStore.swift index 015284ce..b057484a 100644 --- a/Paicord/Stores/GatewayStore.swift +++ b/Paicord/Stores/GatewayStore.swift @@ -114,14 +114,15 @@ final class GatewayStore { 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) } } @@ -136,8 +137,9 @@ final class GatewayStore { switcher = .init() voice.cancelEventHandling() // cancel any ongoing voice stuff voice = .init() - channels = [:] - guilds = [:] + voiceChannels = .init() + _channels = [:] + _guilds = [:] subscribedGuilds = [] } @@ -153,24 +155,25 @@ final class GatewayStore { 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) { @@ -196,13 +199,13 @@ final class GatewayStore { } } } - 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 } } @@ -222,8 +225,8 @@ final class GatewayStore { let channelIds = PaicordAppState.instances.compactMap( \.value.selectedChannel ) - channels = channels.filter { channelIds.contains($0.key) } - if let channel = channels.values.first { + _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)." ) @@ -263,22 +266,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/VoiceChannelsStore.swift b/Paicord/Stores/VoiceChannelsStore.swift new file mode 100644 index 00000000..a6ce1fb0 --- /dev/null +++ b/Paicord/Stores/VoiceChannelsStore.swift @@ -0,0 +1,116 @@ +// +// 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]] = + [:] + + 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) + default: + break + } + } + } + } + + func handleReady(_ payload: Gateway.Ready) { + 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) + } + } + } +} diff --git a/Paicord/macOS/Sidebar/ProfileBar.swift b/Paicord/macOS/Sidebar/ProfileBar.swift index fc7a67db..ee7d4181 100644 --- a/Paicord/macOS/Sidebar/ProfileBar.swift +++ b/Paicord/macOS/Sidebar/ProfileBar.swift @@ -13,351 +13,412 @@ 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 + @State var micError = false - @State var micError = false + 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(30) - .profileAnimated(barHovered) - .profileShowsAvatarDecoration() + var body: some View { + if gw.voice.voiceGateway != nil { + VStack { + 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) + } + } + .imageScale(.large) + .frame(width: 30, height: 30) + .background(Color.black.opacity(0.2)) + .clipShape(.rect(cornerRadius: 5)) + + Button { + Task { + await vgw.updateVoiceConnection(.disconnect) + } + } label: { + // hang up call + Image(systemName: "phone.down.fill") + .font(.title2) + .padding(5) + .background(.ultraThinMaterial) + .clipShape(.circle) + } + .buttonStyle(.borderless) } - - 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)) + + HStack { + Button { + Task { + switch AVAudioApplication.shared.recordPermission { + case .granted: + await vgw.updateVoiceState(isMuted: !gw.voice.isMuted) + case .denied: + micError = true + case .undetermined: + if await AVAudioApplication.requestRecordPermission() { + await vgw.updateVoiceState(isMuted: false) } + @unknown default: + fatalError() } - - Text(status.state ?? "") - .transition(.opacity) } + } label: { + Image(systemName: vgw.isMuted ? "mic.slash.fill" : "mic.fill") + .font(.title2) + .padding(5) + .background(.ultraThinMaterial) + .clipShape(.circle) } + .buttonStyle(.borderless) + .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." + ) + } + + Button { + Task { + await vgw.updateVoiceState(isDeafened: !vgw.isDeafened) + } + } label: { + Image( + systemName: gw.voice.isDeafened + ? "speaker.slash.fill" : "speaker.wave.2.fill" + ) + .font(.title2) + .padding(5) + .background(.ultraThinMaterial) + .clipShape(.circle) + } + .buttonStyle(.borderless) } - .background(.black.opacity(0.001)) - .onHover { showingUsername = $0 } - .animation(.spring(), value: showingUsername) } + } else { + EmptyView() } - .buttonStyle(.plain) - .popover(isPresented: $showingPopover) { - ProfileButtonPopout() - } + } + } - Spacer() + struct ProfileBarSection: View { + @Environment(\.gateway) var gw + #if os(macOS) + @Environment(\.openWindow) var openWindow + #endif - if gw.voice.voiceGateway != nil { + @State var showingUsername = false + @State var showingPopover = false + @State var barHovered = false + + var body: some View { + HStack { Button { - Task { - await gw.voice.updateVoiceConnection(.disconnect) - } + showingPopover.toggle() } label: { - // hang up call - Image(systemName: "phone.down.fill") - .font(.title2) - .padding(5) - .background(.ultraThinMaterial) - .clipShape(.circle) - } - .buttonStyle(.borderless) - - Button { - Task { - switch AVAudioApplication.shared.recordPermission { - case .granted: - await gw.voice.updateVoiceState(isMuted: !gw.voice.isMuted) - case .denied: - micError = true - case .undetermined: - if await AVAudioApplication.requestRecordPermission() { - await gw.voice.updateVoiceState(isMuted: false) + 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) + } } - @unknown default: - fatalError() } + .background(.black.opacity(0.001)) + .onHover { showingUsername = $0 } + .animation(.spring(), value: showingUsername) } - } label: { - Image(systemName: gw.voice.isMuted ? "mic.slash.fill" : "mic.fill") - .font(.title2) - .padding(5) - .background(.ultraThinMaterial) - .clipShape(.circle) } - .buttonStyle(.borderless) - .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." - ) + .buttonStyle(.plain) + .popover(isPresented: $showingPopover) { + ProfileButtonPopout() } - - Button { - Task { - await gw.voice.updateVoiceState(isDeafened: !gw.voice.isDeafened) + + Spacer() + + #if os(macOS) + Button { + openWindow(id: "settings") + } label: { + Image(systemName: "gearshape.fill") + .font(.title2) + .padding(5) + .background(.ultraThinMaterial) + .clipShape(.circle) } - } label: { - Image(systemName: gw.voice.isDeafened ? "speaker.slash.fill" : "speaker.wave.2.fill") - .font(.title2) - .padding(5) - .background(.ultraThinMaterial) - .clipShape(.circle) - } - .buttonStyle(.borderless) + .buttonStyle(.borderless) + #elseif os(iOS) + /// targetting ipad here, ios wouldnt have this at all + // do something + #endif } - - #if os(macOS) - Button { - openWindow(id: "settings") - } label: { - Image(systemName: "gearshape.fill") - .font(.title2) - .padding(5) - .background(.ultraThinMaterial) - .clipShape(.circle) + .padding(8) + .background { + if let nameplate = gw.user.currentUser?.collectibles?.nameplate { + Profile.NameplateView(nameplate: nameplate) + .nameplateAnimated(barHovered) + .saturation(0.9) + .brightness(0.1) } - .buttonStyle(.borderless) - #elseif os(iOS) - /// targetting ipad here, ios wouldnt have this at all - // do something - #endif - } - .padding(8) - .background { - if let nameplate = gw.user.currentUser?.collectibles?.nameplate { - Profile.NameplateView(nameplate: nameplate) - .nameplateAnimated(barHovered) - .saturation(0.9) - .brightness(0.1) } + .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" + ) } - .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 { + @Environment(\.gateway) var gw + @Environment(\.appState) var appState + @State var statusSelectionExpanded = false + @State var accountSelectionExpanded = false - 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() + } - 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) - VStack(alignment: .leading) { - Text( - gw.user.currentUser?.global_name ?? gw.user.currentUser?.username - ?? "Unknown User" - ) - .bold() - Text( - verbatim: "@\(gw.user.currentUser?.username ?? "Unknown User")" - ) + NavigationLink(value: "gm") { + Label("Edit Profile", systemImage: "pencil") + .padding(.vertical, 4) } - } - .padding(.vertical, 5) + .disabled(true) - NavigationLink(value: "gm") { - Label("Edit Profile", systemImage: "pencil") - .padding(.vertical, 4) - } - .disabled(true) - - DisclosureGroup(isExpanded: $statusSelectionExpanded) { - let statuses: [Gateway.Status] = [ - .online, - .afk, - .doNotDisturb, - .invisible, - ] + DisclosureGroup(isExpanded: $statusSelectionExpanded) { + let statuses: [Gateway.Status] = [ + .online, + .afk, + .doNotDisturb, + .invisible, + ] - ForEach(statuses, id: \.self) { status in - AsyncButton { - } catch: { error in - appState.error = error + 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) } } } - } From ddd41609781a58b58b50e83760aba5f1282b0e7e Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Thu, 12 Mar 2026 21:58:41 +0000 Subject: [PATCH 45/66] request member data need to switch to using ready supplemental --- Paicord/Stores/ChannelStore.swift | 4 ++- Paicord/Stores/GatewayStore.swift | 5 +-- Paicord/Stores/GuildStore.swift | 56 +++++++++++++++++++++++++++-- Paicord/Stores/ReadStateStore.swift | 6 ---- 4 files changed, 58 insertions(+), 13 deletions(-) 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 b057484a..e0117e60 100644 --- a/Paicord/Stores/GatewayStore.swift +++ b/Paicord/Stores/GatewayStore.swift @@ -177,9 +177,6 @@ final class GatewayStore { 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( @@ -195,7 +192,7 @@ final class GatewayStore { ) ]) ) - print("[GatewayStore] Subscribed to guild \(id.rawValue)") + print("[GatewayStore] Subscribed for GuildStore \(id.rawValue)") } } } 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() { From d42b3d5f7519df43c4ef834c2b22173bc07de281 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Fri, 13 Mar 2026 00:02:00 +0000 Subject: [PATCH 46/66] fix disconnection bug, fix weird voice channel spacing --- Paicord/Common/Guilds/ChannelButton.swift | 22 ++++++++++++---------- Paicord/Common/Guilds/GuildView.swift | 2 +- Paicord/Stores/VoiceConnectionStore.swift | 14 +++++++++----- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/Paicord/Common/Guilds/ChannelButton.swift b/Paicord/Common/Guilds/ChannelButton.swift index c5b7445d..fd049693 100644 --- a/Paicord/Common/Guilds/ChannelButton.swift +++ b/Paicord/Common/Guilds/ChannelButton.swift @@ -297,23 +297,27 @@ struct ChannelButton: View { @Environment(\.gateway) var gw @Environment(\.appState) var appState var channel: DiscordChannel - + var body: some View { let voiceChannels = gw.voiceChannels if let voiceStates = voiceChannels.voiceStates[appState.selectedGuild]?[ channel.id - ] { - ForEach(voiceStates.values) { state in - UserButton(state: state) + ], !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 - + @State var isHovered = false @State var showPopover = false var body: some View { @@ -321,7 +325,7 @@ struct ChannelButton: View { let user = state.member?.user?.toPartialUser() ?? gw.user.users[state.user_id] Button { - if user != nil { + if user != nil { showPopover.toggle() } } label: { @@ -376,7 +380,7 @@ struct ChannelButton: View { ) -> some View { - VStack(spacing: 2) { + LazyVStack(spacing: 2) { VoiceChannelButton( channels: channels, channel: channel @@ -407,8 +411,6 @@ struct ChannelButton: View { } VoiceChannelUsers(channel: channel) - .padding(.leading, 32) - .padding(.bottom, 4) } } diff --git a/Paicord/Common/Guilds/GuildView.swift b/Paicord/Common/Guilds/GuildView.swift index f809cc9e..7879acaf 100644 --- a/Paicord/Common/Guilds/GuildView.swift +++ b/Paicord/Common/Guilds/GuildView.swift @@ -57,7 +57,7 @@ struct GuildView: View { } } - VStack(spacing: 1) { + LazyVStack(spacing: 1) { ForEach(uncategorizedChannels) { channel in ChannelButton(channels: guild.channels, channel: channel) .padding(.horizontal, 4) diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 084bfaef..3e377170 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -299,15 +299,20 @@ final class VoiceConnectionStore: DiscordDataStore { // from another client and we should destroy this connection. // criteria for disconnecting: + // - its a voice state update for our user id // - session id isnt ours // - guild id matches the guild id of current voice connection Task { + let vUserId = payload.user_id let vSessionID = payload.session_id let vGuildID = payload.guild_id - - if self.guildId == vGuildID, await self.gateway?.gateway?.getSessionID() != vSessionID { - print("[Voice] Another client made this clientth disconnect") + + if 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 } @@ -582,7 +587,7 @@ final class VoiceConnectionStore: DiscordDataStore { capacityFrames: opusFrameSize * 8 ) - let tapFormat = inputNode.inputFormat(forBus: 0) + let tapFormat = inputNode.outputFormat(forBus: 0) print("[Voice] Installing tap with format:", tapFormat) // print graph description @@ -593,7 +598,6 @@ final class VoiceConnectionStore: DiscordDataStore { ) { buffer, _ in - print("received mic buffer") guard buffer.frameLength > 0 else { return } guard let src = buffer.floatChannelData else { return } From 0525f9d89f528205f3c7f72988e473ac7d43da7d Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sat, 14 Mar 2026 14:06:03 +0000 Subject: [PATCH 47/66] make call bar nicer --- .../xcshareddata/swiftpm/Package.resolved | 2 +- Paicord/Common/Chat/ChannelHeader.swift | 16 +- Paicord/Common/Utilities/Profile.swift | 18 + Paicord/Resources/Localizable.xcstrings | 15 + Paicord/macOS/Sidebar/ProfileBar.swift | 358 ++++++++++++++---- 5 files changed, 339 insertions(+), 70 deletions(-) diff --git a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0f0a56a1..9c7d01bc 100644 --- a/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Paicord.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -79,7 +79,7 @@ "location" : "https://github.com/llsc12/DaveKit.git", "state" : { "branch" : "main", - "revision" : "3ec5186503c2b92b19d850705e1386ae49588ed1" + "revision" : "d532837cd3c929c0b1e8c64566b1b55ffe830948" } }, { diff --git a/Paicord/Common/Chat/ChannelHeader.swift b/Paicord/Common/Chat/ChannelHeader.swift index a9f53852..b92b7079 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: ", ") @@ -94,10 +95,15 @@ extension ChatView { 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) + 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/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/Resources/Localizable.xcstrings b/Paicord/Resources/Localizable.xcstrings index 365d2ec3..17a931cb 100644 --- a/Paicord/Resources/Localizable.xcstrings +++ b/Paicord/Resources/Localizable.xcstrings @@ -76,6 +76,9 @@ }, "Authenticator App" : { + }, + "Awaiting Audio Setup" : { + }, "Awaiting READY" : { @@ -94,6 +97,9 @@ }, "ComponentsV2 unsupported" : { + }, + "Connecting to Voice" : { + }, "Copy" : { @@ -343,6 +349,9 @@ }, "Unimplemented" : { + }, + "Unknown Channel" : { + }, "Unknown User" : { @@ -374,6 +383,12 @@ }, "View Logs" : { + }, + "Voice Connected" : { + + }, + "Voice Disconnected" : { + }, "We're so excited to see you again!" : { diff --git a/Paicord/macOS/Sidebar/ProfileBar.swift b/Paicord/macOS/Sidebar/ProfileBar.swift index ee7d4181..80d0bd53 100644 --- a/Paicord/macOS/Sidebar/ProfileBar.swift +++ b/Paicord/macOS/Sidebar/ProfileBar.swift @@ -23,13 +23,11 @@ struct ProfileBar: View { struct VoiceBarSection: View { @Environment(\.gateway) var gw - @State var micError = false - var vgw: VoiceConnectionStore { gw.voice } var body: some View { if gw.voice.voiceGateway != nil { - VStack { + VStack(spacing: 2) { HStack { Group { switch vgw.voiceStatus { @@ -42,7 +40,10 @@ struct ProfileBar: View { case .connecting: if #available(macOS 15.0, *) { Image(systemName: "wifi") - .symbolEffect(.bounce.up.byLayer, options: .repeat(.periodic(delay: 0.0))) + .symbolEffect( + .bounce.up.byLayer, + options: .repeat(.periodic(delay: 0.0)) + ) .foregroundStyle(.yellow) } else { Image(systemName: "wifi.exclamationmark") @@ -54,13 +55,66 @@ struct ProfileBar: View { 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) { + 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("Unknown Channel") + } + } + .font(.caption) + } + } + + Spacer() + Button { Task { await vgw.updateVoiceConnection(.disconnect) @@ -69,62 +123,25 @@ struct ProfileBar: View { // hang up call Image(systemName: "phone.down.fill") .font(.title2) - .padding(5) - .background(.ultraThinMaterial) - .clipShape(.circle) + .maxWidth(35) + .maxHeight(35) } - .buttonStyle(.borderless) + .buttonStyle( + .borderlessHoverEffect( + hoverColor: .red, + pressedColor: .red + ) + ) + } - + .frame(maxWidth: .infinity, alignment: .leading) HStack { - Button { - Task { - switch AVAudioApplication.shared.recordPermission { - case .granted: - await vgw.updateVoiceState(isMuted: !gw.voice.isMuted) - case .denied: - micError = true - case .undetermined: - if await AVAudioApplication.requestRecordPermission() { - await vgw.updateVoiceState(isMuted: false) - } - @unknown default: - fatalError() - } - } - } label: { - Image(systemName: vgw.isMuted ? "mic.slash.fill" : "mic.fill") - .font(.title2) - .padding(5) - .background(.ultraThinMaterial) - .clipShape(.circle) - } - .buttonStyle(.borderless) - .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." - ) - } - - Button { - Task { - await vgw.updateVoiceState(isDeafened: !vgw.isDeafened) - } - } label: { - Image( - systemName: gw.voice.isDeafened - ? "speaker.slash.fill" : "speaker.wave.2.fill" - ) - .font(.title2) - .padding(5) - .background(.ultraThinMaterial) - .clipShape(.circle) - } - .buttonStyle(.borderless) + } + .frame(maxWidth: .infinity, alignment: .leading) } + .padding(4) + .padding(.horizontal, 6) } else { EmptyView() } @@ -141,6 +158,10 @@ struct ProfileBar: View { @State var showingPopover = false @State var barHovered = false + @State var micError = false + + var vgw: VoiceConnectionStore { gw.voice } + var body: some View { HStack { Button { @@ -207,17 +228,109 @@ struct ProfileBar: View { Spacer() + Button { + Task { + switch AVAudioApplication.shared.recordPermission { + case .granted: + await vgw.updateVoiceState(isMuted: !gw.voice.isMuted) + 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." + ) + } + + Button { + Task { + await vgw.updateVoiceState(isDeafened: !vgw.isDeafened) + } + } 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) - .padding(5) - .background(.ultraThinMaterial) - .clipShape(.circle) + .maxWidth(35) + .maxHeight(35) } - .buttonStyle(.borderless) + + .buttonStyle( + .borderlessHoverEffect() + ) #elseif os(iOS) /// targetting ipad here, ios wouldnt have this at all // do something @@ -228,14 +341,13 @@ struct ProfileBar: View { if let nameplate = gw.user.currentUser?.collectibles?.nameplate { Profile.NameplateView(nameplate: nameplate) .nameplateAnimated(barHovered) - .saturation(0.9) - .brightness(0.1) + .nameplateImageOpacity(0.4) } } .clipped() .onHover { barHovered = $0 } } - + func emojiURL(for emoji: Gateway.Activity.ActivityEmoji, animated: Bool) -> URL? { @@ -268,7 +380,8 @@ struct ProfileBar: View { VStack(alignment: .leading) { Text( - gw.user.currentUser?.global_name ?? gw.user.currentUser?.username + gw.user.currentUser?.global_name ?? gw.user.currentUser? + .username ?? "Unknown User" ) .bold() @@ -422,3 +535,120 @@ struct ProfileBar: View { } } } + +// 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() +} + +#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) + } +} From 65aaef725e81b59b109f9f0b7fcba3f38c1395b3 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Mon, 16 Mar 2026 13:13:38 +0000 Subject: [PATCH 48/66] change how navigation works --- Paicord.xcodeproj/project.pbxproj | 32 +++ Paicord/App/PaicordAppState.swift | 184 +++++++++++++++--- Paicord/Baseplates/LargeBaseplate.swift | 56 ++++-- Paicord/Baseplates/SmallBaseplate.swift | 4 +- Paicord/Common/Chat/ChatView.swift | 2 +- Paicord/Common/Chat/Input/InputBar.swift | 2 +- Paicord/Common/Friends/FriendsView.swift | 8 + Paicord/Common/Guilds/ChannelButton.swift | 28 +-- Paicord/Common/Guilds/GuildButton.swift | 11 +- .../QuickSwitcherModifier.swift | 18 +- .../BorderlessHoverEffectButtonStyle.swift | 84 ++++++++ .../Modifiers/EntityContextMenu.swift | 2 +- Paicord/Common/Voice/CallView.swift | 8 + Paicord/Common/Voice/VoiceView.swift | 22 +++ Paicord/Resources/Localizable.xcstrings | 9 +- Paicord/Stores/GatewayStore.swift | 10 +- Paicord/Stores/VoiceConnectionStore.swift | 64 ++++-- Paicord/macOS/Sidebar/ProfileBar.swift | 75 ------- 18 files changed, 440 insertions(+), 179 deletions(-) create mode 100644 Paicord/Common/Friends/FriendsView.swift create mode 100644 Paicord/Common/Utilities/Components/BorderlessHoverEffectButtonStyle.swift create mode 100644 Paicord/Common/Voice/CallView.swift create mode 100644 Paicord/Common/Voice/VoiceView.swift diff --git a/Paicord.xcodeproj/project.pbxproj b/Paicord.xcodeproj/project.pbxproj index 0b7d5f5e..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 */; }; @@ -199,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 = ""; }; @@ -525,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 = ( @@ -895,8 +920,10 @@ children = ( 57A4E7092E6B315700470131 /* Login */, 57DA04282E79893B00DB4C7C /* Launch */, + AA0E4FDE2F6645B7007105F6 /* Friends */, 57A55C552E7560BB005C8226 /* Guilds */, AA1097082E64C181005BC3D2 /* Chat */, + AA0E4FD92F6641B8007105F6 /* Voice */, AA4207CD2F2D1165006B8227 /* Emoji Picker */, AA21D2852EAB08E500C75093 /* Member Sidebar */, 57DA042D2E79DC4400DB4C7C /* Settings */, @@ -911,6 +938,7 @@ AA9C81812E6660AB0086B1DA /* Components */ = { isa = PBXGroup; children = ( + AA0E4FD72F663F08007105F6 /* BorderlessHoverEffectButtonStyle.swift */, AAC07FC22F2D9E460077B8FA /* DownloadButton.swift */, AA9D26B02EC95EE3006071FE /* FlowLayout.swift */, AAFC9DD92EB7DAE200BB8028 /* VariableBlurView.swift */, @@ -1157,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 */, @@ -1183,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 */, @@ -1230,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 */, 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..65be5b22 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 { @@ -99,14 +102,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 +121,33 @@ struct LargeBaseplate: View { } } } + + @ViewBuilder + func textChannelLayout(_ channelStore: ChannelStore) -> some View { + ChatView(vm: channelStore) + .inspector(isPresented: $showingInspector) { + MemberSidebarView( + guildStore: currentGuildStore, + channelStore: currentChannelStore + ) + .inspectorColumnWidth(min: 250, ideal: 250, max: 360) + } + .id(channelStore.channelId) // force view update + .environment(\.guildStore, currentGuildStore) + .environment(\.channelStore, currentChannelStore) + } + + @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/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..d18f8343 --- /dev/null +++ b/Paicord/Common/Friends/FriendsView.swift @@ -0,0 +1,8 @@ +// +// FriendsView.swift +// Paicord +// +// Created by Lakhan Lothiyi on 15/03/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + diff --git a/Paicord/Common/Guilds/ChannelButton.swift b/Paicord/Common/Guilds/ChannelButton.swift index fd049693..ce3296ba 100644 --- a/Paicord/Common/Guilds/ChannelButton.swift +++ b/Paicord/Common/Guilds/ChannelButton.swift @@ -21,7 +21,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( @@ -205,7 +205,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() @@ -246,7 +246,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 @@ -277,10 +277,12 @@ struct ChannelButton: View { if !shouldHide { Button { Task { + appState.selectedChannel = .voiceChannel(channel.id) + guard let guildID = appState.selectedGuild.guildID else { return } await gw.voice.updateVoiceConnection( .join( channelId: channel.id, - guildId: appState.selectedGuild, + guildId: guildID, ) ) } @@ -300,7 +302,7 @@ struct ChannelButton: View { var body: some View { let voiceChannels = gw.voiceChannels - if let voiceStates = voiceChannels.voiceStates[appState.selectedGuild]?[ + if let guildID = appState.selectedGuild.guildID, let voiceStates = voiceChannels.voiceStates[guildID]?[ channel.id ], !voiceStates.isEmpty { LazyVStack(spacing: 2) { @@ -318,7 +320,6 @@ struct ChannelButton: View { @Environment(\.guildStore) var guildStore @Environment(\.gateway) var gw - @State var isHovered = false @State var showPopover = false var body: some View { let member = state.member ?? guildStore?.members[state.user_id] @@ -347,19 +348,8 @@ struct ChannelButton: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 6) .padding(.horizontal, 8) - .background( - Group { - if isHovered { - Color.gray.opacity(0.2) - } else { - Color.clear - } - } - .clipShape(.rounded) - ) } - .buttonStyle(.borderless) - .onHover { isHovered = $0 } + .buttonStyle(.borderlessHoverEffect(isSelected: showPopover, selectionShape: .init(.rounded))) .popover(isPresented: $showPopover) { if let user { ProfilePopoutView( @@ -400,7 +390,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 diff --git a/Paicord/Common/Guilds/GuildButton.swift b/Paicord/Common/Guilds/GuildButton.swift index c135d89d..0ad7c6ad 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,20 +260,21 @@ struct GuildButton: View { /// A button representing a guild or DMs func guildButton(from guild: Guild?) -> some View { Button { - if appState.selectedGuild == guild?.id { + if appState.selectedGuild.guildID == guild?.id { #if os(iOS) appState.chatOpen = true #endif } else { ImpactGenerator.impact(style: .light) - appState.selectedGuild = guild?.id + guard let id = guild?.id else { return } + appState.selectedGuild = .guild(id) } } 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/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/Voice/CallView.swift b/Paicord/Common/Voice/CallView.swift new file mode 100644 index 00000000..ed758feb --- /dev/null +++ b/Paicord/Common/Voice/CallView.swift @@ -0,0 +1,8 @@ +// +// CallView.swift +// Paicord +// +// Created by Lakhan Lothiyi on 15/03/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + diff --git a/Paicord/Common/Voice/VoiceView.swift b/Paicord/Common/Voice/VoiceView.swift new file mode 100644 index 00000000..8f1544ab --- /dev/null +++ b/Paicord/Common/Voice/VoiceView.swift @@ -0,0 +1,22 @@ +// +// VoiceView.swift +// Paicord +// +// Created by Lakhan Lothiyi on 15/03/2026. +// Copyright © 2026 Lakhan Lothiyi. +// + +import PaicordLib +import SwiftUIX + +struct VoiceView: View { + @Environment(\.gateway) var gw + @Environment(\.appState) var appState + var vm: ChannelStore + + var body: some View { + Text("Voice Channel") + .font(.largeTitle) + .foregroundStyle(.secondary) + } +} diff --git a/Paicord/Resources/Localizable.xcstrings b/Paicord/Resources/Localizable.xcstrings index 17a931cb..292fe29a 100644 --- a/Paicord/Resources/Localizable.xcstrings +++ b/Paicord/Resources/Localizable.xcstrings @@ -24,6 +24,9 @@ }, ":3" : { + }, + ":3c" : { + }, "(edited)" : { @@ -355,9 +358,6 @@ }, "Unknown User" : { - }, - "Unsupported block: %@" : { - }, "Unsupported embed type: %@" : { @@ -383,6 +383,9 @@ }, "View Logs" : { + }, + "Voice Channel" : { + }, "Voice Connected" : { diff --git a/Paicord/Stores/GatewayStore.swift b/Paicord/Stores/GatewayStore.swift index e0117e60..7890a237 100644 --- a/Paicord/Stores/GatewayStore.swift +++ b/Paicord/Stores/GatewayStore.swift @@ -219,9 +219,13 @@ final class GatewayStore { print( "[GatewayStore] Reconnected, resubscribing to previously subscribed guilds." ) - let channelIds = PaicordAppState.instances.compactMap( - \.value.selectedChannel - ) + 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( diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 3e377170..85ee8a9b 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -572,12 +572,7 @@ final class VoiceConnectionStore: DiscordDataStore { private let micChannel = AsyncChannel() private func setupTap() { - let targetFormat = AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: .opus48khz, - channels: 2, - interleaved: false - )! + let targetFormat = Self.pcmFormat let opusFrameCount: AVAudioFrameCount = 960 let opusFrameSize = Int(opusFrameCount) @@ -587,24 +582,57 @@ final class VoiceConnectionStore: DiscordDataStore { capacityFrames: opusFrameSize * 8 ) - let tapFormat = inputNode.outputFormat(forBus: 0) - print("[Voice] Installing tap with format:", tapFormat) + 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 - // print graph description inputNode.installTap( onBus: 0, bufferSize: opusFrameCount, - format: tapFormat - ) { - buffer, - _ in - guard buffer.frameLength > 0 else { return } - guard let src = buffer.floatChannelData else { return } + 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(buffer.frameLength), - srcChannels: Int(buffer.format.channelCount) + frames: Int(convertedBuffer.frameLength), + srcChannels: Int(targetFormat.channelCount) ) while ring.availableFrames >= opusFrameSize { @@ -633,7 +661,7 @@ final class VoiceConnectionStore: DiscordDataStore { if self.isMuted { continue } do { - guard let interleaved = interleavedBuffer(from: buffer) else { + guard let interleaved = self.interleavedBuffer(from: buffer) else { continue } let encodedSize = try self.opusEncoder.encode( diff --git a/Paicord/macOS/Sidebar/ProfileBar.swift b/Paicord/macOS/Sidebar/ProfileBar.swift index 80d0bd53..dce1d63d 100644 --- a/Paicord/macOS/Sidebar/ProfileBar.swift +++ b/Paicord/macOS/Sidebar/ProfileBar.swift @@ -536,81 +536,6 @@ struct ProfileBar: View { } } -// 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() -} - #Preview("nameplate test") { let decoration = DiscordUser.AvatarDecoration( asset: "a_741750ac1c9091a58059be33590c2821", From 90514e089ee760a85348f4ac362a3a92bc1ad9d9 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Tue, 17 Mar 2026 00:46:47 +0000 Subject: [PATCH 49/66] wip voice ui --- Paicord/Common/Chat/ChannelHeader.swift | 17 +- Paicord/Common/Guilds/ChannelButton.swift | 74 ++++- Paicord/Common/Voice/VoiceView.swift | 325 +++++++++++++++++++++- Paicord/Resources/Localizable.xcstrings | 22 +- Paicord/Stores/VoiceConnectionStore.swift | 6 +- Paicord/macOS/Sidebar/ProfileBar.swift | 18 +- 6 files changed, 438 insertions(+), 24 deletions(-) diff --git a/Paicord/Common/Chat/ChannelHeader.swift b/Paicord/Common/Chat/ChannelHeader.swift index b92b7079..9c3a0046 100644 --- a/Paicord/Common/Chat/ChannelHeader.swift +++ b/Paicord/Common/Chat/ChannelHeader.swift @@ -92,9 +92,20 @@ extension ChatView { } default: HStack(spacing: 4) { - Image(systemName: "number") - .foregroundStyle(.secondary) - .imageScale(idiom == .phone ? .medium : .large) + 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) diff --git a/Paicord/Common/Guilds/ChannelButton.swift b/Paicord/Common/Guilds/ChannelButton.swift index ce3296ba..ae6ddd05 100644 --- a/Paicord/Common/Guilds/ChannelButton.swift +++ b/Paicord/Common/Guilds/ChannelButton.swift @@ -156,11 +156,22 @@ struct ChannelButton: View { } .tint(.primary) case .guildVoice: - voiceChannelButton { _ in + voiceChannelButton { hovered in HStack { 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) @@ -319,31 +330,78 @@ struct ChannelButton: 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 { - let member = state.member ?? guildStore?.members[state.user_id] - let user = - state.member?.user?.toPartialUser() ?? gw.user.users[state.user_id] Button { if user != nil { showPopover.toggle() } } label: { HStack { - Profile.AvatarWithPresence( + Profile.Avatar( member: member, user: user ) - .profileShowsAvatarDecoration() .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(.secondary) + .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) diff --git a/Paicord/Common/Voice/VoiceView.swift b/Paicord/Common/Voice/VoiceView.swift index 8f1544ab..8ef9b0b3 100644 --- a/Paicord/Common/Voice/VoiceView.swift +++ b/Paicord/Common/Voice/VoiceView.swift @@ -4,19 +4,336 @@ // // 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 monitor: Any? + @ViewStorage var isHovering: Bool = false + @ViewStorage var timer: Timer? = nil + @State var showingVoiceUI = false var body: some View { - Text("Voice Channel") - .font(.largeTitle) - .foregroundStyle(.secondary) + 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") + } + } + } + .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 } + ) + .onAppear { + // monitor mouse movement + monitor = NSEvent.addLocalMonitorForEvents(matching: .mouseMoved) { + event in + if isHovering { + if !showingVoiceUI { + showingVoiceUI = true + } + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { + _ in + self.showingVoiceUI = false + } + } else { + if showingVoiceUI { + showingVoiceUI = false + } + } + return event + } + + timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in + self.showingVoiceUI = false + } + } + .onHover { isHovering = $0 } + .onDisappear { + if let monitor { + NSEvent.removeMonitor(monitor) + } + } + } + + // 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 + > = { + if let itemSize { + return members.values.chunks( + ofCount: max(1, min(4, Int(contentSize.width / itemSize))) + ) + } else { + // 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 + }() + 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) + .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 292fe29a..d1c7d2ef 100644 --- a/Paicord/Resources/Localizable.xcstrings +++ b/Paicord/Resources/Localizable.xcstrings @@ -33,6 +33,16 @@ }, "%@ unsupported" : { + }, + "%@%@ %@ currently in voice" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@%2$@ %3$@ currently in voice" + } + } + } }, "%lld members" : { @@ -214,6 +224,9 @@ }, "Join the [Paicord Server](https://discord.gg/fqhPGHPyaK) to manage badges!" : { + }, + "Join Voice" : { + }, "Log In" : { @@ -241,6 +254,9 @@ }, "Multi-Factor Authentication" : { + }, + "No one is currently in voice" : { + }, "Notifications" : { @@ -358,6 +374,9 @@ }, "Unknown User" : { + }, + "Unsupported block: %@" : { + }, "Unsupported embed type: %@" : { @@ -383,9 +402,6 @@ }, "View Logs" : { - }, - "Voice Channel" : { - }, "Voice Connected" : { diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 85ee8a9b..58ad2f57 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -491,10 +491,6 @@ final class VoiceConnectionStore: DiscordDataStore { /// voice processing mode do { try inputNode.setVoiceProcessingEnabled(true) - inputNode.voiceProcessingOtherAudioDuckingConfiguration = .init( - enableAdvancedDucking: true, - duckingLevel: .max - ) inputNode.isVoiceProcessingAGCEnabled = true inputNode.isVoiceProcessingBypassed = false inputNode.isVoiceProcessingInputMuted = false @@ -746,6 +742,8 @@ final class VoiceConnectionStore: DiscordDataStore { private func ensureIncomingStreamExists(ssrc: UInt32) { if incomingStreamsBySSRC[ssrc] != nil { return } + // ensure it isn't us + guard knownSSRCs[UInt(ssrc)] != gateway?.user.currentUser?.id else { return } let stream = IncomingStream(ssrc: ssrc) incomingStreamsBySSRC[ssrc] = stream diff --git a/Paicord/macOS/Sidebar/ProfileBar.swift b/Paicord/macOS/Sidebar/ProfileBar.swift index dce1d63d..def19e71 100644 --- a/Paicord/macOS/Sidebar/ProfileBar.swift +++ b/Paicord/macOS/Sidebar/ProfileBar.swift @@ -159,6 +159,8 @@ struct ProfileBar: View { @State var barHovered = false @State var micError = false + + @ViewStorage var didDeafenBeforeMute = false var vgw: VoiceConnectionStore { gw.voice } @@ -232,7 +234,8 @@ struct ProfileBar: View { Task { switch AVAudioApplication.shared.recordPermission { case .granted: - await vgw.updateVoiceState(isMuted: !gw.voice.isMuted) + // 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: @@ -281,7 +284,18 @@ struct ProfileBar: View { Button { Task { - await vgw.updateVoiceState(isDeafened: !vgw.isDeafened) + // 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, *) { From 11f2c629e6e87b6280e9ac5b58eb971d92b5add6 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Tue, 17 Mar 2026 02:01:15 +0000 Subject: [PATCH 50/66] permissions --- Paicord/Common/Guilds/ChannelButton.swift | 20 +++++++++++--- Paicord/Common/Voice/VoiceView.swift | 33 +++++++++++------------ 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/Paicord/Common/Guilds/ChannelButton.swift b/Paicord/Common/Guilds/ChannelButton.swift index ae6ddd05..ae7f3ebc 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 @@ -158,8 +159,13 @@ struct ChannelButton: View { case .guildVoice: 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() @@ -284,12 +290,19 @@ struct ChannelButton: View { .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 else { return } + guard let guildID = appState.selectedGuild.guildID, canConnect else { return } await gw.voice.updateVoiceConnection( .join( channelId: channel.id, @@ -302,6 +315,7 @@ struct ChannelButton: View { } .onHover { isHovered = $0 } .buttonStyle(.borderless) + .disabled(!canConnect) } } } diff --git a/Paicord/Common/Voice/VoiceView.swift b/Paicord/Common/Voice/VoiceView.swift index 8ef9b0b3..bacf2a0e 100644 --- a/Paicord/Common/Voice/VoiceView.swift +++ b/Paicord/Common/Voice/VoiceView.swift @@ -28,7 +28,7 @@ struct VoiceView: View { var body: some View { Group { - VStack(spacing: 15) { + LazyVStack(spacing: 15) { let voiceChannels = gw.voiceChannels let guildID = vm.guildStore?.guildId let voiceStates = @@ -81,6 +81,7 @@ struct VoiceView: View { } label: { Text("Join Voice") } + .disabled(!(vm.guildStore?.hasPermission(channel: vm, .connect) ?? true)) } } .foregroundStyle(.white.secondary) @@ -149,29 +150,25 @@ struct VoiceView: View { @Binding var showingVoiceUI: Bool var namespace: Namespace.ID - var itemSize: CGFloat? { vgw?.channelId != channelStore?.channelId ? 150 : nil } + var itemSize: CGFloat? { + vgw?.channelId != channelStore?.channelId ? 150 : nil + } var body: some View { let chunks: ChunksOfCountCollection< OrderedDictionary.Values > = { - if let itemSize { - return members.values.chunks( - ofCount: max(1, min(4, Int(contentSize.width / itemSize))) - ) - } else { - // 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 - }() - return members.values.chunks(ofCount: chunkSize) - } + // 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 + }() + return members.values.chunks(ofCount: chunkSize) }() VStack(alignment: .center, spacing: 0) { ForEach(Array(chunks.enumerated()), id: \.offset) { _, chunk in From a3d771a3ffe3ec07775075f65282cec91d9ae505 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Tue, 17 Mar 2026 09:56:10 +0000 Subject: [PATCH 51/66] fixes --- Paicord/Common/Guilds/ChannelButton.swift | 16 +++++++++++----- Paicord/Common/Voice/VoiceView.swift | 11 +++++++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/Paicord/Common/Guilds/ChannelButton.swift b/Paicord/Common/Guilds/ChannelButton.swift index ae7f3ebc..a89830ec 100644 --- a/Paicord/Common/Guilds/ChannelButton.swift +++ b/Paicord/Common/Guilds/ChannelButton.swift @@ -110,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) diff --git a/Paicord/Common/Voice/VoiceView.swift b/Paicord/Common/Voice/VoiceView.swift index bacf2a0e..cdcb313c 100644 --- a/Paicord/Common/Voice/VoiceView.swift +++ b/Paicord/Common/Voice/VoiceView.swift @@ -28,7 +28,7 @@ struct VoiceView: View { var body: some View { Group { - LazyVStack(spacing: 15) { + VStack(spacing: 15) { let voiceChannels = gw.voiceChannels let guildID = vm.guildStore?.guildId let voiceStates = @@ -116,7 +116,7 @@ struct VoiceView: View { showingVoiceUI = true } timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { + timer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in self.showingVoiceUI = false } @@ -128,7 +128,7 @@ struct VoiceView: View { return event } - timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in + timer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in self.showingVoiceUI = false } } @@ -166,7 +166,7 @@ struct VoiceView: View { while Int(pow(2.0, Double(size))) < count { size += 1 } - return size + return size + (itemSize != nil ? 1 : 0) }() return members.values.chunks(ofCount: chunkSize) }() @@ -292,6 +292,9 @@ struct VoiceView: View { } } .padding(2) + .transaction(value: isSpeaking) { t in + t.disableAnimations() + } .task(id: user, grabColor) .task(id: member, grabColor) } From 81e7b2c712627ed11043cb656f6d7b9b450494c7 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 18 Mar 2026 09:24:48 +0000 Subject: [PATCH 52/66] fix dms button --- Paicord/Common/Guilds/GuildButton.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Paicord/Common/Guilds/GuildButton.swift b/Paicord/Common/Guilds/GuildButton.swift index 0ad7c6ad..66dde5ef 100644 --- a/Paicord/Common/Guilds/GuildButton.swift +++ b/Paicord/Common/Guilds/GuildButton.swift @@ -266,8 +266,11 @@ struct GuildButton: View { #endif } else { ImpactGenerator.impact(style: .light) - guard let id = guild?.id else { return } - appState.selectedGuild = .guild(id) + if let id = guild?.id { + appState.selectedGuild = .guild(id) + } else { + appState.selectedGuild = .directMessages + } } } label: { let isSelected = appState.selectedGuild.guildID == guild?.id From e7ccd8cacdb81f4ebdb37a973f02698eeefd1f1c Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 18 Mar 2026 09:25:17 +0000 Subject: [PATCH 53/66] point links away from discordbm's issues --- .../DiscordGateway/UserGatewayManager.swift | 16 ++++++++-------- .../DiscordHTTP/AuthenticationHeader.swift | 2 +- PaicordLib/Sources/DiscordHTTP/HTTP Types.swift | 2 +- .../Sources/DiscordModels/Types/BitField.swift | 2 +- .../Sources/DiscordModels/Types/Gateway.swift | 2 +- .../DiscordModels/Types/Interaction.swift | 4 ++-- .../Sources/DiscordModels/Types/Shared.swift | 6 +++--- .../DiscordModels/Types/VoiceGateway.swift | 2 +- .../IntegrationTests/GatwayConnection.swift | 2 +- README.md | 8 +++++++- 10 files changed, 26 insertions(+), 20 deletions(-) diff --git a/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift b/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift index ec908aca..efb4b965 100644 --- a/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift +++ b/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift @@ -215,8 +215,9 @@ public actor UserGatewayManager { #if DEBUG let queries: [(String, String)] = [ ("v", "\(DiscordGlobalConfiguration.apiVersion)"), - ("encoding", "json") + ("encoding", "json"), ] + #else let decompressorWSExtension: ZstdDecompressorWSExtension do { decompressorWSExtension = try ZstdDecompressorWSExtension( @@ -224,17 +225,16 @@ public actor UserGatewayManager { ) } 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", + "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 } - #else - let queries: [(String, String)] = [ - ("v", "\(DiscordGlobalConfiguration.apiVersion)"), - ("encoding", "json"), - ("compress", "zstd-stream"), - ] + let queries: [(String, String)] = [ + ("v", "\(DiscordGlobalConfiguration.apiVersion)"), + ("encoding", "json"), + ("compress", "zstd-stream"), + ] #endif #if DEBUG 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/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/Gateway.swift b/PaicordLib/Sources/DiscordModels/Types/Gateway.swift index 628d3269..94d15fe8 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Gateway.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Gateway.swift @@ -761,7 +761,7 @@ 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 { 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/Shared.swift b/PaicordLib/Sources/DiscordModels/Types/Shared.swift index 9eeb0204..2755ee84 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Shared.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Shared.swift @@ -255,9 +255,9 @@ extension DiscordLocaleDict: Equatable where C: Equatable { public struct DiscordTimestamp: Codable, Hashable { public enum DecodingError: Swift.Error, CustomStringConvertible { - /// The timestamp had an unexpected format. This is a library decoding issue, please report this at https://github.com/DiscordBM/DiscordBM/issues. + /// The timestamp had an unexpected format. This is a library decoding issue, please report this at https://github.com/llsc12/Paicord/issues. case unexpectedFormat([any CodingKey], String) - /// Could not convert the timestamp to a 'Date'. This is a library decoding issue, please report this at https://github.com/DiscordBM/DiscordBM/issues. + /// Could not convert the timestamp to a 'Date'. This is a library decoding issue, please report this at https://github.com/llsc12/Paicord/issues. case conversionFailure([any CodingKey], String, DateComponents) public var description: String { @@ -370,7 +370,7 @@ public struct DiscordTimestamp: Codable, Hashable { .init( codingPath: container.codingPath, debugDescription: - "Programming Error. Could not encode Date '\(date.debugDescription)' to Discord Timestamp. Please report: https://github.com/DiscordBM/DiscordBM/issues" + "Programming Error. Could not encode Date '\(date.debugDescription)' to Discord Timestamp. Please report: https://github.com/llsc12/Paicord/issues" ) ) } diff --git a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift index 9bad4c30..976011b5 100644 --- a/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift +++ b/PaicordLib/Sources/DiscordModels/Types/VoiceGateway.swift @@ -249,7 +249,7 @@ public struct VoiceGateway: 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 { 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 8a3f3716..a639d4ee 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A native Discord client written in Swift using SwiftUI, with a goal of feature ## Progress -Paicord has support for core chat features, like partial markdown, attachments and embeds with support for file uploads, editing, replying and deleting messages, and more! +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) @@ -76,6 +76,12 @@ Will never be implemented, using the same token on two clients like the one you 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]()! From ad0a4fb039f7dea68380fee6665be5685e33cada Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 18 Mar 2026 17:35:25 +0000 Subject: [PATCH 54/66] discord can send no name for connected accounts --- PaicordLib/Sources/DiscordModels/Types/User.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PaicordLib/Sources/DiscordModels/Types/User.swift b/PaicordLib/Sources/DiscordModels/Types/User.swift index 86617c4f..2ce4987e 100644 --- a/PaicordLib/Sources/DiscordModels/Types/User.swift +++ b/PaicordLib/Sources/DiscordModels/Types/User.swift @@ -481,7 +481,7 @@ extension DiscordUser { } public var id: String - public var name: String + public var name: String? public var type: Service public var revoked: Bool? public var integrations: [PartialIntegration]? @@ -495,7 +495,7 @@ extension DiscordUser { /// https://docs.discord.food/resources/connected-accounts#partial-connection-structure public struct PartialConnection: Sendable, Codable, Equatable, Hashable { public var id: String - public var name: String + public var name: String? public var type: Connection.Service public var verified: Bool } From de68d6cc68c290de89522a864dec6e73ace0ced3 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 18 Mar 2026 17:35:32 +0000 Subject: [PATCH 55/66] Update VoiceGatewayManager.swift --- .../DiscordVoice/VoiceGatewayManager.swift | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift index 835b9640..11209722 100644 --- a/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift +++ b/PaicordLib/Sources/DiscordVoice/VoiceGatewayManager.swift @@ -114,7 +114,6 @@ public actor VoiceGatewayManager { } //MARK: Connection data - public nonisolated let identifyPayload: VoiceGateway.Identify // discord uses this for analytics but we'll send it anyways public nonisolated let rtcConnectionID = UUID().uuidString.lowercased() @@ -205,26 +204,6 @@ public actor VoiceGatewayManager { self.stateCallback = stateCallback self.maxFrameSize = maxFrameSize self.connectionData = connectionData - self.identifyPayload = .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 - ), - ] - ) var logger = DiscordGlobalConfiguration.makeLogger("VoiceGatewayManager") logger[metadataKey: "gateway-id"] = .string("\(self.id)") @@ -1186,7 +1165,26 @@ extension VoiceGatewayManager { connectionBackoff.willTry() let identify = VoiceGateway.Event( opcode: .identify, - data: .identify(identifyPayload) + 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)) } From 3c58754431f04ff9bb0e1fb49b55f21ebb14f051 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 18 Mar 2026 17:46:34 +0000 Subject: [PATCH 56/66] ringing --- Paicord/Utilities/PaicordLib++/Merging.swift | 9 ++++ .../DiscordGateway/UserGatewayManager.swift | 1 + .../DiscordClient+UserAPIEndpoint.swift | 52 ++++++++++++++++++- .../Endpoints/UserAPIEndpoint.swift | 51 ++++++++++++++++++ .../Types/Gateway+Payloads.swift | 9 +++- .../DiscordModels/Types/Payloads.swift | 22 ++++++++ .../Sources/DiscordModels/Types/Voice.swift | 5 ++ 7 files changed, 146 insertions(+), 3 deletions(-) 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/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift b/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift index efb4b965..fa318282 100644 --- a/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift +++ b/PaicordLib/Sources/DiscordGateway/UserGatewayManager.swift @@ -153,6 +153,7 @@ public actor UserGatewayManager { .userSettingsProto, .debounceMessageReactions, .nonChannelReadStates, + .autoCallConnect ], captchaCallback: CaptchaChallengeHandler? = nil, mfaCallback: MFAVerificationHandler? = nil, 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/DiscordModels/Types/Gateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift index bc739a67..25a913aa 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift @@ -1485,7 +1485,7 @@ 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: Secret - public var guild_id: GuildSnowflake + 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/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/Voice.swift b/PaicordLib/Sources/DiscordModels/Types/Voice.swift index b2e506a0..abf87890 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Voice.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Voice.swift @@ -140,3 +140,8 @@ public struct VoiceStateUpdate: Sendable, Codable { #endif } } + +/// https://docs.discord.food/resources/channel#response-body +public struct CallEligibility: Sendable, Codable { + public var ringable: Bool +} From 50a9be065a135a7426874b280df1fe9cfc9fddc7 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Wed, 18 Mar 2026 17:47:47 +0000 Subject: [PATCH 57/66] calls (dm vc) support, wip call ui --- Paicord/Baseplates/LargeBaseplate.swift | 66 ++++++--- Paicord/Common/Friends/FriendsView.swift | 11 ++ Paicord/Common/Voice/CallView.swift | 160 +++++++++++++++++++++- Paicord/Resources/Localizable.xcstrings | 3 + Paicord/Stores/VoiceChannelsStore.swift | 25 ++++ Paicord/Stores/VoiceConnectionStore.swift | 77 ++++++++--- Paicord/Utilities/Extensions/Maths.swift | 34 +++++ 7 files changed, 335 insertions(+), 41 deletions(-) diff --git a/Paicord/Baseplates/LargeBaseplate.swift b/Paicord/Baseplates/LargeBaseplate.swift index 65be5b22..e0b1aeff 100644 --- a/Paicord/Baseplates/LargeBaseplate.swift +++ b/Paicord/Baseplates/LargeBaseplate.swift @@ -59,9 +59,9 @@ struct LargeBaseplate: View { case .voiceChannel: voiceChannelLayout(currentChannelStore) case .dashboard: - Text(":3") - .font(.largeTitle) - .foregroundStyle(.secondary) + Text(":3") + .font(.largeTitle) + .foregroundStyle(.secondary) case .friends: Text(":3c") .font(.largeTitle) @@ -95,6 +95,25 @@ struct LargeBaseplate: View { } } .toolbar { + if let vm = currentChannelStore, + vm.channel?.type == .dm || vm.channel?.type == .groupDm, + gw.voiceChannels.voiceStates[nil]?[vm.channelId] == nil + && gw.voiceChannels.calls[vm.channelId] == nil + { + Button { + Task { + await gw.voice.updateVoiceConnection( + .join( + channelId: vm.channelId, + guildId: nil, + ) + ) + } + } label: { + Label("Start Call", systemImage: "phone.fill") + } + } + Button { showingInspector.toggle() } label: { @@ -121,28 +140,39 @@ struct LargeBaseplate: View { } } } - + + @State var panelSize: CGSize = .zero @ViewBuilder func textChannelLayout(_ channelStore: ChannelStore) -> some View { - ChatView(vm: channelStore) - .inspector(isPresented: $showingInspector) { - MemberSidebarView( - guildStore: currentGuildStore, - channelStore: currentChannelStore - ) - .inspectorColumnWidth(min: 250, ideal: 250, max: 360) - } - .id(channelStore.channelId) // force view update - .environment(\.guildStore, currentGuildStore) - .environment(\.channelStore, currentChannelStore) + 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) + VoiceView(vm: channelStore) .inspector(isPresented: $showingInspector) { ChatView(vm: channelStore) - .inspectorColumnWidth(min: 400, ideal: 450, max: 750) + .inspectorColumnWidth(min: 400, ideal: 450, max: 750) } .id(channelStore.channelId) // force view update .environment(\.guildStore, currentGuildStore) diff --git a/Paicord/Common/Friends/FriendsView.swift b/Paicord/Common/Friends/FriendsView.swift index d18f8343..bfa2de30 100644 --- a/Paicord/Common/Friends/FriendsView.swift +++ b/Paicord/Common/Friends/FriendsView.swift @@ -6,3 +6,14 @@ // 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/Voice/CallView.swift b/Paicord/Common/Voice/CallView.swift index ed758feb..05ebb4a9 100644 --- a/Paicord/Common/Voice/CallView.swift +++ b/Paicord/Common/Voice/CallView.swift @@ -4,5 +4,163 @@ // // Created by Lakhan Lothiyi on 15/03/2026. // Copyright © 2026 Lakhan Lothiyi. -// +// + +// stacked on top of chat view in dms. + +import PaicordLib +import SwiftUIX +import Collections + +struct CallView: View { + @Environment(\.gateway) var gw + @Environment(\.channelStore) var channel + var vcs: VoiceChannelsStore { gw.voiceChannels } + var currentUser: CurrentUserStore { gw.user } + + var panelSize: CGSize = .zero + @State var viewHeight: CGFloat = 200 + @State var isDragging = false + @State var isHovering = 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 + ) + } else { + EmptyView() + } + } + + @ViewBuilder + func callInterface( + states: OrderedDictionary.Values, + call: Gateway.CallCreate + ) -> some View { + VStack(spacing: 0) { + HStack { + ForEach(Array(states)) { state in + CallParticipantView( + channelID: call.channel_id, + userID: state.user_id, + isRinging: false + ) + } + + ForEach(call.ringing) { userID in + CallParticipantView( + channelID: call.channel_id, + userID: userID, + isRinging: true + ) + } + } + } + .overlay(alignment: .bottom) { + drawerResizeGrabber + } + .maxHeight(viewHeight) + } + + struct CallParticipantView: View { + @Environment(\.gateway) var gw + var vgw: VoiceConnectionStore? { gw.voice } + var vc: VoiceChannelsStore { gw.voiceChannels } + + var channelID: ChannelSnowflake + var userID: UserSnowflake + var isRinging: Bool + + + var user: PartialUser? { gw.user.users[userID] } + + 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) + } + } + + @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/Resources/Localizable.xcstrings b/Paicord/Resources/Localizable.xcstrings index d1c7d2ef..efa621a5 100644 --- a/Paicord/Resources/Localizable.xcstrings +++ b/Paicord/Resources/Localizable.xcstrings @@ -338,6 +338,9 @@ }, "Sponsor" : { + }, + "Start Call" : { + }, "Sticker Picker Coming Soon!" : { diff --git a/Paicord/Stores/VoiceChannelsStore.swift b/Paicord/Stores/VoiceChannelsStore.swift index a6ce1fb0..c8b76af6 100644 --- a/Paicord/Stores/VoiceChannelsStore.swift +++ b/Paicord/Stores/VoiceChannelsStore.swift @@ -27,6 +27,8 @@ final class VoiceChannelsStore: DiscordDataStore { var userChannelIndex: [GuildSnowflake?: [UserSnowflake: ChannelSnowflake]] = [:] + var calls: [ChannelSnowflake: Gateway.CallCreate] = [:] + func setupEventHandling() { guard let gateway = gateway?.gateway else { return } @@ -39,6 +41,12 @@ final class VoiceChannelsStore: DiscordDataStore { 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 } @@ -47,6 +55,11 @@ final class VoiceChannelsStore: DiscordDataStore { } 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 } @@ -113,4 +126,16 @@ final class VoiceChannelsStore: DiscordDataStore { } } } + + 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 index 58ad2f57..943d68e9 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -253,7 +253,7 @@ final class VoiceConnectionStore: DiscordDataStore { // else we can start a new voice gateway connection with the new endpoint and token Task { guard let endpoint = payload.endpoint, !endpoint.isEmpty, - let guildId, let channelId, + let channelId, let sessionId = await gateway?.gateway?.getSessionID(), let userId = gateway?.user.currentUser?.id else { @@ -263,17 +263,29 @@ final class VoiceConnectionStore: DiscordDataStore { 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) and channel \(channelId.rawValue)" + "[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: guildId, + guildID: self.guildId ?? .init(channelId.rawValue), channelID: channelId, userID: userId, sessionID: sessionId, @@ -290,25 +302,33 @@ final class VoiceConnectionStore: DiscordDataStore { 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 receie 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 - // - guild id matches the guild id of current voice connection - + // - 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 self.gateway?.user.currentUser?.id == vUserId, + if + voiceGateway != nil, + self.gateway?.user.currentUser?.id == vUserId, self.guildId == vGuildID, await self.gateway?.gateway?.getSessionID() != vSessionID { @@ -484,7 +504,7 @@ final class VoiceConnectionStore: DiscordDataStore { /// avaudioengine will throw a c++ exception if you start it when `inputNode == nullptr || outputNode == nullptr`. self.ensureDummyNodeAttached() - + /// microphone tap self.setupTap() @@ -494,7 +514,7 @@ final class VoiceConnectionStore: DiscordDataStore { inputNode.isVoiceProcessingAGCEnabled = true inputNode.isVoiceProcessingBypassed = false inputNode.isVoiceProcessingInputMuted = false - + } catch { print("[Voice] Failed to enable voice processing mode:", error) } @@ -591,24 +611,33 @@ final class VoiceConnectionStore: DiscordDataStore { 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 } - + 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 + let status = activeConverter.convert(to: convertedBuffer, error: &error) { + inNumPackets, + outStatus in if inputBlockProvided { outStatus.pointee = .noDataNow return nil @@ -617,13 +646,15 @@ final class VoiceConnectionStore: DiscordDataStore { 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 } + + guard convertedBuffer.frameLength > 0, + let src = convertedBuffer.floatChannelData + else { return } ring.write( from: src, @@ -743,7 +774,9 @@ final class VoiceConnectionStore: DiscordDataStore { private func ensureIncomingStreamExists(ssrc: UInt32) { if incomingStreamsBySSRC[ssrc] != nil { return } // ensure it isn't us - guard knownSSRCs[UInt(ssrc)] != gateway?.user.currentUser?.id else { return } + guard knownSSRCs[UInt(ssrc)] != gateway?.user.currentUser?.id else { + return + } let stream = IncomingStream(ssrc: ssrc) incomingStreamsBySSRC[ssrc] = stream diff --git a/Paicord/Utilities/Extensions/Maths.swift b/Paicord/Utilities/Extensions/Maths.swift index 4aeed559..65d8195e 100644 --- a/Paicord/Utilities/Extensions/Maths.swift +++ b/Paicord/Utilities/Extensions/Maths.swift @@ -25,3 +25,37 @@ func min(_ 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) + } +} From ee443c12f7018aada4567b894b23436dcb2cdeb3 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Mon, 23 Mar 2026 17:55:58 +0000 Subject: [PATCH 58/66] fixes to channel order, dms ui wip --- Paicord/Baseplates/LargeBaseplate.swift | 3 +- Paicord/Common/Guilds/ChannelButton.swift | 12 +- Paicord/Common/Guilds/GuildView.swift | 5 +- Paicord/Common/Voice/CallView.swift | 335 ++++++++++++++++++++-- Paicord/Common/Voice/VoiceView.swift | 45 +-- Paicord/Stores/VoiceConnectionStore.swift | 2 +- Paicord/macOS/Sidebar/ProfileBar.swift | 192 +++++++------ 7 files changed, 435 insertions(+), 159 deletions(-) diff --git a/Paicord/Baseplates/LargeBaseplate.swift b/Paicord/Baseplates/LargeBaseplate.swift index e0b1aeff..67e68864 100644 --- a/Paicord/Baseplates/LargeBaseplate.swift +++ b/Paicord/Baseplates/LargeBaseplate.swift @@ -97,8 +97,7 @@ struct LargeBaseplate: View { .toolbar { if let vm = currentChannelStore, vm.channel?.type == .dm || vm.channel?.type == .groupDm, - gw.voiceChannels.voiceStates[nil]?[vm.channelId] == nil - && gw.voiceChannels.calls[vm.channelId] == nil + gw.voice.channelId != vm.channelId { Button { Task { diff --git a/Paicord/Common/Guilds/ChannelButton.swift b/Paicord/Common/Guilds/ChannelButton.swift index a89830ec..be0fab7e 100644 --- a/Paicord/Common/Guilds/ChannelButton.swift +++ b/Paicord/Common/Guilds/ChannelButton.swift @@ -133,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) diff --git a/Paicord/Common/Guilds/GuildView.swift b/Paicord/Common/Guilds/GuildView.swift index 7879acaf..1086d4b7 100644 --- a/Paicord/Common/Guilds/GuildView.swift +++ b/Paicord/Common/Guilds/GuildView.swift @@ -46,11 +46,14 @@ 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 diff --git a/Paicord/Common/Voice/CallView.swift b/Paicord/Common/Voice/CallView.swift index 05ebb4a9..c8656798 100644 --- a/Paicord/Common/Voice/CallView.swift +++ b/Paicord/Common/Voice/CallView.swift @@ -8,20 +8,18 @@ // stacked on top of chat view in dms. +import AVFAudio +import Collections import PaicordLib import SwiftUIX -import Collections struct CallView: View { @Environment(\.gateway) var gw @Environment(\.channelStore) var channel var vcs: VoiceChannelsStore { gw.voiceChannels } var currentUser: CurrentUserStore { gw.user } - - var panelSize: CGSize = .zero - @State var viewHeight: CGFloat = 200 - @State var isDragging = false - @State var isHovering = false + @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 @@ -35,6 +33,32 @@ struct CallView: View { 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() } @@ -47,45 +71,245 @@ struct CallView: View { ) -> some View { VStack(spacing: 0) { HStack { - ForEach(Array(states)) { state in + let ids = states.map(\.user_id) + call.ringing + ForEach(ids) { id in CallParticipantView( channelID: call.channel_id, - userID: state.user_id, - isRinging: false + userID: id, + isRinging: call.ringing.contains(id) ) } - - ForEach(call.ringing) { userID in - CallParticipantView( - channelID: call.channel_id, - userID: userID, - isRinging: true + } + } + } + + 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 + ) + ) } - .overlay(alignment: .bottom) { - drawerResizeGrabber + + @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 + ) + ) } - .maxHeight(viewHeight) } - + struct CallParticipantView: View { @Environment(\.gateway) var gw - var vgw: VoiceConnectionStore? { gw.voice } + 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] } - + + 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 } @@ -103,21 +327,76 @@ struct CallView: View { } var isSpeaking: Bool { - if let state = vgw?.usersSpeakingState[userID] { + 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() diff --git a/Paicord/Common/Voice/VoiceView.swift b/Paicord/Common/Voice/VoiceView.swift index cdcb313c..c684b8cd 100644 --- a/Paicord/Common/Voice/VoiceView.swift +++ b/Paicord/Common/Voice/VoiceView.swift @@ -21,8 +21,6 @@ struct VoiceView: View { @Namespace private var voiceGridAnimations @ViewStorage var frame: CGRect = .zero - @ViewStorage var monitor: Any? - @ViewStorage var isHovering: Bool = false @ViewStorage var timer: Timer? = nil @State var showingVoiceUI = false @@ -81,7 +79,9 @@ struct VoiceView: View { } label: { Text("Join Voice") } - .disabled(!(vm.guildStore?.hasPermission(channel: vm, .connect) ?? true)) + .disabled( + !(vm.guildStore?.hasPermission(channel: vm, .connect) ?? true) + ) } } .foregroundStyle(.white.secondary) @@ -107,35 +107,18 @@ struct VoiceView: View { of: { $0.frame(in: .local) }, action: { frame = $0 } ) - .onAppear { - // monitor mouse movement - monitor = NSEvent.addLocalMonitorForEvents(matching: .mouseMoved) { - event in - if isHovering { - if !showingVoiceUI { - showingVoiceUI = true - } - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { - _ in - self.showingVoiceUI = false - } - } else { - if showingVoiceUI { - showingVoiceUI = false - } + .onContinuousHover(coordinateSpace: .local) { phase in + switch phase { + case .active: + if !showingVoiceUI { + showingVoiceUI = true } - return event - } - - timer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in - self.showingVoiceUI = false - } - } - .onHover { isHovering = $0 } - .onDisappear { - if let monitor { - NSEvent.removeMonitor(monitor) + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { + _ in + self.showingVoiceUI = false + } + case .ended: break } } } diff --git a/Paicord/Stores/VoiceConnectionStore.swift b/Paicord/Stores/VoiceConnectionStore.swift index 943d68e9..902aa182 100644 --- a/Paicord/Stores/VoiceConnectionStore.swift +++ b/Paicord/Stores/VoiceConnectionStore.swift @@ -311,7 +311,7 @@ final class VoiceConnectionStore: DiscordDataStore { } private func handleVoiceStateUpdate(_ payload: VoiceState) { - // if we receie a voice state payload and it contains a session + // 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. diff --git a/Paicord/macOS/Sidebar/ProfileBar.swift b/Paicord/macOS/Sidebar/ProfileBar.swift index def19e71..f2452ef1 100644 --- a/Paicord/macOS/Sidebar/ProfileBar.swift +++ b/Paicord/macOS/Sidebar/ProfileBar.swift @@ -230,25 +230,80 @@ struct ProfileBar: View { Spacer() - 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) + 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() } - @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) } } - } label: { - if #available(macOS 15.0, iOS 18.0, *) { - Image(systemName: vgw.isMuted ? "mic.slash.fill" : "mic.fill") + .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." + ) + } + + 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), @@ -258,97 +313,44 @@ struct ProfileBar: View { .font(.title2) .maxWidth(35) .maxHeight(35) - } else { - Image(systemName: vgw.isMuted ? "mic.slash.fill" : "mic.fill") + } 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.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." + .buttonStyle( + .borderlessHoverEffect( + pressedColor: .red, + isSelected: vgw.isDeafened + ) ) - } - 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 + #if os(macOS) + Button { + openWindow(id: "settings") + } label: { + Image(systemName: "gearshape.fill") + .font(.title2) + .maxWidth(35) + .maxHeight(35) } - 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) + .buttonStyle( + .borderlessHoverEffect() ) - .font(.title2) - .maxWidth(35) - .maxHeight(35) - } + #elseif os(iOS) + /// targetting ipad here, ios wouldnt have this at all + // do something + #endif } - .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 { From 805e65d1bedc11e440ddadb28cfaaf8ffef3f3e7 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sun, 29 Mar 2026 22:27:28 +0100 Subject: [PATCH 59/66] localisation fixes? --- .../Common/Quick Switcher/QuickSwitcherModifier.swift | 2 +- Paicord/Common/Voice/VoiceView.swift | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Paicord/Common/Quick Switcher/QuickSwitcherModifier.swift b/Paicord/Common/Quick Switcher/QuickSwitcherModifier.swift index 0c77d979..e3d6e591 100644 --- a/Paicord/Common/Quick Switcher/QuickSwitcherModifier.swift +++ b/Paicord/Common/Quick Switcher/QuickSwitcherModifier.swift @@ -223,7 +223,7 @@ struct QuickSwitcherView: View { ProgressView() .progressViewStyle(.circular) } else if !results.isEmpty { - Text("\(results.count) Result\(results.count == 1 ? "" : "s")") + Text("\(results.count) Results") .font(.caption) .foregroundStyle(.secondary) } diff --git a/Paicord/Common/Voice/VoiceView.swift b/Paicord/Common/Voice/VoiceView.swift index c684b8cd..40d463c9 100644 --- a/Paicord/Common/Voice/VoiceView.swift +++ b/Paicord/Common/Voice/VoiceView.swift @@ -20,6 +20,7 @@ struct VoiceView: View { var vgw: VoiceConnectionStore? { gw.voice } @Namespace private var voiceGridAnimations + // chat size @ViewStorage var frame: CGRect = .zero @ViewStorage var timer: Timer? = nil @State var showingVoiceUI = false @@ -55,9 +56,15 @@ struct VoiceView: View { ?? "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" + let totalCount = voiceStates.count + let firstTwoNames = firstTwo.joined(separator: totalCount > 2 ? ", " : " and ") + let localised = String.init( + localized: + "voice channel string \(firstTwoNames) \(remainderCount) \(totalCount)", + comment: + "1 person: \"Person is currently in voice\", 2 people: \"Person1 and Person2 are currently in voice\" using firstTwoNames, 3+ people: \"Person1, Person2 and 1 other(s) are currently in voice\" using firstTwoNames and remainderCount checking it for pluralization. " ) + Text(localised) } Button { From b8b051979f11c85312bab23f44125a8d11a03063 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Mon, 30 Mar 2026 23:04:42 +0100 Subject: [PATCH 60/66] Update .gitignore --- .gitignore | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 45106437..9d87cda9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,10 @@ target Package.resolved **/xcuserdata/ -**/*.xcuserstate \ No newline at end of file +**/*.xcuserstate +/.gradle +/.idea +/PaicordKt/.idea +/PaicordLib/build +/build +local.properties From 9b0d9b849e653440846330b99bd08db65398a935 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Tue, 14 Apr 2026 13:50:33 +0100 Subject: [PATCH 61/66] update superprops --- .../Sources/DiscordHTTP/DefaultDiscordClient.swift | 2 +- .../Sources/DiscordModels/SuperProperties.swift | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/PaicordLib/Sources/DiscordHTTP/DefaultDiscordClient.swift b/PaicordLib/Sources/DiscordHTTP/DefaultDiscordClient.swift index d1accd52..a6e4a506 100644 --- a/PaicordLib/Sources/DiscordHTTP/DefaultDiscordClient.swift +++ b/PaicordLib/Sources/DiscordHTTP/DefaultDiscordClient.swift @@ -886,7 +886,7 @@ public struct DefaultDiscordClient: DiscordClient { headers.add(name: "Sec-CH-UA-Platform", value: "\"macOS\"") // in speechmarks headers.add( name: "Sec-CH-UA", - value: "\"Not:A-Brand\";v=\"24\", \"Chromium\";v=\"134\"") // in speechmarks + value: "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"\(SuperProperties.chromeMajorVer())\"") // in speechmarks /// dolfies says this being static is ok, though im thinking about login flows bc it'd have like https://discord.com/login as referer headers.add(name: "Referer", value: "https://discord.com/channels/@me") headers.add(name: "Origin", value: "https://discord.com") diff --git a/PaicordLib/Sources/DiscordModels/SuperProperties.swift b/PaicordLib/Sources/DiscordModels/SuperProperties.swift index 5589a04d..831c9e2d 100644 --- a/PaicordLib/Sources/DiscordModels/SuperProperties.swift +++ b/PaicordLib/Sources/DiscordModels/SuperProperties.swift @@ -201,6 +201,10 @@ public enum SuperProperties { "138.0.7204.251" } + public static func chromeMajorVer() -> String { + chromeVer().split(separator: ".").first.map(String.init) ?? "138" + } + public static func webkitVer() -> String { "537.36" } @@ -320,20 +324,20 @@ public enum SuperProperties { public static func client_version() -> String { switch Gateway.Identify.ConnectionProperties.__defaultOS { case "iOS", "watchOS": - return "310.3" + return "323.0" case "Mac OS X": - return "0.0.372" + return "0.0.384" default: - return "0.0.372" + return "0.0.384" } } public static func client_build_number() -> Int? { switch Gateway.Identify.ConnectionProperties.__defaultOS { case "iOS", "watchOS": - return 91102 + return 98117 case "Mac OS X": - return 485097 + return 526941 default: return nil } From 1a40cfc20bf241d25b18337ed041256901ec6717 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Thu, 7 May 2026 16:14:57 +0100 Subject: [PATCH 62/66] watchos fixes --- .../xcschemes/xcschememanagement.plist | 14 ++-- PaicordLib/Package.swift | 3 +- .../DiscordModels/SuperProperties.swift | 19 ++++- .../DiscordModels/Types/Application.swift | 4 +- .../DiscordModels/Types/BitField.swift | 73 ++++++++++++++----- .../Types/Gateway+Payloads.swift | 12 +-- .../Sources/DiscordModels/Types/Shared.swift | 4 +- 7 files changed, 88 insertions(+), 41 deletions(-) diff --git a/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist b/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist index 44d5ff57..080c62a6 100644 --- a/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/PaicordLib/.swiftpm/xcode/xcuserdata/llsc12.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ DiscordBMTests.xcscheme_^#shared#^_ orderHint - 2 + 0 DiscordCore.xcscheme_^#shared#^_ @@ -39,6 +39,11 @@ orderHint 6 + DiscordVoice.xcscheme_^#shared#^_ + + orderHint + 4 + GenerateAPIEndpointsExec.xcscheme_^#shared#^_ orderHint @@ -72,7 +77,7 @@ TestCode.xcscheme_^#shared#^_ orderHint - 3 + 1 SuppressBuildableAutocreation @@ -112,11 +117,6 @@ primary
- DiscordVoice - - primary - - GenerateAPIEndpointsExec primary diff --git a/PaicordLib/Package.swift b/PaicordLib/Package.swift index bb646d96..24f3a6cf 100644 --- a/PaicordLib/Package.swift +++ b/PaicordLib/Package.swift @@ -46,7 +46,8 @@ let package = Package( ), ], traits: [ - "Non64BitSystemsCompatibility" + .trait(name: "Non64BitSystemsCompatibility"), + .default(enabledTraits: ["Non64BitSystemsCompatibility"]) ], dependencies: [ .package(url: "https://github.com/apple/swift-nio.git", from: "2.49.0"), diff --git a/PaicordLib/Sources/DiscordModels/SuperProperties.swift b/PaicordLib/Sources/DiscordModels/SuperProperties.swift index 831c9e2d..6699dee9 100644 --- a/PaicordLib/Sources/DiscordModels/SuperProperties.swift +++ b/PaicordLib/Sources/DiscordModels/SuperProperties.swift @@ -18,6 +18,10 @@ import UInt128 import Bionic #endif +#if os(watchOS) + import WatchKit +#endif + // Discord clients send a horrific header containing your host machine information, // this is used for anti-abuse systems. It is sent at IDENTIFY in gateway and with all API requests as // the `X-Super-Properties` header. This extension adds the extra data. The definition only has initializer @@ -101,7 +105,8 @@ public enum SuperProperties { nonisolated(unsafe) private static var _client_heartbeat_session_id_last_generated: Date = Date .distantPast - nonisolated(unsafe) private static var _client_heartbeat_session_id_cached: UUID? = nil + nonisolated(unsafe) private static var _client_heartbeat_session_id_cached: + UUID? = nil static var _client_heartbeat_session_id: UUID { let now = Date.now if now.timeIntervalSince(_client_heartbeat_session_id_last_generated) @@ -406,9 +411,15 @@ public enum SuperProperties { public static func device_vendor_id() -> String? { #if os(iOS) || os(watchOS) DispatchQueue.main.sync { - if let uuid = UIDevice.current.identifierForVendor { - return uuid.uuidString.uppercased() - } + #if os(iOS) + if let uuid = UIDevice.current.identifierForVendor { + return uuid.uuidString.uppercased() + } + #elseif os(watchOS) + if let uuid = WKInterfaceDevice.current().identifierForVendor { + return uuid.uuidString.uppercased() + } + #endif return UUID().uuidString.uppercased() // fallback } #elseif os(macOS) diff --git a/PaicordLib/Sources/DiscordModels/Types/Application.swift b/PaicordLib/Sources/DiscordModels/Types/Application.swift index 1e5a2373..06699713 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Application.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Application.swift @@ -43,9 +43,7 @@ public struct DiscordApplication: Sendable, Codable { #else @UnstableEnum #endif - public enum IntegrationKind: Sendable, Codable, CodingKeyRepresentable, - Equatable - { + public enum IntegrationKind: Sendable, Codable, Equatable { case guildInstall // 0 case userInstall // 1 #if Non64BitSystemsCompatibility diff --git a/PaicordLib/Sources/DiscordModels/Types/BitField.swift b/PaicordLib/Sources/DiscordModels/Types/BitField.swift index 843b530e..a3deb40e 100644 --- a/PaicordLib/Sources/DiscordModels/Types/BitField.swift +++ b/PaicordLib/Sources/DiscordModels/Types/BitField.swift @@ -1,11 +1,18 @@ -public protocol BitField: OptionSet, Hashable, CustomStringConvertible where RawValue == UInt { +#if Non64BitSystemsCompatibility + public typealias _CompatibilityUIntTypeAlias = UInt64 +#else + public typealias _CompatibilityUIntTypeAlias = UInt +#endif + +public protocol BitField: OptionSet, Hashable, CustomStringConvertible +where RawValue == _CompatibilityUIntTypeAlias { associatedtype R: RawRepresentable & LosslessRawRepresentable - where R: Hashable, R.RawValue == UInt - var rawValue: UInt { get set } + where R: Hashable, R.RawValue == _CompatibilityUIntTypeAlias + var rawValue: _CompatibilityUIntTypeAlias { get set } } extension BitField { - + /// Checks if the value exists in this `BitField`. public func contains(_ member: R) -> Bool { ((self.rawValue >> member.rawValue) & 1) == 1 @@ -13,7 +20,9 @@ extension BitField { /// Inserts a new value to the `BitField`. @discardableResult - public mutating func insert(_ newMember: __owned R) -> (inserted: Bool, memberAfterInsert: R) { + public mutating func insert(_ newMember: __owned R) -> ( + inserted: Bool, memberAfterInsert: R + ) { if self.contains(newMember) { return (inserted: false, memberAfterInsert: newMember) } else { @@ -49,7 +58,7 @@ extension BitField { public func representableValues() -> Set { var bitValue = self.rawValue var values: [R] = [] - var counter: UInt = 0 + var counter: _CompatibilityUIntTypeAlias = 0 while bitValue != 0 { if (bitValue & 1) == 1 { /// `R` is ``LosslessRawRepresentable``. Safe to force-unwrap. @@ -82,17 +91,30 @@ extension BitField { /// A bit-field that decode/encodes itself as an integer. public struct IntBitField: BitField -where R: RawRepresentable & LosslessRawRepresentable & Hashable, R.RawValue == UInt { - public var rawValue: UInt - +where + R: RawRepresentable & LosslessRawRepresentable & Hashable, + R.RawValue == _CompatibilityUIntTypeAlias +{ + public var rawValue: _CompatibilityUIntTypeAlias + + #if Non64BitSystemsCompatibility + @_disfavoredOverload + #endif public init(rawValue: UInt = 0) { - self.rawValue = rawValue + self.rawValue = .init(rawValue) + } + + #if !Non64BitSystemsCompatibility + @_disfavoredOverload + #endif + public init(rawValue: UInt64 = 0) { + self.rawValue = .init(truncatingIfNeeded: rawValue) } } extension IntBitField: Codable { public init(from decoder: any Decoder) throws { - self.rawValue = try UInt(from: decoder) + self.rawValue = try _CompatibilityUIntTypeAlias(from: decoder) } public func encode(to encoder: any Encoder) throws { @@ -104,11 +126,24 @@ extension IntBitField: Sendable where R: Sendable {} /// A bit-field that decode/encodes itself as a string. public struct StringBitField: BitField -where R: RawRepresentable & LosslessRawRepresentable & Hashable, R.RawValue == UInt { - public var rawValue: UInt - +where + R: RawRepresentable & LosslessRawRepresentable & Hashable, + R.RawValue == _CompatibilityUIntTypeAlias +{ + public var rawValue: _CompatibilityUIntTypeAlias + + #if Non64BitSystemsCompatibility + @_disfavoredOverload + #endif public init(rawValue: UInt = 0) { - self.rawValue = rawValue + self.rawValue = .init(rawValue) + } + + #if !Non64BitSystemsCompatibility + @_disfavoredOverload + #endif + public init(rawValue: UInt64 = 0) { + self.rawValue = .init(truncatingIfNeeded: rawValue) } } @@ -128,7 +163,7 @@ extension StringBitField: Codable { public init(from decoder: any Decoder) throws { let string = try String(from: decoder) - guard let int = UInt(string) else { + guard let int = _CompatibilityUIntTypeAlias(string) else { throw DecodingError.notRepresentingUInt(string) } self.rawValue = int @@ -144,13 +179,15 @@ extension StringBitField: Sendable where R: Sendable {} //MARK: RangeReplaceableCollection + BitField extension RangeReplaceableCollection { @inlinable - public init(_ bitField: Field) where Field: BitField, Self.Element == Field.R { + public init(_ bitField: Field) + where Field: BitField, Self.Element == Field.R { self.init(bitField.representableValues()) } // Useful for optional-field conversions @inlinable - public init?(_ bitField: Field?) where Field: BitField, Self.Element == Field.R { + public init?(_ bitField: Field?) + where Field: BitField, Self.Element == Field.R { if let values = bitField?.representableValues() { self.init(values) } else { diff --git a/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift index 25a913aa..4131f1e8 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift @@ -419,7 +419,7 @@ extension Gateway { /// https://docs.discord.food/topics/gateway-events#update-time-spent-session-id public struct UpdateTimeSpentSessionID: Sendable, Codable { // Unix timestamp (in milliseconds) of when the session ID was generated - public var initialization_timestamp: Int = Int( + public var initialization_timestamp: Int64 = Int64( SuperProperties._initialisation_date.timeIntervalSince1970 * 1000 ) // A client-generated UUID, same as client_heartbeat_session_id in client properties @@ -1257,10 +1257,10 @@ extension Gateway { /// https://discord.com/developers/docs/topics/gateway-events#activity-object-activity-timestamps public struct Timestamps: Sendable, Codable, Equatable, Hashable { - public var start: Int? - public var end: Int? + public var start: DiscordTimestamp? + public var end: DiscordTimestamp? - public init(start: Int? = nil, end: Int? = nil) { + public init(start: DiscordTimestamp? = nil, end: DiscordTimestamp? = nil) { self.start = start self.end = end } @@ -1379,7 +1379,7 @@ extension Gateway { public var name: String? public var type: Kind? public var url: String? - public var created_at: Int? + public var created_at: DiscordTimestamp? public var timestamps: Timestamps? public var application_id: ApplicationSnowflake? public var details: String? @@ -1398,7 +1398,7 @@ extension Gateway { self.type = try container.decodeIfPresent(Kind.self, forKey: .type) self.url = try container.decodeIfPresent(String.self, forKey: .url) self.created_at = try container.decodeIfPresent( - Int.self, + DiscordTimestamp.self, forKey: .created_at ) self.timestamps = try container.decodeIfPresent( diff --git a/PaicordLib/Sources/DiscordModels/Types/Shared.swift b/PaicordLib/Sources/DiscordModels/Types/Shared.swift index 2755ee84..c416a1b0 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Shared.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Shared.swift @@ -340,12 +340,12 @@ public struct DiscordTimestamp: Codable, Hashable { ) } self.date = date - } else if let int = try? container.decode(Int.self) { + } else if let int = try? container.decode(Int64.self) { self.date = Date(timeIntervalSince1970: TimeInterval(int)) } else { throw DecodingError.unexpectedFormat( container.codingPath, - "Non String/Int value" + "Non String/Int64 value" ) } } From c531018850c38b96c8d40e0cbb9f5f14d5838287 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Thu, 7 May 2026 16:18:59 +0100 Subject: [PATCH 63/66] Update Audit Log.swift --- .../DiscordModels/Types/Audit Log.swift | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/PaicordLib/Sources/DiscordModels/Types/Audit Log.swift b/PaicordLib/Sources/DiscordModels/Types/Audit Log.swift index ef9050bc..7692fd74 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Audit Log.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Audit Log.swift @@ -344,7 +344,10 @@ public struct AuditLog: Sendable, Codable { String.self, forKey: .auto_moderation_rule_trigger_type ) - if let intTrigger = Int(triggerType), + + #if Non64BitSystemsCompatibility + if + let intTrigger = Int64(triggerType), let type = AutoModerationRule.TriggerKind(rawValue: intTrigger) { self.auto_moderation_rule_trigger_type = type @@ -357,6 +360,22 @@ public struct AuditLog: Sendable, Codable { ) ) } + #else + if + let intTrigger = Int(triggerType), + let type = AutoModerationRule.TriggerKind(rawValue: intTrigger) + { + self.auto_moderation_rule_trigger_type = type + } else { + throw DecodingError.keyNotFound( + CodingKeys.auto_moderation_rule_trigger_type, + .init( + codingPath: decoder.codingPath, + debugDescription: "Can't decode from value: '\(triggerType)'" + ) + ) + } + #endif self.channel_id = try container.decode( ChannelSnowflake.self, forKey: .channel_id From f8371e3edc4c67fc4a594f3ebe1e847b37ab6fef Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sat, 9 May 2026 20:56:22 +0100 Subject: [PATCH 64/66] 64 bit int usage --- PaicordLib/Sources/DiscordModels/SuperProperties.swift | 4 ++++ PaicordLib/Sources/DiscordModels/Types/Channel.swift | 2 +- PaicordLib/Sources/DiscordModels/Types/Emoji.swift | 4 ++-- .../Sources/DiscordModels/Types/Gateway+Payloads.swift | 10 +++++----- .../Sources/DiscordModels/Types/Guild Template.swift | 4 ++-- PaicordLib/Sources/DiscordModels/Types/Guild.swift | 8 ++++---- .../Sources/DiscordModels/Types/Interaction.swift | 4 ++-- .../Sources/DiscordModels/Types/Permission.swift | 7 ++++++- 8 files changed, 26 insertions(+), 17 deletions(-) diff --git a/PaicordLib/Sources/DiscordModels/SuperProperties.swift b/PaicordLib/Sources/DiscordModels/SuperProperties.swift index eda33d06..c69750ad 100644 --- a/PaicordLib/Sources/DiscordModels/SuperProperties.swift +++ b/PaicordLib/Sources/DiscordModels/SuperProperties.swift @@ -37,6 +37,10 @@ extension Gateway.Identify.ConnectionProperties { // if this is in header, the user agent will be included, otherwise it will be nil public init(ws: Bool = true) { self.os = Self.__defaultOS + #if os(watchOS) + // needs to be iOS + self.os = "iOS" + #endif self.browser = SuperProperties.browser() self.release_channel = "stable" self.system_locale = SuperProperties.GenerateLocaleHeader() diff --git a/PaicordLib/Sources/DiscordModels/Types/Channel.swift b/PaicordLib/Sources/DiscordModels/Types/Channel.swift index ee9e93f5..0553896f 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Channel.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Channel.swift @@ -221,7 +221,7 @@ public struct DiscordChannel: Sendable, Codable, Equatable, Hashable { public var available_tags: [ForumTag]? public var template: String? public var member_ids_preview: [String]? - public var version: Int? + public var version: Int64? /// Thread-only: public var member: ThreadMember? public var newly_created: Bool? diff --git a/PaicordLib/Sources/DiscordModels/Types/Emoji.swift b/PaicordLib/Sources/DiscordModels/Types/Emoji.swift index f0018f18..5538c393 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Emoji.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Emoji.swift @@ -12,7 +12,7 @@ public struct Emoji: Sendable, Codable, Equatable, Hashable { public var managed: Bool? public var animated: Bool? public var available: Bool? - public var version: Int? + public var version: Int64? public init( id: EmojiSnowflake? = nil, @@ -23,7 +23,7 @@ public struct Emoji: Sendable, Codable, Equatable, Hashable { managed: Bool? = nil, animated: Bool? = nil, available: Bool? = nil, - version: Int? = nil + version: Int64? = nil ) { self.id = id self.name = name diff --git a/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift b/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift index 4131f1e8..61eb9ca2 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Gateway+Payloads.swift @@ -589,7 +589,7 @@ extension Gateway { public var nsfw: Bool public var application_command_counts: [String: Int]? public var embedded_activities: [Gateway.Activity]? - public var version: Int? + public var version: Int64? public var guild_id: GuildSnowflake? /// Extra fields: public var joined_at: DiscordTimestamp @@ -807,7 +807,7 @@ extension Gateway { public struct GuildRoleDelete: Sendable, Codable { public var guild_id: GuildSnowflake public var role_id: RoleSnowflake - public var version: Int? + public var version: Int64? } /// Not the same as what Discord calls `Guild Scheduled Event User`. @@ -1777,7 +1777,7 @@ extension Gateway { // TODO: Make enums public var client: ClientType public var os: String - public var version: Int + public var version: Int64 @UnstableEnum public enum ClientType: Sendable, Codable { @@ -2035,7 +2035,7 @@ extension Gateway { public struct ChannelPinsAcknowledge: Sendable, Codable { public var channel_id: ChannelSnowflake public var timestamp: DiscordTimestamp - public var version: Int + public var version: Int64 } /// https://docs.discord.food/topics/gateway-events#user-non-channel-ack-structure @@ -2043,7 +2043,7 @@ extension Gateway { public var ack_type: ReadState.Kind public var resource_id: UserSnowflake public var entity_id: AnySnowflake - public var version: Int + public var version: Int64 } /// https://docs.discord.food/resources/message#create-attachments diff --git a/PaicordLib/Sources/DiscordModels/Types/Guild Template.swift b/PaicordLib/Sources/DiscordModels/Types/Guild Template.swift index 7bc7c9b9..bcdeaa15 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Guild Template.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Guild Template.swift @@ -21,7 +21,7 @@ public struct GuildTemplate: Codable, Sendable { public var managed: Bool? public var mentionable: Bool public var tags: DiscordModels.Role.Tags? - public var version: Int? + public var version: Int64? } /// https://discord.com/developers/docs/resources/guild#guild-object-guild-structure @@ -75,7 +75,7 @@ public struct GuildTemplate: Codable, Sendable { public var nsfw: Bool? public var application_command_counts: [String: Int]? public var embedded_activities: [Gateway.Activity]? - public var version: Int? + public var version: Int64? public var guild_id: GuildSnowflake? } diff --git a/PaicordLib/Sources/DiscordModels/Types/Guild.swift b/PaicordLib/Sources/DiscordModels/Types/Guild.swift index 13ddca7d..2b88d805 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Guild.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Guild.swift @@ -51,7 +51,7 @@ public struct Guild: Sendable, Codable, Hashable, Equatable, Identifiable { application_command_counts: [String: Int]? = nil, embedded_activities: [Gateway.Activity]? = nil, members: [Guild.Member]? = nil, - version: Int? = nil, + version: Int64? = nil, guild_id: GuildSnowflake? = nil, voice_states: [VoiceState]? = nil ) { @@ -517,7 +517,7 @@ public struct Guild: Sendable, Codable, Hashable, Equatable, Identifiable { public var application_command_counts: [String: Int]? public var embedded_activities: [Gateway.Activity]? public var members: [Guild.Member]? - public var version: Int? + public var version: Int64? public var guild_id: GuildSnowflake? public var voice_states: [VoiceState]? } @@ -572,7 +572,7 @@ public struct PartialGuild: Sendable, Codable, Equatable, Hashable { public var nsfw: Bool? public var application_command_counts: [String: Int]? public var embedded_activities: [Gateway.Activity]? - public var version: Int? + public var version: Int64? public var guild_id: GuildSnowflake? public var voice_states: [VoiceState]? } @@ -786,7 +786,7 @@ extension Guild { public var notify_highlights: Int public var suppress_everyone: Bool public var suppress_roles: Bool - public var version: Int + public var version: Int64 /// https://docs.discord.food/resources/user-settings#partial-user-guild-settings-structure public struct Partial: Sendable, Codable { diff --git a/PaicordLib/Sources/DiscordModels/Types/Interaction.swift b/PaicordLib/Sources/DiscordModels/Types/Interaction.swift index f959a00c..3c50e5c7 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Interaction.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Interaction.swift @@ -302,7 +302,7 @@ public struct Interaction: Sendable, Codable { public var member: Guild.Member? public var user: DiscordUser? public var token: String - public var version: Int + public var version: Int64 public var message: DiscordChannel.Message? public var locale: DiscordLocale? public var guild_locale: DiscordLocale? @@ -390,7 +390,7 @@ public struct Interaction: Sendable, Codable { ) self.user = try container.decodeIfPresent(DiscordUser.self, forKey: .user) self.token = try container.decode(String.self, forKey: .token) - self.version = try container.decode(Int.self, forKey: .version) + self.version = try container.decode(Int64.self, forKey: .version) self.message = try container.decodeIfPresent( DiscordChannel.Message.self, forKey: .message diff --git a/PaicordLib/Sources/DiscordModels/Types/Permission.swift b/PaicordLib/Sources/DiscordModels/Types/Permission.swift index 707ff7c4..6f6bfe40 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Permission.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Permission.swift @@ -57,7 +57,12 @@ public enum Permission: Sendable, Codable { case sendPolls // 49 case useExternalApps // 50 case pinMessages // 51 + + #if Non64BitSystemsCompatibility + case __undocumented(UInt64) + #else case __undocumented(UInt) + #endif } /// https://discord.com/developers/docs/topics/permissions#role-object @@ -104,6 +109,6 @@ public struct Role: Sendable, Codable, Equatable, Hashable { public var managed: Bool public var mentionable: Bool public var tags: Tags? - public var version: Int? + public var version: Int64? public var flags: IntBitField } From ebcc2a63d450e7d24c7f0ce411bb5a9d0a3bdff0 Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Sun, 10 May 2026 16:29:15 +0100 Subject: [PATCH 65/66] Update Channel.swift --- .../Sources/DiscordModels/Types/Channel.swift | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/PaicordLib/Sources/DiscordModels/Types/Channel.swift b/PaicordLib/Sources/DiscordModels/Types/Channel.swift index 0553896f..2b427eb8 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Channel.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Channel.swift @@ -653,10 +653,34 @@ extension DiscordChannel { public var id: InteractionSnowflake public var type: Interaction.Kind public var user: DiscordUser - public var authorizing_integration_owners: [DiscordApplication.IntegrationKind: AnySnowflake] + public var authorizing_integration_owners: + [DiscordApplication.IntegrationKind: AnySnowflake] public var original_response_message_id: MessageSnowflake? public var target_user: DiscordUser? public var target_message_id: MessageSnowflake? + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(InteractionSnowflake.self, forKey: .id) + self.type = try container.decode(Interaction.Kind.self, forKey: .type) + self.user = try container.decode(DiscordUser.self, forKey: .user) + self.authorizing_integration_owners = (try? container.decode( + [DiscordApplication.IntegrationKind: AnySnowflake].self, + forKey: .authorizing_integration_owners + )) ?? [:] + self.original_response_message_id = try container.decodeIfPresent( + MessageSnowflake.self, + forKey: .original_response_message_id + ) + self.target_user = try container.decodeIfPresent( + DiscordUser.self, + forKey: .target_user + ) + self.target_message_id = try container.decodeIfPresent( + MessageSnowflake.self, + forKey: .target_message_id + ) + } } public struct Call: Sendable, Codable, Equatable, Hashable { @@ -943,7 +967,8 @@ extension DiscordChannel { } /// https://discord.com/developers/docs/resources/message#embed-object -public struct Embed: Sendable, Codable, Equatable, Hashable, ValidatablePayload { +public struct Embed: Sendable, Codable, Equatable, Hashable, ValidatablePayload +{ /// https://discord.com/developers/docs/resources/message#embed-object-embed-types @UnstableEnum From 8b85e31c3d03d483f78a7f42eb5bd4ed7b46f20c Mon Sep 17 00:00:00 2001 From: Lakhan Lothiyi Date: Mon, 11 May 2026 11:10:31 +0100 Subject: [PATCH 66/66] Update Snowflake.swift --- PaicordLib/Sources/DiscordModels/Types/Snowflake.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PaicordLib/Sources/DiscordModels/Types/Snowflake.swift b/PaicordLib/Sources/DiscordModels/Types/Snowflake.swift index 1db6551d..ba3b3cec 100644 --- a/PaicordLib/Sources/DiscordModels/Types/Snowflake.swift +++ b/PaicordLib/Sources/DiscordModels/Types/Snowflake.swift @@ -175,7 +175,7 @@ public struct SnowflakeInfo: Sendable { public enum Error: Swift.Error, CustomStringConvertible { /// Entered field '\(name)' is bigger than expected. It has a value of '\(value)', but max accepted is '\(max)' - case fieldTooBig(_ name: String, value: String, max: Int) + case fieldTooBig(_ name: String, value: String, max: Int64) /// Entered field '\(name)' is smaller than expected. It has a value of '\(value)', but min accepted is '\(min)' case fieldTooSmall(_ name: String, value: String, min: UInt64) @@ -269,7 +269,7 @@ public struct SnowflakeInfo: Sendable { } let timeSince1970 = UInt64(date.timeIntervalSince1970) - guard timeSince1970 <= (1 << 42 / 1_000) else { + guard timeSince1970 <= (Int64(1) << 42 / 1_000) else { throw Error.fieldTooBig( "date", value: "\(timeSince1970)", @@ -279,8 +279,8 @@ public struct SnowflakeInfo: Sendable { self.timestamp = UInt64(date.timeIntervalSince1970 * 1_000) - guard timestamp < (1 << 42) else { - let max = (1 << 42 / 1_000) - 1 + guard timestamp < (Int64(1) << 42) else { + let max = (Int64(1) << 42 / 1_000) - 1 throw Error.fieldTooBig("date", value: "\(timestamp)", max: max) } guard workerId <= (1 << 5) else {