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