///|
/// UDP seam used by voice discovery and media transport.
pub(open) trait VoiceUdp {
async fn recv(Self, FixedArray[Byte]) -> Int32
async fn send(Self, Bytes) -> Unit
async fn close(Self) -> Unit
}
///|
/// Errors raised by voice UDP setup and IP discovery.
#doc(hidden)
pub(all) suberror VoiceUdpError {
InvalidDiscoveryPacket(reason~ : String)
IpDiscoveryFailed(attempts~ : Int32, reason~ : String)
} derive(Debug, Eq)
///|
priv struct SocketVoiceUdp {
client : @socket.UdpClient
}
///|
impl VoiceUdp for SocketVoiceUdp with fn recv(self, buffer) {
self.client.recv(buffer)
}
///|
impl VoiceUdp for SocketVoiceUdp with fn send(self, bytes) {
self.client.send(bytes)
}
///|
impl VoiceUdp for SocketVoiceUdp with fn close(self) {
self.client.close()
}
///|
/// Open a connected UDP socket for a Discord voice server.
#doc(hidden)
pub async fn open_voice_udp(ip : String, port : Int32) -> &VoiceUdp {
let address = @socket.Addr::resolve(ip, port~)
let client = @socket.UdpClient(address)
SocketVoiceUdp::{ client, }
}
///|
/// Build Discord's 74-byte voice IP discovery request.
#doc(hidden)
pub fn build_ip_discovery_request(ssrc : UInt32) -> Bytes {
let bytes : Array[Byte] = [0, 1, 0, 70]
bytes.push((ssrc >> 24).to_byte())
bytes.push((ssrc >> 16).to_byte())
bytes.push((ssrc >> 8).to_byte())
bytes.push(ssrc.to_byte())
for _ in 8..<74 {
bytes.push(0)
}
Bytes::from_array(bytes)
}
///|
/// Parse Discord's 74-byte voice IP discovery response.
#doc(hidden)
pub fn parse_ip_discovery_response(
bytes : Bytes,
expected_ssrc~ : UInt32,
) -> (String, Int32) raise VoiceUdpError {
if bytes.length() != 74 {
raise InvalidDiscoveryPacket(reason="response must contain 74 bytes")
}
// type, length, SSRC, NUL-padded address, port
guard bytes
is [u16be(2), u16be(70), u32be(ssrc), .. address_field, u16be(port)] else {
raise InvalidDiscoveryPacket(
reason="expected discovery response type 2 with length 70",
)
}
if ssrc != expected_ssrc {
raise InvalidDiscoveryPacket(reason="response SSRC does not match request")
}
let mut terminator = -1
for index in 0.. raise InvalidDiscoveryPacket(reason="response address is not UTF-8")
}
if address.is_empty() {
raise InvalidDiscoveryPacket(reason="response address is empty")
}
(address, port.reinterpret_as_int())
}
///|
/// Run voice IP discovery, retrying only receive timeouts, each timed on
/// `clock`.
#doc(hidden)
pub async fn discover_external_address(
udp : &VoiceUdp,
ssrc : UInt32,
retries~ : Int32 = 3,
timeout_ms~ : Int32 = 1000,
clock~ : &@hclock.Clock = @transport.AsyncClock::new(),
) -> (String, Int32) {
if retries <= 0 || timeout_ms <= 0 {
raise VoiceUdpError::IpDiscoveryFailed(
attempts=0,
reason="retries and timeout_ms must be positive",
)
}
let request = build_ip_discovery_request(ssrc)
for attempt in 0.. {
udp.recv(buffer)
})
guard received is Some(received) else {
if attempt + 1 == retries {
raise VoiceUdpError::IpDiscoveryFailed(
attempts=retries,
reason="timed out waiting for discovery response",
)
}
continue
}
let response = Bytes::from_array(buffer[:received])
return parse_ip_discovery_response(response, expected_ssrc=ssrc)
}
raise VoiceUdpError::IpDiscoveryFailed(
attempts=retries,
reason="discovery exhausted",
)
}