diff --git a/modules/helpserv/src/cancel.rs b/modules/helpserv/src/cancel.rs new file mode 100644 index 0000000..66c8d81 --- /dev/null +++ b/modules/helpserv/src/cancel.rs @@ -0,0 +1,14 @@ +use fedserv_api::{Sender, ServiceCtx, Store}; + +// CANCEL: withdraw your own newest open ticket. +pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) { + let who = from.account.unwrap_or(from.nick); + let mine = db.help_tickets(true).into_iter().find(|t| t.requester.eq_ignore_ascii_case(who)); + match mine { + Some(t) => { + db.help_close(t.id); + ctx.notice(me, from.uid, format!("Your request \x02#{}\x02 has been cancelled.", t.id)); + } + None => ctx.notice(me, from.uid, "You have no open request to cancel."), + } +} diff --git a/modules/helpserv/src/close.rs b/modules/helpserv/src/close.rs new file mode 100644 index 0000000..e8d997e --- /dev/null +++ b/modules/helpserv/src/close.rs @@ -0,0 +1,17 @@ +use fedserv_api::{Sender, ServiceCtx, Store}; + +// CLOSE (aka RESOLVE): resolve a ticket. +pub fn handle(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !super::require_oper(me, from, ctx) { + return; + } + let Some(n) = id.and_then(|n| n.parse::().ok()) else { + ctx.notice(me, from.uid, "Syntax: CLOSE "); + return; + }; + if db.help_close(n) { + ctx.notice(me, from.uid, format!("Ticket \x02#{n}\x02 closed.")); + } else { + ctx.notice(me, from.uid, format!("Ticket \x02#{n}\x02 isn't open (or doesn't exist).")); + } +} diff --git a/modules/helpserv/src/lib.rs b/modules/helpserv/src/lib.rs index db141e4..5d5d7c0 100644 --- a/modules/helpserv/src/lib.rs +++ b/modules/helpserv/src/lib.rs @@ -3,8 +3,26 @@ //! feed — is announced to staff and shows up in OperServ LOGSEARCH, the same //! trail as reports. Operators work the queue: LIST, VIEW, TAKE (claim), NEXT //! (claim the oldest), and CLOSE. A user can CANCEL their own open ticket. +//! +//! `lib.rs` holds the dispatcher and the shared guard/claim helpers; each +//! command lives in its own file. -use fedserv_api::{human_time, NetView, Sender, Service, ServiceCtx, Store}; +use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store}; + +#[path = "request.rs"] +mod request; +#[path = "cancel.rs"] +mod cancel; +#[path = "list.rs"] +mod list; +#[path = "view.rs"] +mod view; +#[path = "take.rs"] +mod take; +#[path = "next.rs"] +mod next; +#[path = "close.rs"] +mod close; pub struct HelpServ { pub uid: String, @@ -24,49 +42,20 @@ impl Service for HelpServ { 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(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { - Some("REQUEST") | Some("HELPME") => request(me, from, &args[1..], ctx, db), - Some("CANCEL") => cancel(me, from, ctx, db), - Some("LIST") => list(me, from, args.get(1).copied(), ctx, db), - Some("VIEW") | Some("READ") => view(me, from, args.get(1).copied(), ctx, db), - Some("TAKE") | Some("ASSIGN") => take(me, from, args.get(1).copied(), ctx, db), - Some("NEXT") => next(me, from, ctx, db), - Some("CLOSE") | Some("RESOLVE") => close(me, from, args.get(1).copied(), ctx, db), - Some("HELP") | None => help(me, from, ctx), + Some("REQUEST") | Some("HELPME") => request::handle(me, from, &args[1..], ctx, db), + Some("CANCEL") => cancel::handle(me, from, ctx, db), + Some("LIST") => list::handle(me, from, args.get(1).copied(), ctx, db), + Some("VIEW") | Some("READ") => view::handle(me, from, args.get(1).copied(), ctx, db), + Some("TAKE") | Some("ASSIGN") => take::handle(me, from, args.get(1).copied(), ctx, db), + Some("NEXT") => next::handle(me, from, ctx, db), + Some("CLOSE") | Some("RESOLVE") => close::handle(me, from, args.get(1).copied(), ctx, db), + Some("HELP") | None => ctx.notice(me, from.uid, "HelpServ is the help desk. \x02REQUEST\x02 opens a ticket for the staff; \x02CANCEL\x02 withdraws yours. Operators: \x02LIST\x02 [ALL], \x02VIEW\x02 , \x02TAKE\x02 , \x02NEXT\x02, \x02CLOSE\x02 ."), Some(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02REQUEST\x02 or \x02HELP\x02.")), } } } -fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) { - ctx.notice(me, from.uid, "HelpServ is the help desk. \x02REQUEST\x02 opens a ticket for the staff; \x02CANCEL\x02 withdraws yours. Operators: \x02LIST\x02 [ALL], \x02VIEW\x02 , \x02TAKE\x02 , \x02NEXT\x02, \x02CLOSE\x02 ."); -} - -fn request(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { - let message = rest.join(" "); - if message.trim().is_empty() { - ctx.notice(me, from.uid, "Tell us what you need help with: REQUEST "); - return; - } - let requester = from.account.unwrap_or(from.nick); - match db.help_request(requester, &message) { - Some(id) => ctx.notice(me, from.uid, format!("Thanks — your request (\x02#{id}\x02) is in the queue. A staff member will be with you.")), - None => ctx.notice(me, from.uid, "You just opened a ticket — please wait a moment before opening another."), - } -} - -fn cancel(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) { - let who = from.account.unwrap_or(from.nick); - // Close the requester's own newest open ticket. - let mine = db.help_tickets(true).into_iter().find(|t| t.requester.eq_ignore_ascii_case(who)); - match mine { - Some(t) => { - db.help_close(t.id); - ctx.notice(me, from.uid, format!("Your request \x02#{}\x02 has been cancelled.", t.id)); - } - None => ctx.notice(me, from.uid, "You have no open request to cancel."), - } -} - +// Working the help queue is operator-only. fn require_oper(me: &str, from: &Sender, ctx: &mut ServiceCtx) -> bool { if from.privs.any() { return true; @@ -75,67 +64,7 @@ fn require_oper(me: &str, from: &Sender, ctx: &mut ServiceCtx) -> bool { false } -fn list(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { - if !require_oper(me, from, ctx) { - return; - } - let open_only = !arg.is_some_and(|a| a.eq_ignore_ascii_case("ALL")); - let tickets = db.help_tickets(open_only); - if tickets.is_empty() { - ctx.notice(me, from.uid, if open_only { "No open tickets." } else { "No tickets." }); - return; - } - for t in &tickets { - let state = match (&t.handler, t.open) { - (_, false) => " (closed)".to_string(), - (Some(h), true) => format!(" (taken by {h})"), - (None, true) => String::new(), - }; - let short: String = t.message.chars().take(60).collect(); - ctx.notice(me, from.uid, format!("\x02#{}\x02 {} — {}{}", t.id, t.requester, short, state)); - } - ctx.notice(me, from.uid, format!("{} ticket(s). \x02VIEW\x02 for detail.", tickets.len())); -} - -fn view(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { - if !require_oper(me, from, ctx) { - return; - } - let Some(t) = id.and_then(|n| n.parse::().ok()).and_then(|n| db.help_ticket(n)) else { - ctx.notice(me, from.uid, "No such ticket. Syntax: VIEW "); - return; - }; - let state = if !t.open { "closed" } else if t.handler.is_some() { "taken" } else { "open" }; - ctx.notice(me, from.uid, format!("Ticket \x02#{}\x02 ({state}):", t.id)); - ctx.notice(me, from.uid, format!(" From : \x02{}\x02", t.requester)); - if let Some(h) = &t.handler { - ctx.notice(me, from.uid, format!(" Handler : \x02{h}\x02")); - } - ctx.notice(me, from.uid, format!(" Opened : {}", human_time(t.ts))); - ctx.notice(me, from.uid, format!(" Message : {}", t.message)); -} - -fn take(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { - if !require_oper(me, from, ctx) { - return; - } - let Some(n) = id.and_then(|n| n.parse::().ok()) else { - ctx.notice(me, from.uid, "Syntax: TAKE "); - return; - }; - take_id(me, from, n, ctx, db); -} - -fn next(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) { - if !require_oper(me, from, ctx) { - return; - } - match db.help_next_open() { - Some(id) => take_id(me, from, id, ctx, db), - None => ctx.notice(me, from.uid, "No unassigned tickets are waiting."), - } -} - +// Claim ticket `id` for the calling operator (shared by TAKE and NEXT). fn take_id(me: &str, from: &Sender, id: u64, ctx: &mut ServiceCtx, db: &mut dyn Store) { let handler = from.account.unwrap_or(from.nick); if db.help_take(id, handler) { @@ -145,18 +74,3 @@ fn take_id(me: &str, from: &Sender, id: u64, ctx: &mut ServiceCtx, db: &mut dyn ctx.notice(me, from.uid, format!("Ticket \x02#{id}\x02 isn't open (or doesn't exist).")); } } - -fn close(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { - if !require_oper(me, from, ctx) { - return; - } - let Some(n) = id.and_then(|n| n.parse::().ok()) else { - ctx.notice(me, from.uid, "Syntax: CLOSE "); - return; - }; - if db.help_close(n) { - ctx.notice(me, from.uid, format!("Ticket \x02#{n}\x02 closed.")); - } else { - ctx.notice(me, from.uid, format!("Ticket \x02#{n}\x02 isn't open (or doesn't exist).")); - } -} diff --git a/modules/helpserv/src/list.rs b/modules/helpserv/src/list.rs new file mode 100644 index 0000000..b77949d --- /dev/null +++ b/modules/helpserv/src/list.rs @@ -0,0 +1,24 @@ +use fedserv_api::{Sender, ServiceCtx, Store}; + +// LIST [ALL]: operators list the open queue (or every ticket with ALL). +pub fn handle(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !super::require_oper(me, from, ctx) { + return; + } + let open_only = !arg.is_some_and(|a| a.eq_ignore_ascii_case("ALL")); + let tickets = db.help_tickets(open_only); + if tickets.is_empty() { + ctx.notice(me, from.uid, if open_only { "No open tickets." } else { "No tickets." }); + return; + } + for t in &tickets { + let state = match (&t.handler, t.open) { + (_, false) => " (closed)".to_string(), + (Some(h), true) => format!(" (taken by {h})"), + (None, true) => String::new(), + }; + let short: String = t.message.chars().take(60).collect(); + ctx.notice(me, from.uid, format!("\x02#{}\x02 {} — {}{}", t.id, t.requester, short, state)); + } + ctx.notice(me, from.uid, format!("{} ticket(s). \x02VIEW\x02 for detail.", tickets.len())); +} diff --git a/modules/helpserv/src/next.rs b/modules/helpserv/src/next.rs new file mode 100644 index 0000000..c9b4e22 --- /dev/null +++ b/modules/helpserv/src/next.rs @@ -0,0 +1,12 @@ +use fedserv_api::{Sender, ServiceCtx, Store}; + +// NEXT: claim the oldest unassigned ticket. +pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !super::require_oper(me, from, ctx) { + return; + } + match db.help_next_open() { + Some(id) => super::take_id(me, from, id, ctx, db), + None => ctx.notice(me, from.uid, "No unassigned tickets are waiting."), + } +} diff --git a/modules/helpserv/src/request.rs b/modules/helpserv/src/request.rs new file mode 100644 index 0000000..ee297b9 --- /dev/null +++ b/modules/helpserv/src/request.rs @@ -0,0 +1,15 @@ +use fedserv_api::{Sender, ServiceCtx, Store}; + +// REQUEST (aka HELPME): open a help-desk ticket for the staff. +pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + let message = rest.join(" "); + if message.trim().is_empty() { + ctx.notice(me, from.uid, "Tell us what you need help with: REQUEST "); + return; + } + let requester = from.account.unwrap_or(from.nick); + match db.help_request(requester, &message) { + Some(id) => ctx.notice(me, from.uid, format!("Thanks — your request (\x02#{id}\x02) is in the queue. A staff member will be with you.")), + None => ctx.notice(me, from.uid, "You just opened a ticket — please wait a moment before opening another."), + } +} diff --git a/modules/helpserv/src/take.rs b/modules/helpserv/src/take.rs new file mode 100644 index 0000000..665bc20 --- /dev/null +++ b/modules/helpserv/src/take.rs @@ -0,0 +1,13 @@ +use fedserv_api::{Sender, ServiceCtx, Store}; + +// TAKE (aka ASSIGN): claim a specific ticket. +pub fn handle(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !super::require_oper(me, from, ctx) { + return; + } + let Some(n) = id.and_then(|n| n.parse::().ok()) else { + ctx.notice(me, from.uid, "Syntax: TAKE "); + return; + }; + super::take_id(me, from, n, ctx, db); +} diff --git a/modules/helpserv/src/view.rs b/modules/helpserv/src/view.rs new file mode 100644 index 0000000..4960a84 --- /dev/null +++ b/modules/helpserv/src/view.rs @@ -0,0 +1,20 @@ +use fedserv_api::{human_time, Sender, ServiceCtx, Store}; + +// VIEW (aka READ): operators read a ticket in full. +pub fn handle(me: &str, from: &Sender, id: Option<&str>, ctx: &mut ServiceCtx, db: &mut dyn Store) { + if !super::require_oper(me, from, ctx) { + return; + } + let Some(t) = id.and_then(|n| n.parse::().ok()).and_then(|n| db.help_ticket(n)) else { + ctx.notice(me, from.uid, "No such ticket. Syntax: VIEW "); + return; + }; + let state = if !t.open { "closed" } else if t.handler.is_some() { "taken" } else { "open" }; + ctx.notice(me, from.uid, format!("Ticket \x02#{}\x02 ({state}):", t.id)); + ctx.notice(me, from.uid, format!(" From : \x02{}\x02", t.requester)); + if let Some(h) = &t.handler { + ctx.notice(me, from.uid, format!(" Handler : \x02{h}\x02")); + } + ctx.notice(me, from.uid, format!(" Opened : {}", human_time(t.ts))); + ctx.notice(me, from.uid, format!(" Message : {}", t.message)); +}