1pub mod help;
2pub mod stats;
3
4use std::{
5 collections::HashMap,
6 future::Future,
7 path::{Path, PathBuf},
8 pin::Pin,
9 sync::{
10 atomic::{AtomicU64, Ordering},
11 Arc,
12 },
13};
14
15use chrono::{DateTime, Utc};
16
17use crate::{
18 broker::{
19 fanout::broadcast_and_persist,
20 state::{ClientMap, StatusMap},
21 },
22 history,
23 message::{make_system, Message},
24};
25
26pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
28
29pub trait Plugin: Send + Sync {
38 fn name(&self) -> &str;
40
41 fn commands(&self) -> Vec<CommandInfo>;
44
45 fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>>;
50}
51
52#[derive(Debug, Clone)]
56pub struct CommandInfo {
57 pub name: String,
59 pub description: String,
61 pub usage: String,
63 pub completions: Vec<Completion>,
65}
66
67#[derive(Debug, Clone)]
69pub struct Completion {
70 pub position: usize,
72 pub values: Vec<String>,
74}
75
76pub struct CommandContext {
80 pub command: String,
82 pub params: Vec<String>,
84 pub sender: String,
86 pub room_id: String,
88 pub message_id: String,
90 pub timestamp: DateTime<Utc>,
92 pub history: HistoryReader,
94 pub writer: ChatWriter,
96 pub metadata: RoomMetadata,
98 pub available_commands: Vec<CommandInfo>,
101}
102
103pub enum PluginResult {
107 Reply(String),
109 Broadcast(String),
111 Handled,
113}
114
115pub struct HistoryReader {
122 chat_path: PathBuf,
123 viewer: String,
124}
125
126impl HistoryReader {
127 pub(crate) fn new(chat_path: &Path, viewer: &str) -> Self {
128 Self {
129 chat_path: chat_path.to_owned(),
130 viewer: viewer.to_owned(),
131 }
132 }
133
134 pub async fn all(&self) -> anyhow::Result<Vec<Message>> {
136 let all = history::load(&self.chat_path).await?;
137 Ok(self.filter_dms(all))
138 }
139
140 pub async fn tail(&self, n: usize) -> anyhow::Result<Vec<Message>> {
142 let all = history::tail(&self.chat_path, n).await?;
143 Ok(self.filter_dms(all))
144 }
145
146 pub async fn since(&self, message_id: &str) -> anyhow::Result<Vec<Message>> {
148 let all = history::load(&self.chat_path).await?;
149 let start = all
150 .iter()
151 .position(|m| m.id() == message_id)
152 .map(|i| i + 1)
153 .unwrap_or(0);
154 Ok(self.filter_dms(all[start..].to_vec()))
155 }
156
157 pub async fn count(&self) -> anyhow::Result<usize> {
159 let all = history::load(&self.chat_path).await?;
160 Ok(all.len())
161 }
162
163 fn filter_dms(&self, messages: Vec<Message>) -> Vec<Message> {
164 messages
165 .into_iter()
166 .filter(|m| match m {
167 Message::DirectMessage { user, to, .. } => {
168 user == &self.viewer || to == &self.viewer
169 }
170 _ => true,
171 })
172 .collect()
173 }
174}
175
176pub struct ChatWriter {
183 clients: ClientMap,
184 chat_path: Arc<PathBuf>,
185 room_id: Arc<String>,
186 seq_counter: Arc<AtomicU64>,
187 identity: String,
189}
190
191impl ChatWriter {
192 pub(crate) fn new(
193 clients: &ClientMap,
194 chat_path: &Arc<PathBuf>,
195 room_id: &Arc<String>,
196 seq_counter: &Arc<AtomicU64>,
197 plugin_name: &str,
198 ) -> Self {
199 Self {
200 clients: clients.clone(),
201 chat_path: chat_path.clone(),
202 room_id: room_id.clone(),
203 seq_counter: seq_counter.clone(),
204 identity: format!("plugin:{plugin_name}"),
205 }
206 }
207
208 pub async fn broadcast(&self, content: &str) -> anyhow::Result<()> {
210 let msg = make_system(&self.room_id, &self.identity, content);
211 broadcast_and_persist(&msg, &self.clients, &self.chat_path, &self.seq_counter).await?;
212 Ok(())
213 }
214
215 pub async fn reply_to(&self, username: &str, content: &str) -> anyhow::Result<()> {
217 let msg = make_system(&self.room_id, &self.identity, content);
218 let seq = self.seq_counter.fetch_add(1, Ordering::SeqCst) + 1;
219 let mut msg = msg;
220 msg.set_seq(seq);
221 history::append(&self.chat_path, &msg).await?;
222
223 let line = format!("{}\n", serde_json::to_string(&msg)?);
224 let map = self.clients.lock().await;
225 for (uname, tx) in map.values() {
226 if uname == username {
227 let _ = tx.send(line.clone());
228 }
229 }
230 Ok(())
231 }
232}
233
234pub struct RoomMetadata {
238 pub online_users: Vec<UserInfo>,
240 pub host: Option<String>,
242 pub message_count: usize,
244}
245
246pub struct UserInfo {
248 pub username: String,
249 pub status: String,
250}
251
252impl RoomMetadata {
253 pub(crate) async fn snapshot(
254 status_map: &StatusMap,
255 host_user: &Arc<tokio::sync::Mutex<Option<String>>>,
256 chat_path: &Path,
257 ) -> Self {
258 let map = status_map.lock().await;
259 let online_users: Vec<UserInfo> = map
260 .iter()
261 .map(|(u, s)| UserInfo {
262 username: u.clone(),
263 status: s.clone(),
264 })
265 .collect();
266 drop(map);
267
268 let host = host_user.lock().await.clone();
269
270 let message_count = history::load(chat_path)
271 .await
272 .map(|msgs| msgs.len())
273 .unwrap_or(0);
274
275 Self {
276 online_users,
277 host,
278 message_count,
279 }
280 }
281}
282
283const RESERVED_COMMANDS: &[&str] = &[
287 "set_status",
288 "who",
289 "kick",
290 "reauth",
291 "clear-tokens",
292 "exit",
293 "clear",
294];
295
296pub struct PluginRegistry {
298 plugins: Vec<Box<dyn Plugin>>,
299 command_map: HashMap<String, usize>,
301}
302
303impl PluginRegistry {
304 pub fn new() -> Self {
305 Self {
306 plugins: Vec::new(),
307 command_map: HashMap::new(),
308 }
309 }
310
311 pub fn register(&mut self, plugin: Box<dyn Plugin>) -> anyhow::Result<()> {
314 let idx = self.plugins.len();
315 for cmd in plugin.commands() {
316 if RESERVED_COMMANDS.contains(&cmd.name.as_str()) {
317 anyhow::bail!(
318 "plugin '{}' cannot register command '{}': reserved by built-in",
319 plugin.name(),
320 cmd.name
321 );
322 }
323 if let Some(&existing_idx) = self.command_map.get(&cmd.name) {
324 anyhow::bail!(
325 "plugin '{}' cannot register command '{}': already registered by '{}'",
326 plugin.name(),
327 cmd.name,
328 self.plugins[existing_idx].name()
329 );
330 }
331 self.command_map.insert(cmd.name.clone(), idx);
332 }
333 self.plugins.push(plugin);
334 Ok(())
335 }
336
337 pub fn resolve(&self, command: &str) -> Option<&dyn Plugin> {
339 self.command_map
340 .get(command)
341 .map(|&idx| self.plugins[idx].as_ref())
342 }
343
344 pub fn all_commands(&self) -> Vec<CommandInfo> {
346 self.plugins.iter().flat_map(|p| p.commands()).collect()
347 }
348
349 pub fn completions_for(&self, command: &str, arg_pos: usize) -> Vec<String> {
351 self.all_commands()
352 .iter()
353 .find(|c| c.name == command)
354 .map(|c| {
355 c.completions
356 .iter()
357 .filter(|comp| comp.position == arg_pos)
358 .flat_map(|comp| comp.values.clone())
359 .collect()
360 })
361 .unwrap_or_default()
362 }
363}
364
365impl Default for PluginRegistry {
366 fn default() -> Self {
367 Self::new()
368 }
369}
370
371#[cfg(test)]
374mod tests {
375 use super::*;
376
377 struct DummyPlugin {
378 name: &'static str,
379 cmd: &'static str,
380 }
381
382 impl Plugin for DummyPlugin {
383 fn name(&self) -> &str {
384 self.name
385 }
386
387 fn commands(&self) -> Vec<CommandInfo> {
388 vec![CommandInfo {
389 name: self.cmd.to_owned(),
390 description: "dummy".to_owned(),
391 usage: format!("/{}", self.cmd),
392 completions: vec![],
393 }]
394 }
395
396 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
397 Box::pin(async { Ok(PluginResult::Reply("dummy".to_owned())) })
398 }
399 }
400
401 #[test]
402 fn registry_register_and_resolve() {
403 let mut reg = PluginRegistry::new();
404 reg.register(Box::new(DummyPlugin {
405 name: "test",
406 cmd: "foo",
407 }))
408 .unwrap();
409 assert!(reg.resolve("foo").is_some());
410 assert!(reg.resolve("bar").is_none());
411 }
412
413 #[test]
414 fn registry_rejects_reserved_command() {
415 let mut reg = PluginRegistry::new();
416 let result = reg.register(Box::new(DummyPlugin {
417 name: "bad",
418 cmd: "kick",
419 }));
420 assert!(result.is_err());
421 let err = result.unwrap_err().to_string();
422 assert!(err.contains("reserved by built-in"));
423 }
424
425 #[test]
426 fn registry_rejects_duplicate_command() {
427 let mut reg = PluginRegistry::new();
428 reg.register(Box::new(DummyPlugin {
429 name: "first",
430 cmd: "foo",
431 }))
432 .unwrap();
433 let result = reg.register(Box::new(DummyPlugin {
434 name: "second",
435 cmd: "foo",
436 }));
437 assert!(result.is_err());
438 let err = result.unwrap_err().to_string();
439 assert!(err.contains("already registered by 'first'"));
440 }
441
442 #[test]
443 fn registry_all_commands_lists_everything() {
444 let mut reg = PluginRegistry::new();
445 reg.register(Box::new(DummyPlugin {
446 name: "a",
447 cmd: "alpha",
448 }))
449 .unwrap();
450 reg.register(Box::new(DummyPlugin {
451 name: "b",
452 cmd: "beta",
453 }))
454 .unwrap();
455 let cmds = reg.all_commands();
456 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
457 assert!(names.contains(&"alpha"));
458 assert!(names.contains(&"beta"));
459 assert_eq!(names.len(), 2);
460 }
461
462 #[test]
463 fn registry_completions_for_returns_values() {
464 let mut reg = PluginRegistry::new();
465 reg.register(Box::new({
466 struct CompPlugin;
467 impl Plugin for CompPlugin {
468 fn name(&self) -> &str {
469 "comp"
470 }
471 fn commands(&self) -> Vec<CommandInfo> {
472 vec![CommandInfo {
473 name: "test".to_owned(),
474 description: "test".to_owned(),
475 usage: "/test".to_owned(),
476 completions: vec![Completion {
477 position: 0,
478 values: vec!["10".to_owned(), "20".to_owned()],
479 }],
480 }]
481 }
482 fn handle(
483 &self,
484 _ctx: CommandContext,
485 ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
486 Box::pin(async { Ok(PluginResult::Handled) })
487 }
488 }
489 CompPlugin
490 }))
491 .unwrap();
492 let completions = reg.completions_for("test", 0);
493 assert_eq!(completions, vec!["10", "20"]);
494 assert!(reg.completions_for("test", 1).is_empty());
495 assert!(reg.completions_for("nonexistent", 0).is_empty());
496 }
497
498 #[test]
499 fn registry_rejects_all_reserved_commands() {
500 for &reserved in RESERVED_COMMANDS {
501 let mut reg = PluginRegistry::new();
502 let result = reg.register(Box::new(DummyPlugin {
503 name: "bad",
504 cmd: reserved,
505 }));
506 assert!(
507 result.is_err(),
508 "should reject reserved command '{reserved}'"
509 );
510 }
511 }
512
513 #[tokio::test]
514 async fn history_reader_filters_dms() {
515 let tmp = tempfile::NamedTempFile::new().unwrap();
516 let path = tmp.path();
517
518 let dm = crate::message::make_dm("r", "alice", "bob", "secret");
520 let public = crate::message::make_message("r", "carol", "hello all");
521 history::append(path, &dm).await.unwrap();
522 history::append(path, &public).await.unwrap();
523
524 let reader_alice = HistoryReader::new(path, "alice");
526 let msgs = reader_alice.all().await.unwrap();
527 assert_eq!(msgs.len(), 2);
528
529 let reader_carol = HistoryReader::new(path, "carol");
531 let msgs = reader_carol.all().await.unwrap();
532 assert_eq!(msgs.len(), 1);
533 assert_eq!(msgs[0].user(), "carol");
534 }
535
536 #[tokio::test]
537 async fn history_reader_tail_and_count() {
538 let tmp = tempfile::NamedTempFile::new().unwrap();
539 let path = tmp.path();
540
541 for i in 0..5 {
542 history::append(
543 path,
544 &crate::message::make_message("r", "u", format!("msg {i}")),
545 )
546 .await
547 .unwrap();
548 }
549
550 let reader = HistoryReader::new(path, "u");
551 assert_eq!(reader.count().await.unwrap(), 5);
552
553 let tail = reader.tail(3).await.unwrap();
554 assert_eq!(tail.len(), 3);
555 }
556
557 #[tokio::test]
558 async fn history_reader_since() {
559 let tmp = tempfile::NamedTempFile::new().unwrap();
560 let path = tmp.path();
561
562 let msg1 = crate::message::make_message("r", "u", "first");
563 let msg2 = crate::message::make_message("r", "u", "second");
564 let msg3 = crate::message::make_message("r", "u", "third");
565 let id1 = msg1.id().to_owned();
566 history::append(path, &msg1).await.unwrap();
567 history::append(path, &msg2).await.unwrap();
568 history::append(path, &msg3).await.unwrap();
569
570 let reader = HistoryReader::new(path, "u");
571 let since = reader.since(&id1).await.unwrap();
572 assert_eq!(since.len(), 2);
573 }
574}