Skip to main content

room_cli/plugin/
stats.rs

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