Persist the channel topic setter as a nick!ident@host mask for KEEPTOPIC and INFO
All checks were successful
CI / check (push) Successful in 5m34s

This commit is contained in:
Jean Chevronnet 2026-07-21 09:26:41 +00:00
parent 9ccc681a62
commit 948a59707e
No known key found for this signature in database
17 changed files with 102 additions and 35 deletions

View file

@ -62,7 +62,9 @@ pub enum NetEvent {
ChannelKey { channel: String, key: Option<String> },
// A channel's topic changed (FTOPIC), for KEEPTOPIC / TOPICLOCK. `setter` is
// the source uid; our own changes are filtered out by the protocol layer.
TopicChange { channel: String, setter: String, topic: String },
// `setter` is the ephemeral source uid (for auth/notify only — never persisted).
// `setby` is the ircd's nick!ident@host author mask, the stable value to store.
TopicChange { channel: String, setter: String, topic: String, setby: String },
// A user disconnected (QUIT). `reason` is the quit message (may be empty).
Quit { uid: String, reason: String },
// A user was forcibly removed (KILL). Handled like a quit, except a killed
@ -175,7 +177,10 @@ pub enum NetAction {
// Kick a user from a channel, sourced from pseudoclient `from`.
Kick { from: String, channel: String, uid: String, reason: String },
// Set a channel's topic, sourced from pseudoclient `from`.
Topic { from: String, channel: String, topic: String },
// `setter` is the name attributed as having set the topic (the FTOPIC "setby"
// field); empty falls back to `from`. Lets a KEEPTOPIC restore credit the
// original author rather than the service.
Topic { from: String, channel: String, topic: String, setter: String },
// Invite a user to a channel, sourced from pseudoclient `from`.
Invite { from: String, uid: String, channel: String },
// Add a network ban (X-line — `kind` is the ircd's line type, e.g. "G" for a
@ -909,12 +914,14 @@ impl ServiceCtx {
});
}
// Set a channel's topic, sourced from pseudoclient `from`.
pub fn topic(&mut self, from: &str, channel: &str, topic: &str) {
// Set a channel's topic, sourced from pseudoclient `from`, credited to `setter`
// in the FTOPIC "setby" field (empty = credit `from`).
pub fn topic(&mut self, from: &str, channel: &str, topic: &str, setter: &str) {
self.actions.push(NetAction::Topic {
from: from.to_string(),
channel: channel.to_string(),
topic: topic.to_string(),
setter: setter.to_string(),
});
}
@ -1906,6 +1913,8 @@ pub struct ChannelView {
pub suspended: bool,
// Last known topic (for KEEPTOPIC / TOPICLOCK).
pub topic: String,
// Who last set the topic (a nick), shown in INFO; empty if unknown.
pub topic_setter: String,
// BotServ bot assigned to this channel, if any.
pub assigned_bot: Option<String>,
// BotServ: whether members' personal greets are shown on join.
@ -2316,7 +2325,7 @@ pub trait Store {
fn trigger_del(&mut self, channel: &str, index: usize) -> Result<bool, ChanError>;
fn trigger_clear(&mut self, channel: &str) -> Result<usize, ChanError>;
fn triggers(&self, channel: &str) -> Vec<TriggerView>;
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError>;
fn set_channel_topic(&mut self, channel: &str, topic: &str, setter: &str) -> Result<(), ChanError>;
fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option<u64>) -> Result<(), ChanError>;
fn unsuspend_channel(&mut self, channel: &str) -> Result<bool, ChanError>;
fn is_channel_suspended(&self, channel: &str) -> bool;

View file

@ -147,6 +147,7 @@
" Email : {email} (unconfirmed)": " E-Mail : {email} (unbestätigt)",
" Filed : {when}": " Eingereicht: {when}",
" Founder : \u0002{founder}\u0002": " Gründer : \u0002{founder}\u0002",
" Topic by : \u0002{who}\u0002": " Gesetzt von: \u0002{who}\u0002",
" Founder : \u0002{founder}\u0002": " Gründer : \u0002{founder}\u0002",
" From : \u0002{requester}\u0002": " Von : \u0002{requester}\u0002",
" Greet : {greet}": " Greet : {greet}",

View file

@ -147,6 +147,7 @@
" Email : {email} (unconfirmed)": " Email : {email} (sin confirmar)",
" Filed : {when}": " Enviado : {when}",
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
" Topic by : \u0002{who}\u0002": " Puesto por : \u0002{who}\u0002",
" Founder : \u0002{founder}\u0002": " Fundador: \u0002{founder}\u0002",
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
" Greet : {greet}": " Greet : {greet}",

View file

@ -147,6 +147,7 @@
" Email : {email} (unconfirmed)": " Email : {email} (sin confirmar)",
" Filed : {when}": " Presentado: {when}",
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
" Topic by : \u0002{who}\u0002": " Puesto por : \u0002{who}\u0002",
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
" Greet : {greet}": " Saludo : {greet}",

View file

@ -147,6 +147,7 @@
" Email : {email} (unconfirmed)": " Email : {email} (non confirmé)",
" Filed : {when}": " Déposé : {when}",
" Founder : \u0002{founder}\u0002": " Fondateur : \u0002{founder}\u0002",
" Topic by : \u0002{who}\u0002": " Défini par : \u0002{who}\u0002",
" Founder : \u0002{founder}\u0002": " Fondateur : \u0002{founder}\u0002",
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
" Greet : {greet}": " Message d'accueil : {greet}",

View file

@ -147,6 +147,7 @@
" Email : {email} (unconfirmed)": " Email : {email} (não confirmado)",
" Filed : {when}": " Registrado: {when}",
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
" Topic by : \u0002{who}\u0002": " Definido por: \u0002{who}\u0002",
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
" Greet : {greet}": " Saudação : {greet}",

View file

@ -147,6 +147,7 @@
" Email : {email} (unconfirmed)": " Email : {email} (não confirmado)",
" Filed : {when}": " Submetido: {when}",
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
" Topic by : \u0002{who}\u0002": " Definido por: \u0002{who}\u0002",
" Founder : \u0002{founder}\u0002": " Fundador : \u0002{founder}\u0002",
" From : \u0002{requester}\u0002": " De : \u0002{requester}\u0002",
" Greet : {greet}": " Saudação : {greet}",

View file

@ -193,6 +193,9 @@ impl Service for ChanServ {
ctx.notice(me, from.uid, t!(ctx, " Email : {email}", email = info.email));
}
ctx.notice(me, from.uid, t!(ctx, " Registered : {when}", when = echo_api::human_time(info.ts)));
if !info.topic_setter.is_empty() {
ctx.notice(me, from.uid, t!(ctx, " Topic by : \x02{who}\x02", who = info.topic_setter));
}
if let Some(s) = db.channel_suspension(chan) {
ctx.notice(me, from.uid, t!(ctx, " Suspended : by \x02{by}\x02 — {reason}", by = s.by, reason = s.reason));
}

View file

@ -12,10 +12,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
return;
}
let text = if args.len() > 2 { args[2..].join(" ") } else { String::new() };
ctx.topic(me, chan, &text);
ctx.topic(me, chan, &text, from.nick);
// Persist it too: the ircd filters our own FTOPIC back out, so without this the
// stored topic stays stale and KEEPTOPIC/TOPICLOCK restore the old one on
// recreation/restart. Ignore NoChannel (require_op already proved it exists).
let _ = db.set_channel_topic(chan, &text);
let _ = db.set_channel_topic(chan, &text, from.nick);
ctx.notice(me, from.uid, t!(ctx, "Topic for \x02{chan}\x02 updated.", chan = chan));
}

View file

@ -268,7 +268,11 @@ impl Protocol for InspIrcd {
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) }]
// FTOPIC <chan> <chanTS> <topicTS> [setby] :<topic>. The optional
// setby (index 3) is the author's nick!ident@host; a ':'-prefixed
// token there is the trailing topic, meaning no setby was sent.
let setby = a.get(3).filter(|s| !s.starts_with(':')).map(|s| s.to_string()).unwrap_or_default();
vec![NetEvent::TopicChange { channel: chan.to_string(), setter: src.to_string(), topic: trailing(rest), setby }]
}
_ => vec![],
}
@ -541,9 +545,16 @@ impl Protocol for InspIrcd {
}
// FTOPIC <chan> <chanTS> <topicTS> <setter> :<topic>. TS 1 so the ircd
// always accepts it; sourced from the given pseudoclient.
NetAction::Topic { from, channel, topic } => {
NetAction::Topic { from, channel, topic, setter } => {
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts);
vec![format!(":{} FTOPIC {} 1 {} {} :{}", from, channel, now, from, topic)]
// The setby field must be a nick / nick!ident@host — never a uid. When
// we don't know the author, omit it so the ircd credits the source's
// nick instead of printing our raw source uid.
if setter.is_empty() {
vec![format!(":{} FTOPIC {} 1 {} :{}", from, channel, now, topic)]
} else {
vec![format!(":{} FTOPIC {} 1 {} {} :{}", from, channel, now, setter, topic)]
}
}
// INVITE <uid> <chan> <chanTS> <expiry>. Expiry 0 = no expiry.
NetAction::Invite { from, uid, channel } => {
@ -1076,6 +1087,20 @@ mod tests {
assert!(matches!(ev.as_slice(), [NetEvent::Privmsg { msgid: None, .. }]));
}
// The FTOPIC setby field is a nick / nick!ident@host when known, and omitted
// otherwise — never our raw source uid (which is meaningless after a reconnect).
#[test]
fn topic_setby_is_a_mask_or_omitted_never_a_uid() {
let with = proto().serialize(&NetAction::Topic { from: "42SAAAAAA".into(), channel: "#c".into(), topic: "hi".into(), setter: "bob!u@host".into() });
let head = with[0].split(" :").next().unwrap();
assert!(head.ends_with("bob!u@host"), "known author -> mask in setby: {with:?}");
let without = proto().serialize(&NetAction::Topic { from: "42SAAAAAA".into(), channel: "#c".into(), topic: "hi".into(), setter: String::new() });
let head = without[0].split(" :").next().unwrap();
assert!(head.split(' ').next_back().unwrap().chars().all(|c| c.is_ascii_digit()),
"unknown author -> no setby token (ends with topicTS), no uid: {without:?}");
}
#[test]
fn serializes_svspart() {
let lines = proto().serialize(&NetAction::ForcePart { uid: "0IRAAAAAB".into(), channel: "#chan".into(), reason: "bye".into() });
@ -1197,12 +1222,12 @@ mod tests {
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"),
matches!(ev.as_slice(), [NetEvent::TopicChange { channel, setter, topic, setby }] if channel == "#chan" && setter == "0IRAAAAAB" && topic == "Welcome all" && setby.is_empty()),
"{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:?}");
assert!(matches!(ev.as_slice(), [NetEvent::TopicChange { topic, setby, .. }] if topic == "Hi there" && setby == "someone!u@h"), "{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:?}");

View file

@ -15,7 +15,7 @@ impl Db {
self.log
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
.map_err(|_| ChanError::Internal)?;
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), levels: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), levels: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), topic_setter: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
Ok(())
}
@ -505,16 +505,18 @@ impl Db {
Ok(())
}
/// Remember a channel's topic (KEEPTOPIC / TOPICLOCK).
pub fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
/// Remember a channel's topic and who set it (KEEPTOPIC / TOPICLOCK / INFO).
pub fn set_channel_topic(&mut self, channel: &str, topic: &str, setter: &str) -> Result<(), ChanError> {
let k = key(channel);
if !self.channels.contains_key(&k) {
return Err(ChanError::NoChannel);
}
self.log
.append(Event::ChannelTopicSet { channel: channel.to_string(), topic: topic.to_string() })
.append(Event::ChannelTopicSet { channel: channel.to_string(), topic: topic.to_string(), setter: setter.to_string() })
.map_err(|_| ChanError::Internal)?;
self.channels.get_mut(&k).unwrap().topic = topic.to_string();
let c = self.channels.get_mut(&k).unwrap();
c.topic = topic.to_string();
c.topic_setter = setter.to_string();
Ok(())
}

View file

@ -59,7 +59,7 @@ pub enum Event {
ChannelKickerSet { channel: String, kickers: KickerSettings },
ChannelBadwordsSet { channel: String, badwords: Vec<String> },
ChannelTriggersSet { channel: String, triggers: Vec<Trigger> },
ChannelTopicSet { channel: String, topic: String },
ChannelTopicSet { channel: String, topic: String, #[serde(default)] setter: String },
ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option<u64> },
ChannelUnsuspended { channel: String },
ChannelBotAssigned { channel: String, bot: String },
@ -493,7 +493,7 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
grouped.remove(&key(&nick));
}
Event::ChannelRegistered { name, founder, ts } => {
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), levels: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), levels: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), topic_setter: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
}
Event::ChannelDropped { name } => {
channels.remove(&key(&name));
@ -585,9 +585,10 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
c.triggers_rev = c.triggers_rev.wrapping_add(1);
}
}
Event::ChannelTopicSet { channel, topic } => {
Event::ChannelTopicSet { channel, topic, setter } => {
if let Some(c) = channels.get_mut(&key(&channel)) {
c.topic = topic;
c.topic_setter = setter;
}
}
Event::ChannelSuspended { channel, by, reason, ts, expires } => {

View file

@ -501,6 +501,10 @@ pub struct ChannelInfo {
// Last known topic, kept for KEEPTOPIC / TOPICLOCK.
#[serde(default)]
pub topic: String,
// Nick credited with setting `topic`, restored as the FTOPIC "setby" on
// KEEPTOPIC and shown in INFO. Empty if unknown (e.g. pre-upgrade state).
#[serde(default)]
pub topic_setter: String,
// Services suspension, if any (channel frozen while set and unexpired).
#[serde(default)]
pub suspension: Option<Suspension>,
@ -1384,7 +1388,7 @@ impl Db {
snapshot.push(Event::ChannelSettingsSet { channel: c.name.clone(), settings: c.settings });
}
if !c.topic.is_empty() {
snapshot.push(Event::ChannelTopicSet { channel: c.name.clone(), topic: c.topic.clone() });
snapshot.push(Event::ChannelTopicSet { channel: c.name.clone(), topic: c.topic.clone(), setter: c.topic_setter.clone() });
}
if let Some(s) = &c.suspension {
snapshot.push(Event::ChannelSuspended { channel: c.name.clone(), by: s.by.clone(), reason: s.reason.clone(), ts: s.ts, expires: s.expires });

View file

@ -515,8 +515,8 @@ impl Store for Db {
fn triggers(&self, channel: &str) -> Vec<TriggerView> {
Db::triggers(self, channel).iter().map(|t| TriggerView { pattern: t.pattern.clone(), response: t.response.clone(), cooldown: t.cooldown }).collect()
}
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
Db::set_channel_topic(self, channel, topic)
fn set_channel_topic(&mut self, channel: &str, topic: &str, setter: &str) -> Result<(), ChanError> {
Db::set_channel_topic(self, channel, topic, setter)
}
fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option<u64>) -> Result<(), ChanError> {
Db::suspend_channel(self, channel, by, reason, expires)
@ -661,6 +661,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView {
keeptopic: c.settings.keeptopic,
topiclock: c.settings.topiclock,
topic: c.topic.clone(),
topic_setter: c.topic_setter.clone(),
suspended: c.suspension.as_ref().is_some_and(|s| s.expires.is_none_or(|e| e > now())),
assigned_bot: c.assigned_bot.clone(),
bot_greet: c.settings.bot_greet,

View file

@ -1412,9 +1412,10 @@ impl Engine {
match self.db.channel(&channel) {
Some(info) => {
let mut out = vec![NetAction::ChannelMode { from: from.clone(), channel: channel.clone(), modes: info.lock_modes() }];
// KEEPTOPIC: restore the remembered topic on a recreated channel.
// KEEPTOPIC: restore the remembered topic on a recreated channel,
// credited to whoever originally set it.
if info.settings.keeptopic && !info.topic.is_empty() {
out.push(NetAction::Topic { from, channel, topic: info.topic.clone() });
out.push(NetAction::Topic { from, channel, topic: info.topic.clone(), setter: info.topic_setter.clone() });
}
out
}
@ -1563,20 +1564,34 @@ impl Engine {
self.network.set_channel_key(&channel, key);
Vec::new()
}
NetEvent::TopicChange { channel, setter, topic } => {
NetEvent::TopicChange { channel, setter, topic, setby } => {
let watch = self.notify_line('t', &setter, Some(&channel), &format!("changed the topic of {channel}"));
let info = self.db.channel(&channel).map(|c| (c.settings.keeptopic, c.settings.topiclock, c.topic.clone()));
// Who set it, as a stable nick!ident@host — NEVER the source uid, which
// is recycled to another user the instant this connection drops. Prefer
// the ircd's own setby mask; else snapshot the still-connected source.
let author = if !setby.is_empty() {
setby
} else {
match self.network.nick_of(&setter) {
Some(nick) => match (self.network.ident_of(&setter), self.network.host_of(&setter)) {
(Some(ident), Some(host)) => format!("{nick}!{ident}@{host}"),
_ => nick.to_string(),
},
None => String::new(),
}
};
let info = self.db.channel(&channel).map(|c| (c.settings.keeptopic, c.settings.topiclock, c.topic.clone(), c.topic_setter.clone()));
// The setter may change a locked topic only with op-level access.
let authorized = self.network.account_of(&setter).and_then(|a| self.db.channel(&channel).map(|c| c.is_op(a))).unwrap_or(false);
let mut out: Vec<NetAction> = match info {
// TOPICLOCK + unauthorised: put the kept topic back.
Some((_, true, stored)) if !authorized => {
// TOPICLOCK + unauthorised: put the kept topic back, keeping its author.
Some((_, true, stored, stored_by)) if !authorized => {
let from = self.chan_service.clone().unwrap_or_default();
vec![NetAction::Topic { from, channel, topic: stored }]
vec![NetAction::Topic { from, channel, topic: stored, setter: stored_by }]
}
// Otherwise remember the new topic if the channel keeps it.
Some((keep, lock, _)) if keep || lock => {
let _ = self.db.set_channel_topic(&channel, &topic);
Some((keep, lock, _, _)) if keep || lock => {
let _ = self.db.set_channel_topic(&channel, &topic, &author);
Vec::new()
}
_ => Vec::new(),

View file

@ -3810,11 +3810,11 @@
db.register_channel("#c", "boss").unwrap();
db.set_channel_setting("#c", db::ChanSetting::KeepTopic, true).unwrap();
db.set_channel_setting("#c", db::ChanSetting::TopicLock, true).unwrap();
db.set_channel_topic("#c", "Official topic").unwrap();
db.set_channel_topic("#c", "Official topic", "alice").unwrap();
let mut e = Engine::new(vec![Box::new(ChanServ { uid: "42SAAAAAB".into() })], db);
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "rando".into(), host: "h".into() , ip: "0.0.0.0".into() });
// A user without access changes the topic -> reverted to the stored one.
let out = e.handle(NetEvent::TopicChange { channel: "#c".into(), setter: "000AAAAAB".into(), topic: "hacked".into() });
let out = e.handle(NetEvent::TopicChange { channel: "#c".into(), setter: "000AAAAAB".into(), topic: "hacked".into(), setby: String::new() });
assert!(out.iter().any(|a| matches!(a, NetAction::Topic { topic, .. } if topic == "Official topic")), "topiclock reverts: {out:?}");
// KEEPTOPIC restores the remembered topic on channel (re)creation.
let out = e.handle(NetEvent::ChannelCreate { channel: "#c".into() });

View file

@ -246,7 +246,8 @@ pub fn import_anope(anope_path: &str, out_path: &str, node: &str) -> std::io::Re
db.migrate_append(Event::ChannelDescSet { channel: name.to_string(), desc: desc.to_string() })?;
}
if let Some(topic) = field(ci, "last_topic") {
db.migrate_append(Event::ChannelTopicSet { channel: name.to_string(), topic: topic.to_string() })?;
let setter = field(ci, "last_topic_setter").unwrap_or("").to_string();
db.migrate_append(Event::ChannelTopicSet { channel: name.to_string(), topic: topic.to_string(), setter })?;
}
if let Some(bot) = field(ci, "bi") {
if !SERVICE_NICKS.contains(&bot.to_ascii_lowercase().as_str()) {