Skip to content

Commit 4c85eae

Browse files
committed
feat(slack): add trogon-source-slack webhook receiver
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 6fdf052 commit 4c85eae

File tree

9 files changed

+1359
-0
lines changed

9 files changed

+1359
-0
lines changed

rsworkspace/Cargo.lock

Lines changed: 22 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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub enum ServiceName {
66
AcpNatsStdio,
77
AcpNatsWs,
88
TrogonSourceGithub,
9+
TrogonSourceSlack,
910
}
1011

1112
impl ServiceName {
@@ -14,6 +15,7 @@ impl ServiceName {
1415
Self::AcpNatsStdio => "acp-nats-stdio",
1516
Self::AcpNatsWs => "acp-nats-ws",
1617
Self::TrogonSourceGithub => "trogon-source-github",
18+
Self::TrogonSourceSlack => "trogon-source-slack",
1719
}
1820
}
1921
}
@@ -33,6 +35,7 @@ mod tests {
3335
assert_eq!(ServiceName::AcpNatsStdio.as_str(), "acp-nats-stdio");
3436
assert_eq!(ServiceName::AcpNatsWs.as_str(), "acp-nats-ws");
3537
assert_eq!(ServiceName::TrogonSourceGithub.as_str(), "trogon-source-github");
38+
assert_eq!(ServiceName::TrogonSourceSlack.as_str(), "trogon-source-slack");
3639
}
3740

3841
#[test]
@@ -43,5 +46,9 @@ mod tests {
4346
format!("{}", ServiceName::TrogonSourceGithub),
4447
"trogon-source-github"
4548
);
49+
assert_eq!(
50+
format!("{}", ServiceName::TrogonSourceSlack),
51+
"trogon-source-slack"
52+
);
4653
}
4754
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
[package]
2+
name = "trogon-source-slack"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[lints]
7+
workspace = true
8+
9+
[[bin]]
10+
name = "trogon-source-slack"
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+
hex = "0.4"
20+
hmac = "0.12"
21+
sha2 = "0.10"
22+
tokio = { workspace = true, features = ["full"] }
23+
tower-http = { workspace = true, features = ["limit"] }
24+
serde_json = { workspace = true }
25+
tracing = { workspace = true }
26+
trogon-nats = { workspace = true }
27+
trogon-std = { workspace = true }
28+
29+
[dev-dependencies]
30+
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
31+
tower = "0.5"
32+
tracing-subscriber = { workspace = true }
33+
trogon-nats = { workspace = true, features = ["test-support"] }
34+
trogon-std = { workspace = true, features = ["test-support"] }
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
use std::time::Duration;
2+
3+
use bytesize::ByteSize;
4+
use trogon_nats::NatsConfig;
5+
use trogon_std::env::ReadEnv;
6+
7+
use crate::constants::{
8+
DEFAULT_MAX_BODY_SIZE, DEFAULT_NATS_ACK_TIMEOUT, DEFAULT_PORT, DEFAULT_STREAM_MAX_AGE,
9+
DEFAULT_STREAM_NAME, DEFAULT_SUBJECT_PREFIX, DEFAULT_TIMESTAMP_MAX_DRIFT_SECS,
10+
};
11+
12+
/// Configuration for the Slack Events API webhook server.
13+
///
14+
/// Resolved from environment variables:
15+
/// - `SLACK_SIGNING_SECRET`: Slack app signing secret (**required**)
16+
/// - `SLACK_WEBHOOK_PORT`: HTTP listening port (default: 3000)
17+
/// - `SLACK_SUBJECT_PREFIX`: NATS subject prefix (default: `slack`)
18+
/// - `SLACK_STREAM_NAME`: JetStream stream name (default: `SLACK`)
19+
/// - `SLACK_STREAM_MAX_AGE_SECS`: max age of messages in the JetStream stream in seconds (default: 604800 / 7 days)
20+
/// - `SLACK_NATS_ACK_TIMEOUT_SECS`: NATS ack timeout in seconds (default: 10)
21+
/// - `SLACK_MAX_BODY_SIZE`: maximum webhook body size in bytes (default: 1048576 / 1 MB)
22+
/// - `SLACK_TIMESTAMP_MAX_DRIFT_SECS`: max allowed clock drift for request timestamps in seconds (default: 300 / 5 min)
23+
/// - Standard `NATS_*` variables for NATS connection (see `trogon-nats`)
24+
pub struct SlackConfig {
25+
pub signing_secret: String,
26+
pub port: u16,
27+
pub subject_prefix: String,
28+
pub stream_name: String,
29+
pub stream_max_age: Duration,
30+
pub nats_ack_timeout: Duration,
31+
pub max_body_size: ByteSize,
32+
pub timestamp_max_drift: Duration,
33+
pub nats: NatsConfig,
34+
}
35+
36+
impl SlackConfig {
37+
pub fn from_env<E: ReadEnv>(env: &E) -> Self {
38+
Self {
39+
signing_secret: env
40+
.var("SLACK_SIGNING_SECRET")
41+
.ok()
42+
.filter(|s| !s.is_empty())
43+
.expect("SLACK_SIGNING_SECRET is required"),
44+
port: env
45+
.var("SLACK_WEBHOOK_PORT")
46+
.ok()
47+
.and_then(|p| p.parse().ok())
48+
.unwrap_or(DEFAULT_PORT),
49+
subject_prefix: env
50+
.var("SLACK_SUBJECT_PREFIX")
51+
.unwrap_or_else(|_| DEFAULT_SUBJECT_PREFIX.to_string()),
52+
stream_name: env
53+
.var("SLACK_STREAM_NAME")
54+
.unwrap_or_else(|_| DEFAULT_STREAM_NAME.to_string()),
55+
stream_max_age: env
56+
.var("SLACK_STREAM_MAX_AGE_SECS")
57+
.ok()
58+
.and_then(|v| v.parse().ok())
59+
.map(Duration::from_secs)
60+
.unwrap_or(DEFAULT_STREAM_MAX_AGE),
61+
nats_ack_timeout: env
62+
.var("SLACK_NATS_ACK_TIMEOUT_SECS")
63+
.ok()
64+
.and_then(|v| v.parse().ok())
65+
.map(Duration::from_secs)
66+
.unwrap_or(DEFAULT_NATS_ACK_TIMEOUT),
67+
max_body_size: env
68+
.var("SLACK_MAX_BODY_SIZE")
69+
.ok()
70+
.and_then(|v| v.parse::<u64>().ok())
71+
.map(ByteSize)
72+
.unwrap_or(DEFAULT_MAX_BODY_SIZE),
73+
timestamp_max_drift: env
74+
.var("SLACK_TIMESTAMP_MAX_DRIFT_SECS")
75+
.ok()
76+
.and_then(|v| v.parse().ok())
77+
.map(Duration::from_secs)
78+
.unwrap_or(Duration::from_secs(DEFAULT_TIMESTAMP_MAX_DRIFT_SECS)),
79+
nats: NatsConfig::from_env(env),
80+
}
81+
}
82+
}
83+
84+
#[cfg(test)]
85+
mod tests {
86+
use super::*;
87+
use trogon_std::env::InMemoryEnv;
88+
89+
fn env_with_secret() -> InMemoryEnv {
90+
let env = InMemoryEnv::new();
91+
env.set("SLACK_SIGNING_SECRET", "test-secret");
92+
env
93+
}
94+
95+
#[test]
96+
fn defaults_with_required_secret() {
97+
let env = env_with_secret();
98+
let config = SlackConfig::from_env(&env);
99+
100+
assert_eq!(config.signing_secret, "test-secret");
101+
assert_eq!(config.port, 3000);
102+
assert_eq!(config.subject_prefix, "slack");
103+
assert_eq!(config.stream_name, "SLACK");
104+
assert_eq!(config.stream_max_age, Duration::from_secs(7 * 24 * 60 * 60));
105+
assert_eq!(config.nats_ack_timeout, Duration::from_secs(10));
106+
assert_eq!(config.max_body_size, ByteSize::mib(1));
107+
assert_eq!(config.timestamp_max_drift, Duration::from_secs(300));
108+
}
109+
110+
#[test]
111+
fn reads_all_env_vars() {
112+
let env = InMemoryEnv::new();
113+
env.set("SLACK_SIGNING_SECRET", "my-secret");
114+
env.set("SLACK_WEBHOOK_PORT", "9090");
115+
env.set("SLACK_SUBJECT_PREFIX", "slk");
116+
env.set("SLACK_STREAM_NAME", "SLK_EVENTS");
117+
env.set("SLACK_STREAM_MAX_AGE_SECS", "3600");
118+
env.set("SLACK_NATS_ACK_TIMEOUT_SECS", "30");
119+
env.set("SLACK_MAX_BODY_SIZE", "1048576");
120+
env.set("SLACK_TIMESTAMP_MAX_DRIFT_SECS", "60");
121+
122+
let config = SlackConfig::from_env(&env);
123+
124+
assert_eq!(config.signing_secret, "my-secret");
125+
assert_eq!(config.port, 9090);
126+
assert_eq!(config.subject_prefix, "slk");
127+
assert_eq!(config.stream_name, "SLK_EVENTS");
128+
assert_eq!(config.stream_max_age, Duration::from_secs(3600));
129+
assert_eq!(config.nats_ack_timeout, Duration::from_secs(30));
130+
assert_eq!(config.max_body_size, ByteSize::mib(1));
131+
assert_eq!(config.timestamp_max_drift, Duration::from_secs(60));
132+
}
133+
134+
#[test]
135+
#[should_panic(expected = "SLACK_SIGNING_SECRET is required")]
136+
fn missing_signing_secret_panics() {
137+
let env = InMemoryEnv::new();
138+
SlackConfig::from_env(&env);
139+
}
140+
141+
#[test]
142+
#[should_panic(expected = "SLACK_SIGNING_SECRET is required")]
143+
fn empty_signing_secret_panics() {
144+
let env = InMemoryEnv::new();
145+
env.set("SLACK_SIGNING_SECRET", "");
146+
SlackConfig::from_env(&env);
147+
}
148+
149+
#[test]
150+
fn invalid_port_falls_back_to_default() {
151+
let env = env_with_secret();
152+
env.set("SLACK_WEBHOOK_PORT", "not-a-number");
153+
let config = SlackConfig::from_env(&env);
154+
assert_eq!(config.port, 3000);
155+
}
156+
157+
#[test]
158+
fn invalid_max_age_falls_back_to_default() {
159+
let env = env_with_secret();
160+
env.set("SLACK_STREAM_MAX_AGE_SECS", "not-a-number");
161+
let config = SlackConfig::from_env(&env);
162+
assert_eq!(config.stream_max_age, DEFAULT_STREAM_MAX_AGE);
163+
}
164+
165+
#[test]
166+
fn invalid_nats_ack_timeout_falls_back_to_default() {
167+
let env = env_with_secret();
168+
env.set("SLACK_NATS_ACK_TIMEOUT_SECS", "not-a-number");
169+
let config = SlackConfig::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_secret();
176+
env.set("SLACK_MAX_BODY_SIZE", "not-a-number");
177+
let config = SlackConfig::from_env(&env);
178+
assert_eq!(config.max_body_size, DEFAULT_MAX_BODY_SIZE);
179+
}
180+
181+
#[test]
182+
fn invalid_timestamp_drift_falls_back_to_default() {
183+
let env = env_with_secret();
184+
env.set("SLACK_TIMESTAMP_MAX_DRIFT_SECS", "not-a-number");
185+
let config = SlackConfig::from_env(&env);
186+
assert_eq!(config.timestamp_max_drift, Duration::from_secs(300));
187+
}
188+
}
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 = 3000;
6+
pub const DEFAULT_SUBJECT_PREFIX: &str = "slack";
7+
pub const DEFAULT_STREAM_NAME: &str = "SLACK";
8+
pub const DEFAULT_STREAM_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); // 7 days
9+
pub const DEFAULT_NATS_ACK_TIMEOUT: Duration = Duration::from_secs(10);
10+
pub const DEFAULT_MAX_BODY_SIZE: ByteSize = ByteSize::mib(1);
11+
pub const DEFAULT_NATS_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
12+
pub const DEFAULT_TIMESTAMP_MAX_DRIFT_SECS: u64 = 300; // 5 minutes
13+
14+
pub const HEADER_SIGNATURE: &str = "x-slack-signature";
15+
pub const HEADER_TIMESTAMP: &str = "x-slack-request-timestamp";
16+
17+
pub const NATS_HEADER_EVENT_TYPE: &str = "X-Slack-Event-Type";
18+
pub const NATS_HEADER_TEAM_ID: &str = "X-Slack-Team-Id";
19+
pub const NATS_HEADER_EVENT_ID: &str = "X-Slack-Event-Id";
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//! # trogon-source-slack
2+
//!
3+
//! Slack Events API webhook receiver that publishes events to NATS JetStream.
4+
//!
5+
//! ## How it works
6+
//!
7+
//! 1. Slack sends `POST /webhook` with `X-Slack-Signature` and
8+
//! `X-Slack-Request-Timestamp` headers plus a JSON payload.
9+
//! 2. The server validates the HMAC-SHA256 signature against `SLACK_SIGNING_SECRET`.
10+
//! 3. `url_verification` challenges are answered inline (no NATS publish).
11+
//! 4. `event_callback` payloads are published to NATS JetStream on
12+
//! `slack.{event.type}` subjects (e.g. `slack.message`, `slack.app_mention`).
13+
//! 5. The JetStream stream (`SLACK` by default, capturing `slack.>`) is created
14+
//! automatically on startup if it doesn't exist.
15+
//!
16+
//! ## NATS message format
17+
//!
18+
//! - **Subject**: `{SLACK_SUBJECT_PREFIX}.{event.type}` (e.g. `slack.message`)
19+
//! - **Headers**: `X-Slack-Event-Type`, `X-Slack-Event-Id`, `X-Slack-Team-Id`
20+
//! - **Payload**: raw JSON body from Slack
21+
//!
22+
//! ## Configuration (env vars)
23+
//!
24+
//! | Variable | Default | Description |
25+
//! |---|---|---|
26+
//! | `SLACK_SIGNING_SECRET` | **required** | Slack app signing secret |
27+
//! | `SLACK_WEBHOOK_PORT` | `3000` | HTTP listening port |
28+
//! | `SLACK_SUBJECT_PREFIX` | `slack` | NATS subject prefix |
29+
//! | `SLACK_STREAM_NAME` | `SLACK` | JetStream stream name |
30+
//! | `SLACK_STREAM_MAX_AGE_SECS` | `604800` | Max age of messages in JetStream (seconds, default 7 days) |
31+
//! | `SLACK_NATS_ACK_TIMEOUT_SECS` | `10` | NATS publish ack timeout in seconds |
32+
//! | `SLACK_MAX_BODY_SIZE` | `1048576` | Maximum webhook body size in bytes (default 1 MB) |
33+
//! | `SLACK_TIMESTAMP_MAX_DRIFT_SECS` | `300` | Max clock drift for request timestamps (default 5 min) |
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::SlackConfig;
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)