Skip to main content

room_cli/plugin/
mod.rs

1pub mod help;
2pub mod stats;
3
4use std::{
5    collections::HashMap,
6    future::Future,
7    path::{Path, PathBuf},
8    pin::Pin,
9    sync::{
10        atomic::{AtomicU64, Ordering},
11        Arc,
12    },
13};
14
15use chrono::{DateTime, Utc};
16
17use crate::{
18    broker::{
19        fanout::broadcast_and_persist,
20        state::{ClientMap, StatusMap},
21    },
22    history,
23    message::{make_system, Message},
24};
25
26/// Boxed future type used by [`Plugin::handle`] for dyn compatibility.
27pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
28
29// ── Plugin trait ────────────────────────────────────────────────────────────
30
31/// A plugin that handles one or more `/` commands.
32///
33/// Implement this trait and register it with [`PluginRegistry`] to add
34/// custom commands to a room broker. The broker dispatches matching
35/// `Message::Command` messages to the plugin's [`handle`](Plugin::handle)
36/// method.
37pub trait Plugin: Send + Sync {
38    /// Unique identifier for this plugin (e.g. `"stats"`, `"help"`).
39    fn name(&self) -> &str;
40
41    /// Commands this plugin handles. Each entry drives `/help` output
42    /// and TUI autocomplete.
43    fn commands(&self) -> Vec<CommandInfo>;
44
45    /// Handle an invocation of one of this plugin's commands.
46    ///
47    /// Returns a boxed future for dyn compatibility (required because the
48    /// registry stores `Box<dyn Plugin>`).
49    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>>;
50}
51
52// ── CommandInfo ─────────────────────────────────────────────────────────────
53
54/// Describes a single command for `/help` and autocomplete.
55#[derive(Debug, Clone)]
56pub struct CommandInfo {
57    /// Command name without the leading `/`.
58    pub name: String,
59    /// One-line description shown in `/help` and autocomplete.
60    pub description: String,
61    /// Usage string (e.g. `"/stats [last N]"`).
62    pub usage: String,
63    /// Static argument completions for autocomplete.
64    pub completions: Vec<Completion>,
65}
66
67/// A static autocomplete hint for a command argument.
68#[derive(Debug, Clone)]
69pub struct Completion {
70    /// Argument position (0-indexed).
71    pub position: usize,
72    /// Possible values. Empty means freeform.
73    pub values: Vec<String>,
74}
75
76// ── CommandContext ───────────────────────────────────────────────────────────
77
78/// Context passed to a plugin's `handle` method.
79pub struct CommandContext {
80    /// The command name that was invoked (without `/`).
81    pub command: String,
82    /// Arguments passed after the command name.
83    pub params: Vec<String>,
84    /// Username of the invoker.
85    pub sender: String,
86    /// Room ID.
87    pub room_id: String,
88    /// Message ID that triggered this command.
89    pub message_id: String,
90    /// Timestamp of the triggering message.
91    pub timestamp: DateTime<Utc>,
92    /// Scoped handle for reading chat history.
93    pub history: HistoryReader,
94    /// Scoped handle for writing back to the chat.
95    pub writer: ChatWriter,
96    /// Snapshot of room metadata.
97    pub metadata: RoomMetadata,
98    /// All registered commands (so `/help` can list them without
99    /// holding a reference to the registry).
100    pub available_commands: Vec<CommandInfo>,
101}
102
103// ── PluginResult ────────────────────────────────────────────────────────────
104
105/// What the broker should do after a plugin handles a command.
106pub enum PluginResult {
107    /// Send a private reply only to the invoker.
108    Reply(String),
109    /// Broadcast a message to the entire room.
110    Broadcast(String),
111    /// Command handled silently (side effects already done via [`ChatWriter`]).
112    Handled,
113}
114
115// ── HistoryReader ───────────────────────────────────────────────────────────
116
117/// Scoped read-only handle to a room's chat history.
118///
119/// Respects DM visibility — a plugin invoked by user X will not see DMs
120/// between Y and Z.
121pub struct HistoryReader {
122    chat_path: PathBuf,
123    viewer: String,
124}
125
126impl HistoryReader {
127    pub(crate) fn new(chat_path: &Path, viewer: &str) -> Self {
128        Self {
129            chat_path: chat_path.to_owned(),
130            viewer: viewer.to_owned(),
131        }
132    }
133
134    /// Load all messages (filtered by DM visibility).
135    pub async fn all(&self) -> anyhow::Result<Vec<Message>> {
136        let all = history::load(&self.chat_path).await?;
137        Ok(self.filter_dms(all))
138    }
139
140    /// Load the last `n` messages (filtered by DM visibility).
141    pub async fn tail(&self, n: usize) -> anyhow::Result<Vec<Message>> {
142        let all = history::tail(&self.chat_path, n).await?;
143        Ok(self.filter_dms(all))
144    }
145
146    /// Load messages after the one with the given ID (filtered by DM visibility).
147    pub async fn since(&self, message_id: &str) -> anyhow::Result<Vec<Message>> {
148        let all = history::load(&self.chat_path).await?;
149        let start = all
150            .iter()
151            .position(|m| m.id() == message_id)
152            .map(|i| i + 1)
153            .unwrap_or(0);
154        Ok(self.filter_dms(all[start..].to_vec()))
155    }
156
157    /// Count total messages in the chat.
158    pub async fn count(&self) -> anyhow::Result<usize> {
159        let all = history::load(&self.chat_path).await?;
160        Ok(all.len())
161    }
162
163    fn filter_dms(&self, messages: Vec<Message>) -> Vec<Message> {
164        messages
165            .into_iter()
166            .filter(|m| match m {
167                Message::DirectMessage { user, to, .. } => {
168                    user == &self.viewer || to == &self.viewer
169                }
170                _ => true,
171            })
172            .collect()
173    }
174}
175
176// ── ChatWriter ──────────────────────────────────────────────────────────────
177
178/// Short-lived scoped handle for a plugin to write messages to the chat.
179///
180/// Posts as `plugin:<name>` — plugins cannot impersonate users. The writer
181/// is valid only for the duration of [`Plugin::handle`].
182pub struct ChatWriter {
183    clients: ClientMap,
184    chat_path: Arc<PathBuf>,
185    room_id: Arc<String>,
186    seq_counter: Arc<AtomicU64>,
187    /// Identity the writer posts as (e.g. `"plugin:stats"`).
188    identity: String,
189}
190
191impl ChatWriter {
192    pub(crate) fn new(
193        clients: &ClientMap,
194        chat_path: &Arc<PathBuf>,
195        room_id: &Arc<String>,
196        seq_counter: &Arc<AtomicU64>,
197        plugin_name: &str,
198    ) -> Self {
199        Self {
200            clients: clients.clone(),
201            chat_path: chat_path.clone(),
202            room_id: room_id.clone(),
203            seq_counter: seq_counter.clone(),
204            identity: format!("plugin:{plugin_name}"),
205        }
206    }
207
208    /// Broadcast a system message to all connected clients and persist to history.
209    pub async fn broadcast(&self, content: &str) -> anyhow::Result<()> {
210        let msg = make_system(&self.room_id, &self.identity, content);
211        broadcast_and_persist(&msg, &self.clients, &self.chat_path, &self.seq_counter).await?;
212        Ok(())
213    }
214
215    /// Send a private system message only to a specific user.
216    pub async fn reply_to(&self, username: &str, content: &str) -> anyhow::Result<()> {
217        let msg = make_system(&self.room_id, &self.identity, content);
218        let seq = self.seq_counter.fetch_add(1, Ordering::SeqCst) + 1;
219        let mut msg = msg;
220        msg.set_seq(seq);
221        history::append(&self.chat_path, &msg).await?;
222
223        let line = format!("{}\n", serde_json::to_string(&msg)?);
224        let map = self.clients.lock().await;
225        for (uname, tx) in map.values() {
226            if uname == username {
227                let _ = tx.send(line.clone());
228            }
229        }
230        Ok(())
231    }
232}
233
234// ── RoomMetadata ────────────────────────────────────────────────────────────
235
236/// Frozen snapshot of room state for plugin consumption.
237pub struct RoomMetadata {
238    /// Users currently online with their status.
239    pub online_users: Vec<UserInfo>,
240    /// Username of the room host.
241    pub host: Option<String>,
242    /// Total messages in the chat file.
243    pub message_count: usize,
244}
245
246/// A user's online presence.
247pub struct UserInfo {
248    pub username: String,
249    pub status: String,
250}
251
252impl RoomMetadata {
253    pub(crate) async fn snapshot(
254        status_map: &StatusMap,
255        host_user: &Arc<tokio::sync::Mutex<Option<String>>>,
256        chat_path: &Path,
257    ) -> Self {
258        let map = status_map.lock().await;
259        let online_users: Vec<UserInfo> = map
260            .iter()
261            .map(|(u, s)| UserInfo {
262                username: u.clone(),
263                status: s.clone(),
264            })
265            .collect();
266        drop(map);
267
268        let host = host_user.lock().await.clone();
269
270        let message_count = history::load(chat_path)
271            .await
272            .map(|msgs| msgs.len())
273            .unwrap_or(0);
274
275        Self {
276            online_users,
277            host,
278            message_count,
279        }
280    }
281}
282
283// ── PluginRegistry ──────────────────────────────────────────────────────────
284
285/// Built-in command names that plugins may not override.
286const RESERVED_COMMANDS: &[&str] = &[
287    "set_status",
288    "who",
289    "kick",
290    "reauth",
291    "clear-tokens",
292    "exit",
293    "clear",
294];
295
296/// Central registry of plugins. The broker uses this to dispatch `/` commands.
297pub struct PluginRegistry {
298    plugins: Vec<Box<dyn Plugin>>,
299    /// command_name → index into `plugins`.
300    command_map: HashMap<String, usize>,
301}
302
303impl PluginRegistry {
304    pub fn new() -> Self {
305        Self {
306            plugins: Vec::new(),
307            command_map: HashMap::new(),
308        }
309    }
310
311    /// Register a plugin. Returns an error if any command name collides with
312    /// a built-in command or another plugin's command.
313    pub fn register(&mut self, plugin: Box<dyn Plugin>) -> anyhow::Result<()> {
314        let idx = self.plugins.len();
315        for cmd in plugin.commands() {
316            if RESERVED_COMMANDS.contains(&cmd.name.as_str()) {
317                anyhow::bail!(
318                    "plugin '{}' cannot register command '{}': reserved by built-in",
319                    plugin.name(),
320                    cmd.name
321                );
322            }
323            if let Some(&existing_idx) = self.command_map.get(&cmd.name) {
324                anyhow::bail!(
325                    "plugin '{}' cannot register command '{}': already registered by '{}'",
326                    plugin.name(),
327                    cmd.name,
328                    self.plugins[existing_idx].name()
329                );
330            }
331            self.command_map.insert(cmd.name.clone(), idx);
332        }
333        self.plugins.push(plugin);
334        Ok(())
335    }
336
337    /// Look up which plugin handles a command name.
338    pub fn resolve(&self, command: &str) -> Option<&dyn Plugin> {
339        self.command_map
340            .get(command)
341            .map(|&idx| self.plugins[idx].as_ref())
342    }
343
344    /// All registered commands across all plugins.
345    pub fn all_commands(&self) -> Vec<CommandInfo> {
346        self.plugins.iter().flat_map(|p| p.commands()).collect()
347    }
348
349    /// Static completions for a specific command at a given argument position.
350    pub fn completions_for(&self, command: &str, arg_pos: usize) -> Vec<String> {
351        self.all_commands()
352            .iter()
353            .find(|c| c.name == command)
354            .map(|c| {
355                c.completions
356                    .iter()
357                    .filter(|comp| comp.position == arg_pos)
358                    .flat_map(|comp| comp.values.clone())
359                    .collect()
360            })
361            .unwrap_or_default()
362    }
363}
364
365impl Default for PluginRegistry {
366    fn default() -> Self {
367        Self::new()
368    }
369}
370
371// ── Tests ───────────────────────────────────────────────────────────────────
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    struct DummyPlugin {
378        name: &'static str,
379        cmd: &'static str,
380    }
381
382    impl Plugin for DummyPlugin {
383        fn name(&self) -> &str {
384            self.name
385        }
386
387        fn commands(&self) -> Vec<CommandInfo> {
388            vec![CommandInfo {
389                name: self.cmd.to_owned(),
390                description: "dummy".to_owned(),
391                usage: format!("/{}", self.cmd),
392                completions: vec![],
393            }]
394        }
395
396        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
397            Box::pin(async { Ok(PluginResult::Reply("dummy".to_owned())) })
398        }
399    }
400
401    #[test]
402    fn registry_register_and_resolve() {
403        let mut reg = PluginRegistry::new();
404        reg.register(Box::new(DummyPlugin {
405            name: "test",
406            cmd: "foo",
407        }))
408        .unwrap();
409        assert!(reg.resolve("foo").is_some());
410        assert!(reg.resolve("bar").is_none());
411    }
412
413    #[test]
414    fn registry_rejects_reserved_command() {
415        let mut reg = PluginRegistry::new();
416        let result = reg.register(Box::new(DummyPlugin {
417            name: "bad",
418            cmd: "kick",
419        }));
420        assert!(result.is_err());
421        let err = result.unwrap_err().to_string();
422        assert!(err.contains("reserved by built-in"));
423    }
424
425    #[test]
426    fn registry_rejects_duplicate_command() {
427        let mut reg = PluginRegistry::new();
428        reg.register(Box::new(DummyPlugin {
429            name: "first",
430            cmd: "foo",
431        }))
432        .unwrap();
433        let result = reg.register(Box::new(DummyPlugin {
434            name: "second",
435            cmd: "foo",
436        }));
437        assert!(result.is_err());
438        let err = result.unwrap_err().to_string();
439        assert!(err.contains("already registered by 'first'"));
440    }
441
442    #[test]
443    fn registry_all_commands_lists_everything() {
444        let mut reg = PluginRegistry::new();
445        reg.register(Box::new(DummyPlugin {
446            name: "a",
447            cmd: "alpha",
448        }))
449        .unwrap();
450        reg.register(Box::new(DummyPlugin {
451            name: "b",
452            cmd: "beta",
453        }))
454        .unwrap();
455        let cmds = reg.all_commands();
456        let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
457        assert!(names.contains(&"alpha"));
458        assert!(names.contains(&"beta"));
459        assert_eq!(names.len(), 2);
460    }
461
462    #[test]
463    fn registry_completions_for_returns_values() {
464        let mut reg = PluginRegistry::new();
465        reg.register(Box::new({
466            struct CompPlugin;
467            impl Plugin for CompPlugin {
468                fn name(&self) -> &str {
469                    "comp"
470                }
471                fn commands(&self) -> Vec<CommandInfo> {
472                    vec![CommandInfo {
473                        name: "test".to_owned(),
474                        description: "test".to_owned(),
475                        usage: "/test".to_owned(),
476                        completions: vec![Completion {
477                            position: 0,
478                            values: vec!["10".to_owned(), "20".to_owned()],
479                        }],
480                    }]
481                }
482                fn handle(
483                    &self,
484                    _ctx: CommandContext,
485                ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
486                    Box::pin(async { Ok(PluginResult::Handled) })
487                }
488            }
489            CompPlugin
490        }))
491        .unwrap();
492        let completions = reg.completions_for("test", 0);
493        assert_eq!(completions, vec!["10", "20"]);
494        assert!(reg.completions_for("test", 1).is_empty());
495        assert!(reg.completions_for("nonexistent", 0).is_empty());
496    }
497
498    #[test]
499    fn registry_rejects_all_reserved_commands() {
500        for &reserved in RESERVED_COMMANDS {
501            let mut reg = PluginRegistry::new();
502            let result = reg.register(Box::new(DummyPlugin {
503                name: "bad",
504                cmd: reserved,
505            }));
506            assert!(
507                result.is_err(),
508                "should reject reserved command '{reserved}'"
509            );
510        }
511    }
512
513    #[tokio::test]
514    async fn history_reader_filters_dms() {
515        let tmp = tempfile::NamedTempFile::new().unwrap();
516        let path = tmp.path();
517
518        // Write a DM between alice and bob, and a public message
519        let dm = crate::message::make_dm("r", "alice", "bob", "secret");
520        let public = crate::message::make_message("r", "carol", "hello all");
521        history::append(path, &dm).await.unwrap();
522        history::append(path, &public).await.unwrap();
523
524        // alice sees both
525        let reader_alice = HistoryReader::new(path, "alice");
526        let msgs = reader_alice.all().await.unwrap();
527        assert_eq!(msgs.len(), 2);
528
529        // carol sees only the public message
530        let reader_carol = HistoryReader::new(path, "carol");
531        let msgs = reader_carol.all().await.unwrap();
532        assert_eq!(msgs.len(), 1);
533        assert_eq!(msgs[0].user(), "carol");
534    }
535
536    #[tokio::test]
537    async fn history_reader_tail_and_count() {
538        let tmp = tempfile::NamedTempFile::new().unwrap();
539        let path = tmp.path();
540
541        for i in 0..5 {
542            history::append(
543                path,
544                &crate::message::make_message("r", "u", format!("msg {i}")),
545            )
546            .await
547            .unwrap();
548        }
549
550        let reader = HistoryReader::new(path, "u");
551        assert_eq!(reader.count().await.unwrap(), 5);
552
553        let tail = reader.tail(3).await.unwrap();
554        assert_eq!(tail.len(), 3);
555    }
556
557    #[tokio::test]
558    async fn history_reader_since() {
559        let tmp = tempfile::NamedTempFile::new().unwrap();
560        let path = tmp.path();
561
562        let msg1 = crate::message::make_message("r", "u", "first");
563        let msg2 = crate::message::make_message("r", "u", "second");
564        let msg3 = crate::message::make_message("r", "u", "third");
565        let id1 = msg1.id().to_owned();
566        history::append(path, &msg1).await.unwrap();
567        history::append(path, &msg2).await.unwrap();
568        history::append(path, &msg3).await.unwrap();
569
570        let reader = HistoryReader::new(path, "u");
571        let since = reader.since(&id1).await.unwrap();
572        assert_eq!(since.len(), 2);
573    }
574}