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 let agent_state_path = chat_path.with_extension("agents");
84 let agent_log_dir = chat_path.parent().unwrap_or(chat_path).join("agent-logs");
85 let agent_socket_path = crate::paths::effective_socket_path(None);
87 registry.register(Box::new(room_plugin_agent::AgentPlugin::new(
88 agent_state_path,
89 agent_socket_path,
90 agent_log_dir,
91 )))?;
92
93 Ok(registry)
94 }
95
96 pub fn register(&mut self, plugin: Box<dyn Plugin>) -> anyhow::Result<()> {
101 let api_v = plugin.api_version();
103 if api_v > PLUGIN_API_VERSION {
104 anyhow::bail!(
105 "plugin '{}' requires api_version {api_v} but broker supports up to {PLUGIN_API_VERSION}",
106 plugin.name(),
107 );
108 }
109
110 let min_proto = plugin.min_protocol();
111 if !semver_satisfies(PROTOCOL_VERSION, min_proto) {
112 anyhow::bail!(
113 "plugin '{}' requires room-protocol >= {min_proto} but broker has {PROTOCOL_VERSION}",
114 plugin.name(),
115 );
116 }
117
118 let idx = self.plugins.len();
120 for cmd in plugin.commands() {
121 if RESERVED_COMMANDS.contains(&cmd.name.as_str()) {
122 anyhow::bail!(
123 "plugin '{}' cannot register command '{}': reserved by built-in",
124 plugin.name(),
125 cmd.name
126 );
127 }
128 if let Some(&existing_idx) = self.command_map.get(&cmd.name) {
129 anyhow::bail!(
130 "plugin '{}' cannot register command '{}': already registered by '{}'",
131 plugin.name(),
132 cmd.name,
133 self.plugins[existing_idx].name()
134 );
135 }
136 self.command_map.insert(cmd.name.clone(), idx);
137 }
138 self.plugins.push(plugin);
139 Ok(())
140 }
141
142 pub fn resolve(&self, command: &str) -> Option<&dyn Plugin> {
144 self.command_map
145 .get(command)
146 .map(|&idx| self.plugins[idx].as_ref())
147 }
148
149 pub fn all_commands(&self) -> Vec<CommandInfo> {
151 self.plugins.iter().flat_map(|p| p.commands()).collect()
152 }
153
154 pub fn notify_join(&self, user: &str) {
158 for plugin in &self.plugins {
159 plugin.on_user_join(user);
160 }
161 }
162
163 pub fn notify_leave(&self, user: &str) {
167 for plugin in &self.plugins {
168 plugin.on_user_leave(user);
169 }
170 }
171
172 pub fn completions_for(&self, command: &str, arg_pos: usize) -> Vec<String> {
178 self.all_commands()
179 .iter()
180 .find(|c| c.name == command)
181 .and_then(|c| c.params.get(arg_pos))
182 .map(|p| match &p.param_type {
183 ParamType::Choice(values) => values.clone(),
184 _ => vec![],
185 })
186 .unwrap_or_default()
187 }
188}
189
190impl Default for PluginRegistry {
191 fn default() -> Self {
192 Self::new()
193 }
194}
195
196fn semver_satisfies(running: &str, required: &str) -> bool {
199 let parse = |s: &str| -> (u64, u64, u64) {
200 let mut parts = s.split('.');
201 let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
202 let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
203 let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
204 (major, minor, patch)
205 };
206 parse(running) >= parse(required)
207}
208
209#[cfg(test)]
212mod tests {
213 use super::*;
214
215 struct DummyPlugin {
216 name: &'static str,
217 cmd: &'static str,
218 }
219
220 impl Plugin for DummyPlugin {
221 fn name(&self) -> &str {
222 self.name
223 }
224
225 fn commands(&self) -> Vec<CommandInfo> {
226 vec![CommandInfo {
227 name: self.cmd.to_owned(),
228 description: "dummy".to_owned(),
229 usage: format!("/{}", self.cmd),
230 params: vec![],
231 }]
232 }
233
234 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
235 Box::pin(async { Ok(PluginResult::Reply("dummy".to_owned(), None)) })
236 }
237 }
238
239 #[test]
240 fn registry_register_and_resolve() {
241 let mut reg = PluginRegistry::new();
242 reg.register(Box::new(DummyPlugin {
243 name: "test",
244 cmd: "foo",
245 }))
246 .unwrap();
247 assert!(reg.resolve("foo").is_some());
248 assert!(reg.resolve("bar").is_none());
249 }
250
251 #[test]
252 fn registry_rejects_reserved_command() {
253 let mut reg = PluginRegistry::new();
254 let result = reg.register(Box::new(DummyPlugin {
255 name: "bad",
256 cmd: "kick",
257 }));
258 assert!(result.is_err());
259 let err = result.unwrap_err().to_string();
260 assert!(err.contains("reserved by built-in"));
261 }
262
263 #[test]
264 fn registry_rejects_duplicate_command() {
265 let mut reg = PluginRegistry::new();
266 reg.register(Box::new(DummyPlugin {
267 name: "first",
268 cmd: "foo",
269 }))
270 .unwrap();
271 let result = reg.register(Box::new(DummyPlugin {
272 name: "second",
273 cmd: "foo",
274 }));
275 assert!(result.is_err());
276 let err = result.unwrap_err().to_string();
277 assert!(err.contains("already registered by 'first'"));
278 }
279
280 #[test]
281 fn registry_all_commands_lists_everything() {
282 let mut reg = PluginRegistry::new();
283 reg.register(Box::new(DummyPlugin {
284 name: "a",
285 cmd: "alpha",
286 }))
287 .unwrap();
288 reg.register(Box::new(DummyPlugin {
289 name: "b",
290 cmd: "beta",
291 }))
292 .unwrap();
293 let cmds = reg.all_commands();
294 let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
295 assert!(names.contains(&"alpha"));
296 assert!(names.contains(&"beta"));
297 assert_eq!(names.len(), 2);
298 }
299
300 #[test]
301 fn registry_completions_for_returns_choice_values() {
302 let mut reg = PluginRegistry::new();
303 reg.register(Box::new({
304 struct CompPlugin;
305 impl Plugin for CompPlugin {
306 fn name(&self) -> &str {
307 "comp"
308 }
309 fn commands(&self) -> Vec<CommandInfo> {
310 vec![CommandInfo {
311 name: "test".to_owned(),
312 description: "test".to_owned(),
313 usage: "/test".to_owned(),
314 params: vec![ParamSchema {
315 name: "count".to_owned(),
316 param_type: ParamType::Choice(vec!["10".to_owned(), "20".to_owned()]),
317 required: false,
318 description: "Number of items".to_owned(),
319 }],
320 }]
321 }
322 fn handle(
323 &self,
324 _ctx: CommandContext,
325 ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
326 Box::pin(async { Ok(PluginResult::Handled) })
327 }
328 }
329 CompPlugin
330 }))
331 .unwrap();
332 let completions = reg.completions_for("test", 0);
333 assert_eq!(completions, vec!["10", "20"]);
334 assert!(reg.completions_for("test", 1).is_empty());
335 assert!(reg.completions_for("nonexistent", 0).is_empty());
336 }
337
338 #[test]
339 fn registry_completions_for_non_choice_returns_empty() {
340 let mut reg = PluginRegistry::new();
341 reg.register(Box::new({
342 struct TextPlugin;
343 impl Plugin for TextPlugin {
344 fn name(&self) -> &str {
345 "text"
346 }
347 fn commands(&self) -> Vec<CommandInfo> {
348 vec![CommandInfo {
349 name: "echo".to_owned(),
350 description: "echo".to_owned(),
351 usage: "/echo".to_owned(),
352 params: vec![ParamSchema {
353 name: "msg".to_owned(),
354 param_type: ParamType::Text,
355 required: true,
356 description: "Message".to_owned(),
357 }],
358 }]
359 }
360 fn handle(
361 &self,
362 _ctx: CommandContext,
363 ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
364 Box::pin(async { Ok(PluginResult::Handled) })
365 }
366 }
367 TextPlugin
368 }))
369 .unwrap();
370 assert!(reg.completions_for("echo", 0).is_empty());
372 }
373
374 #[test]
375 fn registry_rejects_all_reserved_commands() {
376 for &reserved in RESERVED_COMMANDS {
377 let mut reg = PluginRegistry::new();
378 let result = reg.register(Box::new(DummyPlugin {
379 name: "bad",
380 cmd: reserved,
381 }));
382 assert!(
383 result.is_err(),
384 "should reject reserved command '{reserved}'"
385 );
386 }
387 }
388
389 struct MinimalPlugin;
397
398 impl Plugin for MinimalPlugin {
399 fn name(&self) -> &str {
400 "minimal"
401 }
402
403 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
404 Box::pin(async { Ok(PluginResult::Handled) })
405 }
406 }
408
409 #[test]
410 fn default_commands_returns_empty_vec() {
411 assert!(MinimalPlugin.commands().is_empty());
412 }
413
414 #[test]
415 fn default_lifecycle_hooks_are_noop() {
416 MinimalPlugin.on_user_join("alice");
418 MinimalPlugin.on_user_leave("alice");
419 }
420
421 #[test]
422 fn registry_notify_join_calls_all_plugins() {
423 use std::sync::{Arc, Mutex};
424
425 struct TrackingPlugin {
426 joined: Arc<Mutex<Vec<String>>>,
427 left: Arc<Mutex<Vec<String>>>,
428 }
429
430 impl Plugin for TrackingPlugin {
431 fn name(&self) -> &str {
432 "tracking"
433 }
434
435 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
436 Box::pin(async { Ok(PluginResult::Handled) })
437 }
438
439 fn on_user_join(&self, user: &str) {
440 self.joined.lock().unwrap().push(user.to_owned());
441 }
442
443 fn on_user_leave(&self, user: &str) {
444 self.left.lock().unwrap().push(user.to_owned());
445 }
446 }
447
448 let joined = Arc::new(Mutex::new(Vec::<String>::new()));
449 let left = Arc::new(Mutex::new(Vec::<String>::new()));
450 let mut reg = PluginRegistry::new();
451 reg.register(Box::new(TrackingPlugin {
452 joined: joined.clone(),
453 left: left.clone(),
454 }))
455 .unwrap();
456
457 reg.notify_join("alice");
458 reg.notify_join("bob");
459 reg.notify_leave("alice");
460
461 assert_eq!(*joined.lock().unwrap(), vec!["alice", "bob"]);
462 assert_eq!(*left.lock().unwrap(), vec!["alice"]);
463 }
464
465 #[test]
466 fn registry_notify_join_empty_registry_is_noop() {
467 let reg = PluginRegistry::new();
468 reg.notify_join("alice");
470 reg.notify_leave("alice");
471 }
472
473 #[test]
474 fn minimal_plugin_can_be_registered_without_commands() {
475 let mut reg = PluginRegistry::new();
476 reg.register(Box::new(MinimalPlugin)).unwrap();
479 assert_eq!(reg.all_commands().len(), 0);
481 }
482
483 #[test]
486 fn failed_register_does_not_pollute_registry() {
487 let mut reg = PluginRegistry::new();
488 reg.register(Box::new(DummyPlugin {
489 name: "good",
490 cmd: "foo",
491 }))
492 .unwrap();
493
494 let result = reg.register(Box::new(DummyPlugin {
496 name: "bad",
497 cmd: "kick",
498 }));
499 assert!(result.is_err());
500
501 assert!(
503 reg.resolve("foo").is_some(),
504 "pre-existing command must still resolve"
505 );
506 assert_eq!(reg.all_commands().len(), 1, "command count must not change");
507 assert!(
509 reg.resolve("kick").is_none(),
510 "failed command must not be resolvable"
511 );
512 }
513
514 #[test]
515 fn all_builtin_schemas_have_valid_fields() {
516 let cmds = super::schema::builtin_command_infos();
517 assert!(!cmds.is_empty(), "builtins must not be empty");
518 for cmd in &cmds {
519 assert!(!cmd.name.is_empty(), "name must not be empty");
520 assert!(
521 !cmd.description.is_empty(),
522 "description must not be empty for /{}",
523 cmd.name
524 );
525 assert!(
526 !cmd.usage.is_empty(),
527 "usage must not be empty for /{}",
528 cmd.name
529 );
530 for param in &cmd.params {
531 assert!(
532 !param.name.is_empty(),
533 "param name must not be empty in /{}",
534 cmd.name
535 );
536 assert!(
537 !param.description.is_empty(),
538 "param description must not be empty in /{} param '{}'",
539 cmd.name,
540 param.name
541 );
542 }
543 }
544 }
545
546 #[test]
547 fn duplicate_plugin_names_with_different_commands_succeed() {
548 let mut reg = PluginRegistry::new();
549 reg.register(Box::new(DummyPlugin {
550 name: "same-name",
551 cmd: "alpha",
552 }))
553 .unwrap();
554 reg.register(Box::new(DummyPlugin {
556 name: "same-name",
557 cmd: "beta",
558 }))
559 .unwrap();
560 assert!(reg.resolve("alpha").is_some());
561 assert!(reg.resolve("beta").is_some());
562 assert_eq!(reg.all_commands().len(), 2);
563 }
564
565 #[test]
566 fn completions_for_number_param_returns_empty() {
567 let mut reg = PluginRegistry::new();
568 reg.register(Box::new({
569 struct NumPlugin;
570 impl Plugin for NumPlugin {
571 fn name(&self) -> &str {
572 "num"
573 }
574 fn commands(&self) -> Vec<CommandInfo> {
575 vec![CommandInfo {
576 name: "repeat".to_owned(),
577 description: "repeat".to_owned(),
578 usage: "/repeat".to_owned(),
579 params: vec![ParamSchema {
580 name: "count".to_owned(),
581 param_type: ParamType::Number {
582 min: Some(1),
583 max: Some(100),
584 },
585 required: true,
586 description: "Number of repetitions".to_owned(),
587 }],
588 }]
589 }
590 fn handle(
591 &self,
592 _ctx: CommandContext,
593 ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
594 Box::pin(async { Ok(PluginResult::Handled) })
595 }
596 }
597 NumPlugin
598 }))
599 .unwrap();
600 assert!(reg.completions_for("repeat", 0).is_empty());
602 }
603
604 #[test]
607 fn semver_satisfies_equal_versions() {
608 assert!(super::semver_satisfies("3.1.0", "3.1.0"));
609 }
610
611 #[test]
612 fn semver_satisfies_running_newer_major() {
613 assert!(super::semver_satisfies("4.0.0", "3.1.0"));
614 }
615
616 #[test]
617 fn semver_satisfies_running_newer_minor() {
618 assert!(super::semver_satisfies("3.2.0", "3.1.0"));
619 }
620
621 #[test]
622 fn semver_satisfies_running_newer_patch() {
623 assert!(super::semver_satisfies("3.1.1", "3.1.0"));
624 }
625
626 #[test]
627 fn semver_satisfies_running_older_fails() {
628 assert!(!super::semver_satisfies("3.0.9", "3.1.0"));
629 }
630
631 #[test]
632 fn semver_satisfies_running_older_major_fails() {
633 assert!(!super::semver_satisfies("2.9.9", "3.0.0"));
634 }
635
636 #[test]
637 fn semver_satisfies_zero_required_always_passes() {
638 assert!(super::semver_satisfies("0.0.1", "0.0.0"));
639 assert!(super::semver_satisfies("3.1.0", "0.0.0"));
640 }
641
642 #[test]
643 fn semver_satisfies_malformed_treated_as_zero() {
644 assert!(super::semver_satisfies("garbage", "0.0.0"));
645 assert!(super::semver_satisfies("3.1.0", "garbage"));
646 assert!(super::semver_satisfies("garbage", "garbage"));
647 }
648
649 struct FutureApiPlugin;
653
654 impl Plugin for FutureApiPlugin {
655 fn name(&self) -> &str {
656 "future-api"
657 }
658
659 fn api_version(&self) -> u32 {
660 PLUGIN_API_VERSION + 1
661 }
662
663 fn commands(&self) -> Vec<CommandInfo> {
664 vec![CommandInfo {
665 name: "future".to_owned(),
666 description: "from the future".to_owned(),
667 usage: "/future".to_owned(),
668 params: vec![],
669 }]
670 }
671
672 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
673 Box::pin(async { Ok(PluginResult::Handled) })
674 }
675 }
676
677 #[test]
678 fn register_rejects_future_api_version() {
679 let mut reg = PluginRegistry::new();
680 let result = reg.register(Box::new(FutureApiPlugin));
681 assert!(result.is_err());
682 let err = result.unwrap_err().to_string();
683 assert!(
684 err.contains("api_version"),
685 "error should mention api_version: {err}"
686 );
687 assert!(
688 err.contains("future-api"),
689 "error should mention plugin name: {err}"
690 );
691 }
692
693 struct FutureProtocolPlugin;
695
696 impl Plugin for FutureProtocolPlugin {
697 fn name(&self) -> &str {
698 "future-proto"
699 }
700
701 fn min_protocol(&self) -> &str {
702 "99.0.0"
703 }
704
705 fn commands(&self) -> Vec<CommandInfo> {
706 vec![CommandInfo {
707 name: "proto".to_owned(),
708 description: "needs future protocol".to_owned(),
709 usage: "/proto".to_owned(),
710 params: vec![],
711 }]
712 }
713
714 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
715 Box::pin(async { Ok(PluginResult::Handled) })
716 }
717 }
718
719 #[test]
720 fn register_rejects_incompatible_min_protocol() {
721 let mut reg = PluginRegistry::new();
722 let result = reg.register(Box::new(FutureProtocolPlugin));
723 assert!(result.is_err());
724 let err = result.unwrap_err().to_string();
725 assert!(
726 err.contains("room-protocol"),
727 "error should mention room-protocol: {err}"
728 );
729 assert!(
730 err.contains("99.0.0"),
731 "error should mention required version: {err}"
732 );
733 }
734
735 #[test]
736 fn register_accepts_compatible_versioned_plugin() {
737 let mut reg = PluginRegistry::new();
738 let result = reg.register(Box::new(DummyPlugin {
740 name: "compat",
741 cmd: "compat_cmd",
742 }));
743 assert!(result.is_ok());
744 assert!(reg.resolve("compat_cmd").is_some());
745 }
746
747 #[test]
748 fn register_version_check_runs_before_command_check() {
749 struct DoubleBadPlugin;
752
753 impl Plugin for DoubleBadPlugin {
754 fn name(&self) -> &str {
755 "double-bad"
756 }
757
758 fn api_version(&self) -> u32 {
759 PLUGIN_API_VERSION + 1
760 }
761
762 fn commands(&self) -> Vec<CommandInfo> {
763 vec![CommandInfo {
764 name: "kick".to_owned(),
765 description: "bad".to_owned(),
766 usage: "/kick".to_owned(),
767 params: vec![],
768 }]
769 }
770
771 fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
772 Box::pin(async { Ok(PluginResult::Handled) })
773 }
774 }
775
776 let mut reg = PluginRegistry::new();
777 let result = reg.register(Box::new(DoubleBadPlugin));
778 assert!(result.is_err());
779 let err = result.unwrap_err().to_string();
780 assert!(
782 err.contains("api_version"),
783 "should reject on api_version first: {err}"
784 );
785 }
786
787 #[test]
788 fn failed_version_check_does_not_pollute_registry() {
789 let mut reg = PluginRegistry::new();
790 reg.register(Box::new(DummyPlugin {
791 name: "good",
792 cmd: "foo",
793 }))
794 .unwrap();
795
796 let result = reg.register(Box::new(FutureProtocolPlugin));
798 assert!(result.is_err());
799
800 assert!(reg.resolve("foo").is_some());
802 assert_eq!(reg.all_commands().len(), 1);
803 assert!(reg.resolve("proto").is_none());
805 }
806}