1use std::collections::HashMap;
2
3use room_protocol::Message;
4
5use super::{BoxFuture, CommandContext, CommandInfo, ParamSchema, ParamType, Plugin, PluginResult};
6
7pub 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 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 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 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 let broadcast_msg = rx.try_recv().unwrap();
191 assert!(broadcast_msg.contains("stats"));
192 assert!(broadcast_msg.contains("alice"));
193 }
194}