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:
Jean Chevronnet 2026-07-14 04:39:45 +00:00
parent 06227e73da
commit a47dd6bda3
No known key found for this signature in database
9 changed files with 385 additions and 2 deletions

8
Cargo.lock generated
View file

@ -332,6 +332,7 @@ dependencies = [
"fedserv-diceserv",
"fedserv-example",
"fedserv-groupserv",
"fedserv-helpserv",
"fedserv-hostserv",
"fedserv-infoserv",
"fedserv-inspircd",
@ -406,6 +407,13 @@ dependencies = [
"fedserv-api",
]
[[package]]
name = "fedserv-helpserv"
version = "0.0.1"
dependencies = [
"fedserv-api",
]
[[package]]
name = "fedserv-hostserv"
version = "0.0.1"

View file

@ -1,5 +1,5 @@
[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]
name = "fedserv"
@ -23,6 +23,7 @@ fedserv-infoserv = { path = "infoserv" }
fedserv-reportserv = { path = "reportserv" }
fedserv-groupserv = { path = "groupserv" }
fedserv-chanfix = { path = "chanfix" }
fedserv-helpserv = { path = "helpserv" }
tokio = { version = "1", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync", "process"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -640,6 +640,17 @@ pub struct NewsView {
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.
#[derive(Debug, Clone)]
pub struct ReportView {
@ -934,6 +945,13 @@ pub trait Store {
fn report_del(&mut self, id: u64) -> bool;
fn reports(&self, open_only: bool) -> Vec<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).
fn group_register(&mut self, name: &str, founder: &str) -> Result<(), ChanError>;
fn group_drop(&mut self, name: &str) -> Result<(), ChanError>;

8
helpserv/Cargo.toml Normal file
View 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
View 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)."));
}
}

View file

@ -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)
}

View file

@ -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]

View file

@ -181,6 +181,9 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
| Event::GroupFounderSet { .. }
| Event::GroupFlagsSet { .. }
| Event::GroupMemberDel { .. }
| Event::HelpRequested { .. }
| Event::HelpTaken { .. }
| Event::HelpClosed { .. }
| Event::OperGranted { .. }
| Event::OperRevoked { .. }
| Event::SessionExceptionAdded { .. }

View file

@ -26,6 +26,7 @@ use fedserv_infoserv::InfoServ;
use fedserv_reportserv::ReportServ;
use fedserv_groupserv::GroupServ;
use fedserv_chanfix::ChanFix;
use fedserv_helpserv::HelpServ;
use fedserv_example::ExampleServ;
use fedserv_inspircd::InspIrcd;
use fedserv_nickserv::NickServ;
@ -119,6 +120,11 @@ async fn main() -> Result<()> {
uid: format!("{}AAAAAM", cfg.server.sid),
}));
}
if enabled("helpserv") {
services.push(Box::new(HelpServ {
uid: format!("{}AAAAAN", cfg.server.sid),
}));
}
if enabled("example") {
services.push(Box::new(ExampleServ {
uid: format!("{}AAAAAC", cfg.server.sid),