Add HelpServ: a help-desk ticket queue
Users open tickets with REQUEST (or HELPME); the queue is event-sourced and, because a request is an event, the audit feed announces it to staff and records it in the searchable log — so OperServ LOGSEARCH finds a ticket by any word, the same trail as reports. A user can CANCEL their own open ticket. Operators work the queue: LIST [ALL], VIEW, TAKE (claim), NEXT (claim the oldest unassigned), CLOSE. Filing is rate-limited. Opt-in. That completes the six third-party modules — all interconnected: they share the account/channel/oper identity, the event-sourced store, and the one audit/incident trail.
This commit is contained in:
parent
06227e73da
commit
a47dd6bda3
9 changed files with 385 additions and 2 deletions
129
src/engine/db.rs
129
src/engine/db.rs
|
|
@ -30,7 +30,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, AkillView, BotView, Caps, GroupView, IgnoreView, MemoView, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView,
|
||||
AccountView, AjoinView, AkillView, BotView, Caps, GroupView, HelpView, IgnoreView, MemoView, NewsView, Privs, ReportView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, VhostView,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -200,6 +200,10 @@ pub enum Event {
|
|||
GroupFlagsSet { name: String, account: String, flags: String },
|
||||
// Remove a member from a group entirely.
|
||||
GroupMemberDel { name: String, account: String },
|
||||
// Help-desk tickets (HelpServ). Global so any node's helpers see the queue.
|
||||
HelpRequested { id: u64, requester: String, message: String, ts: u64 },
|
||||
HelpTaken { id: u64, handler: String },
|
||||
HelpClosed { id: u64 },
|
||||
// Runtime operator grants (OperServ OPER).
|
||||
OperGranted {
|
||||
account: String,
|
||||
|
|
@ -280,6 +284,9 @@ impl Event {
|
|||
| Event::GroupFounderSet { .. }
|
||||
| Event::GroupFlagsSet { .. }
|
||||
| Event::GroupMemberDel { .. }
|
||||
| Event::HelpRequested { .. }
|
||||
| Event::HelpTaken { .. }
|
||||
| Event::HelpClosed { .. }
|
||||
| Event::OperGranted { .. }
|
||||
| Event::OperRevoked { .. }
|
||||
| Event::SessionExceptionAdded { .. }
|
||||
|
|
@ -395,6 +402,18 @@ pub struct News {
|
|||
pub ts: u64,
|
||||
}
|
||||
|
||||
// A help-desk ticket (HelpServ): who asked, their message, when, the staff member
|
||||
// handling it (if taken), and whether it's still open.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HelpTicket {
|
||||
pub id: u64,
|
||||
pub requester: String,
|
||||
pub message: String,
|
||||
pub ts: u64,
|
||||
pub handler: Option<String>,
|
||||
pub open: bool,
|
||||
}
|
||||
|
||||
// A user group (GroupServ): a `!name`, its founder account, and member accounts
|
||||
// each with group-access flags. Groups can be granted channel access.
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -436,6 +455,9 @@ pub struct NetData {
|
|||
pub report_seq: u64,
|
||||
// User groups (GroupServ).
|
||||
pub groups: Vec<Group>,
|
||||
// Help-desk tickets (HelpServ), oldest first.
|
||||
pub help: Vec<HelpTicket>,
|
||||
pub help_seq: u64,
|
||||
// Runtime services operators (OperServ OPER), casefolded account -> grant.
|
||||
// Merged with the declarative [[oper]] config at the engine.
|
||||
pub opers: HashMap<String, OperGrant>,
|
||||
|
|
@ -1216,6 +1238,15 @@ impl Db {
|
|||
snapshot.push(Event::GroupFlagsSet { name: g.name.clone(), account: m.account.clone(), flags: m.flags.clone() });
|
||||
}
|
||||
}
|
||||
for t in &self.net.help {
|
||||
snapshot.push(Event::HelpRequested { id: t.id, requester: t.requester.clone(), message: t.message.clone(), ts: t.ts });
|
||||
if let Some(h) = &t.handler {
|
||||
snapshot.push(Event::HelpTaken { id: t.id, handler: h.clone() });
|
||||
}
|
||||
if !t.open {
|
||||
snapshot.push(Event::HelpClosed { id: t.id });
|
||||
}
|
||||
}
|
||||
for (account, grant) in &self.net.opers {
|
||||
snapshot.push(Event::OperGranted { account: account.clone(), privs: grant.privs.clone(), expires: grant.expires });
|
||||
}
|
||||
|
|
@ -2273,6 +2304,68 @@ impl Db {
|
|||
self.net.reports.iter().find(|r| r.id == id).map(|r| ReportView { id: r.id, reporter: r.reporter.clone(), target: r.target.clone(), reason: r.reason.clone(), ts: r.ts, open: r.open })
|
||||
}
|
||||
|
||||
/// Open a help-desk ticket, rate-limited per requester (shares the report
|
||||
/// throttle namespace). Returns the new ticket's id, or None if too soon.
|
||||
pub fn help_request(&mut self, requester: &str, message: &str) -> Option<u64> {
|
||||
const COOLDOWN: u64 = 30;
|
||||
let now = now();
|
||||
let tkey = format!("help:{}", requester.to_ascii_lowercase());
|
||||
if self.report_times.get(&tkey).is_some_and(|&t| now.saturating_sub(t) < COOLDOWN) {
|
||||
return None;
|
||||
}
|
||||
self.report_times.insert(tkey, now);
|
||||
let id = self.net.help_seq;
|
||||
let _ = self.log.append(Event::HelpRequested { id, requester: requester.to_string(), message: message.to_string(), ts: now });
|
||||
self.net.help_seq = id + 1;
|
||||
self.net.help.push(HelpTicket { id, requester: requester.to_string(), message: message.to_string(), ts: now, handler: None, open: true });
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// Assign an open ticket to a handler. Returns whether an open one was taken.
|
||||
pub fn help_take(&mut self, id: u64, handler: &str) -> bool {
|
||||
let ok = self.net.help.iter().any(|t| t.id == id && t.open);
|
||||
if ok {
|
||||
let _ = self.log.append(Event::HelpTaken { id, handler: handler.to_string() });
|
||||
if let Some(t) = self.net.help.iter_mut().find(|t| t.id == id) {
|
||||
t.handler = Some(handler.to_string());
|
||||
}
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// Close a ticket. Returns whether an open one was closed.
|
||||
pub fn help_close(&mut self, id: u64) -> bool {
|
||||
let ok = self.net.help.iter().any(|t| t.id == id && t.open);
|
||||
if ok {
|
||||
let _ = self.log.append(Event::HelpClosed { id });
|
||||
if let Some(t) = self.net.help.iter_mut().find(|t| t.id == id) {
|
||||
t.open = false;
|
||||
}
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// The tickets, newest first. `open_only` hides closed ones.
|
||||
pub fn help_tickets(&self, open_only: bool) -> Vec<HelpView> {
|
||||
self.net
|
||||
.help
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|t| !open_only || t.open)
|
||||
.map(|t| HelpView { id: t.id, requester: t.requester.clone(), message: t.message.clone(), ts: t.ts, handler: t.handler.clone(), open: t.open })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A single ticket by id.
|
||||
pub fn help_ticket(&self, id: u64) -> Option<HelpView> {
|
||||
self.net.help.iter().find(|t| t.id == id).map(|t| HelpView { id: t.id, requester: t.requester.clone(), message: t.message.clone(), ts: t.ts, handler: t.handler.clone(), open: t.open })
|
||||
}
|
||||
|
||||
/// The id of the oldest open, unassigned ticket (for HelpServ NEXT).
|
||||
pub fn help_next_open(&self) -> Option<u64> {
|
||||
self.net.help.iter().find(|t| t.open && t.handler.is_none()).map(|t| t.id)
|
||||
}
|
||||
|
||||
/// Register a new group (name must start with `!`). Founder is an account.
|
||||
pub fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError> {
|
||||
if !name.starts_with('!') || name.len() < 2 {
|
||||
|
|
@ -3415,6 +3508,22 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
|||
g.members.retain(|m| !m.account.eq_ignore_ascii_case(&account));
|
||||
}
|
||||
}
|
||||
Event::HelpRequested { id, requester, message, ts } => {
|
||||
net.help_seq = net.help_seq.max(id + 1);
|
||||
if !net.help.iter().any(|t| t.id == id) {
|
||||
net.help.push(HelpTicket { id, requester, message, ts, handler: None, open: true });
|
||||
}
|
||||
}
|
||||
Event::HelpTaken { id, handler } => {
|
||||
if let Some(t) = net.help.iter_mut().find(|t| t.id == id) {
|
||||
t.handler = Some(handler);
|
||||
}
|
||||
}
|
||||
Event::HelpClosed { id } => {
|
||||
if let Some(t) = net.help.iter_mut().find(|t| t.id == id) {
|
||||
t.open = false;
|
||||
}
|
||||
}
|
||||
Event::OperGranted { account, privs, expires } => {
|
||||
net.opers.insert(key(&account), OperGrant { privs, expires });
|
||||
}
|
||||
|
|
@ -3738,6 +3847,24 @@ impl Store for Db {
|
|||
fn report(&self, id: u64) -> Option<ReportView> {
|
||||
Db::report(self, id)
|
||||
}
|
||||
fn help_request(&mut self, requester: &str, message: &str) -> Option<u64> {
|
||||
Db::help_request(self, requester, message)
|
||||
}
|
||||
fn help_take(&mut self, id: u64, handler: &str) -> bool {
|
||||
Db::help_take(self, id, handler)
|
||||
}
|
||||
fn help_close(&mut self, id: u64) -> bool {
|
||||
Db::help_close(self, id)
|
||||
}
|
||||
fn help_tickets(&self, open_only: bool) -> Vec<HelpView> {
|
||||
Db::help_tickets(self, open_only)
|
||||
}
|
||||
fn help_ticket(&self, id: u64) -> Option<HelpView> {
|
||||
Db::help_ticket(self, id)
|
||||
}
|
||||
fn help_next_open(&self) -> Option<u64> {
|
||||
Db::help_next_open(self)
|
||||
}
|
||||
fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError> {
|
||||
Db::group_register(self, name, founder)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1706,6 +1706,9 @@ fn audit_summary(event: &db::Event) -> Option<String> {
|
|||
ReportFiled { reporter, target, reason, .. } => format!("\x02{reporter}\x02 reported \x02{target}\x02: {reason}"),
|
||||
ReportClosed { id } => format!("closed report #{id}"),
|
||||
ReportDeleted { id } => format!("deleted report #{id}"),
|
||||
HelpRequested { requester, message, .. } => format!("\x02{requester}\x02 asked for help: {message}"),
|
||||
HelpTaken { id, handler } => format!("\x02{handler}\x02 took help ticket #{id}"),
|
||||
HelpClosed { id } => format!("closed help ticket #{id}"),
|
||||
GroupRegistered { name, founder, .. } => format!("registered group \x02{name}\x02 (founder \x02{founder}\x02)"),
|
||||
GroupDropped { name } => format!("dropped group \x02{name}\x02"),
|
||||
GroupFounderSet { name, founder } => format!("set founder of \x02{name}\x02 to \x02{founder}\x02"),
|
||||
|
|
@ -4586,6 +4589,53 @@ mod tests {
|
|||
assert!(denied(&os(&mut e, "000AAAAAP", "STATS")), "expired grant confers nothing");
|
||||
}
|
||||
|
||||
// HelpServ: a user opens a ticket; it queues, flows into the audit trail
|
||||
// (LOGSEARCH), and operators TAKE / CLOSE it. Reviewing is oper-only.
|
||||
#[test]
|
||||
fn helpserv_ticket_flow() {
|
||||
use fedserv_helpserv::HelpServ;
|
||||
use fedserv_operserv::OperServ;
|
||||
let path = std::env::temp_dir().join("fedserv-helpserv.jsonl");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut db = Db::open(&path, "42S");
|
||||
db.scram_iterations = 4096;
|
||||
db.register("staff", "password1", None).unwrap();
|
||||
let mut e = Engine::new(
|
||||
vec![
|
||||
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||||
Box::new(OperServ { uid: "42SAAAAAH".into() }),
|
||||
Box::new(HelpServ { uid: "42SAAAAAN".into() }),
|
||||
],
|
||||
db,
|
||||
);
|
||||
e.set_sid("42S".into());
|
||||
let mut opers = std::collections::HashMap::new();
|
||||
opers.insert("staff".to_string(), Privs::default().with(fedserv_api::Priv::Admin).with(fedserv_api::Priv::Auspex));
|
||||
e.set_opers(opers);
|
||||
let hs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAN".into(), text: t.into() });
|
||||
let os = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAH".into(), text: t.into() });
|
||||
let has = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||||
|
||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "staff".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||||
e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAU".into(), nick: "newbie".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||||
|
||||
// A user asks for help; it queues and is searchable via the audit trail.
|
||||
assert!(has(&hs(&mut e, "000AAAAAU", "REQUEST how do I register a channel"), "in the queue"), "request accepted");
|
||||
assert!(has(&os(&mut e, "000AAAAAS", "LOGSEARCH register a channel"), "register a channel"), "searchable via LOGSEARCH");
|
||||
|
||||
// Operator works the queue: NEXT claims the oldest, then CLOSE.
|
||||
assert!(has(&hs(&mut e, "000AAAAAS", "LIST"), "newbie"), "oper sees the queue");
|
||||
assert!(has(&hs(&mut e, "000AAAAAS", "NEXT"), "You took ticket \x02#0\x02"), "next claims oldest");
|
||||
assert!(has(&hs(&mut e, "000AAAAAS", "VIEW 0"), "register a channel"), "view detail");
|
||||
assert!(has(&hs(&mut e, "000AAAAAS", "CLOSE 0"), "closed"), "close");
|
||||
assert!(!has(&hs(&mut e, "000AAAAAS", "LIST"), "newbie"), "closed drops off the open list");
|
||||
|
||||
// Non-opers can't work the queue; and filing is rate-limited.
|
||||
assert!(has(&hs(&mut e, "000AAAAAU", "LIST"), "Access denied"), "queue is oper-only");
|
||||
assert!(has(&hs(&mut e, "000AAAAAU", "REQUEST again please"), "wait a moment"), "rate-limited");
|
||||
}
|
||||
|
||||
// ReportServ: any user files an abuse report; it lands in the queue AND flows
|
||||
// through the audit trail so OperServ LOGSEARCH finds it. Reviewing is oper-only.
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue