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 }]
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 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 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 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 let broadcast_msg = rx.try_recv().unwrap();
190 assert!(broadcast_msg.contains("stats"));
191 assert!(broadcast_msg.contains("alice"));
192 }
193}