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    /// Second element is optional machine-readable data for programmatic consumers.
187    Reply(String, Option<serde_json::Value>),
188    /// Broadcast a message to the entire room.
189    /// Second element is optional machine-readable data for programmatic consumers.
190    Broadcast(String, Option<serde_json::Value>),
191    /// Command handled silently (side effects already done via [`MessageWriter`]).
192    Handled,
193}
194
195// ── MessageWriter trait ─────────────────────────────────────────────────────
196
197/// Async message dispatch for plugins. Abstracts over the broker's broadcast
198/// and persistence machinery so plugins never touch broker internals.
199///
200/// The broker provides a concrete implementation; external crates only see
201/// this trait.
202pub trait MessageWriter: Send + Sync {
203    /// Broadcast a system message to all connected clients and persist to history.
204    fn broadcast(&self, content: &str) -> BoxFuture<'_, anyhow::Result<()>>;
205
206    /// Send a private system message only to a specific user.
207    fn reply_to(&self, username: &str, content: &str) -> BoxFuture<'_, anyhow::Result<()>>;
208
209    /// Broadcast a typed event to all connected clients and persist to history.
210    fn emit_event(
211        &self,
212        event_type: EventType,
213        content: &str,
214        params: Option<serde_json::Value>,
215    ) -> BoxFuture<'_, anyhow::Result<()>>;
216}
217
218// ── HistoryAccess trait ─────────────────────────────────────────────────────
219
220/// Async read-only access to a room's chat history.
221///
222/// Respects DM visibility — a plugin invoked by user X will not see DMs
223/// between Y and Z.
224pub trait HistoryAccess: Send + Sync {
225    /// Load all messages (filtered by DM visibility).
226    fn all(&self) -> BoxFuture<'_, anyhow::Result<Vec<Message>>>;
227
228    /// Load the last `n` messages (filtered by DM visibility).
229    fn tail(&self, n: usize) -> BoxFuture<'_, anyhow::Result<Vec<Message>>>;
230
231    /// Load messages after the one with the given ID (filtered by DM visibility).
232    fn since(&self, message_id: &str) -> BoxFuture<'_, anyhow::Result<Vec<Message>>>;
233
234    /// Count total messages in the chat.
235    fn count(&self) -> BoxFuture<'_, anyhow::Result<usize>>;
236}
237
238// ── TeamAccess trait ────────────────────────────────────────────────────────
239
240/// Read-only access to daemon-level team membership.
241///
242/// Plugins use this trait to check whether a user belongs to a team without
243/// depending on `room-daemon` or `UserRegistry` directly. The broker provides
244/// a concrete implementation backed by the registry; standalone mode passes
245/// `None` (no team checking available).
246pub trait TeamAccess: Send + Sync {
247    /// Returns `true` if the named team exists in the registry.
248    fn team_exists(&self, team: &str) -> bool;
249
250    /// Returns `true` if `user` is a member of `team`.
251    fn is_member(&self, team: &str, user: &str) -> bool;
252}
253
254// ── RoomMetadata ────────────────────────────────────────────────────────────
255
256/// Frozen snapshot of room state for plugin consumption.
257pub struct RoomMetadata {
258    /// Users currently online with their status.
259    pub online_users: Vec<UserInfo>,
260    /// Username of the room host.
261    pub host: Option<String>,
262    /// Total messages in the chat file.
263    pub message_count: usize,
264}
265
266/// A user's online presence.
267pub struct UserInfo {
268    pub username: String,
269    pub status: String,
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn param_type_choice_equality() {
278        let a = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
279        let b = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
280        assert_eq!(a, b);
281        let c = ParamType::Choice(vec!["x".to_owned()]);
282        assert_ne!(a, c);
283    }
284
285    #[test]
286    fn param_type_number_equality() {
287        let a = ParamType::Number {
288            min: Some(1),
289            max: Some(100),
290        };
291        let b = ParamType::Number {
292            min: Some(1),
293            max: Some(100),
294        };
295        assert_eq!(a, b);
296        let c = ParamType::Number {
297            min: None,
298            max: None,
299        };
300        assert_ne!(a, c);
301    }
302
303    #[test]
304    fn param_type_variants_are_distinct() {
305        assert_ne!(ParamType::Text, ParamType::Username);
306        assert_ne!(
307            ParamType::Text,
308            ParamType::Number {
309                min: None,
310                max: None
311            }
312        );
313        assert_ne!(ParamType::Text, ParamType::Choice(vec!["a".to_owned()]));
314    }
315
316    // ── Versioning defaults ─────────────────────────────────────────────
317
318    struct DefaultsPlugin;
319
320    impl Plugin for DefaultsPlugin {
321        fn name(&self) -> &str {
322            "defaults"
323        }
324
325        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
326            Box::pin(async { Ok(PluginResult::Handled) })
327        }
328    }
329
330    #[test]
331    fn default_version_is_zero() {
332        assert_eq!(DefaultsPlugin.version(), "0.0.0");
333    }
334
335    #[test]
336    fn default_api_version_is_one() {
337        assert_eq!(DefaultsPlugin.api_version(), 1);
338    }
339
340    #[test]
341    fn default_min_protocol_is_zero() {
342        assert_eq!(DefaultsPlugin.min_protocol(), "0.0.0");
343    }
344
345    #[test]
346    fn plugin_api_version_const_is_one() {
347        assert_eq!(PLUGIN_API_VERSION, 1);
348    }
349
350    #[test]
351    fn protocol_version_const_matches_cargo() {
352        // PROTOCOL_VERSION is set at compile time via env!("CARGO_PKG_VERSION").
353        // It must be a non-empty semver string with at least major.minor.patch.
354        assert!(!PROTOCOL_VERSION.is_empty());
355        let parts: Vec<&str> = PROTOCOL_VERSION.split('.').collect();
356        assert!(
357            parts.len() >= 3,
358            "PROTOCOL_VERSION must be major.minor.patch, got: {PROTOCOL_VERSION}"
359        );
360        for part in &parts {
361            assert!(
362                part.parse::<u64>().is_ok(),
363                "each segment must be numeric, got: {part}"
364            );
365        }
366    }
367
368    struct VersionedPlugin;
369
370    impl Plugin for VersionedPlugin {
371        fn name(&self) -> &str {
372            "versioned"
373        }
374
375        fn version(&self) -> &str {
376            "2.5.1"
377        }
378
379        fn api_version(&self) -> u32 {
380            1
381        }
382
383        fn min_protocol(&self) -> &str {
384            "3.0.0"
385        }
386
387        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
388            Box::pin(async { Ok(PluginResult::Handled) })
389        }
390    }
391
392    #[test]
393    fn custom_version_methods_override_defaults() {
394        assert_eq!(VersionedPlugin.version(), "2.5.1");
395        assert_eq!(VersionedPlugin.api_version(), 1);
396        assert_eq!(VersionedPlugin.min_protocol(), "3.0.0");
397    }
398}