#[cfg(any(feature = "zlib", feature = "zstd"))]
use crate::compression::Decompressor;
use crate::{
API_VERSION, Command, Config, Message, ShardId,
channel::{MessageChannel, MessageSender},
error::{ReceiveMessageError, ReceiveMessageErrorType},
json,
latency::Latency,
queue::{InMemoryQueue, Queue},
ratelimiter::CommandRatelimiter,
session::Session,
};
use futures_core::Stream;
use futures_sink::Sink;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::{
env::consts::OS,
error::Error,
fmt,
future::Future,
io,
pin::Pin,
str,
sync::Arc,
task::{Context, Poll, ready},
};
use tokio::{
net::TcpStream,
sync::oneshot,
time::{self, Duration, Instant, Interval, MissedTickBehavior},
};
use tokio_websockets::{ClientBuilder, Error as WebsocketError, Limits, MaybeTlsStream};
use twilight_model::gateway::{
CloseCode, CloseFrame, Intents, OpCode,
event::GatewayEventDeserializer,
payload::{
incoming::Hello,
outgoing::{
Heartbeat, Identify, Resume,
identify::{IdentifyInfo, IdentifyProperties},
},
},
};
const GATEWAY_URL: &str = "wss://gateway.discord.gg";
const COMPRESSION_FEATURES: &str = if cfg!(feature = "zstd") {
"&compress=zstd-stream"
} else if cfg!(feature = "zlib") {
"&compress=zlib-stream"
} else {
""
};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
type Connection = tokio_websockets::WebSocketStream<MaybeTlsStream<TcpStream>>;
type GenericError = Box<dyn Error + Send + Sync>;
struct ConnectionFuture(Pin<Box<dyn Future<Output = Result<Connection, GenericError>> + Send>>);
impl fmt::Debug for ConnectionFuture {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("ConnectionFuture")
.field(&"<async fn>")
.finish()
}
}
#[derive(Clone, Debug)]
enum CloseInitiator {
Gateway(Option<u16>),
Shard(CloseFrame<'static>),
Transport,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ShardState {
Active,
Disconnected {
reconnect_attempts: u8,
},
FatallyClosed,
Identifying,
Resuming,
}
impl ShardState {
fn from_close_code(close_code: Option<u16>) -> Self {
match close_code.map(CloseCode::try_from) {
Some(Ok(close_code)) if !close_code.can_reconnect() => Self::FatallyClosed,
_ => Self::Disconnected {
reconnect_attempts: 0,
},
}
}
const fn is_disconnected(self) -> bool {
matches!(self, Self::Disconnected { .. })
}
pub const fn is_identified(self) -> bool {
matches!(self, Self::Active | Self::Resuming)
}
}
#[derive(Deserialize)]
struct MinimalEvent<T> {
#[serde(rename = "d")]
data: T,
}
#[derive(Deserialize)]
struct MinimalReady {
resume_gateway_url: Box<str>,
session_id: String,
}
#[derive(Debug)]
struct Pending {
gateway_event: Option<Message>,
is_heartbeat: bool,
}
impl Pending {
fn event<T: Serialize>(event: T, is_heartbeat: bool) -> Option<Self> {
Some(Self {
gateway_event: Some(Message::Text(
json::to_string(&event).expect("json serialization is infallible"),
)),
is_heartbeat,
})
}
}
#[derive(Debug)]
pub struct Shard<Q = InMemoryQueue> {
config: Config<Q>,
connection_future: Option<ConnectionFuture>,
connection: Option<Connection>,
#[cfg(any(feature = "zlib", feature = "zstd"))]
decompressor: Decompressor,
heartbeat_interval: Option<Interval>,
heartbeat_interval_event: bool,
id: ShardId,
identify_rx: Option<oneshot::Receiver<()>>,
pending: Option<Pending>,
latency: Latency,
ratelimiter: Option<CommandRatelimiter>,
resume_url: Option<Box<str>>,
session: Option<Session>,
state: ShardState,
user_channel: MessageChannel,
}
impl Shard {
pub fn new(id: ShardId, token: String, intents: Intents) -> Self {
Self::with_config(id, Config::new(token, intents))
}
}
impl<Q> Shard<Q> {
pub fn with_config(shard_id: ShardId, mut config: Config<Q>) -> Self {
let session = config.take_session();
let mut resume_url = config.take_resume_url();
if session.is_none() {
resume_url = None;
}
Self {
config,
connection_future: None,
connection: None,
#[cfg(any(feature = "zlib", feature = "zstd"))]
decompressor: Decompressor::new(),
heartbeat_interval: None,
heartbeat_interval_event: false,
id: shard_id,
identify_rx: None,
pending: None,
latency: Latency::new(),
ratelimiter: None,
resume_url,
session,
state: ShardState::Disconnected {
reconnect_attempts: 0,
},
user_channel: MessageChannel::new(),
}
}
pub const fn config(&self) -> &Config<Q> {
&self.config
}
pub const fn id(&self) -> ShardId {
self.id
}
pub const fn state(&self) -> ShardState {
self.state
}
pub const fn latency(&self) -> &Latency {
&self.latency
}
pub const fn ratelimiter(&self) -> Option<&CommandRatelimiter> {
self.ratelimiter.as_ref()
}
pub fn resume_url(&self) -> Option<&str> {
self.resume_url.as_deref()
}
pub const fn session(&self) -> Option<&Session> {
self.session.as_ref()
}
#[allow(clippy::missing_panics_doc)]
pub fn command(&self, command: &impl Command) {
self.send(json::to_string(command).expect("serialization cannot fail"));
}
#[allow(clippy::missing_panics_doc)]
pub fn send(&self, json: String) {
self.user_channel
.command_tx
.send(json)
.expect("channel open");
}
pub fn close(&self, close_frame: CloseFrame<'static>) {
_ = self.user_channel.close_tx.try_send(close_frame);
}
pub fn sender(&self) -> MessageSender {
self.user_channel.sender()
}
fn disconnect(&mut self, initiator: CloseInitiator) {
self.heartbeat_interval = None;
self.ratelimiter = None;
self.identify_rx = None;
self.state = match initiator {
CloseInitiator::Gateway(close_code) => ShardState::from_close_code(close_code),
_ => ShardState::Disconnected {
reconnect_attempts: 0,
},
};
if let CloseInitiator::Shard(frame) = initiator {
if matches!(frame.code, 1000 | 1001) {
self.resume_url = None;
self.session = None;
}
self.pending = Some(Pending {
gateway_event: Some(Message::Close(Some(frame))),
is_heartbeat: false,
});
}
}
fn parse_event<T: DeserializeOwned>(
json: &str,
) -> Result<MinimalEvent<T>, ReceiveMessageError> {
json::from_str::<MinimalEvent<T>>(json).map_err(|source| ReceiveMessageError {
kind: ReceiveMessageErrorType::Deserializing {
event: json.to_owned(),
},
source: Some(Box::new(source)),
})
}
fn poll_connect(
&mut self,
cx: &mut Context<'_>,
attempt: u8,
) -> Poll<Result<(), ReceiveMessageError>> {
let fut = self.connection_future.get_or_insert_with(|| {
let base_url = self
.resume_url
.as_deref()
.or_else(|| self.config.proxy_url())
.unwrap_or(GATEWAY_URL);
let base_url_len = base_url.len();
let uri = format!("{base_url}/?v={API_VERSION}&encoding=json{COMPRESSION_FEATURES}");
let tls = Arc::clone(&self.config.tls);
ConnectionFuture(Box::pin(async move {
let delay = Duration::from_secs(2u8.saturating_pow(attempt.into()).into());
time::sleep(delay).await;
tracing::debug!(url = &uri[..base_url_len], "connecting");
let builder = ClientBuilder::new()
.uri(&uri)
.expect("valid URL")
.limits(Limits::unlimited())
.connector(&tls);
Ok(time::timeout(CONNECT_TIMEOUT, builder.connect()).await??.0)
}))
});
let res = ready!(Pin::new(&mut fut.0).poll(cx));
self.connection_future = None;
match res {
Ok(connection) => {
self.connection = Some(connection);
self.state = ShardState::Identifying;
#[cfg(any(feature = "zlib", feature = "zstd"))]
self.decompressor.reset();
}
Err(source) => {
self.resume_url = None;
self.state = ShardState::Disconnected {
reconnect_attempts: attempt + 1,
};
return Poll::Ready(Err(ReceiveMessageError {
kind: ReceiveMessageErrorType::Reconnect,
source: Some(source),
}));
}
}
Poll::Ready(Ok(()))
}
}
impl<Q: Queue> Shard<Q> {
fn poll_send(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), WebsocketError>> {
loop {
if let Some(pending) = self.pending.as_mut() {
ready!(Pin::new(self.connection.as_mut().unwrap()).poll_ready(cx))?;
let is_ratelimited = pending.gateway_event.as_ref().is_some_and(Message::is_text)
&& !pending.is_heartbeat;
if is_ratelimited && let Some(ratelimiter) = &mut self.ratelimiter {
ready!(ratelimiter.poll_acquire(cx));
}
if let Some(msg) = pending.gateway_event.take().map(Message::into_websocket) {
Pin::new(self.connection.as_mut().unwrap()).start_send(msg)?;
}
ready!(Pin::new(self.connection.as_mut().unwrap()).poll_flush(cx))?;
if pending.is_heartbeat {
self.latency.record_sent();
}
self.pending = None;
}
if !self.state.is_disconnected()
&& let Poll::Ready(frame) = self.user_channel.close_rx.poll_recv(cx)
{
let frame = frame.expect("shard owns channel");
tracing::debug!("sending close frame from user channel");
self.disconnect(CloseInitiator::Shard(frame));
continue;
}
if let Some(heartbeater) = &mut self.heartbeat_interval
&& heartbeater.poll_tick(cx).is_ready()
{
if self.latency.sent().is_some() && !self.heartbeat_interval_event {
tracing::info!("connection is failed or \"zombied\"");
return Poll::Ready(Err(WebsocketError::Io(io::ErrorKind::TimedOut.into())));
}
tracing::debug!("sending heartbeat");
self.pending =
Pending::event(Heartbeat::new(self.session().map(Session::sequence)), true);
self.heartbeat_interval_event = false;
continue;
}
let not_ratelimited = self
.ratelimiter
.as_mut()
.is_none_or(|ratelimiter| ratelimiter.poll_available(cx).is_ready());
if not_ratelimited
&& let Some(rx) = &mut self.identify_rx
&& let Poll::Ready(canceled) = Pin::new(rx).poll(cx).map(|r| r.is_err())
{
if canceled {
self.identify_rx = Some(self.config.queue().enqueue(self.id.number()));
continue;
}
tracing::debug!("sending identify");
self.pending = Pending::event(
Identify::new(IdentifyInfo {
compress: false,
intents: self.config.intents(),
large_threshold: self.config.large_threshold(),
presence: self.config.presence().cloned(),
properties: self
.config
.identify_properties()
.cloned()
.unwrap_or_else(default_identify_properties),
shard: Some(self.id),
token: self.config.token().to_owned(),
}),
false,
);
self.identify_rx = None;
continue;
}
if not_ratelimited
&& self.state.is_identified()
&& let Poll::Ready(command) = self.user_channel.command_rx.poll_recv(cx)
{
let command = command.expect("shard owns channel");
tracing::debug!("sending command from user channel");
self.pending = Some(Pending {
gateway_event: Some(Message::Text(command)),
is_heartbeat: false,
});
continue;
}
return Poll::Ready(Ok(()));
}
}
#[allow(clippy::too_many_lines)]
fn process(&mut self, event: &str) -> Result<(), ReceiveMessageError> {
let (raw_opcode, maybe_sequence, maybe_event_type) =
GatewayEventDeserializer::from_json(event)
.ok_or_else(|| ReceiveMessageError {
kind: ReceiveMessageErrorType::Deserializing {
event: event.to_owned(),
},
source: Some("missing opcode".into()),
})?
.into_parts();
if self.latency.sent().is_some() {
self.heartbeat_interval_event = true;
}
match OpCode::from(raw_opcode) {
Some(OpCode::Dispatch) => {
let event_type = maybe_event_type.ok_or_else(|| ReceiveMessageError {
kind: ReceiveMessageErrorType::Deserializing {
event: event.to_owned(),
},
source: Some("missing dispatch event type".into()),
})?;
let sequence = maybe_sequence.ok_or_else(|| ReceiveMessageError {
kind: ReceiveMessageErrorType::Deserializing {
event: event.to_owned(),
},
source: Some("missing sequence".into()),
})?;
tracing::debug!(%event_type, %sequence, "received dispatch");
match event_type.as_ref() {
"READY" => {
let event = Self::parse_event::<MinimalReady>(event)?;
self.resume_url = Some(event.data.resume_gateway_url);
self.session = Some(Session::new(sequence, event.data.session_id));
self.state = ShardState::Active;
}
"RESUMED" => self.state = ShardState::Active,
_ => {}
}
if let Some(session) = self.session.as_mut() {
session.set_sequence(sequence);
}
}
Some(OpCode::Heartbeat) => {
tracing::debug!("received heartbeat");
self.pending =
Pending::event(Heartbeat::new(self.session().map(Session::sequence)), true);
}
Some(OpCode::HeartbeatAck) => {
let requested = self.latency.received().is_none() && self.latency.sent().is_some();
if requested {
tracing::debug!("received heartbeat ack");
self.latency.record_received();
} else {
tracing::info!("received unrequested heartbeat ack");
}
}
Some(OpCode::Hello) => {
let event = Self::parse_event::<Hello>(event)?;
let heartbeat_interval = Duration::from_millis(event.data.heartbeat_interval);
let jitter = heartbeat_interval.mul_f64(fastrand::f64());
tracing::debug!(?heartbeat_interval, ?jitter, "received hello");
if self.config().ratelimit_messages() {
self.ratelimiter = Some(CommandRatelimiter::new(heartbeat_interval));
}
let mut interval = time::interval_at(Instant::now() + jitter, heartbeat_interval);
interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
self.heartbeat_interval = Some(interval);
self.latency = Latency::new();
if let Some(session) = &self.session {
self.pending = Pending::event(
Resume::new(session.sequence(), session.id(), self.config.token()),
false,
);
self.state = ShardState::Resuming;
} else {
self.identify_rx = Some(self.config.queue().enqueue(self.id.number()));
}
}
Some(OpCode::InvalidSession) => {
let resumable = Self::parse_event(event)?.data;
tracing::debug!(resumable, "received invalid session");
if resumable {
self.disconnect(CloseInitiator::Shard(CloseFrame::RESUME));
} else {
self.disconnect(CloseInitiator::Shard(CloseFrame::NORMAL));
}
}
Some(OpCode::Reconnect) => {
tracing::debug!("received reconnect");
self.disconnect(CloseInitiator::Shard(CloseFrame::RESUME));
}
_ => tracing::info!("received an unknown opcode: {raw_opcode}"),
}
Ok(())
}
}
impl<Q: Queue + Unpin> Stream for Shard<Q> {
type Item = Result<Message, ReceiveMessageError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let message = loop {
match self.state {
ShardState::FatallyClosed => {
_ = ready!(
Pin::new(
self.connection
.as_mut()
.expect("poll_next called after Poll::Ready(None)")
)
.poll_close(cx)
);
self.connection = None;
return Poll::Ready(None);
}
ShardState::Disconnected { reconnect_attempts } if self.connection.is_none() => {
ready!(self.poll_connect(cx, reconnect_attempts))?;
}
_ => {}
}
if ready!(self.poll_send(cx)).is_err() {
self.disconnect(CloseInitiator::Transport);
self.connection = None;
return Poll::Ready(Some(Ok(Message::ABNORMAL_CLOSE)));
}
match ready!(Pin::new(self.connection.as_mut().unwrap()).poll_next(cx)) {
Some(Ok(message)) => {
#[cfg(any(feature = "zlib", feature = "zstd"))]
if message.is_binary() {
match self.decompressor.decompress(message.as_payload()) {
#[cfg(feature = "zstd")]
Ok(message) => break Message::Text(message),
#[cfg(all(not(feature = "zstd"), feature = "zlib"))]
Ok(Some(message)) => break Message::Text(message),
#[cfg(all(not(feature = "zstd"), feature = "zlib"))]
Ok(None) => continue,
Err(source) => {
self.disconnect(CloseInitiator::Shard(CloseFrame::RESUME));
return Poll::Ready(Some(Err(
ReceiveMessageError::from_compression(source),
)));
}
}
}
if let Some(message) = Message::from_websocket_msg(&message) {
break message;
}
}
Some(Err(_)) if self.state.is_disconnected() => {}
Some(Err(_)) => {
self.disconnect(CloseInitiator::Transport);
return Poll::Ready(Some(Ok(Message::ABNORMAL_CLOSE)));
}
None => {
_ = ready!(Pin::new(self.connection.as_mut().unwrap()).poll_close(cx));
tracing::debug!("gateway WebSocket connection closed");
if !self.state.is_disconnected() {
self.disconnect(CloseInitiator::Transport);
}
self.connection = None;
}
}
};
match &message {
Message::Close(frame) => {
tracing::debug!(?frame, "received WebSocket close message");
if !self.state.is_disconnected() {
self.disconnect(CloseInitiator::Gateway(frame.as_ref().map(|f| f.code)));
}
}
Message::Text(event) => {
self.process(event)?;
}
}
Poll::Ready(Some(Ok(message)))
}
}
fn default_identify_properties() -> IdentifyProperties {
IdentifyProperties::new("twilight.rs", "twilight.rs", OS)
}
#[cfg(test)]
mod tests {
use super::Shard;
use static_assertions::{assert_impl_all, assert_not_impl_any};
use std::fmt::Debug;
assert_impl_all!(Shard: Debug, Send);
assert_not_impl_any!(Shard: Sync);
}