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
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -332,6 +332,7 @@ dependencies = [
|
||||||
"fedserv-diceserv",
|
"fedserv-diceserv",
|
||||||
"fedserv-example",
|
"fedserv-example",
|
||||||
"fedserv-groupserv",
|
"fedserv-groupserv",
|
||||||
|
"fedserv-helpserv",
|
||||||
"fedserv-hostserv",
|
"fedserv-hostserv",
|
||||||
"fedserv-infoserv",
|
"fedserv-infoserv",
|
||||||
"fedserv-inspircd",
|
"fedserv-inspircd",
|
||||||
|
|
@ -406,6 +407,13 @@ dependencies = [
|
||||||
"fedserv-api",
|
"fedserv-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fedserv-helpserv"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"fedserv-api",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fedserv-hostserv"
|
name = "fedserv-hostserv"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv", "reportserv", "groupserv", "chanfix"]
|
members = ["api", "inspircd", "chanserv", "nickserv", "example", "botserv", "memoserv", "statserv", "hostserv", "operserv", "diceserv", "infoserv", "reportserv", "groupserv", "chanfix", "helpserv"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "fedserv"
|
name = "fedserv"
|
||||||
|
|
@ -23,6 +23,7 @@ fedserv-infoserv = { path = "infoserv" }
|
||||||
fedserv-reportserv = { path = "reportserv" }
|
fedserv-reportserv = { path = "reportserv" }
|
||||||
fedserv-groupserv = { path = "groupserv" }
|
fedserv-groupserv = { path = "groupserv" }
|
||||||
fedserv-chanfix = { path = "chanfix" }
|
fedserv-chanfix = { path = "chanfix" }
|
||||||
|
fedserv-helpserv = { path = "helpserv" }
|
||||||
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] }
|
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
|
||||||
|
|
@ -640,6 +640,17 @@ pub struct NewsView {
|
||||||
pub ts: u64,
|
pub ts: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A help-desk ticket held by HelpServ.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HelpView {
|
||||||
|
pub id: u64,
|
||||||
|
pub requester: String,
|
||||||
|
pub message: String,
|
||||||
|
pub ts: u64,
|
||||||
|
pub handler: Option<String>,
|
||||||
|
pub open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
// An abuse report held by ReportServ.
|
// An abuse report held by ReportServ.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ReportView {
|
pub struct ReportView {
|
||||||
|
|
@ -934,6 +945,13 @@ pub trait Store {
|
||||||
fn report_del(&mut self, id: u64) -> bool;
|
fn report_del(&mut self, id: u64) -> bool;
|
||||||
fn reports(&self, open_only: bool) -> Vec<ReportView>;
|
fn reports(&self, open_only: bool) -> Vec<ReportView>;
|
||||||
fn report(&self, id: u64) -> Option<ReportView>;
|
fn report(&self, id: u64) -> Option<ReportView>;
|
||||||
|
// Help-desk tickets (HelpServ). `help_request` is rate-limited (None = too soon).
|
||||||
|
fn help_request(&mut self, requester: &str, message: &str) -> Option<u64>;
|
||||||
|
fn help_take(&mut self, id: u64, handler: &str) -> bool;
|
||||||
|
fn help_close(&mut self, id: u64) -> bool;
|
||||||
|
fn help_tickets(&self, open_only: bool) -> Vec<HelpView>;
|
||||||
|
fn help_ticket(&self, id: u64) -> Option<HelpView>;
|
||||||
|
fn help_next_open(&self) -> Option<u64>;
|
||||||
// User groups (GroupServ).
|
// User groups (GroupServ).
|
||||||
fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError>;
|
fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError>;
|
||||||
fn group_drop(&mut self, name: &str) -> Result<(), ChanError>;
|
fn group_drop(&mut self, name: &str) -> Result<(), ChanError>;
|
||||||
|
|
|
||||||
8
helpserv/Cargo.toml
Normal file
8
helpserv/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[package]
|
||||||
|
name = "fedserv-helpserv"
|
||||||
|
version = "0.0.1"
|
||||||
|
edition = "2021"
|
||||||
|
description = "HelpServ: a help-desk queue — users ask, operators take and answer."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fedserv-api = { path = "../api" }
|
||||||
162
helpserv/src/lib.rs
Normal file
162
helpserv/src/lib.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
//! HelpServ is a help desk. A user opens a ticket with REQUEST (or HELPME); it
|
||||||
|
//! joins a queue that — being event-sourced and surfaced by the engine's audit
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
use fedserv_api::{human_time, NetView, Sender, Service, ServiceCtx, Store};
|
||||||
|
|
||||||
|
pub struct HelpServ {
|
||||||
|
pub uid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service for HelpServ {
|
||||||
|
fn nick(&self) -> &str {
|
||||||
|
"HelpServ"
|
||||||
|
}
|
||||||
|
fn uid(&self) -> &str {
|
||||||
|
&self.uid
|
||||||
|
}
|
||||||
|
fn gecos(&self) -> &str {
|
||||||
|
"Help Service"
|
||||||
|
}
|
||||||
|
|
||||||
|
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(other) => ctx.notice(me, from.uid, format!("I don't know \x02{other}\x02. Try \x02REQUEST\x02 <message> or \x02HELP\x02.")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn help(me: &str, from: &Sender, ctx: &mut ServiceCtx) {
|
||||||
|
ctx.notice(me, from.uid, "HelpServ is the help desk. \x02REQUEST\x02 <message> opens a ticket for the staff; \x02CANCEL\x02 withdraws yours. Operators: \x02LIST\x02 [ALL], \x02VIEW\x02 <id>, \x02TAKE\x02 <id>, \x02NEXT\x02, \x02CLOSE\x02 <id>.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <message>");
|
||||||
|
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."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_oper(me: &str, from: &Sender, ctx: &mut ServiceCtx) -> bool {
|
||||||
|
if from.privs.any() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ctx.notice(me, from.uid, "Access denied — working the help queue is for services operators.");
|
||||||
|
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 <id> 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::<u64>().ok()).and_then(|n| db.help_ticket(n)) else {
|
||||||
|
ctx.notice(me, from.uid, "No such ticket. Syntax: VIEW <id>");
|
||||||
|
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::<u64>().ok()) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: TAKE <id>");
|
||||||
|
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."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
let msg = db.help_ticket(id).map(|t| t.message).unwrap_or_default();
|
||||||
|
ctx.notice(me, from.uid, format!("You took ticket \x02#{id}\x02: {msg}"));
|
||||||
|
} else {
|
||||||
|
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::<u64>().ok()) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: CLOSE <id>");
|
||||||
|
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)."));
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
// fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
|
||||||
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
|
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
|
||||||
pub use fedserv_api::{
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -200,6 +200,10 @@ pub enum Event {
|
||||||
GroupFlagsSet { name: String, account: String, flags: String },
|
GroupFlagsSet { name: String, account: String, flags: String },
|
||||||
// Remove a member from a group entirely.
|
// Remove a member from a group entirely.
|
||||||
GroupMemberDel { name: String, account: String },
|
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).
|
// Runtime operator grants (OperServ OPER).
|
||||||
OperGranted {
|
OperGranted {
|
||||||
account: String,
|
account: String,
|
||||||
|
|
@ -280,6 +284,9 @@ impl Event {
|
||||||
| Event::GroupFounderSet { .. }
|
| Event::GroupFounderSet { .. }
|
||||||
| Event::GroupFlagsSet { .. }
|
| Event::GroupFlagsSet { .. }
|
||||||
| Event::GroupMemberDel { .. }
|
| Event::GroupMemberDel { .. }
|
||||||
|
| Event::HelpRequested { .. }
|
||||||
|
| Event::HelpTaken { .. }
|
||||||
|
| Event::HelpClosed { .. }
|
||||||
| Event::OperGranted { .. }
|
| Event::OperGranted { .. }
|
||||||
| Event::OperRevoked { .. }
|
| Event::OperRevoked { .. }
|
||||||
| Event::SessionExceptionAdded { .. }
|
| Event::SessionExceptionAdded { .. }
|
||||||
|
|
@ -395,6 +402,18 @@ pub struct News {
|
||||||
pub ts: u64,
|
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
|
// A user group (GroupServ): a `!name`, its founder account, and member accounts
|
||||||
// each with group-access flags. Groups can be granted channel access.
|
// each with group-access flags. Groups can be granted channel access.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -436,6 +455,9 @@ pub struct NetData {
|
||||||
pub report_seq: u64,
|
pub report_seq: u64,
|
||||||
// User groups (GroupServ).
|
// User groups (GroupServ).
|
||||||
pub groups: Vec<Group>,
|
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.
|
// Runtime services operators (OperServ OPER), casefolded account -> grant.
|
||||||
// Merged with the declarative [[oper]] config at the engine.
|
// Merged with the declarative [[oper]] config at the engine.
|
||||||
pub opers: HashMap<String, OperGrant>,
|
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() });
|
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 {
|
for (account, grant) in &self.net.opers {
|
||||||
snapshot.push(Event::OperGranted { account: account.clone(), privs: grant.privs.clone(), expires: grant.expires });
|
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 })
|
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.
|
/// Register a new group (name must start with `!`). Founder is an account.
|
||||||
pub fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError> {
|
pub fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError> {
|
||||||
if !name.starts_with('!') || name.len() < 2 {
|
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));
|
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 } => {
|
Event::OperGranted { account, privs, expires } => {
|
||||||
net.opers.insert(key(&account), OperGrant { 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> {
|
fn report(&self, id: u64) -> Option<ReportView> {
|
||||||
Db::report(self, id)
|
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> {
|
fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError> {
|
||||||
Db::group_register(self, name, founder)
|
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}"),
|
ReportFiled { reporter, target, reason, .. } => format!("\x02{reporter}\x02 reported \x02{target}\x02: {reason}"),
|
||||||
ReportClosed { id } => format!("closed report #{id}"),
|
ReportClosed { id } => format!("closed report #{id}"),
|
||||||
ReportDeleted { id } => format!("deleted 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)"),
|
GroupRegistered { name, founder, .. } => format!("registered group \x02{name}\x02 (founder \x02{founder}\x02)"),
|
||||||
GroupDropped { name } => format!("dropped group \x02{name}\x02"),
|
GroupDropped { name } => format!("dropped group \x02{name}\x02"),
|
||||||
GroupFounderSet { name, founder } => format!("set founder of \x02{name}\x02 to \x02{founder}\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");
|
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
|
// 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.
|
// through the audit trail so OperServ LOGSEARCH finds it. Reviewing is oper-only.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,9 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
|
||||||
| Event::GroupFounderSet { .. }
|
| Event::GroupFounderSet { .. }
|
||||||
| Event::GroupFlagsSet { .. }
|
| Event::GroupFlagsSet { .. }
|
||||||
| Event::GroupMemberDel { .. }
|
| Event::GroupMemberDel { .. }
|
||||||
|
| Event::HelpRequested { .. }
|
||||||
|
| Event::HelpTaken { .. }
|
||||||
|
| Event::HelpClosed { .. }
|
||||||
| Event::OperGranted { .. }
|
| Event::OperGranted { .. }
|
||||||
| Event::OperRevoked { .. }
|
| Event::OperRevoked { .. }
|
||||||
| Event::SessionExceptionAdded { .. }
|
| Event::SessionExceptionAdded { .. }
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ use fedserv_infoserv::InfoServ;
|
||||||
use fedserv_reportserv::ReportServ;
|
use fedserv_reportserv::ReportServ;
|
||||||
use fedserv_groupserv::GroupServ;
|
use fedserv_groupserv::GroupServ;
|
||||||
use fedserv_chanfix::ChanFix;
|
use fedserv_chanfix::ChanFix;
|
||||||
|
use fedserv_helpserv::HelpServ;
|
||||||
use fedserv_example::ExampleServ;
|
use fedserv_example::ExampleServ;
|
||||||
use fedserv_inspircd::InspIrcd;
|
use fedserv_inspircd::InspIrcd;
|
||||||
use fedserv_nickserv::NickServ;
|
use fedserv_nickserv::NickServ;
|
||||||
|
|
@ -119,6 +120,11 @@ async fn main() -> Result<()> {
|
||||||
uid: format!("{}AAAAAM", cfg.server.sid),
|
uid: format!("{}AAAAAM", cfg.server.sid),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
if enabled("helpserv") {
|
||||||
|
services.push(Box::new(HelpServ {
|
||||||
|
uid: format!("{}AAAAAN", cfg.server.sid),
|
||||||
|
}));
|
||||||
|
}
|
||||||
if enabled("example") {
|
if enabled("example") {
|
||||||
services.push(Box::new(ExampleServ {
|
services.push(Box::new(ExampleServ {
|
||||||
uid: format!("{}AAAAAC", cfg.server.sid),
|
uid: format!("{}AAAAAC", cfg.server.sid),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue