From d94508e72eecd22f7b9fa43207613fecb3aa6047 Mon Sep 17 00:00:00 2001 From: Matteo De Agazio Date: Tue, 31 Mar 2026 19:07:49 +0200 Subject: [PATCH] feat: add free_response_channels support for Discord Allow certain Discord channel IDs to respond without requiring @mention, similar to Hermes gateway's free_response_channels. - Add free_response_channels field to DiscordConfig - Add free_response_channels method to ChannelBridgeHandle trait - Implement free_response_channels in KernelBridgeAdapter - Modify dispatch_message to bypass mention_only policy for free channels - Add test for free_response_channels deserialization --- crates/openfang-api/src/channel_bridge.rs | 13 +++++++++ crates/openfang-channels/src/bridge.rs | 34 ++++++++++++++++------- crates/openfang-types/src/config.rs | 25 +++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/crates/openfang-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index f8336ba5..3ad3a8d3 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -816,6 +816,19 @@ impl ChannelBridgeHandle for KernelBridgeAdapter { } } + async fn free_response_channels(&self, channel_type: &str) -> Vec { + let channels = &self.kernel.config.channels; + match channel_type { + "discord" => channels + .discord + .as_ref() + .map(|c| c.free_response_channels.clone()) + .unwrap_or_default(), + // Add other channel types here as needed (e.g., "telegram" => ...) + _ => Vec::new(), + } + } + async fn authorize_channel_user( &self, channel_type: &str, diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 7cf6870c..f0903149 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -220,6 +220,13 @@ pub trait ChannelBridgeHandle: Send + Sync { None } + /// Get channel IDs that respond without requiring @mention (free response mode). + /// + /// Returns an empty vector if the channel type is not configured or has no free response channels. + async fn free_response_channels(&self, _channel_type: &str) -> Vec { + Vec::new() + } + /// Record a delivery result for tracking (optional — default no-op). /// /// `thread_id` preserves Telegram forum-topic context so cron/workflow @@ -659,16 +666,23 @@ async fn dispatch_message( } } GroupPolicy::MentionOnly => { - // Only allow messages where the bot was @mentioned or commands. - let was_mentioned = message - .metadata - .get("was_mentioned") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let is_command = matches!(&message.content, ChannelContent::Command { .. }); - if !was_mentioned && !is_command { - debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)"); - return; + // Check if this channel is in the free_response list - if so, allow all messages + let free_channels = handle.free_response_channels(ct_str).await; + let channel_id = &message.sender.platform_id; + let is_free_channel = free_channels.iter().any(|id| id == channel_id); + + if !is_free_channel { + // Only allow messages where the bot was @mentioned or commands. + let was_mentioned = message + .metadata + .get("was_mentioned") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let is_command = matches!(&message.content, ChannelContent::Command { .. }); + if !was_mentioned && !is_command { + debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)"); + return; + } } } GroupPolicy::All => {} diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index f290bbf8..7b80e325 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1792,6 +1792,10 @@ pub struct DiscordConfig { /// Default channel ID for outgoing messages when no recipient is specified. #[serde(default)] pub default_channel_id: Option, + /// Channel IDs that respond without requiring @mention (free response mode). + /// In these channels, the bot responds to all group messages without needing to be mentioned. + #[serde(default, deserialize_with = "deserialize_string_or_int_vec")] + pub free_response_channels: Vec, /// Per-channel behavior overrides. #[serde(default)] pub overrides: ChannelOverrides, @@ -1807,6 +1811,7 @@ impl Default for DiscordConfig { intents: 37376, ignore_bots: true, default_channel_id: None, + free_response_channels: vec![], overrides: ChannelOverrides::default(), } } @@ -3706,6 +3711,26 @@ mod tests { assert!(dc2.ignore_bots); } + #[test] + fn test_discord_config_free_response_channels_deserialization() { + // Test with free_response_channels as list of strings + let toml_str = r#" + bot_token_env = "DISCORD_BOT_TOKEN" + free_response_channels = ["123456789", "987654321"] + "#; + let dc: DiscordConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(dc.free_response_channels.len(), 2); + assert_eq!(dc.free_response_channels[0], "123456789"); + assert_eq!(dc.free_response_channels[1], "987654321"); + + // Test default (empty list) + let toml_str2 = r#" + bot_token_env = "DISCORD_BOT_TOKEN" + "#; + let dc2: DiscordConfig = toml::from_str(toml_str2).unwrap(); + assert!(dc2.free_response_channels.is_empty()); + } + #[test] fn test_slack_config_defaults() { let sl = SlackConfig::default();