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}