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}