storkit: merge 389_story_whatsapp_phone_number_allowlist_authorization
This commit is contained in:
@@ -26,3 +26,8 @@ whatsapp_verify_token = "my-secret-verify-token"
|
|||||||
|
|
||||||
# Maximum conversation turns to remember per user (default: 20).
|
# Maximum conversation turns to remember per user (default: 20).
|
||||||
# history_size = 20
|
# history_size = 20
|
||||||
|
|
||||||
|
# Optional: restrict which phone numbers can interact with the bot.
|
||||||
|
# When set, only listed numbers are processed; all others are silently ignored.
|
||||||
|
# When absent or empty, all numbers are allowed (open by default).
|
||||||
|
# whatsapp_allowed_phones = ["+15551234567", "+15559876543"]
|
||||||
|
|||||||
@@ -22,3 +22,8 @@ twilio_whatsapp_number = "+14155238886"
|
|||||||
|
|
||||||
# Maximum conversation turns to remember per user (default: 20).
|
# Maximum conversation turns to remember per user (default: 20).
|
||||||
# history_size = 20
|
# history_size = 20
|
||||||
|
|
||||||
|
# Optional: restrict which phone numbers can interact with the bot.
|
||||||
|
# When set, only listed numbers are processed; all others are silently ignored.
|
||||||
|
# When absent or empty, all numbers are allowed (open by default).
|
||||||
|
# whatsapp_allowed_phones = ["+15551234567", "+15559876543"]
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ pub struct BotConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub twilio_whatsapp_number: Option<String>,
|
pub twilio_whatsapp_number: Option<String>,
|
||||||
|
|
||||||
|
/// Phone numbers allowed to interact with the bot when using WhatsApp.
|
||||||
|
/// When non-empty, only listed numbers can send commands; all others are
|
||||||
|
/// silently ignored. When empty or absent, all numbers are allowed
|
||||||
|
/// (backwards compatible — open by default, unlike Matrix which is
|
||||||
|
/// fail-closed).
|
||||||
|
#[serde(default)]
|
||||||
|
pub whatsapp_allowed_phones: Vec<String>,
|
||||||
|
|
||||||
// ── Slack Bot API fields ─────────────────────────────────────────
|
// ── Slack Bot API fields ─────────────────────────────────────────
|
||||||
// These are only required when `transport = "slack"`.
|
// These are only required when `transport = "slack"`.
|
||||||
|
|
||||||
@@ -1010,4 +1018,40 @@ slack_signing_secret = "secret123"
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(BotConfig::load(tmp.path()).is_none());
|
assert!(BotConfig::load(tmp.path()).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whatsapp_allowed_phones_defaults_to_empty_when_absent() {
|
||||||
|
let config: BotConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
enabled = true
|
||||||
|
transport = "whatsapp"
|
||||||
|
whatsapp_provider = "meta"
|
||||||
|
whatsapp_phone_number_id = "123"
|
||||||
|
whatsapp_access_token = "tok"
|
||||||
|
whatsapp_verify_token = "ver"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(config.whatsapp_allowed_phones.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whatsapp_allowed_phones_deserializes_list() {
|
||||||
|
let config: BotConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
enabled = true
|
||||||
|
transport = "whatsapp"
|
||||||
|
whatsapp_provider = "meta"
|
||||||
|
whatsapp_phone_number_id = "123"
|
||||||
|
whatsapp_access_token = "tok"
|
||||||
|
whatsapp_verify_token = "ver"
|
||||||
|
whatsapp_allowed_phones = ["+15551234567", "+15559876543"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
config.whatsapp_allowed_phones,
|
||||||
|
vec!["+15551234567", "+15559876543"]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -881,6 +881,9 @@ pub struct WhatsAppWebhookContext {
|
|||||||
pub history_size: usize,
|
pub history_size: usize,
|
||||||
/// Tracks the 24-hour messaging window per user phone number.
|
/// Tracks the 24-hour messaging window per user phone number.
|
||||||
pub window_tracker: Arc<MessagingWindowTracker>,
|
pub window_tracker: Arc<MessagingWindowTracker>,
|
||||||
|
/// Phone numbers allowed to send messages to the bot.
|
||||||
|
/// When empty, all numbers are allowed (backwards compatible).
|
||||||
|
pub allowed_phones: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /webhook/whatsapp — webhook verification.
|
/// GET /webhook/whatsapp — webhook verification.
|
||||||
@@ -977,6 +980,14 @@ pub async fn webhook_receive(
|
|||||||
async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) {
|
async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) {
|
||||||
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command};
|
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command};
|
||||||
|
|
||||||
|
// Allowlist check: when configured, silently ignore unauthorized senders.
|
||||||
|
if !ctx.allowed_phones.is_empty()
|
||||||
|
&& !ctx.allowed_phones.iter().any(|p| p == sender)
|
||||||
|
{
|
||||||
|
slog!("[whatsapp] Ignoring message from unauthorized sender: {sender}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Record this inbound message to keep the 24-hour window open.
|
// Record this inbound message to keep the 24-hour window open.
|
||||||
ctx.window_tracker.record_message(sender);
|
ctx.window_tracker.record_message(sender);
|
||||||
|
|
||||||
@@ -1820,6 +1831,106 @@ mod tests {
|
|||||||
let _msgs = extract_twilio_text_messages(body);
|
let _msgs = extract_twilio_text_messages(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Allowlist tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Build a minimal WhatsAppWebhookContext for allowlist tests.
|
||||||
|
fn make_ctx_with_allowlist(
|
||||||
|
allowed_phones: Vec<String>,
|
||||||
|
) -> Arc<WhatsAppWebhookContext> {
|
||||||
|
use crate::agents::AgentPool;
|
||||||
|
use crate::io::watcher::WatcherEvent;
|
||||||
|
|
||||||
|
struct NullTransport;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl crate::chat::ChatTransport for NullTransport {
|
||||||
|
async fn send_message(
|
||||||
|
&self,
|
||||||
|
_room: &str,
|
||||||
|
_plain: &str,
|
||||||
|
_html: &str,
|
||||||
|
) -> Result<crate::chat::MessageId, String> {
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
|
async fn edit_message(
|
||||||
|
&self,
|
||||||
|
_room: &str,
|
||||||
|
_id: &str,
|
||||||
|
_plain: &str,
|
||||||
|
_html: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn send_typing(&self, _room: &str, _typing: bool) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (tx, _rx) = tokio::sync::broadcast::channel::<WatcherEvent>(16);
|
||||||
|
let agents = Arc::new(AgentPool::new(3999, tx));
|
||||||
|
let tracker = Arc::new(MessagingWindowTracker::new());
|
||||||
|
Arc::new(WhatsAppWebhookContext {
|
||||||
|
verify_token: "tok".to_string(),
|
||||||
|
provider: "meta".to_string(),
|
||||||
|
transport: Arc::new(NullTransport),
|
||||||
|
project_root: tmp.path().to_path_buf(),
|
||||||
|
agents,
|
||||||
|
bot_name: "Bot".to_string(),
|
||||||
|
bot_user_id: "whatsapp-bot".to_string(),
|
||||||
|
ambient_rooms: Arc::new(std::sync::Mutex::new(Default::default())),
|
||||||
|
history: Arc::new(tokio::sync::Mutex::new(Default::default())),
|
||||||
|
history_size: 20,
|
||||||
|
window_tracker: tracker,
|
||||||
|
allowed_phones,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn allowlist_blocks_unauthorized_sender() {
|
||||||
|
let allowed = vec!["+15551111111".to_string()];
|
||||||
|
let ctx = make_ctx_with_allowlist(allowed);
|
||||||
|
let unauthorized = "+15559999999";
|
||||||
|
|
||||||
|
handle_incoming_message(&ctx, unauthorized, "hello").await;
|
||||||
|
|
||||||
|
// window_tracker is only updated AFTER the allowlist check, so an
|
||||||
|
// unauthorized sender must leave the tracker untouched.
|
||||||
|
assert!(
|
||||||
|
!ctx.window_tracker.is_within_window(unauthorized),
|
||||||
|
"unauthorized sender should not have updated the window tracker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn allowlist_empty_allows_all_senders() {
|
||||||
|
// Empty allowlist = open (backwards compatible).
|
||||||
|
let ctx = make_ctx_with_allowlist(vec![]);
|
||||||
|
let sender = "+15551234567";
|
||||||
|
|
||||||
|
handle_incoming_message(&ctx, sender, "hello").await;
|
||||||
|
|
||||||
|
// window_tracker.record_message is called right after the allowlist
|
||||||
|
// check passes, so the sender should be recorded.
|
||||||
|
assert!(
|
||||||
|
ctx.window_tracker.is_within_window(sender),
|
||||||
|
"sender should be recorded when allowlist is empty"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn allowlist_allows_listed_sender() {
|
||||||
|
let sender = "+15551111111";
|
||||||
|
let ctx = make_ctx_with_allowlist(vec![sender.to_string()]);
|
||||||
|
|
||||||
|
handle_incoming_message(&ctx, sender, "hello").await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
ctx.window_tracker.is_within_window(sender),
|
||||||
|
"listed sender should be recorded in the window tracker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_whatsapp_history_returns_empty_when_file_missing() {
|
fn load_whatsapp_history_returns_empty_when_file_missing() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
history: std::sync::Arc::new(tokio::sync::Mutex::new(history)),
|
history: std::sync::Arc::new(tokio::sync::Mutex::new(history)),
|
||||||
history_size: cfg.history_size,
|
history_size: cfg.history_size,
|
||||||
window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()),
|
window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()),
|
||||||
|
allowed_phones: cfg.whatsapp_allowed_phones.clone(),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user