redact: draft/message-redaction — author/op can delete a channel message, relayed to capable members + dropped from history

This commit is contained in:
Jean Chevronnet 2026-08-08 21:38:39 +00:00
parent 0578b63bd6
commit e9146167ae
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
2 changed files with 89 additions and 0 deletions

View file

@ -124,9 +124,94 @@ pub fn commands() -> Vec<Box<dyn Command>> {
Box::new(Notice),
Box::new(TagMsg),
Box::new(ChatHistory),
Box::new(Redact),
]
}
/// REDACT — delete a previously-sent channel message (draft/message-redaction).
/// `REDACT <#chan> <msgid> [: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 = &params[0];
let msgid = &params[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<Uid> = 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 {

View file

@ -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,
};