MemoServ: add CANCEL, CHECK, and INFO
All checks were successful
CI / check (push) Successful in 3m49s
All checks were successful
CI / check (push) Successful in 3m49s
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.
This commit is contained in:
parent
a26ee722d1
commit
98ecbca414
9 changed files with 118 additions and 2 deletions
|
|
@ -1020,6 +1020,10 @@ pub trait Store {
|
||||||
fn memo_read(&mut self, account: &str, index: usize) -> Option<MemoView>;
|
fn memo_read(&mut self, account: &str, index: usize) -> Option<MemoView>;
|
||||||
fn memo_del(&mut self, account: &str, index: usize) -> bool;
|
fn memo_del(&mut self, account: &str, index: usize) -> bool;
|
||||||
fn unread_memos(&self, account: &str) -> usize;
|
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_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError>;
|
||||||
fn set_founder(&mut self, channel: &str, account: &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>;
|
fn access_add(&mut self, channel: &str, account: &str, level: &str) -> Result<(), ChanError>;
|
||||||
|
|
|
||||||
19
modules/memoserv/src/cancel.rs
Normal file
19
modules/memoserv/src/cancel.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
use echo_api::{Sender, ServiceCtx, Store};
|
||||||
|
|
||||||
|
// CANCEL <nick>: recall the last unread memo you sent to <nick>. 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 <nick>");
|
||||||
|
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."));
|
||||||
|
}
|
||||||
|
}
|
||||||
18
modules/memoserv/src/check.rs
Normal file
18
modules/memoserv/src/check.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
use echo_api::{human_time, Sender, ServiceCtx, Store};
|
||||||
|
|
||||||
|
// CHECK <nick>: has the last memo you sent to <nick> 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 <nick>");
|
||||||
|
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.")),
|
||||||
|
}
|
||||||
|
}
|
||||||
12
modules/memoserv/src/info.rs
Normal file
12
modules/memoserv/src/info.rs
Normal file
|
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,14 +14,26 @@ mod list;
|
||||||
mod read;
|
mod read;
|
||||||
#[path = "del.rs"]
|
#[path = "del.rs"]
|
||||||
mod del;
|
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.";
|
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] = &[
|
const TOPICS: &[HelpEntry] = &[
|
||||||
HelpEntry { cmd: "SEND", summary: "leave a memo for an account", detail: "Syntax: \x02SEND <nick> <text>\x02\nLeaves a memo on a registered account's mailbox." },
|
HelpEntry { cmd: "SEND", summary: "leave a memo for an account", detail: "Syntax: \x02SEND <nick> <text>\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: "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 <num>|NEW|ALL\x02\nReads a memo by number, your unread ones, or all of them." },
|
HelpEntry { cmd: "READ", summary: "read a memo", detail: "Syntax: \x02READ <num>|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 <num>|ALL\x02\nDeletes a memo by number, or your whole mailbox." },
|
HelpEntry { cmd: "DEL", summary: "delete a memo", detail: "Syntax: \x02DEL <num>|ALL\x02\nDeletes a memo by number, or your whole mailbox." },
|
||||||
|
HelpEntry { cmd: "CANCEL", summary: "recall a memo you sent", detail: "Syntax: \x02CANCEL <nick>\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 <nick>\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 {
|
pub struct MemoServ {
|
||||||
|
|
@ -60,6 +72,9 @@ impl Service for MemoServ {
|
||||||
Some("LIST") => list::handle(me, from, account, ctx, db),
|
Some("LIST") => list::handle(me, from, account, ctx, db),
|
||||||
Some("READ") => read::handle(me, from, account, args, 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("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.")),
|
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
use echo_api::{Sender, ServiceCtx, Store};
|
use echo_api::{Sender, ServiceCtx, Store};
|
||||||
|
|
||||||
// A full mailbox rejects new memos, so nobody can be flooded.
|
use super::MAX_MEMOS;
|
||||||
const MAX_MEMOS: usize = 30;
|
|
||||||
|
|
||||||
// SEND <nick> <text>: leave a memo on a registered account's mailbox.
|
// SEND <nick> <text>: 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) {
|
pub fn handle(me: &str, from: &Sender, account: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
|
||||||
|
|
|
||||||
|
|
@ -591,6 +591,28 @@ impl Db {
|
||||||
true
|
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.
|
/// How many unread memos an account has.
|
||||||
pub fn unread_memos(&self, account: &str) -> usize {
|
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())
|
self.accounts.get(&key(account)).map_or(0, |a| a.memos.iter().filter(|m| !m.read).count())
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,12 @@ impl Store for Db {
|
||||||
fn memo_del(&mut self, account: &str, index: usize) -> bool {
|
fn memo_del(&mut self, account: &str, index: usize) -> bool {
|
||||||
Db::memo_del(self, account, index)
|
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 {
|
fn unread_memos(&self, account: &str) -> usize {
|
||||||
Db::unread_memos(self, account)
|
Db::unread_memos(self, account)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,27 @@
|
||||||
assert_eq!(db.unread_memos("alice"), 1);
|
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
|
// 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.
|
// be ground down online even though it is short.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue