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 params: Vec<ParamSchema>,
65}
66
67#[derive(Debug, Clone)]
72pub struct ParamSchema {
73 pub name: String,
75 pub param_type: ParamType,
77 pub required: bool,
79 pub description: String,
81}
82
83#[derive(Debug, Clone, PartialEq)]
85pub enum ParamType {
86 Text,
88 Choice(Vec<String>),
90 Username,
92 Number { min: Option<i64>, max: Option<i64> },
94}
95
96pub struct CommandContext {
100 pub command: String,
102 pub params: Vec<String>,
104 pub sender: String,
106 pub room_id: String,
108 pub message_id: String,
110 pub timestamp: DateTime<Utc>,
112 pub history: HistoryReader,
114 pub writer: ChatWriter,
116 pub metadata: RoomMetadata,
118 pub available_commands: Vec<CommandInfo>,
121}
122
123pub enum PluginResult {
127 Reply(String),
129 Broadcast(String),
131 Handled,
133}
134
135pub struct HistoryReader {
142 chat_path: PathBuf,
143 viewer: String,
144}
145
146impl HistoryReader {
147 pub(crate) fn new(chat_path: &Path, viewer: &str) -> Self {
148 Self {
149 chat_path: chat_path.to_owned(),
150 viewer: viewer.to_owned(),
151 }
152 }
153
154 pub async fn all(&self) -> anyhow::Result<Vec<Message>> {
156 let all = history::load(&self.chat_path).await?;
157 Ok(self.filter_dms(all))
158 }
159
160 pub async fn tail(&self, n: usize) -> anyhow::Result<Vec<Message>> {
162 let all = history::tail(&self.chat_path, n).await?;
163 Ok(self.filter_dms(all))
164 }
165
166 pub async fn since(&self, message_id: &str) -> anyhow::Result<Vec<Message>> {
168 let all = history::load(&self.chat_path).await?;
169 let start = all
170 .iter()
171 .position(|m| m.id() == message_id)
172 .map(|i| i + 1)
173 .unwrap_or(0);
174 Ok(self.filter_dms(all[start..].to_vec()))
175 }
176
177 pub async fn count(&self) -> anyhow::Result<usize> {
179 let all = history::load(&self.chat_path).await?;
180 Ok(all.len())
181 }
182
183 fn filter_dms(&self, messages: Vec<Message>) -> Vec<Message> {
184 messages
185 .into_iter()
186 .filter(|m| match m {
187 Message::DirectMessage { user, to, .. } => {
188 user == &self.viewer || to == &self.viewer
189 }
190 _ => true,
191 })
192 .collect()
193 }
194}
195
196pub struct ChatWriter {
203 clients: ClientMap,
204 chat_path: Arc<PathBuf>,
205 room_id: Arc<String>,
206 seq_counter: Arc<AtomicU64>,
207 identity: String,
209}
210
211impl ChatWriter {
212 pub(crate) fn new(
213 clients: &ClientMap,
214 chat_path: &Arc<PathBuf>,
215 room_id: &Arc<String>,
216 seq_counter: &Arc<AtomicU64>,
217 plugin_name: &str,
218 ) -> Self {
219 Self {
220 clients: clients.clone(),
221 chat_path: chat_path.clone(),
222 room_id: room_id.clone(),
223 seq_counter: seq_counter.clone(),
224 identity: format!("plugin:{plugin_name}"),
225 }
226 }
227
228 pub async fn broadcast(&self, content: &str) -> anyhow::Result<()> {
230 let msg = make_system(&self.room_id, &self.identity, content);
231 broadcast_and_persist(&msg, &self.clients, &self.chat_path, &self.seq_counter).await?;
232 Ok(())
233 }
234
235 pub async fn reply_to(&self, username: &str, content: &str) -> anyhow::Result<()> {
237 let msg = make_system(&self.room_id, &self.identity, content);
238 let seq = self.seq_counter.fetch_add(1, Ordering::SeqCst) + 1;
239 let mut msg = msg;
240 msg.set_seq(seq);
241 history::append(&self.chat_path, &msg).await?;
242
243 let line = format!("{}\n", serde_json::to_string(&msg)?);
244 let map = self.clients.lock().await;
245 for (uname, tx) in map.values() {
246 if uname == username {
247 let _ = tx.send(line.clone());
248 }
249 }
250 Ok(())
251 }
252}
253
254pub struct RoomMetadata {
258 pub online_users: Vec<UserInfo>,
260 pub host: Option<String>,
262 pub message_count: usize,
264}
265
266pub struct UserInfo {
268 pub username: String,
269 pub status: String,
270}
271
272impl RoomMetadata {
273 pub(crate) async fn snapshot(
274 status_map: &StatusMap,
275 host_user: &Arc<tokio::sync::Mutex<Option<String>>>,
276 chat_path: &Path,
277 ) -> Self {
278 let map = status_map.lock().await;
279 let online_users: Vec<UserInfo> = map
280 .iter()
281 .map(|(u, s)| UserInfo {
282 username: u.clone(),
283 status: s.clone(),
284 })
285 .collect();
286 drop(map);
287
288 let host = host_user.lock().await.clone();
289
290 let message_count = history::load(chat_path)
291 .await
292 .map(|msgs| msgs.len())
293 .unwrap_or(0);
294
295 Self {
296 online_users,
297 host,
298 message_count,
299 }
300 }
301}
302
303const RESERVED_COMMANDS: &[&str] = &[
307 "set_status",
308 "who",
309 "kick",
310 "reauth",
311 "clear-tokens",
312 "dm",
313 "claim",
314 "unclaim",
315 "claimed",
316 "reply",
317 "room-info",
318 "exit",
319 "clear",
320];
321
322pub struct PluginRegistry {
324 plugins: Vec<Box<dyn Plugin>>,
325 command_map: HashMap<String, usize>,
327}
328
329impl PluginRegistry {
330 pub fn new() -> Self {
331 Self {
332 plugins: Vec::new(),
333 command_map: HashMap::new(),
334 }
335 }
336
337 pub fn register(&mut self, plugin: Box<dyn Plugin>) -> anyhow::Result<()> {
340 let idx = self.plugins.len();
341 for cmd in plugin.commands() {
342 if RESERVED_COMMANDS.contains(&cmd.name.as_str()) {
343 anyhow::bail!(
344 "plugin '{}' cannot register command '{}': reserved by built-in",
345 plugin.name(),
346 cmd.name
347 );
348 }
349 if let Some(&existing_idx) = self.command_map.get(&cmd.name) {
350 anyhow::bail!(
351 "plugin '{}' cannot register command '{}': already registered by '{}'",
352 plugin.name(),
353 cmd.name,
354 self.plugins[existing_idx].name()
355 );
356 }
357 self.command_map.insert(cmd.name.clone(), idx);
358 }
359 self.plugins.push(plugin);
360 Ok(())
361 }
362
363 pub fn resolve(&self, command: &str) -> Option<&dyn Plugin> {
365 self.command_map
366 .get(command)
367 .map(|&idx| self.plugins[idx].as_ref())
368 }
369
370 pub fn all_commands(&self) -> Vec<CommandInfo> {
372 self.plugins.iter().flat_map(|p| p.commands()).collect()
373 }
374
375 pub fn completions_for(&self, command: &str, arg_pos: usize) -> Vec<String> {
381 self.all_commands()
382 .iter()
383 .find(|c| c.name == command)
384 .and_then(|c| c.params.get(arg_pos))
385 .map(|p| match &p.param_type {
386 ParamType::Choice(values) => values.clone(),
387 _ => vec![],
388 })
389 .unwrap_or_default()
390 }
391}
392
393impl Default for PluginRegistry {
394 fn default() -> Self {
395 Self::new()
396 }
397}
398
399pub fn builtin_command_infos() -> Vec<CommandInfo> {
405 vec![
406 CommandInfo {
407 name: "dm".to_owned(),
408 description: "Send a private message".to_owned(),
409 usage: "/dm <user> <message>".to_owned(),
410 params: vec![
411 ParamSchema {
412 name: "user".to_owned(),
413 param_type: ParamType::Username,
414 required: true,
415 description: "Recipient username".to_owned(),
416 },
417 ParamSchema {
418 name: "message".to_owned(),
419 param_type: ParamType::Text,
420 required: true,
421 description: "Message content".to_owned(),
422 },
423 ],
424 },
425 CommandInfo {
426 name: "claim".to_owned(),
427 description: "Claim a task".to_owned(),
428 usage: "/claim <task>".to_owned(),
429 params: vec![ParamSchema {
430 name: "task".to_owned(),
431 param_type: ParamType::Text,
432 required: true,
433 description: "Task description".to_owned(),
434 }],
435 },
436 CommandInfo {
437 name: "unclaim".to_owned(),
438 description: "Release your current task claim".to_owned(),
439 usage: "/unclaim".to_owned(),
440 params: vec![],
441 },
442 CommandInfo {
443 name: "claimed".to_owned(),
444 description: "Show the task claim board".to_owned(),
445 usage: "/claimed".to_owned(),
446 params: vec![],
447 },
448 CommandInfo {
449 name: "reply".to_owned(),
450 description: "Reply to a message".to_owned(),
451 usage: "/reply <id> <message>".to_owned(),
452 params: vec![
453 ParamSchema {
454 name: "id".to_owned(),
455 param_type: ParamType::Text,
456 required: true,
457 description: "Message ID to reply to".to_owned(),
458 },
459 ParamSchema {
460 name: "message".to_owned(),
461 param_type: ParamType::Text,
462 required: true,
463 description: "Reply content".to_owned(),
464 },
465 ],
466 },
467 CommandInfo {
468 name: "set_status".to_owned(),
469 description: "Set your presence status".to_owned(),
470 usage: "/set_status <status>".to_owned(),
471 params: vec![ParamSchema {
472 name: "status".to_owned(),
473 param_type: ParamType::Text,
474 required: false,
475 description: "Status text (omit to clear)".to_owned(),
476 }],
477 },
478 CommandInfo {
479 name: "who".to_owned(),
480 description: "List users in the room".to_owned(),
481 usage: "/who".to_owned(),
482 params: vec![],
483 },
484 CommandInfo {
485 name: "kick".to_owned(),
486 description: "Kick a user from the room".to_owned(),
487 usage: "/kick <user>".to_owned(),
488 params: vec![ParamSchema {
489 name: "user".to_owned(),
490 param_type: ParamType::Username,
491 required: true,
492 description: "User to kick (host only)".to_owned(),
493 }],
494 },
495 CommandInfo {
496 name: "reauth".to_owned(),
497 description: "Invalidate a user's token".to_owned(),
498 usage: "/reauth <user>".to_owned(),
499 params: vec![ParamSchema {
500 name: "user".to_owned(),
501 param_type: ParamType::Username,
502 required: true,
503 description: "User to reauth (host only)".to_owned(),
504 }],
505 },
506 CommandInfo {
507 name: "clear-tokens".to_owned(),
508 description: "Revoke all session tokens".to_owned(),
509 usage: "/clear-tokens".to_owned(),
510 params: vec![],
511 },
512 CommandInfo {
513 name: "exit".to_owned(),
514 description: "Shut down the broker".to_owned(),
515 usage: "/exit".to_owned(),
516 params: vec![],
517 },
518 CommandInfo {
519 name: "clear".to_owned(),
520 description: "Clear the room history".to_owned(),
521 usage: "/clear".to_owned(),
522 params: vec![],
523 },
524 CommandInfo {
525 name: "room-info".to_owned(),
526 description: "Show room visibility, config, and member count".to_owned(),
527 usage: "/room-info".to_owned(),
528 params: vec![],
529 },
530 ]
531}
532
533pub fn all_known_commands() -> Vec<CommandInfo> {
538 let mut cmds = builtin_command_infos();
539 cmds.extend(help::HelpPlugin.commands());
540 cmds.extend(stats::StatsPlugin.commands());
541 cmds
542}
543
544#[cfg(test)]
547mod tests {
548 use super::*;
549
550 struct DummyPlugin {
551 name: &'static str,
552 cmd: &'static str,
553 }
554
555 impl Plugin for DummyPlugin {
556 fn name(&self) -> &str {
557 self.name
558 }
559
560 fn commands(&self) -> Vec<CommandInfo> {
561 vec![CommandInfo {
562 name: self.cmd.to_owned(),
563 description: "dummy".to_owned(),
564 usage: format!("/{}", self.cmd),
565 params: vec![],
566 }]
567 }
568
569 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
570 Box::pin(async { Ok(PluginResult::Reply("dummy".to_owned())) })
571 }
572 }
573
574 #[test]
575 fn registry_register_and_resolve() {
576 let mut reg = PluginRegistry::new();
577 reg.register(Box::new(DummyPlugin {
578 name: "test",
579 cmd: "foo",
580 }))
581 .unwrap();
582 assert!(reg.resolve("foo").is_some());
583 assert!(reg.resolve("bar").is_none());
584 }
585
586 #[test]
587 fn registry_rejects_reserved_command() {
588 let mut reg = PluginRegistry::new();
589 let result = reg.register(Box::new(DummyPlugin {
590 name: "bad",
591 cmd: "kick",
592 }));
593 assert!(result.is_err());
594 let err = result.unwrap_err().to_string();
595 assert!(err.contains("reserved by built-in"));
596 }
597
598 #[test]
599 fn registry_rejects_duplicate_command() {
600 let mut reg = PluginRegistry::new();
601 reg.register(Box::new(DummyPlugin {
602 name: "first",
603 cmd: "foo",
604 }))
605 .unwrap();
606 let result = reg.register(Box::new(DummyPlugin {
607 name: "second",
608 cmd: "foo",
609 }));
610 assert!(result.is_err());
611 let err = result.unwrap_err().to_string();
612 assert!(err.contains("already registered by 'first'"));
613 }
614
615 #[test]
616 fn registry_all_commands_lists_everything() {
617 let mut reg = PluginRegistry::new();
618 reg.register(Box::new(DummyPlugin {
619 name: "a",
620 cmd: "alpha",
621 }))
622 .unwrap();
623 reg.register(Box::new(DummyPlugin {
624 name: "b",
625 cmd: "beta",
626 }))
627 .unwrap();
628 let cmds = reg.all_commands();
629 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
630 assert!(names.contains(&"alpha"));
631 assert!(names.contains(&"beta"));
632 assert_eq!(names.len(), 2);
633 }
634
635 #[test]
636 fn registry_completions_for_returns_choice_values() {
637 let mut reg = PluginRegistry::new();
638 reg.register(Box::new({
639 struct CompPlugin;
640 impl Plugin for CompPlugin {
641 fn name(&self) -> &str {
642 "comp"
643 }
644 fn commands(&self) -> Vec<CommandInfo> {
645 vec![CommandInfo {
646 name: "test".to_owned(),
647 description: "test".to_owned(),
648 usage: "/test".to_owned(),
649 params: vec![ParamSchema {
650 name: "count".to_owned(),
651 param_type: ParamType::Choice(vec!["10".to_owned(), "20".to_owned()]),
652 required: false,
653 description: "Number of items".to_owned(),
654 }],
655 }]
656 }
657 fn handle(
658 &self,
659 _ctx: CommandContext,
660 ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
661 Box::pin(async { Ok(PluginResult::Handled) })
662 }
663 }
664 CompPlugin
665 }))
666 .unwrap();
667 let completions = reg.completions_for("test", 0);
668 assert_eq!(completions, vec!["10", "20"]);
669 assert!(reg.completions_for("test", 1).is_empty());
670 assert!(reg.completions_for("nonexistent", 0).is_empty());
671 }
672
673 #[test]
674 fn registry_completions_for_non_choice_returns_empty() {
675 let mut reg = PluginRegistry::new();
676 reg.register(Box::new({
677 struct TextPlugin;
678 impl Plugin for TextPlugin {
679 fn name(&self) -> &str {
680 "text"
681 }
682 fn commands(&self) -> Vec<CommandInfo> {
683 vec![CommandInfo {
684 name: "echo".to_owned(),
685 description: "echo".to_owned(),
686 usage: "/echo".to_owned(),
687 params: vec![ParamSchema {
688 name: "msg".to_owned(),
689 param_type: ParamType::Text,
690 required: true,
691 description: "Message".to_owned(),
692 }],
693 }]
694 }
695 fn handle(
696 &self,
697 _ctx: CommandContext,
698 ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
699 Box::pin(async { Ok(PluginResult::Handled) })
700 }
701 }
702 TextPlugin
703 }))
704 .unwrap();
705 assert!(reg.completions_for("echo", 0).is_empty());
707 }
708
709 #[test]
710 fn registry_rejects_all_reserved_commands() {
711 for &reserved in RESERVED_COMMANDS {
712 let mut reg = PluginRegistry::new();
713 let result = reg.register(Box::new(DummyPlugin {
714 name: "bad",
715 cmd: reserved,
716 }));
717 assert!(
718 result.is_err(),
719 "should reject reserved command '{reserved}'"
720 );
721 }
722 }
723
724 #[test]
727 fn param_type_choice_equality() {
728 let a = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
729 let b = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
730 assert_eq!(a, b);
731 let c = ParamType::Choice(vec!["x".to_owned()]);
732 assert_ne!(a, c);
733 }
734
735 #[test]
736 fn param_type_number_equality() {
737 let a = ParamType::Number {
738 min: Some(1),
739 max: Some(100),
740 };
741 let b = ParamType::Number {
742 min: Some(1),
743 max: Some(100),
744 };
745 assert_eq!(a, b);
746 let c = ParamType::Number {
747 min: None,
748 max: None,
749 };
750 assert_ne!(a, c);
751 }
752
753 #[test]
754 fn param_type_variants_are_distinct() {
755 assert_ne!(ParamType::Text, ParamType::Username);
756 assert_ne!(
757 ParamType::Text,
758 ParamType::Number {
759 min: None,
760 max: None
761 }
762 );
763 assert_ne!(ParamType::Text, ParamType::Choice(vec!["a".to_owned()]));
764 }
765
766 #[test]
769 fn builtin_command_infos_covers_all_expected_commands() {
770 let cmds = builtin_command_infos();
771 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
772 for expected in &[
773 "dm",
774 "claim",
775 "unclaim",
776 "claimed",
777 "reply",
778 "set_status",
779 "who",
780 "kick",
781 "reauth",
782 "clear-tokens",
783 "exit",
784 "clear",
785 "room-info",
786 ] {
787 assert!(
788 names.contains(expected),
789 "missing built-in command: {expected}"
790 );
791 }
792 }
793
794 #[test]
795 fn builtin_command_infos_dm_has_username_param() {
796 let cmds = builtin_command_infos();
797 let dm = cmds.iter().find(|c| c.name == "dm").unwrap();
798 assert_eq!(dm.params.len(), 2);
799 assert_eq!(dm.params[0].param_type, ParamType::Username);
800 assert!(dm.params[0].required);
801 assert_eq!(dm.params[1].param_type, ParamType::Text);
802 }
803
804 #[test]
805 fn builtin_command_infos_kick_has_username_param() {
806 let cmds = builtin_command_infos();
807 let kick = cmds.iter().find(|c| c.name == "kick").unwrap();
808 assert_eq!(kick.params.len(), 1);
809 assert_eq!(kick.params[0].param_type, ParamType::Username);
810 assert!(kick.params[0].required);
811 }
812
813 #[test]
814 fn builtin_command_infos_set_status_is_optional() {
815 let cmds = builtin_command_infos();
816 let ss = cmds.iter().find(|c| c.name == "set_status").unwrap();
817 assert_eq!(ss.params.len(), 1);
818 assert!(!ss.params[0].required);
819 }
820
821 #[test]
822 fn builtin_command_infos_who_has_no_params() {
823 let cmds = builtin_command_infos();
824 let who = cmds.iter().find(|c| c.name == "who").unwrap();
825 assert!(who.params.is_empty());
826 }
827
828 #[test]
831 fn all_known_commands_includes_builtins_and_plugins() {
832 let cmds = all_known_commands();
833 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
834 assert!(names.contains(&"dm"));
836 assert!(names.contains(&"who"));
837 assert!(names.contains(&"kick"));
838 assert!(names.contains(&"help"));
840 assert!(names.contains(&"stats"));
841 }
842
843 #[test]
844 fn all_known_commands_no_duplicates() {
845 let cmds = all_known_commands();
846 let mut names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
847 let before = names.len();
848 names.sort();
849 names.dedup();
850 assert_eq!(before, names.len(), "duplicate command names found");
851 }
852
853 #[tokio::test]
854 async fn history_reader_filters_dms() {
855 let tmp = tempfile::NamedTempFile::new().unwrap();
856 let path = tmp.path();
857
858 let dm = crate::message::make_dm("r", "alice", "bob", "secret");
860 let public = crate::message::make_message("r", "carol", "hello all");
861 history::append(path, &dm).await.unwrap();
862 history::append(path, &public).await.unwrap();
863
864 let reader_alice = HistoryReader::new(path, "alice");
866 let msgs = reader_alice.all().await.unwrap();
867 assert_eq!(msgs.len(), 2);
868
869 let reader_carol = HistoryReader::new(path, "carol");
871 let msgs = reader_carol.all().await.unwrap();
872 assert_eq!(msgs.len(), 1);
873 assert_eq!(msgs[0].user(), "carol");
874 }
875
876 #[tokio::test]
877 async fn history_reader_tail_and_count() {
878 let tmp = tempfile::NamedTempFile::new().unwrap();
879 let path = tmp.path();
880
881 for i in 0..5 {
882 history::append(
883 path,
884 &crate::message::make_message("r", "u", format!("msg {i}")),
885 )
886 .await
887 .unwrap();
888 }
889
890 let reader = HistoryReader::new(path, "u");
891 assert_eq!(reader.count().await.unwrap(), 5);
892
893 let tail = reader.tail(3).await.unwrap();
894 assert_eq!(tail.len(), 3);
895 }
896
897 #[tokio::test]
898 async fn history_reader_since() {
899 let tmp = tempfile::NamedTempFile::new().unwrap();
900 let path = tmp.path();
901
902 let msg1 = crate::message::make_message("r", "u", "first");
903 let msg2 = crate::message::make_message("r", "u", "second");
904 let msg3 = crate::message::make_message("r", "u", "third");
905 let id1 = msg1.id().to_owned();
906 history::append(path, &msg1).await.unwrap();
907 history::append(path, &msg2).await.unwrap();
908 history::append(path, &msg3).await.unwrap();
909
910 let reader = HistoryReader::new(path, "u");
911 let since = reader.since(&id1).await.unwrap();
912 assert_eq!(since.len(), 2);
913 }
914}