use extism_pdk::*;
use nd_pdk::host::{cache, scheduler, websocket};
use serde::{Deserialize, Serialize};
const HEARTBEAT_OP_CODE: i32 = 1;
const GATE_OP_CODE: i32 = 2;
const PRESENCE_OP_CODE: i32 = 3;
const HEARTBEAT_INTERVAL: i32 = 41;
const DEFAULT_IMAGE: &str = "https://i.imgur.com/hb3XPzA.png";
const PAYLOAD_HEARTBEAT: &str = "heartbeat";
#[derive(Serialize)]
pub struct Activity {
pub name: String,
#[serde(rename = "type")]
pub activity_type: i32,
pub details: String,
pub state: String,
#[serde(rename = "application_id")]
pub application: String,
pub timestamps: ActivityTimestamps,
pub assets: ActivityAssets,
}
#[derive(Serialize)]
pub struct ActivityTimestamps {
pub start: i64,
pub end: i64,
}
#[derive(Serialize)]
pub struct ActivityAssets {
pub large_image: String,
pub large_text: String,
}
#[derive(Serialize)]
struct PresencePayload {
activities: Vec<Activity>,
since: i64,
status: String,
afk: bool,
}
#[derive(Serialize)]
struct IdentifyPayload {
token: String,
intents: i32,
properties: IdentifyProperties,
}
#[derive(Serialize)]
struct IdentifyProperties {
os: String,
browser: String,
device: String,
}
#[derive(Serialize)]
struct GatewayMessage<T> {
op: i32,
d: T,
}
#[derive(Deserialize)]
struct GatewayResponse {
op: i32,
#[serde(default)]
#[allow(dead_code)]
d: Option<serde_json::Value>,
#[serde(default)]
s: Option<i64>,
}
fn connection_key(username: &str) -> String {
format!("discord.connection.{}", username)
}
fn token_key(username: &str) -> String {
format!("discord.token.{}", username)
}
fn sequence_key(username: &str) -> String {
format!("discord.sequence.{}", username)
}
fn is_connected(username: &str) -> bool {
match send_heartbeat(username) {
Ok(_) => true,
Err(e) => {
trace!("Connection test failed for user {}: {:?}", username, e);
false
}
}
}
pub fn cleanup_connection(username: &str) {
info!("Cleaning up failed connection for user {}", username);
if let Err(e) = scheduler::cancel_schedule(username) {
warn!("Failed to cancel heartbeat schedule for user {}: {:?}", username, e);
}
let conn_key = connection_key(username);
if let Ok(Some(conn_id)) = cache::get_string(&conn_key) {
if !conn_id.is_empty() {
if let Err(e) = websocket::close_connection(&conn_id, 1000, "Reconnecting") {
trace!("Failed to close WebSocket for user {}: {:?}", username, e);
}
let reverse_key = format!("discord.reverse.{}", conn_id);
let _ = cache::remove(&reverse_key);
}
}
let _ = cache::remove(&conn_key);
let _ = cache::remove(&sequence_key(username));
info!("Cleaned up connection for user {}", username);
}
pub fn handle_connection_close(connection_id: &str) {
if let Ok(Some(username)) = find_username_for_connection(connection_id) {
info!("Connection closed for user {}, cleaning up", username);
if let Err(e) = scheduler::cancel_schedule(&username) {
trace!("Failed to cancel heartbeat schedule for user {}: {:?}", username, e);
}
let _ = scheduler::cancel_schedule(&format!("{}-clear", username));
let conn_key = connection_key(&username);
let _ = cache::remove(&conn_key);
let _ = cache::remove(&sequence_key(&username));
let reverse_key = format!("discord.reverse.{}", connection_id);
let _ = cache::remove(&reverse_key);
info!("Cleaned up connection state for user {}", username);
} else {
let reverse_key = format!("discord.reverse.{}", connection_id);
let _ = cache::remove(&reverse_key);
}
}
pub fn connect(username: &str, token: &str) -> Result<(), Error> {
if is_connected(username) {
info!("Reusing existing connection for user {}", username);
return Ok(());
}
cleanup_connection(username);
info!("Connecting to Discord gateway for user {}", username);
cache::set_string(&token_key(username), token, 86400)?;
let gateway = get_discord_gateway()?;
info!("Using gateway: {}", gateway);
let headers = std::collections::HashMap::new();
let conn_id = websocket::connect(
&gateway,
headers,
username,
)?;
info!("WebSocket connection established: {}", conn_id);
let conn_key = connection_key(username);
cache::set_string(&conn_key, &conn_id, 86400)?;
identify(username)?;
info!("Successfully connected and identified user {}", username);
Ok(())
}
pub fn handle_websocket_message(connection_id: &str, message: &str) -> Result<(), Error> {
let response: GatewayResponse = serde_json::from_str(message)
.map_err(|e| Error::msg(format!("Failed to parse gateway message: {}", e)))?;
if let Some(seq) = response.s {
if let Some(username) = find_username_for_connection(connection_id)? {
cache::set_string(&sequence_key(&username), &seq.to_string(), 86400)?;
}
}
match response.op {
10 => {
}
11 => {
}
1 => {
if let Some(username) = find_username_for_connection(connection_id)? {
send_heartbeat(&username)?;
}
}
_ => {
trace!("Received Discord gateway op: {}", response.op);
}
}
Ok(())
}
pub fn handle_heartbeat_callback(username: &str) -> Result<(), Error> {
send_heartbeat(username)
}
pub fn handle_clear_activity_callback(username: &str) -> Result<(), Error> {
info!("Clearing activity for user {}", username);
let conn_key = connection_key(username);
if let Some(conn_id) = cache::get_string(&conn_key)?.filter(|s| !s.is_empty()) {
let msg = GatewayMessage {
op: PRESENCE_OP_CODE,
d: PresencePayload {
activities: vec![],
since: 0,
status: "dnd".to_string(),
afk: false,
},
};
let json = serde_json::to_string(&msg)
.map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?;
websocket::send_text(&conn_id, &json)?;
}
Ok(())
}
pub fn disconnect(username: &str) -> Result<(), Error> {
info!("Disconnecting from Discord for user {}", username);
if let Err(e) = scheduler::cancel_schedule(username) {
warn!("Failed to cancel heartbeat schedule: {:?}", e);
}
let conn_key = connection_key(username);
if let Some(conn_id) = cache::get_string(&conn_key)?.filter(|s| !s.is_empty()) {
if let Err(e) = websocket::close_connection(&conn_id, 1000, "Navidrome disconnect") {
warn!("Failed to close WebSocket connection: {:?}", e);
}
let reverse_key = format!("discord.reverse.{}", conn_id);
let _ = cache::remove(&reverse_key);
}
let _ = cache::remove(&conn_key);
let _ = cache::remove(&sequence_key(username));
Ok(())
}
pub fn send_activity(
client_id: &str,
username: &str,
token: &str,
mut activity: Activity,
) -> Result<(), Error> {
let conn_key = connection_key(username);
let conn_id = cache::get_string(&conn_key)?
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::msg("Not connected to Discord"))?;
activity.assets.large_image = process_image(&activity.assets.large_image, client_id, token)?;
let msg = GatewayMessage {
op: PRESENCE_OP_CODE,
d: PresencePayload {
activities: vec![activity],
since: 0,
status: "dnd".to_string(),
afk: false,
},
};
let json = serde_json::to_string(&msg)
.map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?;
websocket::send_text(&conn_id, &json)?;
Ok(())
}
fn find_username_for_connection(connection_id: &str) -> Result<Option<String>, Error> {
let reverse_key = format!("discord.reverse.{}", connection_id);
Ok(cache::get_string(&reverse_key)?.filter(|s| !s.is_empty()))
}
fn get_discord_gateway() -> Result<String, Error> {
let req = HttpRequest::new("https://discord.com/api/gateway")
.with_method("GET");
let resp = http::request::<String>(&req, None::<String>)?;
if resp.status_code() >= 400 {
return Err(Error::msg(format!(
"Failed to get Discord gateway: HTTP {}",
resp.status_code()
)));
}
let body = resp.body();
let data: std::collections::HashMap<String, String> = serde_json::from_slice(&body)
.map_err(|e| Error::msg(format!("Failed to parse gateway response: {}", e)))?;
data.get("url")
.map(|url| url.to_string())
.ok_or_else(|| Error::msg("No URL in gateway response"))
}
fn identify(username: &str) -> Result<(), Error> {
info!("Identifying with Discord for user {}", username);
let conn_key = connection_key(username);
let conn_id = cache::get_string(&conn_key)?
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::msg("No connection found"))?;
let token_k = token_key(username);
let token = cache::get_string(&token_k)?
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::msg("No token found"))?;
let reverse_key = format!("discord.reverse.{}", conn_id);
cache::set_string(&reverse_key, username, 86400)?;
let msg = GatewayMessage {
op: GATE_OP_CODE,
d: IdentifyPayload {
token,
intents: 0,
properties: IdentifyProperties {
os: "Windows 10".to_string(),
browser: "Discord Client".to_string(),
device: "Discord Client".to_string(),
},
},
};
let json = serde_json::to_string(&msg)
.map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?;
websocket::send_text(&conn_id, &json)?;
scheduler::schedule_recurring(
&format!("@every {}s", HEARTBEAT_INTERVAL),
PAYLOAD_HEARTBEAT,
username,
)?;
Ok(())
}
fn send_heartbeat(username: &str) -> Result<(), Error> {
let conn_key = connection_key(username);
let conn_id = cache::get_string(&conn_key)?
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::msg("No connection found"))?;
let seq_key = sequence_key(username);
let seq: Option<i64> = cache::get_string(&seq_key)?
.and_then(|s| s.parse().ok());
let msg = GatewayMessage {
op: HEARTBEAT_OP_CODE,
d: seq,
};
let json = serde_json::to_string(&msg)
.map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?;
websocket::send_text(&conn_id, &json)?;
Ok(())
}
fn process_image(image_url: &str, client_id: &str, token: &str) -> Result<String, Error> {
process_image_inner(image_url, client_id, token, false)
}
fn process_image_inner(
image_url: &str,
client_id: &str,
token: &str,
is_default: bool,
) -> Result<String, Error> {
let url = if image_url.is_empty() {
if is_default {
return Err(Error::msg("default image URL is empty"));
}
return process_image_inner(DEFAULT_IMAGE, client_id, token, true);
} else {
image_url
};
if url.starts_with("mp:") {
return Ok(url.to_string());
}
let cache_key = format!("discord.image.{:x}", md5_hash(url));
if let Some(cached) = cache::get_string(&cache_key)?.filter(|s| !s.is_empty()) {
return Ok(cached);
}
let body = format!(r#"{{"urls":["{}"]}}"#, url);
let api_url = format!(
"https://discord.com/api/v9/applications/{}/external-assets",
client_id
);
let req = HttpRequest::new(&api_url)
.with_method("POST")
.with_header("Authorization", token)
.with_header("Content-Type", "application/json");
let resp = http::request::<String>(&req, Some(body))?;
if resp.status_code() >= 400 {
if is_default {
return Err(Error::msg(format!(
"failed to process default image: HTTP {}",
resp.status_code()
)));
}
return process_image_inner(DEFAULT_IMAGE, client_id, token, true);
}
let body = resp.body();
let data: Vec<std::collections::HashMap<String, String>> = serde_json::from_slice(&body)
.map_err(|e| Error::msg(format!("Failed to parse image response: {}", e)))?;
if data.is_empty() {
if is_default {
return Err(Error::msg("no data returned for default image"));
}
return process_image_inner(DEFAULT_IMAGE, client_id, token, true);
}
let asset_path = data[0]
.get("external_asset_path")
.map(|s| s.as_str())
.unwrap_or("");
if asset_path.is_empty() {
if is_default {
return Err(Error::msg("empty external_asset_path for default image"));
}
return process_image_inner(DEFAULT_IMAGE, client_id, token, true);
}
let processed = format!("mp:{}", asset_path);
let ttl = if is_default { 48 * 60 * 60 } else { 4 * 60 * 60 };
let _ = cache::set_string(&cache_key, &processed, ttl);
Ok(processed)
}
fn md5_hash(input: &str) -> u64 {
let mut hash: u64 = 0;
for (i, byte) in input.bytes().enumerate() {
hash = hash.wrapping_add((byte as u64).wrapping_mul((i as u64).wrapping_add(1)));
hash = hash.wrapping_mul(31);
}
hash
}