chanserv: SET TOPICLOCK and KEEPTOPIC (topic subsystem)

Adds topic tracking: a TopicChange event (parsed from InspIRCd FTOPIC, our own
changes filtered by SID like FMODE) and a stored topic on the channel, folded
through the event log. TOPICLOCK reverts a topic change by a user without
op-level access back to the stored topic; KEEPTOPIC remembers the topic and
restores it when a registered channel is recreated. Both via the typed
ChanSettings path; shown in INFO. Parse + engine tests cover both.
This commit is contained in:
Jean Chevronnet 2026-07-13 03:30:04 +00:00
parent d2c6448c6b
commit 4600ee062c
No known key found for this signature in database
7 changed files with 135 additions and 7 deletions

View file

@ -165,6 +165,17 @@ impl Protocol for InspIrcd {
_ => vec![],
}
}
// :<src> FTOPIC <chan> <chants> <topicts> [setby] :<topic> — a topic
// change. Skip changes we made ourselves so enforcement can't loop.
"FTOPIC" => {
let a: Vec<&str> = tokens.collect();
match (source.as_deref(), a.first()) {
(Some(src), Some(chan)) if !src.starts_with(self.sid.as_str()) && chan.starts_with('#') => {
vec![NetEvent::TopicChange { channel: chan.to_string(), setter: src.to_string(), topic: trailing(rest) }]
}
_ => vec![],
}
}
"QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }],
// account-registration relay from an ircd:
// ACCTREGISTER <reqid> <origin> <kind> <account> <p2> :<p3>
@ -412,6 +423,21 @@ mod tests {
assert!(!ev.iter().any(|e| matches!(e, NetEvent::ChannelModeChange { .. })), "own change filtered: {ev:?}");
}
#[test]
fn parses_ftopic_and_filters_own() {
let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 :Welcome all");
assert!(
matches!(ev.as_slice(), [NetEvent::TopicChange { channel, setter, topic }] if channel == "#chan" && setter == "0IRAAAAAB" && topic == "Welcome all"),
"{ev:?}"
);
// The burst/RESYNC form carries a setby field before the topic.
let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 someone!u@h :Hi there");
assert!(matches!(ev.as_slice(), [NetEvent::TopicChange { topic, .. }] if topic == "Hi there"), "{ev:?}");
// Our own topic changes are filtered so enforcement can't loop.
let ev = proto().parse(":42S FTOPIC #chan 1 1 :nope");
assert!(!ev.iter().any(|e| matches!(e, NetEvent::TopicChange { .. })), "own change filtered: {ev:?}");
}
// FJOIN members and IJOIN both surface as Join events.
#[test]
fn parses_joins() {