use std::time::{SystemTime, UNIX_EPOCH};
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{json, Value};
use tandem_channels::discord_blocks::{parse_custom_id, ParsedCustomId};
use tandem_channels::signing::verify_discord_signature;
use crate::app::rate_limit::{ChannelRateLimitKey, ChannelRateLimitKind};
use crate::app::state::channel_user_capabilities::{
channel_requires_approval_step_up, channel_security_profile_from_config,
};
use crate::app::state::principals::channel_identity::{
channel_bound_tenant, channel_is_open_to_all, resolve_channel_user, ChannelIdentityResolution,
ChannelKind,
};
use crate::AppState;
mod replay;
use replay::{prepare_discord_interaction, DiscordReplayClaimPreparation};
const DISCORD_SIGNATURE_TIMESTAMP_TOLERANCE_SECS: u64 = 5 * 60;
pub(crate) async fn discord_interactions(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let public_key = match read_discord_public_key(&state).await {
Some(key) => key,
None => return reject_unauthorized("discord public key not configured"),
};
let signature = headers
.get("x-signature-ed25519")
.and_then(|v| v.to_str().ok());
let timestamp = headers
.get("x-signature-timestamp")
.and_then(|v| v.to_str().ok());
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
if !discord_timestamp_is_fresh(timestamp, now_secs) {
tracing::warn!(
target: "tandem_server::discord_interactions",
"rejecting Discord interaction outside the signed timestamp window"
);
return reject_unauthorized("stale or invalid signature timestamp");
}
if let Err(error) = verify_discord_signature(&body, signature, timestamp, &public_key) {
tracing::warn!(
target: "tandem_server::discord_interactions",
?error,
"rejecting unsigned/forged Discord interaction"
);
return reject_unauthorized(&error.to_string());
}
let payload: Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(err) => return reject_bad_request(&format!("payload is not JSON: {err}")),
};
let interaction_type = payload.get("type").and_then(Value::as_u64).unwrap_or(0);
if interaction_type == 1 {
return Json(json!({ "type": 1 })).into_response();
}
let interaction_id = match bounded_discord_identifier(&payload, "id") {
Some(value) => value,
None => return reject_bad_request("payload missing a valid interaction id"),
};
let application_id = match bounded_discord_identifier(&payload, "application_id") {
Some(value) => value,
None => return reject_bad_request("payload missing a valid application id"),
};
match interaction_type {
3 => {
handle_message_component(
state,
&payload,
application_id,
interaction_id,
body.as_ref(),
now_secs.saturating_mul(1000),
)
.await
},
5 => {
handle_modal_submit(
state,
&payload,
application_id,
interaction_id,
body.as_ref(),
now_secs.saturating_mul(1000),
)
.await
},
2 => Json(json!({
"type": 4,
"data": { "content": "Slash commands land in W5. Use the buttons on approval cards for now." }
}))
.into_response(),
other => {
tracing::info!(
target: "tandem_server::discord_interactions",
interaction_type = other,
"unhandled Discord interaction type"
);
Json(json!({ "type": 6 })).into_response()
}
}
}
fn discord_tenant_context(effective_config: &Value) -> tandem_types::TenantContext {
channel_bound_tenant(effective_config, ChannelKind::Discord)
.map(|(org_id, workspace_id)| {
tandem_types::TenantContext::explicit_user_workspace(
org_id,
workspace_id,
None,
"discord",
)
})
.unwrap_or_else(tandem_types::TenantContext::local_implicit)
}
async fn claim_rate_limited_authorized_discord_interaction(
state: &AppState,
tenant_context: &tandem_types::TenantContext,
application_id: &str,
interaction_id: &str,
body: &[u8],
now_ms: u64,
rate_key: &ChannelRateLimitKey,
profile: tandem_channels::config::ChannelSecurityProfile,
) -> Result<(), Response> {
let pending = match prepare_discord_interaction(
state,
tenant_context,
application_id,
interaction_id,
body,
now_ms,
)
.await
{
Ok(DiscordReplayClaimPreparation::Pending(pending)) => pending,
Ok(DiscordReplayClaimPreparation::Duplicate) => {
tracing::warn!(
target: "tandem_server::discord_interactions",
interaction_id,
"acknowledging duplicate Discord interaction without redispatch"
);
return Err(duplicate_discord_acknowledgement());
}
Ok(DiscordReplayClaimPreparation::Conflict) => {
tracing::warn!(
target: "tandem_server::discord_interactions",
interaction_id,
"rejecting conflicting Discord interaction replay"
);
return Err(reject_conflict("conflicting interaction replay"));
}
Err(error) => {
tracing::error!(
target: "tandem_server::discord_interactions",
error = %error,
tenant = %tenant_context.org_id,
application_id,
"Discord interaction replay claim failed closed"
);
return Err(reject_service_unavailable());
}
};
let rate_decision = state
.channel_rate_limiter
.check(rate_key, ChannelRateLimitKind::Decision, profile)
.await;
if !rate_decision.allowed {
return Err(reject_rate_limited(rate_decision.retry_after_secs));
}
if let Err(error) = pending.commit().await {
tracing::error!(
target: "tandem_server::discord_interactions",
error = %error,
tenant = %tenant_context.org_id,
application_id,
"Discord interaction replay claim failed closed"
);
return Err(reject_service_unavailable());
}
Ok(())
}
fn duplicate_discord_acknowledgement() -> Response {
Json(json!({
"type": 7,
"data": {
"content": "Already processed — refresh to see the latest state.",
"embeds": [],
"components": [],
}
}))
.into_response()
}
async fn handle_message_component(
state: AppState,
payload: &Value,
application_id: &str,
interaction_id: &str,
body: &[u8],
now_ms: u64,
) -> Response {
let custom_id = match payload.pointer("/data/custom_id").and_then(Value::as_str) {
Some(id) => id,
None => return reject_bad_request("button payload missing data.custom_id"),
};
let parsed = match parse_custom_id(custom_id) {
Some(p) => p,
None => return reject_bad_request(&format!("unrecognized custom_id: {custom_id}")),
};
let user_id = match payload
.pointer("/member/user/id")
.or_else(|| payload.pointer("/user/id"))
.and_then(Value::as_str)
{
Some(id) => id.to_string(),
None => return reject_bad_request("payload missing user identification"),
};
let effective_config = state.config.get_effective_value().await;
match resolve_channel_user(&effective_config, ChannelKind::Discord, &user_id) {
ChannelIdentityResolution::Resolved(_principal) => {
}
ChannelIdentityResolution::Denied { .. } => {
tracing::warn!(
target: "tandem_server::discord_interactions",
user_id = %user_id,
"rejecting Discord interaction from unauthorized user"
);
return reject_forbidden("user not in allowed_users");
}
ChannelIdentityResolution::ChannelNotConfigured(_) => {
return reject_bad_request("discord channel not properly configured");
}
}
let profile =
channel_security_profile_from_config(&effective_config, ChannelKind::Discord.as_str());
if !state
.channel_user_can_approve(
ChannelKind::Discord.as_str(),
&user_id,
profile,
channel_is_open_to_all(&effective_config, ChannelKind::Discord),
None,
)
.await
{
tracing::warn!(
target: "tandem_server::discord_interactions",
user_id = %user_id,
"rejecting Discord interaction without approval capability"
);
return reject_forbidden("user lacks approval capability");
}
if channel_requires_approval_step_up(&effective_config, ChannelKind::Discord.as_str())
&& !state
.channel_step_up_active(ChannelKind::Discord.as_str(), &user_id, None)
.await
{
tracing::warn!(
target: "tandem_server::discord_interactions",
user_id = %user_id,
"rejecting Discord interaction without an active step-up"
);
return reject_forbidden("step-up required");
}
let rate_key = ChannelRateLimitKey {
channel: ChannelKind::Discord.as_str().to_string(),
user_id: user_id.clone(),
};
let tenant_context = discord_tenant_context(&effective_config);
if let Err(response) = claim_rate_limited_authorized_discord_interaction(
&state,
&tenant_context,
application_id,
interaction_id,
body,
now_ms,
&rate_key,
profile,
)
.await
{
return response;
}
match parsed.action.as_str() {
"approve" | "cancel" => dispatch_decision(state, parsed, &user_id, None).await,
"rework" => {
let modal_custom_id = format!("tdm-modal:rework:{}:{}", parsed.run_id, parsed.node_id);
Json(json!({
"type": 9,
"data": {
"title": "Rework feedback",
"custom_id": modal_custom_id,
"components": [{
"type": 1,
"components": [{
"type": 4,
"custom_id": "reason_input",
"label": "What should change?",
"style": 2,
"min_length": 1,
"max_length": 4000,
"required": true,
}]
}]
}
}))
.into_response()
}
other => reject_bad_request(&format!("unknown action: {other}")),
}
}
async fn handle_modal_submit(
state: AppState,
payload: &Value,
application_id: &str,
interaction_id: &str,
body: &[u8],
now_ms: u64,
) -> Response {
let custom_id = match payload.pointer("/data/custom_id").and_then(Value::as_str) {
Some(id) => id,
None => return reject_bad_request("modal payload missing data.custom_id"),
};
let mut parts = custom_id.splitn(4, ':');
let prefix = parts.next().unwrap_or("");
let action = parts.next().unwrap_or("");
let run_id = parts.next().unwrap_or("").to_string();
let node_id = parts.next().unwrap_or("").to_string();
if prefix != "tdm-modal" || action != "rework" || run_id.is_empty() || node_id.is_empty() {
return reject_bad_request(&format!(
"unrecognized or malformed modal custom_id: {custom_id}"
));
}
let reason_raw = payload
.pointer("/data/components/0/components/0/value")
.and_then(Value::as_str)
.unwrap_or("")
.trim();
if reason_raw.len() > 4000 {
return reject_bad_request("reason exceeds 4000 character limit");
}
let reason = reason_raw.to_string();
let user_id = match payload
.pointer("/member/user/id")
.or_else(|| payload.pointer("/user/id"))
.and_then(Value::as_str)
{
Some(id) => id.to_string(),
None => return reject_bad_request("modal payload missing user identification"),
};
let effective_config = state.config.get_effective_value().await;
match resolve_channel_user(&effective_config, ChannelKind::Discord, &user_id) {
ChannelIdentityResolution::Resolved(_principal) => {
}
ChannelIdentityResolution::Denied { .. } => {
tracing::warn!(
target: "tandem_server::discord_interactions",
user_id = %user_id,
"rejecting Discord modal submission from unauthorized user"
);
return reject_forbidden("user not in allowed_users");
}
ChannelIdentityResolution::ChannelNotConfigured(_) => {
return reject_bad_request("discord channel not properly configured");
}
}
let profile =
channel_security_profile_from_config(&effective_config, ChannelKind::Discord.as_str());
if !state
.channel_user_can_approve(
ChannelKind::Discord.as_str(),
&user_id,
profile,
channel_is_open_to_all(&effective_config, ChannelKind::Discord),
None,
)
.await
{
tracing::warn!(
target: "tandem_server::discord_interactions",
user_id = %user_id,
"rejecting Discord modal submission without approval capability"
);
return reject_forbidden("user lacks approval capability");
}
if channel_requires_approval_step_up(&effective_config, ChannelKind::Discord.as_str())
&& !state
.channel_step_up_active(ChannelKind::Discord.as_str(), &user_id, None)
.await
{
tracing::warn!(
target: "tandem_server::discord_interactions",
user_id = %user_id,
"rejecting Discord interaction without an active step-up"
);
return reject_forbidden("step-up required");
}
let rate_key = ChannelRateLimitKey {
channel: ChannelKind::Discord.as_str().to_string(),
user_id: user_id.clone(),
};
let tenant_context = discord_tenant_context(&effective_config);
if let Err(response) = claim_rate_limited_authorized_discord_interaction(
&state,
&tenant_context,
application_id,
interaction_id,
body,
now_ms,
&rate_key,
profile,
)
.await
{
return response;
}
dispatch_decision(
state,
ParsedCustomId {
action: "rework".to_string(),
run_id,
node_id,
},
&user_id,
if reason.is_empty() {
None
} else {
Some(reason)
},
)
.await
}
async fn dispatch_decision(
state: AppState,
parsed: ParsedCustomId,
user_id: &str,
reason: Option<String>,
) -> Response {
let input = crate::http::routines_automations::AutomationV2GateDecisionInput {
decision: parsed.action.clone(),
reason,
approval_request_id: None,
transition_id: None,
};
let tenant_context = state
.get_automation_v2_run(&parsed.run_id)
.await
.map(|run| run.tenant_context)
.unwrap_or_else(tandem_types::TenantContext::local_implicit);
let effective_config = state.config.get_effective_value().await;
if let Some((org_id, workspace_id)) =
channel_bound_tenant(&effective_config, ChannelKind::Discord)
{
if tenant_context.org_id != org_id || tenant_context.workspace_id != workspace_id {
tracing::warn!(
target: "tandem_server::discord_interactions",
user_id = %user_id,
"rejecting Discord interaction targeting a run outside the channel's bound tenant"
);
let channel_tenant = tandem_types::TenantContext::explicit_user_workspace(
org_id,
workspace_id,
None,
"discord",
);
if let Err(error) = crate::http::channel_interaction_audit::append_cross_tenant_denial(
&state,
"discord",
user_id,
&parsed.run_id,
channel_tenant,
&tenant_context,
)
.await
{
return reject_forbidden(&format!(
"channel denied; required denial receipt persistence failed: {error}"
));
}
return reject_forbidden("channel not bound to this run's tenant");
}
}
let decider = crate::automation_v2::governance::GovernanceActorRef::human(
Some(user_id.to_string()),
"discord",
);
let result = crate::http::routines_automations::automations_v2_run_gate_decide_inner(
state,
tenant_context,
None,
parsed.run_id.clone(),
input,
decider,
)
.await;
match result {
Ok(_) => {
tracing::info!(
target: "tandem_server::discord_interactions",
run_id = %parsed.run_id,
user = %user_id,
action = %parsed.action,
"Discord interaction decided gate"
);
Json(json!({
"type": 7,
"data": {
"content": format!("`{}` by <@{}>.", parsed.action, user_id),
"embeds": [],
"components": [],
}
}))
.into_response()
}
Err((status, body)) => {
tracing::warn!(
target: "tandem_server::discord_interactions",
run_id = %parsed.run_id,
status = %status,
body = %body.0,
"gate-decide returned non-success"
);
let winner = body
.0
.pointer("/winningDecision/decision")
.and_then(Value::as_str)
.unwrap_or("another operator");
Json(json!({
"type": 7,
"data": {
"content": format!(
"Already decided ({}) — refresh to see the latest state.",
winner
),
"embeds": [],
"components": [],
}
}))
.into_response()
}
}
}
async fn read_discord_public_key(state: &AppState) -> Option<String> {
let effective = state.config.get_effective_value().await;
effective
.pointer("/channels/discord/public_key")
.and_then(Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn bounded_discord_identifier<'a>(payload: &'a Value, field: &str) -> Option<&'a str> {
payload
.get(field)
.and_then(Value::as_str)
.filter(|value| !value.is_empty() && value.len() <= 256 && value.is_ascii())
}
fn discord_timestamp_is_fresh(timestamp: Option<&str>, now_secs: u64) -> bool {
timestamp
.and_then(|value| value.parse::<u64>().ok())
.is_some_and(|timestamp_secs| {
now_secs.abs_diff(timestamp_secs) <= DISCORD_SIGNATURE_TIMESTAMP_TOLERANCE_SECS
})
}
fn reject_unauthorized(reason: &str) -> Response {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized", "reason": reason })),
)
.into_response()
}
fn reject_conflict(reason: &str) -> Response {
(
StatusCode::CONFLICT,
Json(json!({ "error": "Conflict", "reason": reason })),
)
.into_response()
}
fn reject_service_unavailable() -> Response {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"error": "ServiceUnavailable",
"reason": "replay protection unavailable",
})),
)
.into_response()
}
fn reject_forbidden(reason: &str) -> Response {
(
StatusCode::FORBIDDEN,
Json(json!({
"error": "Forbidden",
"reason": reason,
})),
)
.into_response()
}
fn reject_rate_limited(retry_after_secs: u64) -> Response {
let mut response = (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({ "error": "rate limit exceeded" })),
)
.into_response();
if let Ok(value) = axum::http::HeaderValue::from_str(&retry_after_secs.max(1).to_string()) {
response
.headers_mut()
.insert(axum::http::header::RETRY_AFTER, value);
}
response
}
fn reject_bad_request(reason: &str) -> Response {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": "BadRequest", "reason": reason })),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timestamp_freshness_accepts_only_the_five_minute_window() {
let now = 1_000;
assert!(discord_timestamp_is_fresh(Some("1000"), now));
assert!(discord_timestamp_is_fresh(Some("700"), now));
assert!(discord_timestamp_is_fresh(Some("1300"), now));
assert!(!discord_timestamp_is_fresh(Some("699"), now));
assert!(!discord_timestamp_is_fresh(Some("1301"), now));
assert!(!discord_timestamp_is_fresh(Some("not-a-time"), now));
assert!(!discord_timestamp_is_fresh(None, now));
}
#[test]
fn modal_custom_id_format_is_recognizable() {
let raw = "tdm-modal:rework:auto-v2-run-abc123:send_email";
let mut parts = raw.splitn(4, ':');
assert_eq!(parts.next(), Some("tdm-modal"));
assert_eq!(parts.next(), Some("rework"));
assert_eq!(parts.next(), Some("auto-v2-run-abc123"));
assert_eq!(parts.next(), Some("send_email"));
}
}