Skip to content

Commit e495480

Browse files
committed
feat(trogon-source-discord): add Discord interaction webhook receiver
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 430b2db commit e495480

9 files changed

Lines changed: 1484 additions & 0 deletions

File tree

rsworkspace/Cargo.lock

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rsworkspace/crates/acp-telemetry/src/service_name.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
pub enum ServiceName {
66
AcpNatsStdio,
77
AcpNatsWs,
8+
TrogonSourceDiscord,
89
TrogonSourceGithub,
910
TrogonSourceLinear,
1011
TrogonSourceSlack,
@@ -15,6 +16,7 @@ impl ServiceName {
1516
match self {
1617
Self::AcpNatsStdio => "acp-nats-stdio",
1718
Self::AcpNatsWs => "acp-nats-ws",
19+
Self::TrogonSourceDiscord => "trogon-source-discord",
1820
Self::TrogonSourceGithub => "trogon-source-github",
1921
Self::TrogonSourceLinear => "trogon-source-linear",
2022
Self::TrogonSourceSlack => "trogon-source-slack",
@@ -36,6 +38,10 @@ mod tests {
3638
fn as_str_returns_expected_values() {
3739
assert_eq!(ServiceName::AcpNatsStdio.as_str(), "acp-nats-stdio");
3840
assert_eq!(ServiceName::AcpNatsWs.as_str(), "acp-nats-ws");
41+
assert_eq!(
42+
ServiceName::TrogonSourceDiscord.as_str(),
43+
"trogon-source-discord"
44+
);
3945
assert_eq!(
4046
ServiceName::TrogonSourceGithub.as_str(),
4147
"trogon-source-github"
@@ -54,6 +60,10 @@ mod tests {
5460
fn display_delegates_to_as_str() {
5561
assert_eq!(format!("{}", ServiceName::AcpNatsStdio), "acp-nats-stdio");
5662
assert_eq!(format!("{}", ServiceName::AcpNatsWs), "acp-nats-ws");
63+
assert_eq!(
64+
format!("{}", ServiceName::TrogonSourceDiscord),
65+
"trogon-source-discord"
66+
);
5767
assert_eq!(
5868
format!("{}", ServiceName::TrogonSourceGithub),
5969
"trogon-source-github"
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
[package]
2+
name = "trogon-source-discord"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[lints]
7+
workspace = true
8+
9+
[[bin]]
10+
name = "trogon-source-discord"
11+
path = "src/main.rs"
12+
13+
[dependencies]
14+
acp-telemetry = { workspace = true }
15+
async-nats = { workspace = true, features = ["jetstream"] }
16+
axum = { workspace = true }
17+
bytes = { workspace = true }
18+
bytesize = "2.3.1"
19+
ed25519-dalek = { version = "2.1", features = ["std"] }
20+
hex = "0.4"
21+
serde_json = { workspace = true }
22+
tokio = { workspace = true, features = ["full"] }
23+
tower-http = { workspace = true, features = ["limit"] }
24+
tracing = { workspace = true }
25+
trogon-nats = { workspace = true }
26+
trogon-std = { workspace = true }
27+
28+
[dev-dependencies]
29+
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
30+
tower = "0.5"
31+
tracing-subscriber = { workspace = true }
32+
trogon-nats = { workspace = true, features = ["test-support"] }
33+
trogon-std = { workspace = true, features = ["test-support"] }
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
use std::time::Duration;
2+
3+
use bytesize::ByteSize;
4+
use ed25519_dalek::VerifyingKey;
5+
use trogon_nats::NatsConfig;
6+
use trogon_std::env::ReadEnv;
7+
8+
use crate::constants::{
9+
DEFAULT_MAX_BODY_SIZE, DEFAULT_NATS_ACK_TIMEOUT, DEFAULT_PORT, DEFAULT_STREAM_MAX_AGE,
10+
DEFAULT_STREAM_NAME, DEFAULT_SUBJECT_PREFIX,
11+
};
12+
use crate::signature;
13+
14+
pub struct DiscordConfig {
15+
pub public_key: VerifyingKey,
16+
pub port: u16,
17+
pub subject_prefix: String,
18+
pub stream_name: String,
19+
pub stream_max_age: Duration,
20+
pub nats_ack_timeout: Duration,
21+
pub max_body_size: ByteSize,
22+
pub nats: NatsConfig,
23+
}
24+
25+
impl DiscordConfig {
26+
pub fn from_env<E: ReadEnv>(env: &E) -> Self {
27+
let public_key_hex = env
28+
.var("DISCORD_PUBLIC_KEY")
29+
.ok()
30+
.filter(|s| !s.is_empty())
31+
.expect("DISCORD_PUBLIC_KEY is required");
32+
33+
let public_key = signature::parse_public_key(&public_key_hex)
34+
.expect("DISCORD_PUBLIC_KEY must be a valid hex-encoded Ed25519 public key");
35+
36+
Self {
37+
public_key,
38+
port: env
39+
.var("DISCORD_WEBHOOK_PORT")
40+
.ok()
41+
.and_then(|p| p.parse().ok())
42+
.unwrap_or(DEFAULT_PORT),
43+
subject_prefix: env
44+
.var("DISCORD_SUBJECT_PREFIX")
45+
.unwrap_or_else(|_| DEFAULT_SUBJECT_PREFIX.to_string()),
46+
stream_name: env
47+
.var("DISCORD_STREAM_NAME")
48+
.unwrap_or_else(|_| DEFAULT_STREAM_NAME.to_string()),
49+
stream_max_age: env
50+
.var("DISCORD_STREAM_MAX_AGE_SECS")
51+
.ok()
52+
.and_then(|v| v.parse().ok())
53+
.map(Duration::from_secs)
54+
.unwrap_or(DEFAULT_STREAM_MAX_AGE),
55+
nats_ack_timeout: env
56+
.var("DISCORD_NATS_ACK_TIMEOUT_SECS")
57+
.ok()
58+
.and_then(|v| v.parse().ok())
59+
.map(Duration::from_secs)
60+
.unwrap_or(DEFAULT_NATS_ACK_TIMEOUT),
61+
max_body_size: env
62+
.var("DISCORD_MAX_BODY_SIZE")
63+
.ok()
64+
.and_then(|v| v.parse::<u64>().ok())
65+
.map(ByteSize)
66+
.unwrap_or(DEFAULT_MAX_BODY_SIZE),
67+
nats: NatsConfig::from_env(env),
68+
}
69+
}
70+
}
71+
72+
#[cfg(test)]
73+
mod tests {
74+
use super::*;
75+
use ed25519_dalek::SigningKey;
76+
use trogon_std::env::InMemoryEnv;
77+
78+
fn valid_public_key_hex() -> String {
79+
let sk = SigningKey::from_bytes(&[1u8; 32]);
80+
hex::encode(sk.verifying_key().as_bytes())
81+
}
82+
83+
fn env_with_key() -> InMemoryEnv {
84+
let env = InMemoryEnv::new();
85+
env.set("DISCORD_PUBLIC_KEY", &valid_public_key_hex());
86+
env
87+
}
88+
89+
#[test]
90+
fn defaults_with_required_key() {
91+
let env = env_with_key();
92+
let config = DiscordConfig::from_env(&env);
93+
94+
assert_eq!(config.port, 8080);
95+
assert_eq!(config.subject_prefix, "discord");
96+
assert_eq!(config.stream_name, "DISCORD");
97+
assert_eq!(config.stream_max_age, Duration::from_secs(7 * 24 * 60 * 60));
98+
assert_eq!(config.nats_ack_timeout, Duration::from_secs(10));
99+
assert_eq!(config.max_body_size, ByteSize::mib(4));
100+
}
101+
102+
#[test]
103+
fn reads_all_env_vars() {
104+
let env = InMemoryEnv::new();
105+
env.set("DISCORD_PUBLIC_KEY", &valid_public_key_hex());
106+
env.set("DISCORD_WEBHOOK_PORT", "9090");
107+
env.set("DISCORD_SUBJECT_PREFIX", "dc");
108+
env.set("DISCORD_STREAM_NAME", "DC_EVENTS");
109+
env.set("DISCORD_STREAM_MAX_AGE_SECS", "3600");
110+
env.set("DISCORD_NATS_ACK_TIMEOUT_SECS", "30");
111+
env.set("DISCORD_MAX_BODY_SIZE", "1048576");
112+
113+
let config = DiscordConfig::from_env(&env);
114+
115+
assert_eq!(config.port, 9090);
116+
assert_eq!(config.subject_prefix, "dc");
117+
assert_eq!(config.stream_name, "DC_EVENTS");
118+
assert_eq!(config.stream_max_age, Duration::from_secs(3600));
119+
assert_eq!(config.nats_ack_timeout, Duration::from_secs(30));
120+
assert_eq!(config.max_body_size, ByteSize::mib(1));
121+
}
122+
123+
#[test]
124+
#[should_panic(expected = "DISCORD_PUBLIC_KEY is required")]
125+
fn missing_public_key_panics() {
126+
let env = InMemoryEnv::new();
127+
DiscordConfig::from_env(&env);
128+
}
129+
130+
#[test]
131+
#[should_panic(expected = "DISCORD_PUBLIC_KEY is required")]
132+
fn empty_public_key_panics() {
133+
let env = InMemoryEnv::new();
134+
env.set("DISCORD_PUBLIC_KEY", "");
135+
DiscordConfig::from_env(&env);
136+
}
137+
138+
#[test]
139+
#[should_panic(expected = "DISCORD_PUBLIC_KEY must be a valid")]
140+
fn invalid_public_key_panics() {
141+
let env = InMemoryEnv::new();
142+
env.set("DISCORD_PUBLIC_KEY", "deadbeef");
143+
DiscordConfig::from_env(&env);
144+
}
145+
146+
#[test]
147+
fn invalid_port_falls_back_to_default() {
148+
let env = env_with_key();
149+
env.set("DISCORD_WEBHOOK_PORT", "not-a-number");
150+
151+
let config = DiscordConfig::from_env(&env);
152+
assert_eq!(config.port, 8080);
153+
}
154+
155+
#[test]
156+
fn invalid_max_age_falls_back_to_default() {
157+
let env = env_with_key();
158+
env.set("DISCORD_STREAM_MAX_AGE_SECS", "not-a-number");
159+
160+
let config = DiscordConfig::from_env(&env);
161+
assert_eq!(config.stream_max_age, DEFAULT_STREAM_MAX_AGE);
162+
}
163+
164+
#[test]
165+
fn invalid_nats_ack_timeout_falls_back_to_default() {
166+
let env = env_with_key();
167+
env.set("DISCORD_NATS_ACK_TIMEOUT_SECS", "not-a-number");
168+
169+
let config = DiscordConfig::from_env(&env);
170+
assert_eq!(config.nats_ack_timeout, DEFAULT_NATS_ACK_TIMEOUT);
171+
}
172+
173+
#[test]
174+
fn invalid_max_body_size_falls_back_to_default() {
175+
let env = env_with_key();
176+
env.set("DISCORD_MAX_BODY_SIZE", "not-a-number");
177+
178+
let config = DiscordConfig::from_env(&env);
179+
assert_eq!(config.max_body_size, DEFAULT_MAX_BODY_SIZE);
180+
}
181+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
use std::time::Duration;
2+
3+
use bytesize::ByteSize;
4+
5+
pub const DEFAULT_PORT: u16 = 8080;
6+
pub const DEFAULT_SUBJECT_PREFIX: &str = "discord";
7+
pub const DEFAULT_STREAM_NAME: &str = "DISCORD";
8+
pub const DEFAULT_STREAM_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
9+
pub const DEFAULT_NATS_ACK_TIMEOUT: Duration = Duration::from_secs(10);
10+
pub const DEFAULT_MAX_BODY_SIZE: ByteSize = ByteSize::mib(4);
11+
pub const DEFAULT_NATS_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
12+
13+
pub const HEADER_SIGNATURE: &str = "x-signature-ed25519";
14+
pub const HEADER_TIMESTAMP: &str = "x-signature-timestamp";
15+
16+
pub const NATS_HEADER_INTERACTION_TYPE: &str = "X-Discord-Interaction-Type";
17+
pub const NATS_HEADER_INTERACTION_ID: &str = "X-Discord-Interaction-Id";
18+
pub const NATS_HEADER_REJECT_REASON: &str = "X-Discord-Reject-Reason";
19+
pub const NATS_HEADER_PAYLOAD_KIND: &str = "X-Discord-Payload-Kind";
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//! # trogon-source-discord
2+
//!
3+
//! Discord interaction webhook receiver that publishes events to NATS JetStream.
4+
//!
5+
//! ## How it works
6+
//!
7+
//! 1. Discord sends `POST /webhook` with `X-Signature-Ed25519` and
8+
//! `X-Signature-Timestamp` headers plus a JSON interaction payload.
9+
//! 2. The server validates the Ed25519 signature against `DISCORD_PUBLIC_KEY`.
10+
//! 3. PING interactions (type 1) are answered inline with `{"type":1}`.
11+
//! 4. All other interactions are published to NATS JetStream on
12+
//! `discord.{interaction_type}` subjects (e.g. `discord.application_command`).
13+
//! 5. The JetStream stream (`DISCORD` by default, capturing `discord.>`) is
14+
//! created automatically on startup if it doesn't exist.
15+
//!
16+
//! ## NATS message format
17+
//!
18+
//! - **Subject**: `{DISCORD_SUBJECT_PREFIX}.{interaction_type}`
19+
//! (e.g. `discord.application_command`, `discord.message_component`)
20+
//! - **Headers**: `X-Discord-Interaction-Type`, `X-Discord-Interaction-Id`
21+
//! - **Payload**: raw JSON body from Discord
22+
//!
23+
//! ## Configuration (env vars)
24+
//!
25+
//! | Variable | Default | Description |
26+
//! |---|---|---|
27+
//! | `DISCORD_PUBLIC_KEY` | **required** | Ed25519 public key (hex) from Discord app settings |
28+
//! | `DISCORD_WEBHOOK_PORT` | `8080` | HTTP listening port |
29+
//! | `DISCORD_SUBJECT_PREFIX` | `discord` | NATS subject prefix |
30+
//! | `DISCORD_STREAM_NAME` | `DISCORD` | JetStream stream name |
31+
//! | `DISCORD_STREAM_MAX_AGE_SECS` | `604800` | Max age of messages in JetStream (seconds, default 7 days) |
32+
//! | `DISCORD_NATS_ACK_TIMEOUT_SECS` | `10` | NATS publish ack timeout in seconds |
33+
//! | `DISCORD_MAX_BODY_SIZE` | `4194304` | Maximum webhook body size in bytes (default 4 MB) |
34+
//! | `NATS_URL` | `localhost:4222` | NATS server URL(s) |
35+
36+
pub mod config;
37+
pub mod constants;
38+
pub mod server;
39+
pub mod signature;
40+
41+
pub use config::DiscordConfig;
42+
#[cfg(not(coverage))]
43+
pub use server::{ServeError, serve};
44+
pub use server::{provision, router};
45+
pub use signature::SignatureError;

0 commit comments

Comments
 (0)