From e73e1e6371c9b6ba256dbe922a7d83b5ea77e2a8 Mon Sep 17 00:00:00 2001 From: Argenis Date: Sat, 21 Mar 2026 06:04:21 -0400 Subject: [PATCH] fix(config): add missing WhatsApp Web policy config keys (#4131) * fix(config): add missing WhatsApp Web policy config keys (mode, dm_policy, group_policy, self_chat_mode) * fix(onboard): add missing WhatsApp policy fields to wizard struct literals The new mode, dm_policy, group_policy, and self_chat_mode fields added to WhatsAppConfig need default values in the onboard wizard's struct initializers to avoid E0063 compilation errors. --- src/channels/mod.rs | 4 ++ src/channels/whatsapp_web.rs | 112 ++++++++++++++++++++++++++++++++++- src/config/mod.rs | 3 +- src/config/schema.rs | 71 ++++++++++++++++++++++ src/onboard/wizard.rs | 10 +++- 5 files changed, 196 insertions(+), 4 deletions(-) diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 9f428bf89..e77fedc0a 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -3818,6 +3818,10 @@ fn collect_configured_channels( wa.pair_phone.clone(), wa.pair_code.clone(), wa.allowed_numbers.clone(), + wa.mode.clone(), + wa.dm_policy.clone(), + wa.group_policy.clone(), + wa.self_chat_mode, ) .with_transcription(config.transcription.clone()) .with_tts(config.tts.clone()), diff --git a/src/channels/whatsapp_web.rs b/src/channels/whatsapp_web.rs index 60deac742..45b047923 100644 --- a/src/channels/whatsapp_web.rs +++ b/src/channels/whatsapp_web.rs @@ -58,6 +58,14 @@ pub struct WhatsAppWebChannel { pair_code: Option, /// Allowed phone numbers (E.164 format) or "*" for all allowed_numbers: Vec, + /// Usage mode (business vs personal policy filtering) + mode: crate::config::WhatsAppWebMode, + /// DM policy when mode = personal + dm_policy: crate::config::WhatsAppChatPolicy, + /// Group policy when mode = personal + group_policy: crate::config::WhatsAppChatPolicy, + /// Whether to always respond in self-chat when mode = personal + self_chat_mode: bool, /// Bot handle for shutdown bot_handle: Arc>>>, /// Client handle for sending messages and typing indicators @@ -86,18 +94,30 @@ impl WhatsAppWebChannel { /// * `pair_phone` - Optional phone number for pair code linking (format: "15551234567") /// * `pair_code` - Optional custom pair code (leave empty for auto-generated) /// * `allowed_numbers` - Phone numbers allowed to interact (E.164 format) or "*" for all + /// * `mode` - Usage mode (business or personal) + /// * `dm_policy` - DM policy when mode = personal + /// * `group_policy` - Group policy when mode = personal + /// * `self_chat_mode` - Whether to always respond in self-chat when mode = personal #[cfg(feature = "whatsapp-web")] pub fn new( session_path: String, pair_phone: Option, pair_code: Option, allowed_numbers: Vec, + mode: crate::config::WhatsAppWebMode, + dm_policy: crate::config::WhatsAppChatPolicy, + group_policy: crate::config::WhatsAppChatPolicy, + self_chat_mode: bool, ) -> Self { Self { session_path, pair_phone, pair_code, allowed_numbers, + mode, + dm_policy, + group_policy, + self_chat_mode, bot_handle: Arc::new(Mutex::new(None)), client: Arc::new(Mutex::new(None)), tx: Arc::new(Mutex::new(None)), @@ -625,6 +645,10 @@ impl Channel for WhatsAppWebChannel { let session_revoked_clone = session_revoked.clone(); let transcription_config = self.transcription.clone(); let voice_chats = self.voice_chats.clone(); + let wa_mode = self.mode.clone(); + let wa_dm_policy = self.dm_policy.clone(); + let wa_group_policy = self.group_policy.clone(); + let wa_self_chat_mode = self.self_chat_mode; let mut builder = Bot::builder() .with_backend(backend) @@ -638,6 +662,9 @@ impl Channel for WhatsAppWebChannel { let session_revoked = session_revoked_clone.clone(); let transcription_config = transcription_config.clone(); let voice_chats = voice_chats.clone(); + let wa_mode = wa_mode.clone(); + let wa_dm_policy = wa_dm_policy.clone(); + let wa_group_policy = wa_group_policy.clone(); async move { match event { Event::Message(msg, info) => { @@ -674,6 +701,61 @@ impl Channel for WhatsAppWebChannel { } }; + // ── Personal-mode chat-type policy filtering ── + if wa_mode == crate::config::WhatsAppWebMode::Personal { + let is_group = chat.contains("@g.us"); + // Self-chat: the chat JID user part matches + // the sender's user part (message to "Notes + // to Self"). + let sender_user = sender_jid.user(); + let chat_user = chat + .split_once('@') + .map(|(u, _)| u) + .unwrap_or(&chat); + let is_self_chat = !is_group && sender_user == chat_user; + + if is_self_chat { + if !wa_self_chat_mode { + tracing::debug!( + "WhatsApp Web: ignoring self-chat message (self_chat_mode=false)" + ); + return; + } + // self_chat_mode=true: always process, skip further policy checks + } else if is_group { + match wa_group_policy { + crate::config::WhatsAppChatPolicy::Ignore => { + tracing::debug!( + "WhatsApp Web: ignoring group message (group_policy=ignore)" + ); + return; + } + crate::config::WhatsAppChatPolicy::All => { + // allow unconditionally + } + crate::config::WhatsAppChatPolicy::Allowlist => { + // already filtered by allowed_numbers above + } + } + } else { + // DM (non-self) + match wa_dm_policy { + crate::config::WhatsAppChatPolicy::Ignore => { + tracing::debug!( + "WhatsApp Web: ignoring DM (dm_policy=ignore)" + ); + return; + } + crate::config::WhatsAppChatPolicy::All => { + // allow unconditionally + } + crate::config::WhatsAppChatPolicy::Allowlist => { + // already filtered by allowed_numbers above + } + } + } + } + // Attempt voice note transcription (ptt = push-to-talk = voice note) let voice_text = if let Some(ref audio) = msg.audio_message { if audio.ptt == Some(true) { @@ -977,6 +1059,10 @@ impl WhatsAppWebChannel { _pair_phone: Option, _pair_code: Option, _allowed_numbers: Vec, + _mode: crate::config::WhatsAppWebMode, + _dm_policy: crate::config::WhatsAppChatPolicy, + _group_policy: crate::config::WhatsAppChatPolicy, + _self_chat_mode: bool, ) -> Self { Self { _private: () } } @@ -1043,6 +1129,10 @@ mod tests { None, None, vec!["+1234567890".into()], + crate::config::WhatsAppWebMode::default(), + crate::config::WhatsAppChatPolicy::default(), + crate::config::WhatsAppChatPolicy::default(), + false, ) } @@ -1064,7 +1154,16 @@ mod tests { #[test] #[cfg(feature = "whatsapp-web")] fn whatsapp_web_number_allowed_wildcard() { - let ch = WhatsAppWebChannel::new("/tmp/test.db".into(), None, None, vec!["*".into()]); + let ch = WhatsAppWebChannel::new( + "/tmp/test.db".into(), + None, + None, + vec!["*".into()], + crate::config::WhatsAppWebMode::default(), + crate::config::WhatsAppChatPolicy::default(), + crate::config::WhatsAppChatPolicy::default(), + false, + ); assert!(ch.is_number_allowed("+1234567890")); assert!(ch.is_number_allowed("+9999999999")); } @@ -1072,7 +1171,16 @@ mod tests { #[test] #[cfg(feature = "whatsapp-web")] fn whatsapp_web_number_denied_empty() { - let ch = WhatsAppWebChannel::new("/tmp/test.db".into(), None, None, vec![]); + let ch = WhatsAppWebChannel::new( + "/tmp/test.db".into(), + None, + None, + vec![], + crate::config::WhatsAppWebMode::default(), + crate::config::WhatsAppChatPolicy::default(), + crate::config::WhatsAppChatPolicy::default(), + false, + ); // Empty allowlist means "deny all" (matches channel-wide allowlist policy). assert!(!ch.is_number_allowed("+1234567890")); } diff --git a/src/config/mod.rs b/src/config/mod.rs index 98cb24eff..8dc50cb4e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -27,7 +27,8 @@ pub use schema::{ StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, SwarmConfig, SwarmStrategy, TelegramConfig, TextBrowserConfig, ToolFilterGroup, ToolFilterGroupMode, TranscriptionConfig, TtsConfig, TunnelConfig, VerifiableIntentConfig, WebFetchConfig, - WebSearchConfig, WebhookConfig, WorkspaceConfig, DEFAULT_GWS_SERVICES, + WebSearchConfig, WebhookConfig, WhatsAppChatPolicy, WhatsAppWebMode, WorkspaceConfig, + DEFAULT_GWS_SERVICES, }; pub fn name_and_presence(channel: Option<&T>) -> (&'static str, bool) { diff --git a/src/config/schema.rs b/src/config/schema.rs index 9ccca1a17..9c9abc668 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -5112,6 +5112,36 @@ impl ChannelConfig for SignalConfig { } } +/// WhatsApp Web usage mode. +/// +/// `Personal` treats the account as a personal phone — the bot only responds to +/// incoming messages that pass the DM/group/self-chat policy filters. +/// `Business` (default) responds to all incoming messages, subject only to the +/// `allowed_numbers` allowlist. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum WhatsAppWebMode { + /// Respond to all messages passing the allowlist (default). + #[default] + Business, + /// Apply per-chat-type policies (dm_policy, group_policy, self_chat_mode). + Personal, +} + +/// Policy for a particular WhatsApp chat type (DMs or groups) when +/// `mode = "personal"`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum WhatsAppChatPolicy { + /// Only respond to senders on the `allowed_numbers` list (default). + #[default] + Allowlist, + /// Ignore all messages in this chat type. + Ignore, + /// Respond to every message regardless of allowlist. + All, +} + /// WhatsApp channel configuration (Cloud API or Web mode). /// /// Set `phone_number_id` for Cloud API mode, or `session_path` for Web mode. @@ -5148,6 +5178,23 @@ pub struct WhatsAppConfig { /// Allowed phone numbers (E.164 format: +1234567890) or "*" for all #[serde(default)] pub allowed_numbers: Vec, + /// Usage mode for WhatsApp Web: "business" (default) or "personal". + /// In personal mode the bot applies dm_policy, group_policy, and + /// self_chat_mode to decide which chats to respond in. + #[serde(default)] + pub mode: WhatsAppWebMode, + /// Policy for direct messages when mode = "personal". + /// "allowlist" (default) | "ignore" | "all". + #[serde(default)] + pub dm_policy: WhatsAppChatPolicy, + /// Policy for group chats when mode = "personal". + /// "allowlist" (default) | "ignore" | "all". + #[serde(default)] + pub group_policy: WhatsAppChatPolicy, + /// When true and mode = "personal", always respond to messages in the + /// user's own self-chat (Notes to Self). Defaults to false. + #[serde(default)] + pub self_chat_mode: bool, } impl ChannelConfig for WhatsAppConfig { @@ -10273,6 +10320,10 @@ channel_id = "C123" pair_phone: None, pair_code: None, allowed_numbers: vec!["+1234567890".into(), "+9876543210".into()], + mode: WhatsAppWebMode::default(), + dm_policy: WhatsAppChatPolicy::default(), + group_policy: WhatsAppChatPolicy::default(), + self_chat_mode: false, }; let json = serde_json::to_string(&wc).unwrap(); let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap(); @@ -10293,6 +10344,10 @@ channel_id = "C123" pair_phone: None, pair_code: None, allowed_numbers: vec!["+1".into()], + mode: WhatsAppWebMode::default(), + dm_policy: WhatsAppChatPolicy::default(), + group_policy: WhatsAppChatPolicy::default(), + self_chat_mode: false, }; let toml_str = toml::to_string(&wc).unwrap(); let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap(); @@ -10318,6 +10373,10 @@ channel_id = "C123" pair_phone: None, pair_code: None, allowed_numbers: vec!["*".into()], + mode: WhatsAppWebMode::default(), + dm_policy: WhatsAppChatPolicy::default(), + group_policy: WhatsAppChatPolicy::default(), + self_chat_mode: false, }; let toml_str = toml::to_string(&wc).unwrap(); let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap(); @@ -10335,6 +10394,10 @@ channel_id = "C123" pair_phone: None, pair_code: None, allowed_numbers: vec!["+1".into()], + mode: WhatsAppWebMode::default(), + dm_policy: WhatsAppChatPolicy::default(), + group_policy: WhatsAppChatPolicy::default(), + self_chat_mode: false, }; assert!(wc.is_ambiguous_config()); assert_eq!(wc.backend_type(), "cloud"); @@ -10351,6 +10414,10 @@ channel_id = "C123" pair_phone: None, pair_code: None, allowed_numbers: vec![], + mode: WhatsAppWebMode::default(), + dm_policy: WhatsAppChatPolicy::default(), + group_policy: WhatsAppChatPolicy::default(), + self_chat_mode: false, }; assert!(!wc.is_ambiguous_config()); assert_eq!(wc.backend_type(), "web"); @@ -10377,6 +10444,10 @@ channel_id = "C123" pair_phone: None, pair_code: None, allowed_numbers: vec!["+1".into()], + mode: WhatsAppWebMode::default(), + dm_policy: WhatsAppChatPolicy::default(), + group_policy: WhatsAppChatPolicy::default(), + self_chat_mode: false, }), linq: None, wati: None, diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 858f00f32..f38fd12cf 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -3,7 +3,7 @@ use crate::cli_input::Input; use crate::config::schema::{default_nostr_relays, NostrConfig}; use crate::config::schema::{ DingTalkConfig, IrcConfig, LarkReceiveMode, LinqConfig, NextcloudTalkConfig, QQConfig, - SignalConfig, StreamMode, WhatsAppConfig, + SignalConfig, StreamMode, WhatsAppChatPolicy, WhatsAppConfig, WhatsAppWebMode, }; use crate::config::{ AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, @@ -4379,6 +4379,10 @@ fn setup_channels() -> Result { pair_code: (!pair_code.trim().is_empty()) .then(|| pair_code.trim().to_string()), allowed_numbers, + mode: WhatsAppWebMode::default(), + dm_policy: WhatsAppChatPolicy::default(), + group_policy: WhatsAppChatPolicy::default(), + self_chat_mode: false, }); println!( @@ -4480,6 +4484,10 @@ fn setup_channels() -> Result { pair_phone: None, pair_code: None, allowed_numbers, + mode: WhatsAppWebMode::default(), + dm_policy: WhatsAppChatPolicy::default(), + group_policy: WhatsAppChatPolicy::default(), + self_chat_mode: false, }); } ChannelMenuChoice::Linq => {