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