use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde_json::json;
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::Message;
use crate::error::{Error, Result};
pub const GATEWAY_URL: &str = "wss://gateway.discord.gg/?v=10&encoding=json";
const RECONNECT_DELAY: Duration = Duration::from_secs(5);
pub trait EventHandler: Send + Sync {
fn on_event(&self, name: &str, data: &serde_json::Value);
}
pub struct GatewayClient {
token: String,
intents: u32,
}
impl GatewayClient {
#[must_use]
pub fn new(token: impl Into<String>, intents: u32) -> Self {
Self {
token: token.into(),
intents,
}
}
pub async fn run<H: EventHandler>(&self, handler: &H) -> Result<()> {
self.run_with_url(GATEWAY_URL, handler).await
}
pub async fn run_with_url<H: EventHandler>(&self, url: &str, handler: &H) -> Result<()> {
loop {
let closed = self.connect_once(url, handler).await?;
if closed {
return Ok(());
}
tracing::warn!("discord gateway dropped; reconnecting");
tokio::time::sleep(RECONNECT_DELAY).await;
}
}
async fn connect_once<H: EventHandler>(&self, url: &str, handler: &H) -> Result<bool> {
let (stream, _) = tokio_tungstenite::connect_async(url)
.await
.map_err(|err| Error::Gateway(format!("connect failed: {err}")))?;
let (sink, mut stream) = stream.split();
let sink = Arc::new(Mutex::new(sink));
let seq = Arc::new(AtomicI64::new(-1));
let hello = read_text(&mut stream).await?;
let interval_ms = parse_hello(&hello)?;
{
let mut guard = sink.lock().await;
guard
.send(Message::Text(
identify_payload(&self.token, self.intents).into(),
))
.await
.map_err(|err| Error::Gateway(format!("identify failed: {err}")))?;
drop(guard);
}
let heartbeat_sink = Arc::clone(&sink);
let heartbeat_seq = Arc::clone(&seq);
let heartbeat_task = tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_millis(interval_ms));
loop {
ticker.tick().await;
let payload = heartbeat_payload(heartbeat_seq.load(Ordering::SeqCst));
let mut guard = heartbeat_sink.lock().await;
if guard.send(Message::Text(payload.into())).await.is_err() {
break;
}
}
});
let mut reconnect = true;
while let Some(message) = stream.next().await {
let message = message.map_err(|err| Error::Gateway(format!("read failed: {err}")))?;
match message {
Message::Text(text) => {
let value: serde_json::Value = serde_json::from_str(&text)
.map_err(|err| Error::Gateway(format!("bad gateway frame: {err}")))?;
let op = value.get("op").and_then(serde_json::Value::as_u64);
match op {
Some(0) => {
if let Some(s) = value.get("s").and_then(serde_json::Value::as_i64) {
seq.store(s, Ordering::SeqCst);
}
if let (Some(name), Some(data)) = (
value.get("t").and_then(serde_json::Value::as_str),
value.get("d"),
) {
handler.on_event(name, data);
}
}
Some(7 | 9) => break,
Some(1) => {
let payload = heartbeat_payload(seq.load(Ordering::SeqCst));
let mut guard = sink.lock().await;
if guard.send(Message::Text(payload.into())).await.is_err() {
break;
}
}
_ => {}
}
}
Message::Close(_) => {
reconnect = false;
break;
}
_ => {}
}
}
heartbeat_task.abort();
drop(sink);
Ok(!reconnect)
}
}
async fn read_text<S>(stream: &mut S) -> Result<String>
where
S: StreamExt<Item = std::result::Result<Message, tokio_tungstenite::tungstenite::Error>>
+ Unpin,
{
while let Some(message) = stream.next().await {
let message = message.map_err(|err| Error::Gateway(format!("read failed: {err}")))?;
if let Message::Text(text) = message {
return Ok(text.to_string());
}
}
Err(Error::Gateway("gateway closed before hello".to_owned()))
}
fn parse_hello(hello: &str) -> Result<u64> {
let value: serde_json::Value =
serde_json::from_str(hello).map_err(|err| Error::Gateway(format!("bad hello: {err}")))?;
value
.get("d")
.and_then(|d| d.get("heartbeat_interval"))
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| Error::Gateway("hello carries no heartbeat_interval".to_owned()))
}
fn identify_payload(token: &str, intents: u32) -> String {
json!({
"op": 2,
"d": {
"token": token,
"intents": intents,
"properties": {
"os": "linux",
"browser": "autumn-plugin-discord",
"device": "autumn-plugin-discord",
},
},
})
.to_string()
}
fn heartbeat_payload(seq: i64) -> String {
let data = if seq < 0 {
serde_json::Value::Null
} else {
serde_json::Value::from(seq)
};
json!({ "op": 1, "d": data }).to_string()
}
#[cfg(test)]
mod tests;