1pub mod bridge;
2pub mod loader;
3pub mod queue;
4pub mod schema;
5pub mod stats;
6
7pub use room_plugin_taskboard as taskboard;
9
10use std::{collections::HashMap, path::Path};
11
12pub use room_protocol::plugin::{
15 BoxFuture, CommandContext, CommandInfo, HistoryAccess, MessageWriter, ParamSchema, ParamType,
16 Plugin, PluginResult, RoomMetadata, TeamAccess, UserInfo, PLUGIN_API_VERSION, PROTOCOL_VERSION,
17};
18
19pub(crate) use bridge::snapshot_metadata;
22pub use bridge::{ChatWriter, HistoryReader, TeamChecker};
23pub use schema::{all_known_commands, builtin_command_infos};
24
25const RESERVED_COMMANDS: &[&str] = &[
29 "who",
30 "help",
31 "info",
32 "kick",
33 "reauth",
34 "clear-tokens",
35 "dm",
36 "reply",
37 "room-info",
38 "exit",
39 "clear",
40 "subscribe",
41 "set_subscription",
42 "unsubscribe",
43 "subscribe_events",
44 "set_event_filter",
45 "set_status",
46 "subscriptions",
47 "team",
48];
49
50pub struct PluginRegistry {
52 plugins: Vec<Box<dyn Plugin>>,
53 command_map: HashMap<String, usize>,
55}
56
57impl PluginRegistry {
58 pub fn new() -> Self {
59 Self {
60 plugins: Vec::new(),
61 command_map: HashMap::new(),
62 }
63 }
64
65 pub(crate) fn with_all_plugins(chat_path: &Path) -> anyhow::Result<Self> {
70 let mut registry = Self::new();
71
72 let queue_path = queue::QueuePlugin::queue_path_from_chat(chat_path);
73 registry.register(Box::new(queue::QueuePlugin::new(queue_path)?))?;
74
75 registry.register(Box::new(stats::StatsPlugin))?;
76
77 let taskboard_path = taskboard::TaskboardPlugin::taskboard_path_from_chat(chat_path);
78 registry.register(Box::new(taskboard::TaskboardPlugin::new(
79 taskboard_path,
80 None,
81 )))?;
82
83 let plugins_dir = crate::paths::room_plugins_dir();
88 for loaded in loader::scan_plugin_dir(&plugins_dir) {
89 let name = loaded.plugin().name().to_owned();
90 let plugin = unsafe { loaded.into_boxed_plugin() };
94 if let Err(e) = registry.register(plugin) {
95 eprintln!(
96 "[plugin] external plugin '{}' registration failed: {e}",
97 name
98 );
99 }
100 }
101
102 Ok(registry)
103 }
104
105 pub fn register(&mut self, plugin: Box<dyn Plugin>) -> anyhow::Result<()> {
110 let api_v = plugin.api_version();
112 if api_v > PLUGIN_API_VERSION {
113 anyhow::bail!(
114 "plugin '{}' requires api_version {api_v} but broker supports up to {PLUGIN_API_VERSION}",
115 plugin.name(),
116 );
117 }
118
119 let min_proto = plugin.min_protocol();
120 if !semver_satisfies(PROTOCOL_VERSION, min_proto) {
121 anyhow::bail!(
122 "plugin '{}' requires room-protocol >= {min_proto} but broker has {PROTOCOL_VERSION}",
123 plugin.name(),
124 );
125 }
126
127 let idx = self.plugins.len();
129 for cmd in plugin.commands() {
130 if RESERVED_COMMANDS.contains(&cmd.name.as_str()) {
131 anyhow::bail!(
132 "plugin '{}' cannot register command '{}': reserved by built-in",
133 plugin.name(),
134 cmd.name
135 );
136 }
137 if let Some(&existing_idx) = self.command_map.get(&cmd.name) {
138 anyhow::bail!(
139 "plugin '{}' cannot register command '{}': already registered by '{}'",
140 plugin.name(),
141 cmd.name,
142 self.plugins[existing_idx].name()
143 );
144 }
145 self.command_map.insert(cmd.name.clone(), idx);
146 }
147 self.plugins.push(plugin);
148 Ok(())
149 }
150
151 pub fn resolve(&self, command: &str) -> Option<&dyn Plugin> {
153 self.command_map
154 .get(command)
155 .map(|&idx| self.plugins[idx].as_ref())
156 }
157
158 pub fn all_commands(&self) -> Vec<CommandInfo> {
160 self.plugins.iter().flat_map(|p| p.commands()).collect()
161 }
162
163 pub fn notify_join(&self, user: &str) {
167 for plugin in &self.plugins {
168 plugin.on_user_join(user);
169 }
170 }
171
172 pub fn notify_leave(&self, user: &str) {
176 for plugin in &self.plugins {
177 plugin.on_user_leave(user);
178 }
179 }
180
181 pub fn notify_message(&self, msg: &room_protocol::Message) {
185 for plugin in &self.plugins {
186 plugin.on_message(msg);
187 }
188 }
189
190 pub fn completions_for(&self, command: &str, arg_pos: usize) -> Vec<String> {
196 self.all_commands()
197 .iter()
198 .find(|c| c.name == command)
199 .and_then(|c| c.params.get(arg_pos))
200 .map(|p| match &p.param_type {
201 ParamType::Choice(values) => values.clone(),
202 _ => vec![],
203 })
204 .unwrap_or_default()
205 }
206}
207
208impl Default for PluginRegistry {
209 fn default() -> Self {
210 Self::new()
211 }
212}
213
214fn semver_satisfies(running: &str, required: &str) -> bool {
217 let parse = |s: &str| -> (u64, u64, u64) {
218 let mut parts = s.split('.');
219 let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
220 let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
221 let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
222 (major, minor, patch)
223 };
224 parse(running) >= parse(required)
225}
226
227#[cfg(test)]
230mod tests {
231 use super::*;
232
233 struct DummyPlugin {
234 name: &'static str,
235 cmd: &'static str,
236 }
237
238 impl Plugin for DummyPlugin {
239 fn name(&self) -> &str {
240 self.name
241 }
242
243 fn commands(&self) -> Vec<CommandInfo> {
244 vec![CommandInfo {
245 name: self.cmd.to_owned(),
246 description: "dummy".to_owned(),
247 usage: format!("/{}", self.cmd),
248 params: vec![],
249 subcommands: vec![],
250 }]
251 }
252
253 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
254 Box::pin(async { Ok(PluginResult::Reply("dummy".to_owned(), None)) })
255 }
256 }
257
258 #[test]
259 fn registry_register_and_resolve() {
260 let mut reg = PluginRegistry::new();
261 reg.register(Box::new(DummyPlugin {
262 name: "test",
263 cmd: "foo",
264 }))
265 .unwrap();
266 assert!(reg.resolve("foo").is_some());
267 assert!(reg.resolve("bar").is_none());
268 }
269
270 #[test]
271 fn registry_rejects_reserved_command() {
272 let mut reg = PluginRegistry::new();
273 let result = reg.register(Box::new(DummyPlugin {
274 name: "bad",
275 cmd: "kick",
276 }));
277 assert!(result.is_err());
278 let err = result.unwrap_err().to_string();
279 assert!(err.contains("reserved by built-in"));
280 }
281
282 #[test]
283 fn registry_rejects_duplicate_command() {
284 let mut reg = PluginRegistry::new();
285 reg.register(Box::new(DummyPlugin {
286 name: "first",
287 cmd: "foo",
288 }))
289 .unwrap();
290 let result = reg.register(Box::new(DummyPlugin {
291 name: "second",
292 cmd: "foo",
293 }));
294 assert!(result.is_err());
295 let err = result.unwrap_err().to_string();
296 assert!(err.contains("already registered by 'first'"));
297 }
298
299 #[test]
300 fn registry_all_commands_lists_everything() {
301 let mut reg = PluginRegistry::new();
302 reg.register(Box::new(DummyPlugin {
303 name: "a",
304 cmd: "alpha",
305 }))
306 .unwrap();
307 reg.register(Box::new(DummyPlugin {
308 name: "b",
309 cmd: "beta",
310 }))
311 .unwrap();
312 let cmds = reg.all_commands();
313 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
314 assert!(names.contains(&"alpha"));
315 assert!(names.contains(&"beta"));
316 assert_eq!(names.len(), 2);
317 }
318
319 #[test]
320 fn registry_completions_for_returns_choice_values() {
321 let mut reg = PluginRegistry::new();
322 reg.register(Box::new({
323 struct CompPlugin;
324 impl Plugin for CompPlugin {
325 fn name(&self) -> &str {
326 "comp"
327 }
328 fn commands(&self) -> Vec<CommandInfo> {
329 vec![CommandInfo {
330 name: "test".to_owned(),
331 description: "test".to_owned(),
332 usage: "/test".to_owned(),
333 params: vec![ParamSchema {
334 name: "count".to_owned(),
335 param_type: ParamType::Choice(vec!["10".to_owned(), "20".to_owned()]),
336 required: false,
337 description: "Number of items".to_owned(),
338 }],
339 subcommands: vec![],
340 }]
341 }
342 fn handle(
343 &self,
344 _ctx: CommandContext,
345 ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
346 Box::pin(async { Ok(PluginResult::Handled) })
347 }
348 }
349 CompPlugin
350 }))
351 .unwrap();
352 let completions = reg.completions_for("test", 0);
353 assert_eq!(completions, vec!["10", "20"]);
354 assert!(reg.completions_for("test", 1).is_empty());
355 assert!(reg.completions_for("nonexistent", 0).is_empty());
356 }
357
358 #[test]
359 fn registry_completions_for_non_choice_returns_empty() {
360 let mut reg = PluginRegistry::new();
361 reg.register(Box::new({
362 struct TextPlugin;
363 impl Plugin for TextPlugin {
364 fn name(&self) -> &str {
365 "text"
366 }
367 fn commands(&self) -> Vec<CommandInfo> {
368 vec![CommandInfo {
369 name: "echo".to_owned(),
370 description: "echo".to_owned(),
371 usage: "/echo".to_owned(),
372 params: vec![ParamSchema {
373 name: "msg".to_owned(),
374 param_type: ParamType::Text,
375 required: true,
376 description: "Message".to_owned(),
377 }],
378 subcommands: vec![],
379 }]
380 }
381 fn handle(
382 &self,
383 _ctx: CommandContext,
384 ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
385 Box::pin(async { Ok(PluginResult::Handled) })
386 }
387 }
388 TextPlugin
389 }))
390 .unwrap();
391 assert!(reg.completions_for("echo", 0).is_empty());
393 }
394
395 #[test]
396 fn registry_rejects_all_reserved_commands() {
397 for &reserved in RESERVED_COMMANDS {
398 let mut reg = PluginRegistry::new();
399 let result = reg.register(Box::new(DummyPlugin {
400 name: "bad",
401 cmd: reserved,
402 }));
403 assert!(
404 result.is_err(),
405 "should reject reserved command '{reserved}'"
406 );
407 }
408 }
409
410 struct MinimalPlugin;
418
419 impl Plugin for MinimalPlugin {
420 fn name(&self) -> &str {
421 "minimal"
422 }
423
424 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
425 Box::pin(async { Ok(PluginResult::Handled) })
426 }
427 }
429
430 #[test]
431 fn default_commands_returns_empty_vec() {
432 assert!(MinimalPlugin.commands().is_empty());
433 }
434
435 #[test]
436 fn default_lifecycle_hooks_are_noop() {
437 MinimalPlugin.on_user_join("alice");
439 MinimalPlugin.on_user_leave("alice");
440 }
441
442 #[test]
443 fn registry_notify_join_calls_all_plugins() {
444 use std::sync::{Arc, Mutex};
445
446 struct TrackingPlugin {
447 joined: Arc<Mutex<Vec<String>>>,
448 left: Arc<Mutex<Vec<String>>>,
449 }
450
451 impl Plugin for TrackingPlugin {
452 fn name(&self) -> &str {
453 "tracking"
454 }
455
456 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
457 Box::pin(async { Ok(PluginResult::Handled) })
458 }
459
460 fn on_user_join(&self, user: &str) {
461 self.joined.lock().unwrap().push(user.to_owned());
462 }
463
464 fn on_user_leave(&self, user: &str) {
465 self.left.lock().unwrap().push(user.to_owned());
466 }
467 }
468
469 let joined = Arc::new(Mutex::new(Vec::<String>::new()));
470 let left = Arc::new(Mutex::new(Vec::<String>::new()));
471 let mut reg = PluginRegistry::new();
472 reg.register(Box::new(TrackingPlugin {
473 joined: joined.clone(),
474 left: left.clone(),
475 }))
476 .unwrap();
477
478 reg.notify_join("alice");
479 reg.notify_join("bob");
480 reg.notify_leave("alice");
481
482 assert_eq!(*joined.lock().unwrap(), vec!["alice", "bob"]);
483 assert_eq!(*left.lock().unwrap(), vec!["alice"]);
484 }
485
486 #[test]
487 fn registry_notify_join_empty_registry_is_noop() {
488 let reg = PluginRegistry::new();
489 reg.notify_join("alice");
491 reg.notify_leave("alice");
492 }
493
494 #[test]
495 fn registry_notify_message_calls_all_plugins() {
496 use std::sync::{Arc, Mutex};
497
498 struct MessageTracker {
499 messages: Arc<Mutex<Vec<String>>>,
500 }
501
502 impl Plugin for MessageTracker {
503 fn name(&self) -> &str {
504 "msg-tracker"
505 }
506
507 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
508 Box::pin(async { Ok(PluginResult::Handled) })
509 }
510
511 fn on_message(&self, msg: &room_protocol::Message) {
512 self.messages.lock().unwrap().push(msg.user().to_owned());
513 }
514 }
515
516 let messages = Arc::new(Mutex::new(Vec::<String>::new()));
517 let mut reg = PluginRegistry::new();
518 reg.register(Box::new(MessageTracker {
519 messages: messages.clone(),
520 }))
521 .unwrap();
522
523 let msg = room_protocol::make_message("room", "alice", "hello");
524 reg.notify_message(&msg);
525 reg.notify_message(&room_protocol::make_message("room", "bob", "hi"));
526
527 let recorded = messages.lock().unwrap();
528 assert_eq!(*recorded, vec!["alice", "bob"]);
529 }
530
531 #[test]
532 fn registry_notify_message_empty_registry_is_noop() {
533 let reg = PluginRegistry::new();
534 let msg = room_protocol::make_message("room", "alice", "hello");
535 reg.notify_message(&msg);
537 }
538
539 #[test]
540 fn minimal_plugin_can_be_registered_without_commands() {
541 let mut reg = PluginRegistry::new();
542 reg.register(Box::new(MinimalPlugin)).unwrap();
545 assert_eq!(reg.all_commands().len(), 0);
547 }
548
549 #[test]
552 fn failed_register_does_not_pollute_registry() {
553 let mut reg = PluginRegistry::new();
554 reg.register(Box::new(DummyPlugin {
555 name: "good",
556 cmd: "foo",
557 }))
558 .unwrap();
559
560 let result = reg.register(Box::new(DummyPlugin {
562 name: "bad",
563 cmd: "kick",
564 }));
565 assert!(result.is_err());
566
567 assert!(
569 reg.resolve("foo").is_some(),
570 "pre-existing command must still resolve"
571 );
572 assert_eq!(reg.all_commands().len(), 1, "command count must not change");
573 assert!(
575 reg.resolve("kick").is_none(),
576 "failed command must not be resolvable"
577 );
578 }
579
580 #[test]
581 fn all_builtin_schemas_have_valid_fields() {
582 let cmds = super::schema::builtin_command_infos();
583 assert!(!cmds.is_empty(), "builtins must not be empty");
584 for cmd in &cmds {
585 assert!(!cmd.name.is_empty(), "name must not be empty");
586 assert!(
587 !cmd.description.is_empty(),
588 "description must not be empty for /{}",
589 cmd.name
590 );
591 assert!(
592 !cmd.usage.is_empty(),
593 "usage must not be empty for /{}",
594 cmd.name
595 );
596 for param in &cmd.params {
597 assert!(
598 !param.name.is_empty(),
599 "param name must not be empty in /{}",
600 cmd.name
601 );
602 assert!(
603 !param.description.is_empty(),
604 "param description must not be empty in /{} param '{}'",
605 cmd.name,
606 param.name
607 );
608 }
609 }
610 }
611
612 #[test]
613 fn duplicate_plugin_names_with_different_commands_succeed() {
614 let mut reg = PluginRegistry::new();
615 reg.register(Box::new(DummyPlugin {
616 name: "same-name",
617 cmd: "alpha",
618 }))
619 .unwrap();
620 reg.register(Box::new(DummyPlugin {
622 name: "same-name",
623 cmd: "beta",
624 }))
625 .unwrap();
626 assert!(reg.resolve("alpha").is_some());
627 assert!(reg.resolve("beta").is_some());
628 assert_eq!(reg.all_commands().len(), 2);
629 }
630
631 #[test]
632 fn completions_for_number_param_returns_empty() {
633 let mut reg = PluginRegistry::new();
634 reg.register(Box::new({
635 struct NumPlugin;
636 impl Plugin for NumPlugin {
637 fn name(&self) -> &str {
638 "num"
639 }
640 fn commands(&self) -> Vec<CommandInfo> {
641 vec![CommandInfo {
642 name: "repeat".to_owned(),
643 description: "repeat".to_owned(),
644 usage: "/repeat".to_owned(),
645 params: vec![ParamSchema {
646 name: "count".to_owned(),
647 param_type: ParamType::Number {
648 min: Some(1),
649 max: Some(100),
650 },
651 required: true,
652 description: "Number of repetitions".to_owned(),
653 }],
654 subcommands: vec![],
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 NumPlugin
665 }))
666 .unwrap();
667 assert!(reg.completions_for("repeat", 0).is_empty());
669 }
670
671 #[test]
674 fn semver_satisfies_equal_versions() {
675 assert!(super::semver_satisfies("3.1.0", "3.1.0"));
676 }
677
678 #[test]
679 fn semver_satisfies_running_newer_major() {
680 assert!(super::semver_satisfies("4.0.0", "3.1.0"));
681 }
682
683 #[test]
684 fn semver_satisfies_running_newer_minor() {
685 assert!(super::semver_satisfies("3.2.0", "3.1.0"));
686 }
687
688 #[test]
689 fn semver_satisfies_running_newer_patch() {
690 assert!(super::semver_satisfies("3.1.1", "3.1.0"));
691 }
692
693 #[test]
694 fn semver_satisfies_running_older_fails() {
695 assert!(!super::semver_satisfies("3.0.9", "3.1.0"));
696 }
697
698 #[test]
699 fn semver_satisfies_running_older_major_fails() {
700 assert!(!super::semver_satisfies("2.9.9", "3.0.0"));
701 }
702
703 #[test]
704 fn semver_satisfies_zero_required_always_passes() {
705 assert!(super::semver_satisfies("0.0.1", "0.0.0"));
706 assert!(super::semver_satisfies("3.1.0", "0.0.0"));
707 }
708
709 #[test]
710 fn semver_satisfies_malformed_treated_as_zero() {
711 assert!(super::semver_satisfies("garbage", "0.0.0"));
712 assert!(super::semver_satisfies("3.1.0", "garbage"));
713 assert!(super::semver_satisfies("garbage", "garbage"));
714 }
715
716 struct FutureApiPlugin;
720
721 impl Plugin for FutureApiPlugin {
722 fn name(&self) -> &str {
723 "future-api"
724 }
725
726 fn api_version(&self) -> u32 {
727 PLUGIN_API_VERSION + 1
728 }
729
730 fn commands(&self) -> Vec<CommandInfo> {
731 vec![CommandInfo {
732 name: "future".to_owned(),
733 description: "from the future".to_owned(),
734 usage: "/future".to_owned(),
735 params: vec![],
736 subcommands: vec![],
737 }]
738 }
739
740 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
741 Box::pin(async { Ok(PluginResult::Handled) })
742 }
743 }
744
745 #[test]
746 fn register_rejects_future_api_version() {
747 let mut reg = PluginRegistry::new();
748 let result = reg.register(Box::new(FutureApiPlugin));
749 assert!(result.is_err());
750 let err = result.unwrap_err().to_string();
751 assert!(
752 err.contains("api_version"),
753 "error should mention api_version: {err}"
754 );
755 assert!(
756 err.contains("future-api"),
757 "error should mention plugin name: {err}"
758 );
759 }
760
761 struct FutureProtocolPlugin;
763
764 impl Plugin for FutureProtocolPlugin {
765 fn name(&self) -> &str {
766 "future-proto"
767 }
768
769 fn min_protocol(&self) -> &str {
770 "99.0.0"
771 }
772
773 fn commands(&self) -> Vec<CommandInfo> {
774 vec![CommandInfo {
775 name: "proto".to_owned(),
776 description: "needs future protocol".to_owned(),
777 usage: "/proto".to_owned(),
778 params: vec![],
779 subcommands: vec![],
780 }]
781 }
782
783 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
784 Box::pin(async { Ok(PluginResult::Handled) })
785 }
786 }
787
788 #[test]
789 fn register_rejects_incompatible_min_protocol() {
790 let mut reg = PluginRegistry::new();
791 let result = reg.register(Box::new(FutureProtocolPlugin));
792 assert!(result.is_err());
793 let err = result.unwrap_err().to_string();
794 assert!(
795 err.contains("room-protocol"),
796 "error should mention room-protocol: {err}"
797 );
798 assert!(
799 err.contains("99.0.0"),
800 "error should mention required version: {err}"
801 );
802 }
803
804 #[test]
805 fn register_accepts_compatible_versioned_plugin() {
806 let mut reg = PluginRegistry::new();
807 let result = reg.register(Box::new(DummyPlugin {
809 name: "compat",
810 cmd: "compat_cmd",
811 }));
812 assert!(result.is_ok());
813 assert!(reg.resolve("compat_cmd").is_some());
814 }
815
816 #[test]
817 fn register_version_check_runs_before_command_check() {
818 struct DoubleBadPlugin;
821
822 impl Plugin for DoubleBadPlugin {
823 fn name(&self) -> &str {
824 "double-bad"
825 }
826
827 fn api_version(&self) -> u32 {
828 PLUGIN_API_VERSION + 1
829 }
830
831 fn commands(&self) -> Vec<CommandInfo> {
832 vec![CommandInfo {
833 name: "kick".to_owned(),
834 description: "bad".to_owned(),
835 usage: "/kick".to_owned(),
836 params: vec![],
837 subcommands: vec![],
838 }]
839 }
840
841 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
842 Box::pin(async { Ok(PluginResult::Handled) })
843 }
844 }
845
846 let mut reg = PluginRegistry::new();
847 let result = reg.register(Box::new(DoubleBadPlugin));
848 assert!(result.is_err());
849 let err = result.unwrap_err().to_string();
850 assert!(
852 err.contains("api_version"),
853 "should reject on api_version first: {err}"
854 );
855 }
856
857 #[test]
858 fn failed_version_check_does_not_pollute_registry() {
859 let mut reg = PluginRegistry::new();
860 reg.register(Box::new(DummyPlugin {
861 name: "good",
862 cmd: "foo",
863 }))
864 .unwrap();
865
866 let result = reg.register(Box::new(FutureProtocolPlugin));
868 assert!(result.is_err());
869
870 assert!(reg.resolve("foo").is_some());
872 assert_eq!(reg.all_commands().len(), 1);
873 assert!(reg.resolve("proto").is_none());
875 }
876}