304 lines
9.9 KiB
Rust
304 lines
9.9 KiB
Rust
use super::traits::{Channel, ChannelMessage};
|
|
use async_trait::async_trait;
|
|
|
|
/// Slack channel — polls conversations.history via Web API
|
|
pub struct SlackChannel {
|
|
bot_token: String,
|
|
channel_id: Option<String>,
|
|
allowed_users: Vec<String>,
|
|
client: reqwest::Client,
|
|
}
|
|
|
|
impl SlackChannel {
|
|
pub fn new(bot_token: String, channel_id: Option<String>, allowed_users: Vec<String>) -> Self {
|
|
Self {
|
|
bot_token,
|
|
channel_id,
|
|
allowed_users,
|
|
client: reqwest::Client::new(),
|
|
}
|
|
}
|
|
|
|
/// Check if a Slack user ID is in the allowlist.
|
|
/// Empty list means deny everyone until explicitly configured.
|
|
/// `"*"` means allow everyone.
|
|
fn is_user_allowed(&self, user_id: &str) -> bool {
|
|
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
|
|
}
|
|
|
|
/// Get the bot's own user ID so we can ignore our own messages
|
|
async fn get_bot_user_id(&self) -> Option<String> {
|
|
let resp: serde_json::Value = self
|
|
.client
|
|
.get("https://slack.com/api/auth.test")
|
|
.bearer_auth(&self.bot_token)
|
|
.send()
|
|
.await
|
|
.ok()?
|
|
.json()
|
|
.await
|
|
.ok()?;
|
|
|
|
resp.get("user_id")
|
|
.and_then(|u| u.as_str())
|
|
.map(String::from)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for SlackChannel {
|
|
fn name(&self) -> &str {
|
|
"slack"
|
|
}
|
|
|
|
async fn send(&self, message: &str, channel: &str) -> anyhow::Result<()> {
|
|
let body = serde_json::json!({
|
|
"channel": channel,
|
|
"text": message
|
|
});
|
|
|
|
let resp = self
|
|
.client
|
|
.post("https://slack.com/api/chat.postMessage")
|
|
.bearer_auth(&self.bot_token)
|
|
.json(&body)
|
|
.send()
|
|
.await?;
|
|
|
|
let status = resp.status();
|
|
let body = resp
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
|
|
|
|
if !status.is_success() {
|
|
anyhow::bail!("Slack chat.postMessage failed ({status}): {body}");
|
|
}
|
|
|
|
// Slack returns 200 for most app-level errors; check JSON "ok" field
|
|
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
|
|
if parsed.get("ok") == Some(&serde_json::Value::Bool(false)) {
|
|
let err = parsed
|
|
.get("error")
|
|
.and_then(|e| e.as_str())
|
|
.unwrap_or("unknown");
|
|
anyhow::bail!("Slack chat.postMessage failed: {err}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
|
let channel_id = self
|
|
.channel_id
|
|
.clone()
|
|
.ok_or_else(|| anyhow::anyhow!("Slack channel_id required for listening"))?;
|
|
|
|
let bot_user_id = self.get_bot_user_id().await.unwrap_or_default();
|
|
let mut last_ts = String::new();
|
|
|
|
tracing::info!("Slack channel listening on #{channel_id}...");
|
|
|
|
loop {
|
|
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
|
|
|
let mut params = vec![("channel", channel_id.clone()), ("limit", "10".to_string())];
|
|
if !last_ts.is_empty() {
|
|
params.push(("oldest", last_ts.clone()));
|
|
}
|
|
|
|
let resp = match self
|
|
.client
|
|
.get("https://slack.com/api/conversations.history")
|
|
.bearer_auth(&self.bot_token)
|
|
.query(¶ms)
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
tracing::warn!("Slack poll error: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let data: serde_json::Value = match resp.json().await {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
tracing::warn!("Slack parse error: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
if let Some(messages) = data.get("messages").and_then(|m| m.as_array()) {
|
|
// Messages come newest-first, reverse to process oldest first
|
|
for msg in messages.iter().rev() {
|
|
let ts = msg.get("ts").and_then(|t| t.as_str()).unwrap_or("");
|
|
let user = msg
|
|
.get("user")
|
|
.and_then(|u| u.as_str())
|
|
.unwrap_or("unknown");
|
|
let text = msg.get("text").and_then(|t| t.as_str()).unwrap_or("");
|
|
|
|
// Skip bot's own messages
|
|
if user == bot_user_id {
|
|
continue;
|
|
}
|
|
|
|
// Sender validation
|
|
if !self.is_user_allowed(user) {
|
|
tracing::warn!("Slack: ignoring message from unauthorized user: {user}");
|
|
continue;
|
|
}
|
|
|
|
// Skip empty or already-seen
|
|
if text.is_empty() || ts <= last_ts.as_str() {
|
|
continue;
|
|
}
|
|
|
|
last_ts = ts.to_string();
|
|
|
|
let channel_msg = ChannelMessage {
|
|
id: format!("slack_{channel_id}_{ts}"),
|
|
sender: user.to_string(),
|
|
reply_target: channel_id.clone(),
|
|
content: text.to_string(),
|
|
channel: "slack".to_string(),
|
|
timestamp: std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs(),
|
|
};
|
|
|
|
if tx.send(channel_msg).await.is_err() {
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn health_check(&self) -> bool {
|
|
self.client
|
|
.get("https://slack.com/api/auth.test")
|
|
.bearer_auth(&self.bot_token)
|
|
.send()
|
|
.await
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn slack_channel_name() {
|
|
let ch = SlackChannel::new("xoxb-fake".into(), None, vec![]);
|
|
assert_eq!(ch.name(), "slack");
|
|
}
|
|
|
|
#[test]
|
|
fn slack_channel_with_channel_id() {
|
|
let ch = SlackChannel::new("xoxb-fake".into(), Some("C12345".into()), vec![]);
|
|
assert_eq!(ch.channel_id, Some("C12345".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_allowlist_denies_everyone() {
|
|
let ch = SlackChannel::new("xoxb-fake".into(), None, vec![]);
|
|
assert!(!ch.is_user_allowed("U12345"));
|
|
assert!(!ch.is_user_allowed("anyone"));
|
|
}
|
|
|
|
#[test]
|
|
fn wildcard_allows_everyone() {
|
|
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["*".into()]);
|
|
assert!(ch.is_user_allowed("U12345"));
|
|
}
|
|
|
|
#[test]
|
|
fn specific_allowlist_filters() {
|
|
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into(), "U222".into()]);
|
|
assert!(ch.is_user_allowed("U111"));
|
|
assert!(ch.is_user_allowed("U222"));
|
|
assert!(!ch.is_user_allowed("U333"));
|
|
}
|
|
|
|
#[test]
|
|
fn allowlist_exact_match_not_substring() {
|
|
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into()]);
|
|
assert!(!ch.is_user_allowed("U1111"));
|
|
assert!(!ch.is_user_allowed("U11"));
|
|
}
|
|
|
|
#[test]
|
|
fn allowlist_empty_user_id() {
|
|
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into()]);
|
|
assert!(!ch.is_user_allowed(""));
|
|
}
|
|
|
|
#[test]
|
|
fn allowlist_case_sensitive() {
|
|
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into()]);
|
|
assert!(ch.is_user_allowed("U111"));
|
|
assert!(!ch.is_user_allowed("u111"));
|
|
}
|
|
|
|
#[test]
|
|
fn allowlist_wildcard_and_specific() {
|
|
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into(), "*".into()]);
|
|
assert!(ch.is_user_allowed("U111"));
|
|
assert!(ch.is_user_allowed("anyone"));
|
|
}
|
|
|
|
// ── Message ID edge cases ─────────────────────────────────────
|
|
|
|
#[test]
|
|
fn slack_message_id_format_includes_channel_and_ts() {
|
|
// Verify that message IDs follow the format: slack_{channel_id}_{ts}
|
|
let ts = "1234567890.123456";
|
|
let channel_id = "C12345";
|
|
let expected_id = format!("slack_{channel_id}_{ts}");
|
|
assert_eq!(expected_id, "slack_C12345_1234567890.123456");
|
|
}
|
|
|
|
#[test]
|
|
fn slack_message_id_is_deterministic() {
|
|
// Same channel_id + same ts = same ID (prevents duplicates after restart)
|
|
let ts = "1234567890.123456";
|
|
let channel_id = "C12345";
|
|
let id1 = format!("slack_{channel_id}_{ts}");
|
|
let id2 = format!("slack_{channel_id}_{ts}");
|
|
assert_eq!(id1, id2);
|
|
}
|
|
|
|
#[test]
|
|
fn slack_message_id_different_ts_different_id() {
|
|
// Different timestamps produce different IDs
|
|
let channel_id = "C12345";
|
|
let id1 = format!("slack_{channel_id}_1234567890.123456");
|
|
let id2 = format!("slack_{channel_id}_1234567890.123457");
|
|
assert_ne!(id1, id2);
|
|
}
|
|
|
|
#[test]
|
|
fn slack_message_id_different_channel_different_id() {
|
|
// Different channels produce different IDs even with same ts
|
|
let ts = "1234567890.123456";
|
|
let id1 = format!("slack_C12345_{ts}");
|
|
let id2 = format!("slack_C67890_{ts}");
|
|
assert_ne!(id1, id2);
|
|
}
|
|
|
|
#[test]
|
|
fn slack_message_id_no_uuid_randomness() {
|
|
// Verify format doesn't contain random UUID components
|
|
let ts = "1234567890.123456";
|
|
let channel_id = "C12345";
|
|
let id = format!("slack_{channel_id}_{ts}");
|
|
assert!(!id.contains('-')); // No UUID dashes
|
|
assert!(id.starts_with("slack_"));
|
|
}
|
|
}
|