From 577c05ad2e2da41b58073470075569a3d632ad0b Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 04:50:27 +0000 Subject: [PATCH] memoserv: complete + improve the memo service Finish MemoServ on the typed, event-logged memo model: Db memo_send/list/read/ del/unread_memos + Store facade, and a new fedserv-memoserv crate (a default service). Beyond the basics: SEND caps a mailbox (30) so nobody's flooded, READ takes |NEW|ALL, DEL takes |ALL, LIST previews and flags unread, and NickServ notifies you of waiting memos on login. Memos persist and federate (Global scope) like other account data. Also lands NetAction::QuitUser (BotServ plumbing). Data + delivery/login-notify tests. --- Cargo.lock | 8 ++ Cargo.toml | 3 +- api/src/lib.rs | 17 +++++ inspircd/src/lib.rs | 1 + memoserv/Cargo.toml | 8 ++ memoserv/src/lib.rs | 157 +++++++++++++++++++++++++++++++++++++++ nickserv/src/identify.rs | 5 ++ src/config.rs | 2 +- src/engine/db.rs | 134 ++++++++++++++++++++++++++++++++- src/engine/mod.rs | 35 ++++++++- src/grpc.rs | 4 + src/main.rs | 6 ++ 12 files changed, 374 insertions(+), 6 deletions(-) create mode 100644 memoserv/Cargo.toml create mode 100644 memoserv/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 370833e..b1acbbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,6 +289,7 @@ dependencies = [ "fedserv-chanserv", "fedserv-example", "fedserv-inspircd", + "fedserv-memoserv", "fedserv-nickserv", "hmac", "pbkdf2", @@ -340,6 +341,13 @@ dependencies = [ "fedserv-api", ] +[[package]] +name = "fedserv-memoserv" +version = "0.0.1" +dependencies = [ + "fedserv-api", +] + [[package]] name = "fedserv-nickserv" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index fa4b001..ae697c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv"] +members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv"] [package] name = "fedserv" @@ -14,6 +14,7 @@ fedserv-chanserv = { path = "chanserv" } fedserv-nickserv = { path = "nickserv" } fedserv-example = { path = "example" } fedserv-botserv = { path = "botserv" } +fedserv-memoserv = { path = "memoserv" } tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/api/src/lib.rs b/api/src/lib.rs index 6b6ac62..b4a633c 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -65,6 +65,8 @@ pub enum NetAction { // Force a user into a channel (SVSJOIN), e.g. applying an account's auto-join // list on identify. `key` is empty for keyless channels. ForceJoin { uid: String, channel: String, key: String }, + // Remove one of our pseudo-clients (e.g. a deleted bot) from the network. + QuitUser { uid: String, reason: String }, // Set channel modes from services, e.g. +r on a registered channel. `from` is // the pseudoclient uid to source it from (empty = the services server). The // protocol stamps a timestamp the ircd will accept. @@ -308,6 +310,15 @@ pub struct ChanAkickView { pub reason: String, } +// A memo left for an account (MemoServ). +#[derive(Debug, Clone)] +pub struct MemoView { + pub from: String, + pub text: String, + pub ts: u64, + pub read: bool, +} + // A service bot registered with BotServ. #[derive(Debug, Clone)] pub struct BotView { @@ -509,6 +520,12 @@ pub trait Store { fn bot_add(&mut self, nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError>; fn bot_del(&mut self, nick: &str) -> Result; fn bots(&self) -> Vec; + // MemoServ: per-account memos (index is a 0-based position in memo_list). + fn memo_send(&mut self, account: &str, from: &str, text: &str) -> Result<(), RegError>; + fn memo_list(&self, account: &str) -> Vec; + 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; 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/inspircd/src/lib.rs b/inspircd/src/lib.rs index 0cdbe34..54e4999 100644 --- a/inspircd/src/lib.rs +++ b/inspircd/src/lib.rs @@ -261,6 +261,7 @@ impl Protocol for InspIrcd { } // SVSNICK — the new nick takes the current // time as its TS so it wins any collision resolution. + NetAction::QuitUser { uid, reason } => vec![format!(":{} QUIT :{}", uid, reason)], NetAction::ForceNick { uid, nick } => { let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts); vec![self.from_us(format!("SVSNICK {} {} {}", uid, nick, now))] diff --git a/memoserv/Cargo.toml b/memoserv/Cargo.toml new file mode 100644 index 0000000..77fe720 --- /dev/null +++ b/memoserv/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "fedserv-memoserv" +version = "0.0.1" +edition = "2021" +description = "MemoServ: deliver messages to registered users, online or not." + +[dependencies] +fedserv-api = { path = "../api" } diff --git a/memoserv/src/lib.rs b/memoserv/src/lib.rs new file mode 100644 index 0000000..ff8b49b --- /dev/null +++ b/memoserv/src/lib.rs @@ -0,0 +1,157 @@ +//! MemoServ delivers short messages ("memos") to registered accounts whether or +//! not they are online — they read them next time they identify. Memos are typed +//! and event-logged on the account, so they persist and federate like any other +//! account data (no Anope-style flat-file serialization). + +use fedserv_api::{human_time, NetView, Sender, ServiceCtx, Store}; + +// A full mailbox rejects new memos, so nobody can be flooded. +const MAX_MEMOS: usize = 30; + +pub struct MemoServ { + pub uid: String, +} + +impl fedserv_api::Service for MemoServ { + fn nick(&self) -> &str { + "MemoServ" + } + fn uid(&self) -> &str { + &self.uid + } + fn gecos(&self) -> &str { + "Memo Services" + } + + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, db: &mut dyn Store) { + let me = self.uid.as_str(); + let cmd = args.first().map(|s| s.to_ascii_uppercase()); + if matches!(cmd.as_deref(), Some("HELP") | None) { + ctx.notice(me, from.uid, "MemoServ delivers messages to registered users, online or not. Commands: \x02SEND\x02 , \x02LIST\x02, \x02READ\x02 |NEW|ALL, \x02DEL\x02 |ALL."); + return; + } + // Everything else needs you to be identified (memos are per-account). + let Some(account) = from.account else { + ctx.notice(me, from.uid, "You must identify to NickServ before using MemoServ."); + return; + }; + match cmd.as_deref() { + Some("SEND") => send(me, from, account, args, ctx, db), + Some("LIST") => list(me, from, account, ctx, db), + Some("READ") => read(me, from, account, args, ctx, db), + Some("DEL") | Some("DELETE") => del(me, from, account, args, ctx, db), + Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), + None => {} + } + } +} + +fn send(me: &str, from: &Sender, account: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + if args.len() < 3 { + ctx.notice(me, from.uid, "Syntax: SEND "); + return; + } + let target = args[1]; + let text = args[2..].join(" "); + 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_list(&dest).len() >= MAX_MEMOS { + ctx.notice(me, from.uid, format!("\x02{target}\x02's mailbox is full — they'll need to clear some memos first.")); + return; + } + match db.memo_send(&dest, account, &text) { + Ok(()) => ctx.notice(me, from.uid, format!("Memo sent to \x02{target}\x02.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } +} + +fn list(me: &str, from: &Sender, account: &str, ctx: &mut ServiceCtx, db: &dyn Store) { + let memos = db.memo_list(account); + if memos.is_empty() { + ctx.notice(me, from.uid, "You have no memos."); + return; + } + let unread = memos.iter().filter(|m| !m.read).count(); + ctx.notice(me, from.uid, format!("Your memos ({} total, {unread} new). \x02*\x02 marks unread:", memos.len())); + for (i, m) in memos.iter().enumerate() { + let flag = if m.read { ' ' } else { '*' }; + ctx.notice(me, from.uid, format!(" {}{flag} from \x02{}\x02 ({}): {}", i + 1, m.from, human_time(m.ts), preview(&m.text))); + } +} + +fn read(me: &str, from: &Sender, account: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { + Some("NEW") | Some("ALL") => { + let only_new = args[1].eq_ignore_ascii_case("new"); + let targets: Vec = db + .memo_list(account) + .iter() + .enumerate() + .filter(|(_, m)| !only_new || !m.read) + .map(|(i, _)| i) + .collect(); + if targets.is_empty() { + ctx.notice(me, from.uid, if only_new { "You have no new memos." } else { "You have no memos." }); + return; + } + for i in targets { + if let Some(m) = db.memo_read(account, i) { + show(me, from, i, &m, ctx); + } + } + } + Some(numstr) => { + let Some(n) = numstr.parse::().ok().filter(|n| *n >= 1) else { + ctx.notice(me, from.uid, "Syntax: READ |NEW|ALL"); + return; + }; + match db.memo_read(account, n - 1) { + Some(m) => show(me, from, n - 1, &m, ctx), + None => ctx.notice(me, from.uid, format!("You have no memo #\x02{n}\x02.")), + } + } + None => ctx.notice(me, from.uid, "Syntax: READ |NEW|ALL"), + } +} + +fn del(me: &str, from: &Sender, account: &str, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() { + Some("ALL") => { + let count = db.memo_list(account).len(); + // Delete from the end so earlier indices stay valid as we go. + for i in (0..count).rev() { + db.memo_del(account, i); + } + ctx.notice(me, from.uid, format!("Deleted all \x02{count}\x02 memo(s).")); + } + Some(numstr) => { + let Some(n) = numstr.parse::().ok().filter(|n| *n >= 1) else { + ctx.notice(me, from.uid, "Syntax: DEL |ALL"); + return; + }; + if db.memo_del(account, n - 1) { + ctx.notice(me, from.uid, format!("Memo #\x02{n}\x02 deleted.")); + } else { + ctx.notice(me, from.uid, format!("You have no memo #\x02{n}\x02.")); + } + } + None => ctx.notice(me, from.uid, "Syntax: DEL |ALL"), + } +} + +fn show(me: &str, from: &Sender, index: usize, m: &fedserv_api::MemoView, ctx: &mut ServiceCtx) { + ctx.notice(me, from.uid, format!("Memo #\x02{}\x02 from \x02{}\x02 ({}):", index + 1, m.from, human_time(m.ts))); + ctx.notice(me, from.uid, format!(" {}", m.text)); +} + +// First line / first 80 chars, for LIST. +fn preview(text: &str) -> String { + let line = text.lines().next().unwrap_or(""); + if line.chars().count() > 80 { + format!("{}…", line.chars().take(80).collect::()) + } else { + line.to_string() + } +} diff --git a/nickserv/src/identify.rs b/nickserv/src/identify.rs index 77e092e..ffbb0d6 100644 --- a/nickserv/src/identify.rs +++ b/nickserv/src/identify.rs @@ -58,6 +58,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: for entry in db.ajoin_list(&account) { ctx.force_join(from.uid, &entry.channel, &entry.key); } + // Let them know about waiting memos. + let unread = db.unread_memos(&account); + if unread > 0 { + ctx.notice(me, from.uid, format!("You have \x02{unread}\x02 new memo(s). Read them with \x02/msg MemoServ READ NEW\x02.")); + } } None => { db.note_auth(account_name, false); diff --git a/src/config.rs b/src/config.rs index 69804c2..7cee6a4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -69,7 +69,7 @@ impl Default for Modules { } fn default_services() -> Vec { - vec!["nickserv".to_string(), "chanserv".to_string(), "botserv".to_string()] + vec!["nickserv".to_string(), "chanserv".to_string(), "botserv".to_string(), "memoserv".to_string()] } #[derive(Debug, Deserialize, Clone)] diff --git a/src/engine/db.rs b/src/engine/db.rs index 34054b0..6cbb108 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -15,7 +15,7 @@ use super::scram::{self, Hash}; // fedserv-api SDK crate; re-exported so the engine keeps naming them locally and // modules importing `crate::engine::db::{ChanError, ...}` are unaffected. pub use fedserv_api::{ - AccountView, AjoinView, BotView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, RegError, Store, + AccountView, AjoinView, BotView, MemoView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, RegError, Store, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -52,6 +52,9 @@ pub struct Account { // Services suspension, if any (login blocked while set and unexpired). #[serde(default)] pub suspension: Option, + // Memos left for this account (MemoServ), oldest first. + #[serde(default)] + pub memos: Vec, } fn verified_default() -> bool { @@ -75,6 +78,9 @@ pub enum Event { AjoinRemoved { account: String, channel: String }, AccountSuspended { account: String, by: String, reason: String, ts: u64, expires: Option }, AccountUnsuspended { account: String }, + MemoSent { account: String, from: String, text: String, ts: u64 }, + MemoRead { account: String, index: usize }, + MemoDeleted { account: String, index: usize }, NickGrouped { nick: String, account: String }, NickUngrouped { nick: String }, ChannelRegistered { name: String, founder: String, ts: u64 }, @@ -119,6 +125,9 @@ impl Event { | Event::AjoinRemoved { .. } | Event::AccountSuspended { .. } | Event::AccountUnsuspended { .. } + | Event::MemoSent { .. } + | Event::MemoRead { .. } + | Event::MemoDeleted { .. } | Event::NickGrouped { .. } | Event::NickUngrouped { .. } => Scope::Global, Event::ChannelRegistered { .. } @@ -166,6 +175,16 @@ pub struct Suspension { pub expires: Option, } +// A memo left for an account (MemoServ). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Memo { + pub from: String, + pub text: String, + pub ts: u64, + #[serde(default)] + pub read: bool, +} + // A service bot: a pseudo-client BotServ can assign to sit in channels. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Bot { @@ -762,6 +781,7 @@ impl Db { verified, ajoin: Vec::new(), suspension: None, + memos: Vec::new(), }; self.log.append(Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?; self.accounts.insert(key(name), account); @@ -1314,6 +1334,58 @@ impl Db { self.bots.values() } + /// Append a memo to an account's mailbox. + pub fn memo_send(&mut self, account: &str, from: &str, text: &str) -> Result<(), RegError> { + let k = key(account); + if !self.accounts.contains_key(&k) { + return Err(RegError::Internal); + } + let ts = now(); + self.log.append(Event::MemoSent { account: account.to_string(), from: from.to_string(), text: text.to_string(), ts }).map_err(|_| RegError::Internal)?; + self.accounts.get_mut(&k).unwrap().memos.push(Memo { from: from.to_string(), text: text.to_string(), ts, read: false }); + Ok(()) + } + + /// An account's memos, oldest first. + pub fn memo_list(&self, account: &str) -> Vec { + self.accounts.get(&key(account)).map_or(Vec::new(), |a| { + a.memos.iter().map(|m| MemoView { from: m.from.clone(), text: m.text.clone(), ts: m.ts, read: m.read }).collect() + }) + } + + /// Read one memo by index (marks it read), returning its contents. + pub fn memo_read(&mut self, account: &str, index: usize) -> Option { + let k = key(account); + let view = self.accounts.get(&k).and_then(|a| a.memos.get(index)).map(|m| MemoView { from: m.from.clone(), text: m.text.clone(), ts: m.ts, read: m.read })?; + if !view.read { + let _ = self.log.append(Event::MemoRead { account: account.to_string(), index }); + if let Some(m) = self.accounts.get_mut(&k).and_then(|a| a.memos.get_mut(index)) { + m.read = true; + } + } + Some(view) + } + + /// Delete one memo by index. Returns whether it existed. + pub fn memo_del(&mut self, account: &str, index: usize) -> bool { + let k = key(account); + if !self.accounts.get(&k).is_some_and(|a| index < a.memos.len()) { + return false; + } + let _ = self.log.append(Event::MemoDeleted { account: account.to_string(), index }); + if let Some(a) = self.accounts.get_mut(&k) { + if index < a.memos.len() { + a.memos.remove(index); + } + } + true + } + + /// 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()) + } + /// Set `channel`'s entry message (empty clears it). pub fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> { let k = key(channel); @@ -1418,6 +1490,23 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.memos.push(Memo { from, text, ts, read: false }); + } + } + Event::MemoRead { account, index } => { + if let Some(m) = accounts.get_mut(&key(&account)).and_then(|a| a.memos.get_mut(index)) { + m.read = true; + } + } + Event::MemoDeleted { account, index } => { + if let Some(a) = accounts.get_mut(&key(&account)) { + if index < a.memos.len() { + a.memos.remove(index); + } + } + } Event::NickGrouped { nick, account } => { grouped.insert(key(&nick), account); } @@ -1704,6 +1793,21 @@ impl Store for Db { fn bots(&self) -> Vec { Db::bots(self).map(|b| BotView { nick: b.nick.clone(), user: b.user.clone(), host: b.host.clone(), gecos: b.gecos.clone() }).collect() } + fn memo_send(&mut self, account: &str, from: &str, text: &str) -> Result<(), RegError> { + Db::memo_send(self, account, from, text) + } + fn memo_list(&self, account: &str) -> Vec { + Db::memo_list(self, account) + } + fn memo_read(&mut self, account: &str, index: usize) -> Option { + Db::memo_read(self, account, index) + } + fn memo_del(&mut self, account: &str, index: usize) -> bool { + Db::memo_del(self, account, index) + } + fn unread_memos(&self, account: &str) -> usize { + Db::unread_memos(self, account) + } fn set_entrymsg(&mut self, channel: &str, msg: &str) -> Result<(), ChanError> { Db::set_entrymsg(self, channel, msg) } @@ -1780,7 +1884,7 @@ mod tests { fn account_conflict_resolves_deterministically() { let alice = |hash: &str, ts: u64, home: &str| Account { name: "alice".into(), password_hash: hash.into(), email: None, - ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, + ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], }; let converge = |first: &Account, second: &Account| { let (mut acc, mut ch, mut gr, mut bo) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new()); @@ -1958,6 +2062,30 @@ mod tests { assert_eq!(db.bots().next().unwrap().nick, "Botty"); } + // Memos send, mark read on read, delete (shifting indices), and replay. + #[test] + fn memos_send_read_delete_and_persist() { + let p = tmp("memos"); + { + let mut db = Db::open(&p, "N1"); + db.scram_iterations = 4096; + db.register("alice", "password1", None).unwrap(); + assert_eq!(db.unread_memos("alice"), 0); + db.memo_send("alice", "bob", "hello there").unwrap(); + db.memo_send("alice", "carol", "second").unwrap(); + assert_eq!(db.unread_memos("alice"), 2); + let m = db.memo_read("alice", 0).unwrap(); + assert_eq!(m.from, "bob"); + assert_eq!(db.unread_memos("alice"), 1, "reading marks it read"); + assert!(db.memo_del("alice", 0)); + assert_eq!(db.memo_list("alice").len(), 1); + assert_eq!(db.memo_list("alice")[0].text, "second", "delete shifts indices"); + } + let db = Db::open(&p, "N1"); + assert_eq!(db.memo_list("alice").len(), 1, "memos replay from the log"); + assert_eq!(db.unread_memos("alice"), 1); + } + // 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] @@ -2032,7 +2160,7 @@ mod tests { db.register("alice", "pw", None).unwrap(); let bob = Account { name: "bob".into(), password_hash: "x".into(), email: None, - ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], }; let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, event: Event::AccountRegistered(bob) }; db.ingest(entry).unwrap(); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 422259d..e8f6798 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1307,7 +1307,7 @@ mod tests { // An earlier claim from another node wins and takes the name over. let winner = db::Account { name: "alice".into(), password_hash: "OTHER".into(), email: None, - ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], }; let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(winner)); e.gossip_ingest(entry).unwrap(); @@ -1591,6 +1591,39 @@ mod tests { assert!(notice(&bs(&mut e, "000AAAAAC", "BOT LIST"), "Bendy")); } + // MemoServ delivers a memo to an offline account and notifies them on login. + #[test] + fn memoserv_delivers_and_notifies_on_login() { + use fedserv_memoserv::MemoServ; + use fedserv_nickserv::NickServ; + let path = std::env::temp_dir().join("fedserv-memoserv.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("alice", "password1", None).unwrap(); + db.register("bob", "password1", None).unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(MemoServ { uid: "42SAAAAAE".into() }), + ], + db, + ); + let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() }); + let ms = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAE".into(), text: t.into() }); + let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n))); + + // alice identifies and sends bob (offline) a memo. + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() }); + ns(&mut e, "000AAAAAB", "IDENTIFY password1"); + assert!(notice(&ms(&mut e, "000AAAAAB", "SEND bob Hello from alice"), "Memo sent")); + + // bob logs in later and is told he has a new memo, then reads it. + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "h".into() }); + assert!(notice(&ns(&mut e, "000AAAAAC", "IDENTIFY password1"), "new memo")); + assert!(notice(&ms(&mut e, "000AAAAAC", "READ NEW"), "Hello from alice")); + } + // Channel SUSPEND is oper-gated and freezes founder management until lifted. #[test] fn channel_suspend_is_oper_gated_and_freezes_management() { diff --git a/src/grpc.rs b/src/grpc.rs index b3ea865..324b8e8 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -126,6 +126,9 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::AjoinRemoved { .. } | Event::AccountSuspended { .. } | Event::AccountUnsuspended { .. } + | Event::MemoSent { .. } + | Event::MemoRead { .. } + | Event::MemoDeleted { .. } | Event::ChannelMlock { .. } | Event::ChannelAccessAdd { .. } | Event::ChannelAccessDel { .. } @@ -363,6 +366,7 @@ mod tests { verified: true, ajoin: vec![], suspension: None, + memos: vec![], }; let registered = LogEntry::for_test("A", 0, 1, Event::AccountRegistered(acct)); let wire = to_wire(®istered).expect("account registration replicates"); diff --git a/src/main.rs b/src/main.rs index 6139ad1..579e3a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use tokio::sync::Mutex; use engine::Engine; use fedserv_botserv::BotServ; use fedserv_chanserv::ChanServ; +use fedserv_memoserv::MemoServ; use fedserv_example::ExampleServ; use fedserv_inspircd::InspIrcd; use fedserv_nickserv::NickServ; @@ -63,6 +64,11 @@ async fn main() -> Result<()> { uid: format!("{}AAAAAD", cfg.server.sid), })); } + if enabled("memoserv") { + services.push(Box::new(MemoServ { + uid: format!("{}AAAAAE", cfg.server.sid), + })); + } if enabled("example") { services.push(Box::new(ExampleServ { uid: format!("{}AAAAAC", cfg.server.sid),