Files
tranquil-pds/crates/tranquil-api/src/discord_webhook.rs
T
2026-07-25 08:27:40 +03:00

310 lines
8.6 KiB
Rust

use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::Deserialize;
use serde_json::json;
use tracing::{debug, info, warn};
use tranquil_types::Handle;
use tranquil_pds::comms::comms_repo;
use tranquil_pds::state::AppState;
use tranquil_pds::util::discord_public_key;
#[derive(Deserialize)]
struct Interaction {
#[serde(rename = "type")]
interaction_type: u8,
data: Option<InteractionData>,
member: Option<InteractionMember>,
user: Option<InteractionUser>,
}
#[derive(Deserialize)]
struct InteractionData {
name: Option<String>,
options: Option<Vec<InteractionOption>>,
}
#[derive(Deserialize)]
struct InteractionOption {
name: String,
value: serde_json::Value,
}
#[derive(Deserialize)]
struct InteractionMember {
user: Option<InteractionUser>,
}
#[derive(Deserialize)]
struct InteractionUser {
id: String,
username: Option<String>,
}
fn verify_signature(
public_key: &VerifyingKey,
timestamp: &str,
body: &str,
signature: &str,
) -> bool {
let sig_bytes = match hex::decode(signature) {
Ok(b) => b,
Err(_) => return false,
};
let signature = match Signature::from_slice(&sig_bytes) {
Ok(s) => s,
Err(_) => return false,
};
let message = format!("{}{}", timestamp, body);
public_key.verify(message.as_bytes(), &signature).is_ok()
}
fn parse_start_handle(options: Option<&[InteractionOption]>) -> Option<String> {
options
.and_then(|opts| opts.iter().find(|o| o.name == "handle"))
.and_then(|o| o.value.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
pub async fn handle_discord_webhook(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
body: String,
) -> Response {
let public_key = match discord_public_key() {
Some(pk) => pk,
None => {
warn!("Discord webhook called but public key is not configured");
return StatusCode::FORBIDDEN.into_response();
}
};
let signature = headers
.get("x-signature-ed25519")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
let timestamp = headers
.get("x-signature-timestamp")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
if !verify_signature(public_key, timestamp, &body, signature) {
return StatusCode::UNAUTHORIZED.into_response();
}
let interaction: Interaction = match serde_json::from_str(&body) {
Ok(i) => i,
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
};
match interaction.interaction_type {
1 => Json(json!({"type": 1})).into_response(),
2 => handle_command(state, interaction).await,
other => {
debug!(
interaction_type = other,
"Received unknown Discord interaction type"
);
StatusCode::OK.into_response()
}
}
}
async fn handle_command(state: AppState, interaction: Interaction) -> Response {
let command_name = interaction
.data
.as_ref()
.and_then(|d| d.name.as_deref())
.unwrap_or_default();
if command_name != "start" {
return Json(json!({
"type": 4,
"data": {"content": "Unknown command", "flags": 64}
}))
.into_response();
}
let user = interaction
.member
.as_ref()
.and_then(|m| m.user.as_ref())
.or(interaction.user.as_ref());
let (discord_user_id, discord_username) = match user {
Some(u) => (u.id.clone(), u.username.clone().unwrap_or_default()),
None => {
return Json(json!({
"type": 4,
"data": {"content": "Could not identify user", "flags": 64}
}))
.into_response();
}
};
let handle = match parse_start_handle(
interaction.data.as_ref().and_then(|d| d.options.as_deref()),
)
.map(Handle::new)
.transpose()
{
Ok(h) => h,
Err(_) => {
return Json(json!({
"type": 4,
"data": {"content": "Invalid handle format. Handle should look like: nel.oyster.cafe", "flags": 64}
}))
.into_response();
}
};
debug!(
discord_username = %discord_username,
discord_user_id = %discord_user_id,
handle = ?handle,
"Received /start from Discord user"
);
match state
.repos
.user
.store_discord_user_id(&discord_username, &discord_user_id, handle.as_ref())
.await
{
Ok(Some(user_id)) => {
info!(
discord_username = %discord_username,
discord_user_id = %discord_user_id,
"Verified Discord user and stored user ID"
);
if let Err(e) = comms_repo::enqueue_channel_verified(
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
tranquil_db_traits::CommsChannel::Discord,
&discord_user_id,
&tranquil_config::get().server.hostname,
)
.await
{
warn!(error = %e, "Failed to enqueue channel verified notification");
}
Json(json!({
"type": 4,
"data": {"content": "Verified", "flags": 64}
}))
.into_response()
}
Ok(None) => {
debug!(
discord_username = %discord_username,
"No matching user found for Discord username"
);
Json(json!({
"type": 4,
"data": {"content": "No account found with this Discord username. Set your Discord username in your PDS settings first.", "flags": 64}
}))
.into_response()
}
Err(tranquil_db_traits::DbError::Ambiguous(msg)) => {
debug!(
discord_username = %discord_username,
"Ambiguous Discord username match"
);
Json(json!({
"type": 4,
"data": {"content": msg, "flags": 64}
}))
.into_response()
}
Err(tranquil_db_traits::DbError::LockContention) => {
debug!(
discord_username = %discord_username,
"Lock contention during Discord verification"
);
Json(json!({
"type": 4,
"data": {"content": "Server busy, please try again in a moment.", "flags": 64}
}))
.into_response()
}
Err(e) => {
warn!(
discord_username = %discord_username,
error = %e,
"Failed to store Discord user ID"
);
Json(json!({
"type": 4,
"data": {"content": "Verification failed. Try again later.", "flags": 64}
}))
.into_response()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_handle_from_options() {
let options = vec![InteractionOption {
name: "handle".to_string(),
value: serde_json::json!("lewis.buttercup.wizardry.systems"),
}];
assert_eq!(
parse_start_handle(Some(&options)),
Some("lewis.buttercup.wizardry.systems".to_string()),
);
}
#[test]
fn parse_handle_no_options() {
assert_eq!(parse_start_handle(None), None);
}
#[test]
fn parse_handle_empty_options() {
let options: Vec<InteractionOption> = vec![];
assert_eq!(parse_start_handle(Some(&options)), None);
}
#[test]
fn parse_handle_wrong_option_name() {
let options = vec![InteractionOption {
name: "other".to_string(),
value: serde_json::json!("test"),
}];
assert_eq!(parse_start_handle(Some(&options)), None);
}
#[test]
fn parse_handle_empty_string() {
let options = vec![InteractionOption {
name: "handle".to_string(),
value: serde_json::json!(""),
}];
assert_eq!(parse_start_handle(Some(&options)), None);
}
#[test]
fn parse_handle_whitespace_trimmed() {
let options = vec![InteractionOption {
name: "handle".to_string(),
value: serde_json::json!(" alice.example.com "),
}];
assert_eq!(
parse_start_handle(Some(&options)),
Some("alice.example.com".to_string()),
);
}
}