Skip to main content

room_protocol/
plugin.rs

1//! Plugin framework types for the room chat system.
2//!
3//! This module defines the traits and types needed to implement a room plugin.
4//! External crates can depend on `room-protocol` alone to implement [`Plugin`]
5//! — no dependency on `room-cli` or broker internals is required.
6
7use std::future::Future;
8use std::pin::Pin;
9
10use chrono::{DateTime, Utc};
11
12use crate::{EventType, Message};
13
14/// Boxed future type used by [`Plugin::handle`] for dyn compatibility.
15pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
16
17// ── Plugin trait ────────────────────────────────────────────────────────────
18
19/// A plugin that handles one or more `/` commands and/or reacts to room
20/// lifecycle events.
21///
22/// Implement this trait and register it with the broker's plugin registry to
23/// add custom commands to a room. The broker dispatches matching
24/// `Message::Command` messages to the plugin's [`handle`](Plugin::handle)
25/// method, and calls [`on_user_join`](Plugin::on_user_join) /
26/// [`on_user_leave`](Plugin::on_user_leave) when users enter or leave.
27///
28/// Only [`name`](Plugin::name) and [`handle`](Plugin::handle) are required.
29/// All other methods have no-op / empty-vec defaults so that adding new
30/// lifecycle hooks in future releases does not break existing plugins.
31pub trait Plugin: Send + Sync {
32    /// Unique identifier for this plugin (e.g. `"stats"`, `"help"`).
33    fn name(&self) -> &str;
34
35    /// Semantic version of this plugin (e.g. `"1.0.0"`).
36    ///
37    /// Used for diagnostics and `/info` output. Defaults to `"0.0.0"` for
38    /// plugins that do not track their own version.
39    fn version(&self) -> &str {
40        "0.0.0"
41    }
42
43    /// Plugin API version this plugin was written against.
44    ///
45    /// The broker rejects plugins whose `api_version()` exceeds the current
46    /// [`PLUGIN_API_VERSION`]. Bump this constant when the `Plugin` trait
47    /// gains new required methods or changes existing method signatures.
48    ///
49    /// Defaults to `1` (the initial API revision).
50    fn api_version(&self) -> u32 {
51        1
52    }
53
54    /// Minimum `room-protocol` crate version this plugin requires, as a
55    /// semver string (e.g. `"3.1.0"`).
56    ///
57    /// The broker rejects plugins whose `min_protocol()` is newer than the
58    /// running `room-protocol` version. Defaults to `"0.0.0"` (compatible
59    /// with any protocol version).
60    fn min_protocol(&self) -> &str {
61        "0.0.0"
62    }
63
64    /// Commands this plugin handles. Each entry drives `/help` output
65    /// and TUI autocomplete.
66    ///
67    /// Defaults to an empty vec for plugins that only use lifecycle hooks
68    /// and do not register any commands.
69    fn commands(&self) -> Vec<CommandInfo> {
70        vec![]
71    }
72
73    /// Handle an invocation of one of this plugin's commands.
74    ///
75    /// Returns a boxed future for dyn compatibility (required because the
76    /// registry stores `Box<dyn Plugin>`).
77    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>>;
78
79    /// Called after a user joins the room. The default is a no-op.
80    ///
81    /// Invoked synchronously during the join broadcast path. Implementations
82    /// must not block — spawn a task if async work is needed.
83    fn on_user_join(&self, _user: &str) {}
84
85    /// Called after a user leaves the room. The default is a no-op.
86    ///
87    /// Invoked synchronously during the leave broadcast path. Implementations
88    /// must not block — spawn a task if async work is needed.
89    fn on_user_leave(&self, _user: &str) {}
90}
91
92/// Current Plugin API version. Increment when the `Plugin` trait changes in
93/// a way that requires plugin authors to update their code (new required
94/// methods, changed signatures, removed defaults).
95///
96/// Plugins returning an `api_version()` higher than this are rejected at
97/// registration.
98pub const PLUGIN_API_VERSION: u32 = 1;
99
100/// The `room-protocol` crate version, derived from `Cargo.toml` at compile
101/// time. Used by the broker to reject plugins that require a newer protocol
102/// than the one currently running.
103pub const PROTOCOL_VERSION: &str = env!("CARGO_PKG_VERSION");
104
105// ── CommandInfo ─────────────────────────────────────────────────────────────
106
107/// Describes a single command for `/help` and autocomplete.
108#[derive(Debug, Clone)]
109pub struct CommandInfo {
110    /// Command name without the leading `/`.
111    pub name: String,
112    /// One-line description shown in `/help` and autocomplete.
113    pub description: String,
114    /// Usage string (e.g. `"/stats [last N]"`).
115    pub usage: String,
116    /// Typed parameter schemas for validation and autocomplete.
117    pub params: Vec<ParamSchema>,
118}
119
120// ── Typed parameter schema ─────────────────────────────────────────────────
121
122/// Schema for a single command parameter — drives validation, `/help` output,
123/// and TUI argument autocomplete.
124#[derive(Debug, Clone)]
125pub struct ParamSchema {
126    /// Display name (e.g. `"username"`, `"count"`).
127    pub name: String,
128    /// What kind of value this parameter accepts.
129    pub param_type: ParamType,
130    /// Whether the parameter must be provided.
131    pub required: bool,
132    /// One-line description shown in `/help <command>`.
133    pub description: String,
134}
135
136/// The kind of value a parameter accepts.
137#[derive(Debug, Clone, PartialEq)]
138pub enum ParamType {
139    /// Free-form text (no validation beyond presence).
140    Text,
141    /// One of a fixed set of allowed values.
142    Choice(Vec<String>),
143    /// An online username — TUI shows the mention picker.
144    Username,
145    /// An integer, optionally bounded.
146    Number { min: Option<i64>, max: Option<i64> },
147}
148
149// ── CommandContext ───────────────────────────────────────────────────────────
150
151/// Context passed to a plugin's `handle` method.
152pub struct CommandContext {
153    /// The command name that was invoked (without `/`).
154    pub command: String,
155    /// Arguments passed after the command name.
156    pub params: Vec<String>,
157    /// Username of the invoker.
158    pub sender: String,
159    /// Room ID.
160    pub room_id: String,
161    /// Message ID that triggered this command.
162    pub message_id: String,
163    /// Timestamp of the triggering message.
164    pub timestamp: DateTime<Utc>,
165    /// Scoped handle for reading chat history.
166    pub history: Box<dyn HistoryAccess>,
167    /// Scoped handle for writing back to the chat.
168    pub writer: Box<dyn MessageWriter>,
169    /// Snapshot of room metadata.
170    pub metadata: RoomMetadata,
171    /// All registered commands (so `/help` can list them without
172    /// holding a reference to the registry).
173    pub available_commands: Vec<CommandInfo>,
174    /// Optional access to daemon-level team membership.
175    ///
176    /// `Some` in daemon mode (backed by `UserRegistry`), `None` in standalone
177    /// mode where teams are not available.
178    pub team_access: Option<Box<dyn TeamAccess>>,
179}
180
181// ── PluginResult ────────────────────────────────────────────────────────────
182
183/// What the broker should do after a plugin handles a command.
184pub enum PluginResult {
185    /// Send a private reply only to the invoker.
186    Reply(String),
187    /// Broadcast a message to the entire room.
188    Broadcast(String),
189    /// Command handled silently (side effects already done via [`MessageWriter`]).
190    Handled,
191}
192
193// ── MessageWriter trait ─────────────────────────────────────────────────────
194
195/// Async message dispatch for plugins. Abstracts over the broker's broadcast
196/// and persistence machinery so plugins never touch broker internals.
197///
198/// The broker provides a concrete implementation; external crates only see
199/// this trait.
200pub trait MessageWriter: Send + Sync {
201    /// Broadcast a system message to all connected clients and persist to history.
202    fn broadcast(&self, content: &str) -> BoxFuture<'_, anyhow::Result<()>>;
203
204    /// Send a private system message only to a specific user.
205    fn reply_to(&self, username: &str, content: &str) -> BoxFuture<'_, anyhow::Result<()>>;
206
207    /// Broadcast a typed event to all connected clients and persist to history.
208    fn emit_event(
209        &self,
210        event_type: EventType,
211        content: &str,
212        params: Option<serde_json::Value>,
213    ) -> BoxFuture<'_, anyhow::Result<()>>;
214}
215
216// ── HistoryAccess trait ─────────────────────────────────────────────────────
217
218/// Async read-only access to a room's chat history.
219///
220/// Respects DM visibility — a plugin invoked by user X will not see DMs
221/// between Y and Z.
222pub trait HistoryAccess: Send + Sync {
223    /// Load all messages (filtered by DM visibility).
224    fn all(&self) -> BoxFuture<'_, anyhow::Result<Vec<Message>>>;
225
226    /// Load the last `n` messages (filtered by DM visibility).
227    fn tail(&self, n: usize) -> BoxFuture<'_, anyhow::Result<Vec<Message>>>;
228
229    /// Load messages after the one with the given ID (filtered by DM visibility).
230    fn since(&self, message_id: &str) -> BoxFuture<'_, anyhow::Result<Vec<Message>>>;
231
232    /// Count total messages in the chat.
233    fn count(&self) -> BoxFuture<'_, anyhow::Result<usize>>;
234}
235
236// ── TeamAccess trait ────────────────────────────────────────────────────────
237
238/// Read-only access to daemon-level team membership.
239///
240/// Plugins use this trait to check whether a user belongs to a team without
241/// depending on `room-daemon` or `UserRegistry` directly. The broker provides
242/// a concrete implementation backed by the registry; standalone mode passes
243/// `None` (no team checking available).
244pub trait TeamAccess: Send + Sync {
245    /// Returns `true` if the named team exists in the registry.
246    fn team_exists(&self, team: &str) -> bool;
247
248    /// Returns `true` if `user` is a member of `team`.
249    fn is_member(&self, team: &str, user: &str) -> bool;
250}
251
252// ── RoomMetadata ────────────────────────────────────────────────────────────
253
254/// Frozen snapshot of room state for plugin consumption.
255pub struct RoomMetadata {
256    /// Users currently online with their status.
257    pub online_users: Vec<UserInfo>,
258    /// Username of the room host.
259    pub host: Option<String>,
260    /// Total messages in the chat file.
261    pub message_count: usize,
262}
263
264/// A user's online presence.
265pub struct UserInfo {
266    pub username: String,
267    pub status: String,
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn param_type_choice_equality() {
276        let a = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
277        let b = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
278        assert_eq!(a, b);
279        let c = ParamType::Choice(vec!["x".to_owned()]);
280        assert_ne!(a, c);
281    }
282
283    #[test]
284    fn param_type_number_equality() {
285        let a = ParamType::Number {
286            min: Some(1),
287            max: Some(100),
288        };
289        let b = ParamType::Number {
290            min: Some(1),
291            max: Some(100),
292        };
293        assert_eq!(a, b);
294        let c = ParamType::Number {
295            min: None,
296            max: None,
297        };
298        assert_ne!(a, c);
299    }
300
301    #[test]
302    fn param_type_variants_are_distinct() {
303        assert_ne!(ParamType::Text, ParamType::Username);
304        assert_ne!(
305            ParamType::Text,
306            ParamType::Number {
307                min: None,
308                max: None
309            }
310        );
311        assert_ne!(ParamType::Text, ParamType::Choice(vec!["a".to_owned()]));
312    }
313
314    // ── Versioning defaults ─────────────────────────────────────────────
315
316    struct DefaultsPlugin;
317
318    impl Plugin for DefaultsPlugin {
319        fn name(&self) -> &str {
320            "defaults"
321        }
322
323        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
324            Box::pin(async { Ok(PluginResult::Handled) })
325        }
326    }
327
328    #[test]
329    fn default_version_is_zero() {
330        assert_eq!(DefaultsPlugin.version(), "0.0.0");
331    }
332
333    #[test]
334    fn default_api_version_is_one() {
335        assert_eq!(DefaultsPlugin.api_version(), 1);
336    }
337
338    #[test]
339    fn default_min_protocol_is_zero() {
340        assert_eq!(DefaultsPlugin.min_protocol(), "0.0.0");
341    }
342
343    #[test]
344    fn plugin_api_version_const_is_one() {
345        assert_eq!(PLUGIN_API_VERSION, 1);
346    }
347
348    #[test]
349    fn protocol_version_const_matches_cargo() {
350        // PROTOCOL_VERSION is set at compile time via env!("CARGO_PKG_VERSION").
351        // It must be a non-empty semver string with at least major.minor.patch.
352        assert!(!PROTOCOL_VERSION.is_empty());
353        let parts: Vec<&str> = PROTOCOL_VERSION.split('.').collect();
354        assert!(
355            parts.len() >= 3,
356            "PROTOCOL_VERSION must be major.minor.patch, got: {PROTOCOL_VERSION}"
357        );
358        for part in &parts {
359            assert!(
360                part.parse::<u64>().is_ok(),
361                "each segment must be numeric, got: {part}"
362            );
363        }
364    }
365
366    struct VersionedPlugin;
367
368    impl Plugin for VersionedPlugin {
369        fn name(&self) -> &str {
370            "versioned"
371        }
372
373        fn version(&self) -> &str {
374            "2.5.1"
375        }
376
377        fn api_version(&self) -> u32 {
378            1
379        }
380
381        fn min_protocol(&self) -> &str {
382            "3.0.0"
383        }
384
385        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
386            Box::pin(async { Ok(PluginResult::Handled) })
387        }
388    }
389
390    #[test]
391    fn custom_version_methods_override_defaults() {
392        assert_eq!(VersionedPlugin.version(), "2.5.1");
393        assert_eq!(VersionedPlugin.api_version(), 1);
394        assert_eq!(VersionedPlugin.min_protocol(), "3.0.0");
395    }
396}