From 283f8f568304526ded3da71e8fe4c3eb61274b77 Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 8 Aug 2026 21:38:39 +0000 Subject: [PATCH] =?UTF-8?q?redact:=20draft/message-redaction=20=E2=80=94?= =?UTF-8?q?=20author/op=20can=20delete=20a=20channel=20message,=20relayed?= =?UTF-8?q?=20to=20capable=20members=20+=20dropped=20from=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/coremods/core_message.rs | 85 ++++++++++++++++++++++++++++++++++++ src/users.rs | 4 ++ 2 files changed, 89 insertions(+) diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs index cc96e87..aae9f3d 100644 --- a/src/coremods/core_message.rs +++ b/src/coremods/core_message.rs @@ -124,9 +124,94 @@ pub fn commands() -> Vec> { Box::new(Notice), Box::new(TagMsg), Box::new(ChatHistory), + Box::new(Redact), ] } +/// REDACT — delete a previously-sent channel message (draft/message-redaction). +/// `REDACT <#chan> [:reason]`. Allowed for the message's author, a channel +/// half-op-or-above, or an oper. Relayed to channel members who enabled the cap, +/// and the message is dropped from CHATHISTORY. +struct Redact; +impl Command for Redact { + fn name(&self) -> &'static str { + "REDACT" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let target = ¶ms[0]; + let msgid = ¶ms[1]; + let reason = params.get(2).cloned().unwrap_or_default(); + if !target.starts_with('#') { + s.fail( + uid, + "REDACT", + "INVALID_TARGET", + "REDACT only supports channels", + ); + return CmdResult::Fail; + } + let key = target.to_ascii_lowercase(); + let Some(author) = s.history.get(&key).and_then(|buf| { + buf.iter().find(|m| m.msgid == *msgid).map(|m| { + m.prefix + .split('!') + .next() + .unwrap_or("") + .to_ascii_lowercase() + }) + }) else { + s.fail( + uid, + "REDACT", + "UNKNOWN_MSGID", + &format!("No such message id {msgid}"), + ); + return CmdResult::Fail; + }; + let my_nick = s + .users + .get(&uid) + .map(|u| u.nick.to_ascii_lowercase()) + .unwrap_or_default(); + if my_nick != author && s.rank(uid, &key) < RANK_HALFOP && !s.is_oper(uid) { + s.fail( + uid, + "REDACT", + "REDACT_FORBIDDEN", + "You may only redact your own messages", + ); + return CmdResult::Fail; + } + if let Some(buf) = s.history.get_mut(&key) { + buf.retain(|m| m.msgid != *msgid); + } + let prefix = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); + let line = if reason.is_empty() { + format!(":{prefix} REDACT {target} {msgid}") + } else { + format!(":{prefix} REDACT {target} {msgid} :{reason}") + }; + let members: Vec = s + .channels + .get(&key) + .map(|c| c.members.keys().copied().collect()) + .unwrap_or_default(); + for m in members { + if s.users + .get(&m) + .map(|u| u.caps.message_redaction) + .unwrap_or(false) + { + s.send(m, line.clone()); + } + } + CmdResult::Ok + } +} + /// Canonical CHATHISTORY key for a DM between two nicks (order-independent; the /// `\0` prefix keeps it from ever colliding with a `#channel` key). fn dm_key(a: &str, b: &str) -> String { diff --git a/src/users.rs b/src/users.rs index 93bd559..4435840 100644 --- a/src/users.rs +++ b/src/users.rs @@ -98,6 +98,7 @@ pub const SUPPORTED_CAPS: &[&str] = &[ "labeled-response", "batch", "draft/chathistory", + "draft/message-redaction", "cap-notify", ]; @@ -124,6 +125,7 @@ pub struct Caps { pub labeled_response: bool, // tag responses to a labeled command with its label pub batch: bool, // understands BATCH framing pub chathistory: bool, // draft/chathistory — can request message history + pub message_redaction: bool, // draft/message-redaction — understands REDACT pub cap_notify: bool, } @@ -172,6 +174,7 @@ impl Caps { "labeled-response" => self.labeled_response, "batch" => self.batch, "draft/chathistory" => self.chathistory, + "draft/message-redaction" => self.message_redaction, "cap-notify" => self.cap_notify, _ => false, } @@ -198,6 +201,7 @@ impl Caps { "labeled-response" => &mut self.labeled_response, "batch" => &mut self.batch, "draft/chathistory" => &mut self.chathistory, + "draft/message-redaction" => &mut self.message_redaction, "cap-notify" => &mut self.cap_notify, _ => return false, };