Skip to main content

room_daemon/plugin/
stats.rs

1use std::collections::HashMap;
2
3use room_protocol::Message;
4
5use super::{BoxFuture, CommandContext, CommandInfo, ParamSchema, ParamType, Plugin, PluginResult};
6
7/// Example `/stats` plugin. Shows a statistical summary of recent chat
8/// activity: message count, participant count, time range, most active user.
9///
10/// `/summarise` is reserved for LLM-powered v2 — the core binary does not
11/// depend on any LLM SDK.
12pub struct StatsPlugin;
13
14impl Plugin for StatsPlugin {
15    fn name(&self) -> &str {
16        "stats"
17    }
18
19    fn version(&self) -> &str {
20        env!("CARGO_PKG_VERSION")
21    }
22
23    fn commands(&self) -> Vec<CommandInfo> {
24        vec![CommandInfo {
25            name: "stats".to_owned(),
26            description: "Show statistical summary of recent chat activity".to_owned(),
27            usage: "/stats [last N messages, default 50]".to_owned(),
28            params: vec![ParamSchema {
29                name: "count".to_owned(),
30                param_type: ParamType::Choice(vec![
31                    "10".to_owned(),
32                    "25".to_owned(),
33                    "50".to_owned(),
34                    "100".to_owned(),
35                ]),
36                required: false,
37                description: "Number of recent messages to analyze".to_owned(),
38            }],
39            subcommands: vec![],
40        }]
41    }
42
43    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
44        Box::pin(async move {
45            let n: usize = ctx
46                .params
47                .first()
48                .and_then(|s| s.parse().ok())
49                .unwrap_or(50);
50
51            let messages = ctx.history.tail(n).await?;
52            let summary = build_summary(&messages);
53            ctx.writer.broadcast(&summary).await?;
54            Ok(PluginResult::Handled)
55        })
56    }
57}
58
59fn build_summary(messages: &[Message]) -> String {
60    if messages.is_empty() {
61        return "stats: no messages in the requested range".to_owned();
62    }
63
64    let total = messages.len();
65
66    // Count messages per user (only Message variants, not join/leave/system)
67    let mut user_counts: HashMap<&str, usize> = HashMap::new();
68    for msg in messages {
69        if matches!(msg, Message::Message { .. } | Message::DirectMessage { .. }) {
70            *user_counts.entry(msg.user()).or_insert(0) += 1;
71        }
72    }
73    let participant_count = user_counts.len();
74
75    let most_active = user_counts
76        .iter()
77        .max_by_key(|(_, count)| *count)
78        .map(|(user, count)| format!("{user} ({count} msgs)"))
79        .unwrap_or_else(|| "none".to_owned());
80
81    let time_range = match (messages.first(), messages.last()) {
82        (Some(first), Some(last)) => {
83            format!(
84                "{} to {}",
85                first.ts().format("%H:%M UTC"),
86                last.ts().format("%H:%M UTC")
87            )
88        }
89        _ => "unknown".to_owned(),
90    };
91
92    format!(
93        "stats (last {total} events): {participant_count} participants, \
94         most active: {most_active}, time range: {time_range}"
95    )
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use room_protocol::{make_join, make_message, make_system};
102
103    #[test]
104    fn build_summary_empty() {
105        let summary = build_summary(&[]);
106        assert!(summary.contains("no messages"));
107    }
108
109    #[test]
110    fn build_summary_counts_only_chat_messages() {
111        let msgs = vec![
112            make_join("r", "alice"),
113            make_message("r", "alice", "hello"),
114            make_message("r", "bob", "hi"),
115            make_message("r", "alice", "how are you"),
116            make_system("r", "broker", "system notice"),
117        ];
118        let summary = build_summary(&msgs);
119        // 5 events total, but only 2 participants (alice, bob) in chat messages
120        assert!(summary.contains("5 events"));
121        assert!(summary.contains("2 participants"));
122        assert!(summary.contains("alice (2 msgs)"));
123    }
124
125    #[test]
126    fn build_summary_single_user() {
127        let msgs = vec![
128            make_message("r", "alice", "one"),
129            make_message("r", "alice", "two"),
130        ];
131        let summary = build_summary(&msgs);
132        assert!(summary.contains("1 participants"));
133        assert!(summary.contains("alice (2 msgs)"));
134    }
135
136    #[tokio::test]
137    async fn stats_plugin_broadcasts_summary() {
138        use crate::plugin::{ChatWriter, HistoryReader, RoomMetadata, UserInfo};
139        use chrono::Utc;
140        use std::collections::HashMap;
141        use std::sync::{atomic::AtomicU64, Arc};
142        use tempfile::NamedTempFile;
143        use tokio::sync::{broadcast, Mutex};
144
145        let tmp = NamedTempFile::new().unwrap();
146        let path = tmp.path();
147
148        // Write some messages
149        for i in 0..3 {
150            crate::history::append(path, &make_message("r", "alice", format!("msg {i}")))
151                .await
152                .unwrap();
153        }
154
155        let (tx, mut rx) = broadcast::channel::<String>(64);
156        let mut client_map = HashMap::new();
157        client_map.insert(1u64, ("alice".to_owned(), tx));
158        let clients = Arc::new(Mutex::new(client_map));
159        let chat_path = Arc::new(path.to_path_buf());
160        let room_id = Arc::new("r".to_owned());
161        let seq = Arc::new(AtomicU64::new(0));
162
163        let ctx = super::super::CommandContext {
164            command: "stats".to_owned(),
165            params: vec!["10".to_owned()],
166            sender: "alice".to_owned(),
167            room_id: "r".to_owned(),
168            message_id: "msg-1".to_owned(),
169            timestamp: Utc::now(),
170            history: Box::new(HistoryReader::new(path, "alice")),
171            writer: Box::new(ChatWriter::new(
172                &clients, &chat_path, &room_id, &seq, "stats",
173            )),
174            metadata: RoomMetadata {
175                online_users: vec![UserInfo {
176                    username: "alice".to_owned(),
177                    status: String::new(),
178                }],
179                host: Some("alice".to_owned()),
180                message_count: 3,
181            },
182            available_commands: vec![],
183            team_access: None,
184        };
185
186        let result = StatsPlugin.handle(ctx).await.unwrap();
187        assert!(matches!(result, PluginResult::Handled));
188
189        // The broadcast should have sent a message
190        let broadcast_msg = rx.try_recv().unwrap();
191        assert!(broadcast_msg.contains("stats"));
192        assert!(broadcast_msg.contains("alice"));
193    }
194}