From 98ecbca414945cf8ea004185ed56fa9ff04c79ee Mon Sep 17 00:00:00 2001 From: Jean Date: Wed, 15 Jul 2026 18:09:33 +0000 Subject: [PATCH] MemoServ: add CANCEL, CHECK, and INFO CANCEL recalls the sender's most recent unread memo to a target (one the recipient already read can't be recalled); CHECK reports whether the last memo you sent someone has been read; INFO summarises your mailbox. Adds memo_cancel/memo_check store methods and shares MAX_MEMOS from the module root instead of duplicating it in send.rs. --- api/src/lib.rs | 4 ++++ modules/memoserv/src/cancel.rs | 19 +++++++++++++++++++ modules/memoserv/src/check.rs | 18 ++++++++++++++++++ modules/memoserv/src/info.rs | 12 ++++++++++++ modules/memoserv/src/lib.rs | 15 +++++++++++++++ modules/memoserv/src/send.rs | 3 +-- src/engine/db/channel.rs | 22 ++++++++++++++++++++++ src/engine/db/store.rs | 6 ++++++ src/engine/db/tests.rs | 21 +++++++++++++++++++++ 9 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 modules/memoserv/src/cancel.rs create mode 100644 modules/memoserv/src/check.rs create mode 100644 modules/memoserv/src/info.rs diff --git a/api/src/lib.rs b/api/src/lib.rs index f1c9b00..f71dc98 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1020,6 +1020,10 @@ pub trait Store { fn memo_read(&mut self, account: &str, index: usize) -> Option; fn memo_del(&mut self, account: &str, index: usize) -> bool; fn unread_memos(&self, account: &str) -> usize; + /// Recall the most recent unread memo `sender` left for `account` (true if one was). + fn memo_cancel(&mut self, account: &str, sender: &str) -> bool; + /// Read-status and timestamp of the most recent memo `sender` left for `account`. + fn memo_check(&self, account: &str, sender: &str) -> Option<(bool, u64)>; fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError>; fn set_founder(&mut self, channel: &str, account: &str) -> Result<(), ChanError>; fn access_add(&mut self, channel: &str, account: &str, level: &str) -> Result<(), ChanError>; diff --git a/modules/memoserv/src/cancel.rs b/modules/memoserv/src/cancel.rs new file mode 100644 index 0000000..c827145 --- /dev/null +++ b/modules/memoserv/src/cancel.rs @@ -0,0 +1,19 @@ +use echo_api::{Sender, ServiceCtx, Store}; + +// CANCEL : recall the last unread memo you sent to . A memo the +// recipient has already read can't be recalled. +pub fn handle(me: &str, from: &Sender, account: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(&target) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: CANCEL "); + return; + }; + let Some(dest) = db.resolve_account(target).map(str::to_string) else { + ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered.")); + return; + }; + if db.memo_cancel(&dest, account) { + ctx.notice(me, from.uid, format!("Your last unread memo to \x02{target}\x02 has been cancelled.")); + } else { + ctx.notice(me, from.uid, format!("You have no unread memo to \x02{target}\x02 to cancel.")); + } +} diff --git a/modules/memoserv/src/check.rs b/modules/memoserv/src/check.rs new file mode 100644 index 0000000..3ca139e --- /dev/null +++ b/modules/memoserv/src/check.rs @@ -0,0 +1,18 @@ +use echo_api::{human_time, Sender, ServiceCtx, Store}; + +// CHECK : has the last memo you sent to been read yet? +pub fn handle(me: &str, from: &Sender, account: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(&target) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: CHECK "); + return; + }; + let Some(dest) = db.resolve_account(target).map(str::to_string) else { + ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered.")); + return; + }; + match db.memo_check(&dest, account) { + Some((true, ts)) => ctx.notice(me, from.uid, format!("Your last memo to \x02{target}\x02 (sent {}) has been \x02read\x02.", human_time(ts))), + Some((false, ts)) => ctx.notice(me, from.uid, format!("Your last memo to \x02{target}\x02 (sent {}) has \x02not\x02 been read yet.", human_time(ts))), + None => ctx.notice(me, from.uid, format!("You have no memo on record to \x02{target}\x02.")), + } +} diff --git a/modules/memoserv/src/info.rs b/modules/memoserv/src/info.rs new file mode 100644 index 0000000..4e333db --- /dev/null +++ b/modules/memoserv/src/info.rs @@ -0,0 +1,12 @@ +use echo_api::{Sender, ServiceCtx, Store}; + +// INFO: a summary of your mailbox (total, unread, capacity). +pub fn handle(me: &str, from: &Sender, account: &str, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let total = db.memo_list(account).len(); + let unread = db.unread_memos(account); + ctx.notice( + me, + from.uid, + format!("You have \x02{total}\x02 memo(s), \x02{unread}\x02 unread, of a maximum \x02{}\x02.", super::MAX_MEMOS), + ); +} diff --git a/modules/memoserv/src/lib.rs b/modules/memoserv/src/lib.rs index 9790f0d..7820449 100644 --- a/modules/memoserv/src/lib.rs +++ b/modules/memoserv/src/lib.rs @@ -14,14 +14,26 @@ mod list; mod read; #[path = "del.rs"] mod del; +#[path = "cancel.rs"] +mod cancel; +#[path = "check.rs"] +mod check; +#[path = "info.rs"] +mod info; const BLURB: &str = "MemoServ delivers messages to registered users, online or not. You must be identified to use it."; +// A full mailbox rejects new memos, so nobody can be flooded. +pub(crate) const MAX_MEMOS: usize = 30; + const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "SEND", summary: "leave a memo for an account", detail: "Syntax: \x02SEND \x02\nLeaves a memo on a registered account's mailbox." }, HelpEntry { cmd: "LIST", summary: "list your memos", detail: "Syntax: \x02LIST\x02\nLists the memos in your mailbox." }, HelpEntry { cmd: "READ", summary: "read a memo", detail: "Syntax: \x02READ |NEW|ALL\x02\nReads a memo by number, your unread ones, or all of them." }, HelpEntry { cmd: "DEL", summary: "delete a memo", detail: "Syntax: \x02DEL |ALL\x02\nDeletes a memo by number, or your whole mailbox." }, + HelpEntry { cmd: "CANCEL", summary: "recall a memo you sent", detail: "Syntax: \x02CANCEL \x02\nRecalls the last memo you sent them, as long as they haven't read it yet." }, + HelpEntry { cmd: "CHECK", summary: "see if a memo was read", detail: "Syntax: \x02CHECK \x02\nReports whether the last memo you sent them has been read." }, + HelpEntry { cmd: "INFO", summary: "your mailbox summary", detail: "Syntax: \x02INFO\x02\nShows how many memos you have, how many are unread, and your capacity." }, ]; pub struct MemoServ { @@ -60,6 +72,9 @@ impl Service for MemoServ { Some("LIST") => list::handle(me, from, account, ctx, db), Some("READ") => read::handle(me, from, account, args, ctx, db), Some("DEL") | Some("DELETE") => del::handle(me, from, account, args, ctx, db), + Some("CANCEL") => cancel::handle(me, from, account, args, ctx, db), + Some("CHECK") => check::handle(me, from, account, args, ctx, db), + Some("INFO") => info::handle(me, from, account, ctx, db), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), None => {} } diff --git a/modules/memoserv/src/send.rs b/modules/memoserv/src/send.rs index a90a7be..d7e0c01 100644 --- a/modules/memoserv/src/send.rs +++ b/modules/memoserv/src/send.rs @@ -1,7 +1,6 @@ use echo_api::{Sender, ServiceCtx, Store}; -// A full mailbox rejects new memos, so nobody can be flooded. -const MAX_MEMOS: usize = 30; +use super::MAX_MEMOS; // SEND : leave a memo on a registered account's mailbox. pub fn handle(me: &str, from: &Sender, account: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { diff --git a/src/engine/db/channel.rs b/src/engine/db/channel.rs index 581ef05..f17c789 100644 --- a/src/engine/db/channel.rs +++ b/src/engine/db/channel.rs @@ -591,6 +591,28 @@ impl Db { true } + /// Recall the most recent UNREAD memo `sender` left for `account`. Returns + /// whether one was cancelled — a memo the recipient already read can't be recalled. + pub fn memo_cancel(&mut self, account: &str, sender: &str) -> bool { + let sender_lc = sender.to_ascii_lowercase(); + let idx = self.accounts.get(&key(account)).and_then(|a| { + a.memos.iter().enumerate().rev().find(|(_, m)| !m.read && m.from.to_ascii_lowercase() == sender_lc).map(|(i, _)| i) + }); + match idx { + Some(i) => self.memo_del(account, i), + None => false, + } + } + + /// The read-status and timestamp of the most recent memo `sender` left for + /// `account`, or None if they have none in that mailbox. + pub fn memo_check(&self, account: &str, sender: &str) -> Option<(bool, u64)> { + let sender_lc = sender.to_ascii_lowercase(); + self.accounts.get(&key(account)).and_then(|a| { + a.memos.iter().rev().find(|m| m.from.to_ascii_lowercase() == sender_lc).map(|m| (m.read, m.ts)) + }) + } + /// How many unread memos an account has. pub fn unread_memos(&self, account: &str) -> usize { self.accounts.get(&key(account)).map_or(0, |a| a.memos.iter().filter(|m| !m.read).count()) diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index 1f4f34a..43e161c 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -429,6 +429,12 @@ impl Store for Db { fn memo_del(&mut self, account: &str, index: usize) -> bool { Db::memo_del(self, account, index) } + fn memo_cancel(&mut self, account: &str, sender: &str) -> bool { + Db::memo_cancel(self, account, sender) + } + fn memo_check(&self, account: &str, sender: &str) -> Option<(bool, u64)> { + Db::memo_check(self, account, sender) + } fn unread_memos(&self, account: &str) -> usize { Db::unread_memos(self, account) } diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index 7dd0821..4572765 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -234,6 +234,27 @@ assert_eq!(db.unread_memos("alice"), 1); } + // CANCEL recalls the sender's most recent unread memo; CHECK reports read-state. + #[test] + fn memo_cancel_and_check() { + let mut db = Db::open(tmp("memo-cc"), "N1"); + db.scram_iterations = 4096; + db.register("alice", "pw", None).unwrap(); + db.memo_send("alice", "bob", "hi").unwrap(); + db.memo_send("alice", "bob", "you there?").unwrap(); + + assert_eq!(db.memo_check("alice", "bob").map(|(read, _)| read), Some(false), "unread so far"); + assert!(db.memo_cancel("alice", "bob"), "recalls the most recent unread"); + assert_eq!(db.memo_list("alice").len(), 1, "one memo cancelled"); + + db.memo_read("alice", 0); // alice reads the remaining memo + assert!(!db.memo_cancel("alice", "bob"), "a read memo can't be recalled"); + assert_eq!(db.memo_check("alice", "bob").map(|(read, _)| read), Some(true), "now shows read"); + + assert_eq!(db.memo_check("alice", "carol"), None, "no memo from carol"); + assert!(!db.memo_cancel("alice", "carol")); + } + // A wrong code is tolerated a few times, then the code is burned so it can't // be ground down online even though it is short. #[test]