Skip to main content

rustyclaw_core/messengers/
group_chat.rs

1//! Group chat support with isolation and activation modes.
2//!
3//! Provides configuration and logic for how the agent behaves in group
4//! conversations (as opposed to 1:1 DMs). Mirrors OpenClaw's group chat
5//! features:
6//!
7//! - **Activation modes**: How the agent decides to respond in a group.
8//! - **Isolation modes**: Whether group conversations share state or are
9//!   isolated per-group.
10
11use serde::{Deserialize, Serialize};
12use tracing::debug;
13
14/// How the agent is activated in a group chat.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum ActivationMode {
18    /// Always respond to every message in the group.
19    Always,
20    /// Only respond when mentioned by name or @-tag.
21    Mention,
22    /// Only respond when a specific prefix/command is used (e.g., "!claw").
23    Prefix,
24    /// Never respond in groups (DM only).
25    Never,
26}
27
28impl Default for ActivationMode {
29    fn default() -> Self {
30        Self::Mention
31    }
32}
33
34/// How group conversations are isolated from each other.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum IsolationMode {
38    /// Each group gets its own conversation history and context.
39    PerGroup,
40    /// All groups share the same conversation history.
41    Shared,
42    /// Each user in each group gets their own context.
43    PerUser,
44}
45
46impl Default for IsolationMode {
47    fn default() -> Self {
48        Self::PerGroup
49    }
50}
51
52/// Group chat configuration for a messenger.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct GroupChatConfig {
55    /// Whether group chat support is enabled.
56    #[serde(default)]
57    pub enabled: bool,
58
59    /// How the agent is activated in groups.
60    #[serde(default)]
61    pub activation: ActivationMode,
62
63    /// Command prefix for Prefix activation mode (e.g., "!claw").
64    #[serde(default = "default_prefix")]
65    pub prefix: String,
66
67    /// How conversations are isolated.
68    #[serde(default)]
69    pub isolation: IsolationMode,
70
71    /// Maximum number of messages to keep in group context.
72    #[serde(default = "default_max_context")]
73    pub max_context_messages: usize,
74
75    /// Allowed group IDs (empty = all groups allowed).
76    #[serde(default)]
77    pub allowed_groups: Vec<String>,
78
79    /// Blocked group IDs.
80    #[serde(default)]
81    pub blocked_groups: Vec<String>,
82
83    /// Whether to include sender names in the context (helps the model
84    /// understand who is speaking).
85    #[serde(default = "default_true")]
86    pub include_sender_names: bool,
87}
88
89fn default_prefix() -> String {
90    "!claw".to_string()
91}
92
93fn default_max_context() -> usize {
94    50
95}
96
97fn default_true() -> bool {
98    true
99}
100
101impl Default for GroupChatConfig {
102    fn default() -> Self {
103        Self {
104            enabled: false,
105            activation: ActivationMode::default(),
106            prefix: default_prefix(),
107            isolation: IsolationMode::default(),
108            max_context_messages: default_max_context(),
109            allowed_groups: Vec::new(),
110            blocked_groups: Vec::new(),
111            include_sender_names: true,
112        }
113    }
114}
115
116impl GroupChatConfig {
117    /// Check if a group is allowed.
118    pub fn is_group_allowed(&self, group_id: &str) -> bool {
119        if self.blocked_groups.contains(&group_id.to_string()) {
120            return false;
121        }
122        if self.allowed_groups.is_empty() {
123            return true;
124        }
125        self.allowed_groups.contains(&group_id.to_string())
126    }
127
128    /// Check if the agent should respond to a message in a group.
129    pub fn should_respond(&self, message: &str, agent_name: &str) -> bool {
130        if !self.enabled {
131            return false;
132        }
133
134        match self.activation {
135            ActivationMode::Always => true,
136            ActivationMode::Never => false,
137            ActivationMode::Mention => {
138                let lower = message.to_lowercase();
139                let name_lower = agent_name.to_lowercase();
140                lower.contains(&name_lower)
141                    || lower.contains(&format!("@{}", name_lower))
142            }
143            ActivationMode::Prefix => {
144                message.starts_with(&self.prefix)
145            }
146        }
147    }
148
149    /// Generate a session key for isolation.
150    pub fn session_key(&self, group_id: &str, user_id: Option<&str>) -> String {
151        match self.isolation {
152            IsolationMode::PerGroup => format!("group:{}", group_id),
153            IsolationMode::Shared => "shared".to_string(),
154            IsolationMode::PerUser => {
155                if let Some(uid) = user_id {
156                    format!("group:{}:user:{}", group_id, uid)
157                } else {
158                    format!("group:{}", group_id)
159                }
160            }
161        }
162    }
163
164    /// Strip the prefix from a message (for Prefix activation mode).
165    pub fn strip_prefix<'a>(&self, message: &'a str) -> &'a str {
166        if self.activation == ActivationMode::Prefix {
167            message
168                .strip_prefix(&self.prefix)
169                .map(|s| s.trim_start())
170                .unwrap_or(message)
171        } else {
172            message
173        }
174    }
175}
176
177/// Format a group message with sender info for the model context.
178pub fn format_group_message(sender_name: &str, message: &str, include_sender: bool) -> String {
179    if include_sender {
180        format!("[{}]: {}", sender_name, message)
181    } else {
182        message.to_string()
183    }
184}
185
186/// Generate a group context key from messenger + group ID.
187pub fn group_context_key(messenger_name: &str, group_id: &str) -> String {
188    debug!(messenger = %messenger_name, group = %group_id, "Generating group context key");
189    format!("{}:{}", messenger_name, group_id)
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_default_config() {
198        let config = GroupChatConfig::default();
199        assert!(!config.enabled);
200        assert_eq!(config.activation, ActivationMode::Mention);
201        assert_eq!(config.isolation, IsolationMode::PerGroup);
202        assert_eq!(config.prefix, "!claw");
203        assert!(config.include_sender_names);
204    }
205
206    #[test]
207    fn test_should_respond_mention() {
208        let config = GroupChatConfig {
209            enabled: true,
210            activation: ActivationMode::Mention,
211            ..Default::default()
212        };
213
214        assert!(config.should_respond("Hey @rustyclaw help me", "RustyClaw"));
215        assert!(config.should_respond("rustyclaw what do you think?", "RustyClaw"));
216        assert!(!config.should_respond("Just chatting with friends", "RustyClaw"));
217    }
218
219    #[test]
220    fn test_should_respond_prefix() {
221        let config = GroupChatConfig {
222            enabled: true,
223            activation: ActivationMode::Prefix,
224            prefix: "!claw".to_string(),
225            ..Default::default()
226        };
227
228        assert!(config.should_respond("!claw help me", "RustyClaw"));
229        assert!(!config.should_respond("Hey rustyclaw", "RustyClaw"));
230    }
231
232    #[test]
233    fn test_should_respond_always() {
234        let config = GroupChatConfig {
235            enabled: true,
236            activation: ActivationMode::Always,
237            ..Default::default()
238        };
239
240        assert!(config.should_respond("anything at all", "RustyClaw"));
241    }
242
243    #[test]
244    fn test_should_respond_never() {
245        let config = GroupChatConfig {
246            enabled: true,
247            activation: ActivationMode::Never,
248            ..Default::default()
249        };
250
251        assert!(!config.should_respond("@rustyclaw please", "RustyClaw"));
252    }
253
254    #[test]
255    fn test_should_respond_disabled() {
256        let config = GroupChatConfig {
257            enabled: false,
258            activation: ActivationMode::Always,
259            ..Default::default()
260        };
261
262        assert!(!config.should_respond("anything", "RustyClaw"));
263    }
264
265    #[test]
266    fn test_group_allowed() {
267        let config = GroupChatConfig {
268            allowed_groups: vec!["group1".to_string(), "group2".to_string()],
269            ..Default::default()
270        };
271
272        assert!(config.is_group_allowed("group1"));
273        assert!(!config.is_group_allowed("group3"));
274    }
275
276    #[test]
277    fn test_group_blocked() {
278        let config = GroupChatConfig {
279            blocked_groups: vec!["spam".to_string()],
280            ..Default::default()
281        };
282
283        assert!(!config.is_group_allowed("spam"));
284        assert!(config.is_group_allowed("general"));
285    }
286
287    #[test]
288    fn test_session_key_per_group() {
289        let config = GroupChatConfig {
290            isolation: IsolationMode::PerGroup,
291            ..Default::default()
292        };
293        assert_eq!(config.session_key("g123", Some("u456")), "group:g123");
294    }
295
296    #[test]
297    fn test_session_key_per_user() {
298        let config = GroupChatConfig {
299            isolation: IsolationMode::PerUser,
300            ..Default::default()
301        };
302        assert_eq!(
303            config.session_key("g123", Some("u456")),
304            "group:g123:user:u456"
305        );
306    }
307
308    #[test]
309    fn test_session_key_shared() {
310        let config = GroupChatConfig {
311            isolation: IsolationMode::Shared,
312            ..Default::default()
313        };
314        assert_eq!(config.session_key("g123", Some("u456")), "shared");
315    }
316
317    #[test]
318    fn test_strip_prefix() {
319        let config = GroupChatConfig {
320            activation: ActivationMode::Prefix,
321            prefix: "!claw".to_string(),
322            ..Default::default()
323        };
324        assert_eq!(config.strip_prefix("!claw help me"), "help me");
325        assert_eq!(config.strip_prefix("no prefix"), "no prefix");
326    }
327
328    #[test]
329    fn test_format_group_message() {
330        assert_eq!(
331            format_group_message("Alice", "Hello!", true),
332            "[Alice]: Hello!"
333        );
334        assert_eq!(format_group_message("Alice", "Hello!", false), "Hello!");
335    }
336}