Four drafts that other clients already ship
The line this draws: a draft nobody has built can still change underneath an implementation, and a draft WeeChat, Halloy and bIRC interoperate on is a protocol whatever the registry calls it. Four of the twelve qualify. Each degrades to exactly the old behaviour where a network does not offer it. **A message longer than a line** now goes as one message in several pieces rather than being truncated at a limit this client cannot see - and where the network has no multiline, the pieces go as separate messages, which is still better than the server cutting the end off. **Taking a message back**, which IRC never had and every other service here always did. The message is not removed when the delete is sent: the server decides whether it was allowed and says so by sending REDACT back, and that is what removes it. Showing a deletion that may not happen is worse than a moment's delay. **Where a conversation was read to**, sent when a buffer is marked read and received from whatever other client of the account last looked at it. Passed straight to the frontend rather than stored - which messages count as read is a question the thing with the screen owns, and a daemon keeping a second answer would be keeping one that disagrees. **A channel that renamed itself** is followed rather than appearing as a second channel with the first one's history stranded in it. A buffer's identity is its name, so it is a move: the old one goes, the new one arrives carrying what was said. Verified against testnet.ergo.chat, which offers forty capabilities and granted twenty-one of them in one line. Three things only a real server was ever going to catch: - `BATCH` is a parsed variant in the irc crate, not a raw command, so the arm written for it never fired at all. - The batch *type* comes back uppercased, so comparing it exactly meant the batch was never opened and every piece fell through as its own message. - The split broke *at* a space and trimmed both sides, and the pieces are rejoined with nothing between them - so a 659-byte message arrived as 658 with two words run together at the seam. The invariant that the pieces rejoin to exactly the message is now a test. A 659-byte send now leaves as a batch and comes back byte-identical as one message; a redaction goes out, is echoed, and takes the line off screen. Closes #158, #159, #160, #161. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -506,6 +506,23 @@ pub(super) const WANTED_CAPS: &[&str] = &[
|
||||
// - who they are identified as and whether they are away, rather than a
|
||||
// bare "they are online".
|
||||
"extended-monitor",
|
||||
// Four drafts, requested because WeeChat, Halloy and bIRC already ship
|
||||
// them: a draft nobody has built can still change underneath an
|
||||
// implementation, and a draft three clients interoperate on is a protocol
|
||||
// whatever the registry calls it. Each degrades to exactly the old
|
||||
// behaviour where it is not offered. See `drafts.rs`.
|
||||
//
|
||||
// A message longer than a line, sent as one message rather than cut.
|
||||
"draft/multiline",
|
||||
// Taking a message back, which IRC has never had and every other service
|
||||
// here has always had.
|
||||
"draft/message-redaction",
|
||||
// Where this conversation was read up to, kept by the server so a second
|
||||
// client starts where the first left off.
|
||||
"draft/read-marker",
|
||||
// A channel changing its name without becoming a second channel with the
|
||||
// first one's history stranded in it.
|
||||
"draft/channel-rename",
|
||||
];
|
||||
|
||||
/// The capabilities named by a CAP ACK, from whichever field they arrived in.
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
//! The IRCv3 extensions that are still drafts, and that clients ship anyway.
|
||||
//!
|
||||
//! Four of the twelve drafts are implemented in WeeChat, Halloy and bIRC
|
||||
//! today, which is the line this backend draws: a draft nobody has built is a
|
||||
//! specification that can still change underneath an implementation, and a
|
||||
//! draft three clients interoperate on is a de facto protocol.
|
||||
//!
|
||||
//! - `multiline` - one message that is longer than a line
|
||||
//! - `message-redaction` - taking a message back, which IRC never had
|
||||
//! - `read-marker` - where you had read up to, kept by the server
|
||||
//! - `channel-rename` - a channel changing its name without becoming a new one
|
||||
//!
|
||||
//! They are together in one file because they are the same shape of work -
|
||||
//! a capability, something to send, and a line to read - and because they
|
||||
//! share the one property worth stating in a single place: every one of them
|
||||
//! degrades to exactly the old behaviour on a network that does not offer it.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// How long a single IRC line may be, in bytes, including the trailing CRLF.
|
||||
///
|
||||
/// The 512 of RFC 1459, minus room for the parts the server prepends that
|
||||
/// this client cannot see: our own `nick!user@host` and the command and
|
||||
/// target in front of the text. 400 is what other clients settle on for the
|
||||
/// same reason - it is a conservative guess at a number the server knows and
|
||||
/// we do not.
|
||||
pub(super) const SAFE_LINE: usize = 400;
|
||||
|
||||
/// Splits a message into pieces that will each fit on the wire.
|
||||
///
|
||||
/// Split on character boundaries, never inside one: a UTF-8 sequence cut in
|
||||
/// half arrives as replacement characters at both ends of the join, which is
|
||||
/// worse than the truncation this exists to avoid.
|
||||
///
|
||||
/// Prefers to break at a space near the limit, because a line broken
|
||||
/// mid-word reads as a fault even when it is reassembled correctly at the
|
||||
/// other end - and on a client without the capability, mid-word is exactly
|
||||
/// what everybody sees.
|
||||
pub(super) fn split_for_wire(body: &str, limit: usize) -> Vec<String> {
|
||||
if body.len() <= limit {
|
||||
return vec![body.to_string()];
|
||||
}
|
||||
let mut pieces = Vec::new();
|
||||
let mut rest = body;
|
||||
while rest.len() > limit {
|
||||
// The last byte index at or below the limit that starts a character.
|
||||
let mut cut = limit;
|
||||
while cut > 0 && !rest.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
// Back up to a space if there is one reasonably close, so words stay
|
||||
// whole. "Reasonably" is a quarter of the line: further back than
|
||||
// that and the pieces get lopsided enough to look like a bug of
|
||||
// their own.
|
||||
if let Some(space) = rest[..cut].rfind(' ') {
|
||||
if space > cut.saturating_sub(limit / 4) {
|
||||
// After the space, not at it. The pieces are rejoined with
|
||||
// nothing between them, so a separator dropped here is a
|
||||
// separator gone: a 659-byte message came back as 658 with
|
||||
// two words run together at the seam.
|
||||
cut = space + 1;
|
||||
}
|
||||
}
|
||||
if cut == 0 {
|
||||
// One character longer than the limit, which cannot be split any
|
||||
// further. Send it whole and let the server truncate; there is
|
||||
// no better answer.
|
||||
break;
|
||||
}
|
||||
pieces.push(rest[..cut].to_string());
|
||||
rest = &rest[cut..];
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
pieces.push(rest.to_string());
|
||||
}
|
||||
pieces
|
||||
}
|
||||
|
||||
/// Sends a message as a multiline batch.
|
||||
///
|
||||
/// The batch is opened, each piece goes out as an ordinary PRIVMSG carrying
|
||||
/// the batch tag, and the batch is closed. A client that understands the
|
||||
/// batch sees one message; one that does not sees the pieces, which is the
|
||||
/// same thing every client sees today and therefore no worse.
|
||||
///
|
||||
/// The pieces carry `draft/multiline-concat` so a receiver joins them without
|
||||
/// inserting the newline that would otherwise separate them - this splits a
|
||||
/// long paragraph, not a poem.
|
||||
pub(super) fn send_multiline(sender: &Sender, target: &str, pieces: &[String]) -> Result<()> {
|
||||
// A reference this client picked, unique for as long as the batch is
|
||||
// open. The clock is enough: batches do not overlap here, because a send
|
||||
// completes before the next one starts.
|
||||
let reference = format!(
|
||||
"ml{}",
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() % 100_000
|
||||
);
|
||||
sender.send(Command::BATCH(
|
||||
format!("+{reference}"),
|
||||
Some(irc::proto::BatchSubCommand::CUSTOM("draft/multiline".to_string())),
|
||||
Some(vec![target.to_string()]),
|
||||
))?;
|
||||
for piece in pieces {
|
||||
let mut line = Message::new(None, "PRIVMSG", vec![target, piece])?;
|
||||
line.tags = Some(vec![
|
||||
irc::proto::message::Tag("batch".to_string(), Some(reference.clone())),
|
||||
irc::proto::message::Tag("draft/multiline-concat".to_string(), None),
|
||||
]);
|
||||
sender.send(line)?;
|
||||
}
|
||||
sender.send(Command::BATCH(format!("-{reference}"), None, None))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A multiline message being assembled as its pieces arrive.
|
||||
///
|
||||
/// The pieces are ordinary PRIVMSGs carrying a `batch` tag, so without this
|
||||
/// they are ordinary messages: a paragraph somebody sent as one thing arrives
|
||||
/// as four lines, and our own echo of it arrives the same way - which is how
|
||||
/// this was found, a 659-byte send coming back as its first 395 bytes.
|
||||
pub(super) struct Assembling {
|
||||
pub target: String,
|
||||
/// Each piece, and whether it joins the previous one without a newline.
|
||||
pub pieces: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
impl Assembling {
|
||||
/// The message the pieces make.
|
||||
///
|
||||
/// `draft/multiline-concat` on a piece means it continues the one before
|
||||
/// rather than starting a line of its own - the difference between a long
|
||||
/// paragraph that had to be split and a message somebody wrote with line
|
||||
/// breaks in it.
|
||||
pub(super) fn finish(&self) -> String {
|
||||
let mut body = String::new();
|
||||
for (index, (piece, concat)) in self.pieces.iter().enumerate() {
|
||||
if index > 0 && !concat {
|
||||
body.push('\n');
|
||||
}
|
||||
body.push_str(piece);
|
||||
}
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
/// Batches being assembled, keyed by account and by the reference the server
|
||||
/// gave them.
|
||||
///
|
||||
/// Per process rather than per connection, for the same reason `pending_sends`
|
||||
/// is: the dispatch that sees the pieces has no place to keep state of its own,
|
||||
/// and a reference is unique for as long as the batch is open.
|
||||
pub(super) fn open_batches() -> &'static std::sync::Mutex<HashMap<(String, String), Assembling>> {
|
||||
static OPEN: std::sync::OnceLock<std::sync::Mutex<HashMap<(String, String), Assembling>>> = std::sync::OnceLock::new();
|
||||
OPEN.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Reads a BATCH line: which reference, whether it is opening or closing, and
|
||||
/// what kind it is.
|
||||
///
|
||||
/// `BATCH +<ref> <type> [params]` opens one and `BATCH -<ref>` closes it. Only
|
||||
/// multiline batches are collected - a chathistory batch is a container for
|
||||
/// messages that are each their own message, and folding those into one would
|
||||
/// turn a replayed conversation into a wall of text.
|
||||
pub(super) fn read_batch_tag(tag: &str) -> Option<(bool, String)> {
|
||||
match tag.split_at_checked(1)? {
|
||||
("+", rest) => Some((true, rest.to_string())),
|
||||
("-", rest) => Some((false, rest.to_string())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the server to take a message back.
|
||||
///
|
||||
/// `REDACT <target> <msgid> [:reason]`. Whether it is allowed is the server's
|
||||
/// decision - your own message, or somebody else's if you have the rank - and
|
||||
/// the answer comes back as a standard reply, which is already handled.
|
||||
pub fn redact(sender: &Sender, target: &str, msg_id: &str, reason: Option<&str>) -> Result<()> {
|
||||
let mut args = vec![target.to_string(), msg_id.to_string()];
|
||||
if let Some(reason) = reason.filter(|r| !r.is_empty()) {
|
||||
args.push(reason.to_string());
|
||||
}
|
||||
sender.send(Command::Raw("REDACT".to_string(), args))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tells the server where this conversation has been read up to.
|
||||
///
|
||||
/// `MARKREAD <target> timestamp=<iso8601>`. The point is other clients: the
|
||||
/// same account open on a phone starts where you left off here, which every
|
||||
/// other protocol in this daemon has done for a while and IRC could not.
|
||||
pub fn mark_read(sender: &Sender, target: &str, at: i64) -> Result<()> {
|
||||
let stamp = chrono::DateTime::from_timestamp(at, 0)
|
||||
.unwrap_or_else(chrono::Utc::now)
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
.to_string();
|
||||
sender.send(Command::Raw("MARKREAD".to_string(), vec![target.to_string(), format!("timestamp={stamp}")]))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads the timestamp out of a MARKREAD line.
|
||||
///
|
||||
/// `MARKREAD <target> timestamp=<iso8601>` or `MARKREAD <target> *`, the
|
||||
/// second meaning nothing has been read. The star is not an error and not a
|
||||
/// time: it is answered with None and left alone, because "never read" is not
|
||||
/// a position to scroll to.
|
||||
pub(super) fn read_marker(args: &[String]) -> Option<(String, i64)> {
|
||||
let target = args.first()?.clone();
|
||||
let value = args.get(1)?;
|
||||
let stamp = value.strip_prefix("timestamp=")?;
|
||||
let at = chrono::DateTime::parse_from_rfc3339(stamp).ok()?.timestamp();
|
||||
Some((target, at))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The limit is in bytes and the text is in characters, which is the
|
||||
/// whole difficulty: a cut inside a multi-byte character produces
|
||||
/// replacement characters at both ends of the join.
|
||||
#[test]
|
||||
fn a_split_never_cuts_a_character_in_half() {
|
||||
// Every one of these is three bytes and one character.
|
||||
let body = "日".repeat(300);
|
||||
let pieces = split_for_wire(&body, 100);
|
||||
assert!(pieces.len() > 1);
|
||||
for piece in &pieces {
|
||||
assert!(piece.len() <= 100, "piece was {} bytes", piece.len());
|
||||
// If a character had been cut the string would not round-trip.
|
||||
assert!(piece.chars().all(|c| c == '日'));
|
||||
}
|
||||
assert_eq!(pieces.concat().chars().count(), 300);
|
||||
}
|
||||
|
||||
/// A line broken mid-word reads as a fault, and on a client without the
|
||||
/// capability mid-word is what everybody sees - so a space near the limit
|
||||
/// wins over the limit itself.
|
||||
#[test]
|
||||
fn a_split_prefers_to_break_between_words() {
|
||||
let body = format!("{}tail", "word ".repeat(30));
|
||||
let pieces = split_for_wire(&body, 60);
|
||||
assert!(pieces.len() > 1);
|
||||
for piece in &pieces {
|
||||
// Whole words, with the separator kept on the end of the piece
|
||||
// it was cut from rather than thrown away.
|
||||
assert!(piece.trim_end().split(' ').all(|w| w == "word" || w == "tail"), "got {piece:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The invariant that matters more than any of the preferences above: the
|
||||
/// pieces put back together are the message. Found the hard way - the
|
||||
/// split used to break *at* a space and trim both sides, so a 659-byte
|
||||
/// message arrived as 658 with two words run together at the seam.
|
||||
#[test]
|
||||
fn the_pieces_are_exactly_the_message() {
|
||||
for body in [
|
||||
"word ".repeat(200),
|
||||
"日".repeat(400),
|
||||
format!("{}{}", "x".repeat(500), " and some words after it"),
|
||||
"nospacesatallinthiswholeverylongmessage".repeat(20),
|
||||
] {
|
||||
let pieces = split_for_wire(&body, 100);
|
||||
assert_eq!(pieces.concat(), body, "{} pieces did not rejoin", pieces.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Short enough to fit is one piece and no batch at all - the common
|
||||
/// case, and the one that must not grow a wrapper it does not need.
|
||||
#[test]
|
||||
fn something_that_fits_is_left_alone() {
|
||||
assert_eq!(split_for_wire("hello", 400), vec!["hello".to_string()]);
|
||||
let exact = "x".repeat(400);
|
||||
assert_eq!(split_for_wire(&exact, 400), vec![exact]);
|
||||
}
|
||||
|
||||
/// The difference between a paragraph that had to be split and a message
|
||||
/// somebody wrote with line breaks in it. Getting this wrong turns one
|
||||
/// into the other, in both directions.
|
||||
#[test]
|
||||
fn concat_decides_whether_a_piece_starts_a_line() {
|
||||
let split = Assembling {
|
||||
target: "#chat".into(),
|
||||
pieces: vec![("a long ".into(), false), ("paragraph".into(), true)],
|
||||
};
|
||||
assert_eq!(split.finish(), "a long paragraph");
|
||||
|
||||
let written = Assembling {
|
||||
target: "#chat".into(),
|
||||
pieces: vec![("first".into(), false), ("second".into(), false)],
|
||||
};
|
||||
assert_eq!(written.finish(), "first\nsecond");
|
||||
|
||||
// The first piece never gets a newline in front of it, whatever it
|
||||
// says about itself.
|
||||
let one = Assembling { target: "#chat".into(), pieces: vec![("only".into(), true)] };
|
||||
assert_eq!(one.finish(), "only");
|
||||
}
|
||||
|
||||
/// Which batch, and which way it goes.
|
||||
#[test]
|
||||
fn a_batch_tag_says_which_way_it_goes() {
|
||||
assert_eq!(read_batch_tag("+ml42"), Some((true, "ml42".to_string())));
|
||||
assert_eq!(read_batch_tag("-ml42"), Some((false, "ml42".to_string())));
|
||||
|
||||
// Anything without the sign is not a batch reference.
|
||||
assert!(read_batch_tag("ml42").is_none());
|
||||
assert!(read_batch_tag("").is_none());
|
||||
}
|
||||
|
||||
/// A time, and the two things that are not one.
|
||||
#[test]
|
||||
fn a_read_marker_is_a_time_or_it_is_nothing() {
|
||||
let args = ["#chat".to_string(), "timestamp=2026-09-10T12:00:00.000Z".to_string()];
|
||||
let (target, at) = read_marker(&args).expect("a marker");
|
||||
assert_eq!(target, "#chat");
|
||||
assert!(at > 1_700_000_000);
|
||||
|
||||
// "Never read" is not a position to scroll to.
|
||||
assert!(read_marker(&["#chat".to_string(), "*".to_string()]).is_none());
|
||||
// And neither is a malformed one.
|
||||
assert!(read_marker(&["#chat".to_string(), "timestamp=yesterday".to_string()]).is_none());
|
||||
assert!(read_marker(&["#chat".to_string()]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,22 @@ pub(super) async fn handle_message(
|
||||
}
|
||||
}
|
||||
|
||||
// A piece of a multiline message, rather than a message. Held until the
|
||||
// batch closes and then recorded once - without this a paragraph somebody
|
||||
// sent as one thing arrives as four lines, and our own echo of a long
|
||||
// send comes back as its first piece only.
|
||||
if let Some(reference) = message_tag(&msg, "batch").map(str::to_string) {
|
||||
let key = (account_id.to_string(), reference);
|
||||
let mut open = drafts::open_batches().lock().unwrap();
|
||||
if let Some(batch) = open.get_mut(&key) {
|
||||
if let Command::PRIVMSG(_, ref body) = msg.command {
|
||||
let concat = message_tag(&msg, "draft/multiline-concat").is_some();
|
||||
batch.pieces.push((body.clone(), concat));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match msg.command {
|
||||
Command::PRIVMSG(target, body) => {
|
||||
let own = state.runtime.irc_current_nick(account_id).unwrap_or_else(|| own_nick.to_string());
|
||||
@@ -617,6 +633,96 @@ pub(super) async fn handle_message(
|
||||
state.runtime.grant_irc_caps(account_id, caps);
|
||||
}
|
||||
|
||||
// The frame around a multiline message. Only multiline batches are
|
||||
// collected: a chathistory batch contains messages that are each
|
||||
// their own message, and folding those together would turn a replayed
|
||||
// conversation into a wall of text.
|
||||
// `Command::BATCH`, not `Raw`: the crate parses this one into a
|
||||
// variant of its own, and a Raw arm for it silently never fires -
|
||||
// which is exactly what happened, and why a 659-byte message came
|
||||
// back as its two pieces after the reassembly was written.
|
||||
Command::BATCH(ref tag, ref sub, ref params) => {
|
||||
let kind = sub.as_ref().map(|s| s.to_str().to_string());
|
||||
let param = params.as_ref().and_then(|p| p.first().cloned());
|
||||
let Some((opening, reference)) = drafts::read_batch_tag(tag) else { return };
|
||||
let key = (account_id.to_string(), reference);
|
||||
if opening {
|
||||
// Case-insensitively: the crate parses the type through an
|
||||
// uppercasing enum, so what a server sent as
|
||||
// `draft/multiline` arrives as `DRAFT/MULTILINE`. Comparing
|
||||
// exactly meant the batch was never opened, every piece fell
|
||||
// through as an ordinary message, and the reassembly below
|
||||
// had nothing to reassemble.
|
||||
if kind.as_deref().is_some_and(|k| k.eq_ignore_ascii_case("draft/multiline")) {
|
||||
if let Some(target) = param {
|
||||
drafts::open_batches()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key, drafts::Assembling { target, pieces: Vec::new() });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(batch) = drafts::open_batches().lock().unwrap().remove(&key) else { return };
|
||||
if batch.pieces.is_empty() {
|
||||
return;
|
||||
}
|
||||
let body = batch.finish();
|
||||
let own = state.runtime.irc_current_nick(account_id).unwrap_or_else(|| own_nick.to_string());
|
||||
let kind = buffer_kind_hint(&batch.target);
|
||||
// Whose conversation it belongs in, by the same rule a single
|
||||
// PRIVMSG follows: a channel is itself, and a direct message is
|
||||
// filed under whoever is not us.
|
||||
let buffer = if is_channel(&batch.target) || from.eq_ignore_ascii_case(&own) {
|
||||
batch.target.clone()
|
||||
} else {
|
||||
from.clone()
|
||||
};
|
||||
state.runtime.record_message_at(state, account_id, &buffer, kind, &from, &body, false, "chat", None, msg_id, false, None, Vec::new(), Vec::new(), None, sent_at, None, None);
|
||||
}
|
||||
|
||||
// A message somebody took back. IRC never had this; every other
|
||||
// service here always did, which is why the entry existed in the menu
|
||||
// and did nothing on this one.
|
||||
Command::Raw(ref cmd, ref args) if cmd.eq_ignore_ascii_case("REDACT") => {
|
||||
let (Some(target), Some(msg_id)) = (args.first(), args.get(1)) else { return };
|
||||
let buffer = if is_channel(target) { target.clone() } else { from.clone() };
|
||||
state.runtime.delete_message(state, &crate::model::buffer_id(account_id, &buffer), msg_id);
|
||||
}
|
||||
|
||||
// Where this conversation has been read up to, according to the
|
||||
// server - which is to say, according to whatever other client of
|
||||
// this account last looked at it.
|
||||
Command::Raw(ref cmd, ref args) if cmd.eq_ignore_ascii_case("MARKREAD") => {
|
||||
if let Some((target, at)) = drafts::read_marker(args) {
|
||||
state.runtime.note_irc_read_marker(state, account_id, &target, at);
|
||||
}
|
||||
}
|
||||
|
||||
// A channel that changed its name. Without this it looks like leaving
|
||||
// one channel and joining another, and the conversation is split in
|
||||
// two with the history stranded in a room nobody is in.
|
||||
Command::Raw(ref cmd, ref args) if cmd.eq_ignore_ascii_case("RENAME") => {
|
||||
let (Some(old_name), Some(new_name)) = (args.first(), args.get(1)) else { return };
|
||||
if let Some(members) = channels.remove(old_name.as_str()) {
|
||||
channels.insert(new_name.clone(), members);
|
||||
}
|
||||
// The buffer's identity is its name, so this is a move rather
|
||||
// than a rename: the old one goes and the new one arrives
|
||||
// carrying what was said. Ordered that way round on purpose -
|
||||
// creating first would briefly show the channel twice.
|
||||
state.runtime.rename_irc_buffer(state, account_id, old_name, new_name);
|
||||
let reason = args.get(2).map(String::as_str).filter(|r| !r.is_empty());
|
||||
let line = match reason {
|
||||
Some(why) => format!("{old_name} is now called {new_name} ({why})"),
|
||||
None => format!("{old_name} is now called {new_name}"),
|
||||
};
|
||||
state.runtime.record_message(state, account_id, new_name, "channel", "*", &line, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
|
||||
if let Some(members) = channels.get(new_name.as_str()) {
|
||||
emit_presence(state, account_id, new_name, members);
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody's realname changed without them reconnecting, which is
|
||||
// the whole point of `setname`. Reported where they can be seen; a
|
||||
// realname is not shown in a roster, so this is the only place the
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//! - `who` - asking the server who is in a room, and their hostmasks
|
||||
//! - `typing` - the one notification IRC learned late
|
||||
//! - `history` - CHATHISTORY, where a network has it
|
||||
//! - `drafts` - the IRCv3 drafts that clients ship anyway
|
||||
//!
|
||||
//! And three that are IRC's own and nothing else's: `dcc`, its direct file
|
||||
//! transfer; `sasl`, how a client proves who it is during registration; and
|
||||
@@ -24,6 +25,7 @@
|
||||
//! say `backend::irc::send_message` without knowing which file that is, and
|
||||
//! each file opens with `use super::*` to share the import list below.
|
||||
pub mod connect;
|
||||
pub mod drafts;
|
||||
pub mod history;
|
||||
pub mod incoming;
|
||||
pub mod presence;
|
||||
@@ -36,6 +38,8 @@ pub mod sasl;
|
||||
|
||||
pub use connect::*;
|
||||
pub use history::*;
|
||||
// Not `pub use`: these are asked and answered inside this folder.
|
||||
use drafts::*;
|
||||
// Not `pub use`: the dispatch is nobody else's business, but its helpers
|
||||
// are wanted by the files beside it.
|
||||
use incoming::*;
|
||||
|
||||
@@ -330,6 +330,30 @@ pub(super) fn set_channel_mode(sender: &Sender, channel: &str, mode: ChannelMode
|
||||
}
|
||||
|
||||
pub(super) fn send_plain(state: &AppState, account_id: &str, sender: &Sender, target: &str, body: &str) -> Result<()> {
|
||||
// Longer than a line. Without `draft/multiline` the server truncates at
|
||||
// its own limit and the rest is simply gone, which is the one failure
|
||||
// here that loses what somebody wrote - so where the network offers the
|
||||
// capability the message goes as one message in several pieces, and where
|
||||
// it does not the pieces go as separate messages rather than as a cut.
|
||||
let pieces = drafts::split_for_wire(body, drafts::SAFE_LINE);
|
||||
if pieces.len() > 1 {
|
||||
let own_nick = state.runtime.irc_current_nick(account_id).unwrap_or_default();
|
||||
if state.runtime.irc_has_cap(account_id, "draft/multiline") {
|
||||
drafts::send_multiline(sender, target, &pieces)?;
|
||||
// Recorded whole, because whole is what was sent - the pieces are
|
||||
// a transport detail and putting them in the log as separate
|
||||
// lines would be showing the reader the envelope.
|
||||
if !state.runtime.irc_has_cap(account_id, "echo-message") {
|
||||
state.runtime.record_message(state, account_id, target, buffer_kind_hint(target), &own_nick, body, false, "chat", None, None, false, None, Vec::new(), Vec::new(), None);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
for piece in &pieces {
|
||||
send_plain(state, account_id, sender, target, piece)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Where the server echoes what we send, it is the echo that gets written:
|
||||
// it carries the server's id and time, and it is proof the message was
|
||||
// actually delivered rather than merely handed over. Without the
|
||||
|
||||
@@ -836,6 +836,27 @@ pub async fn dispatch(
|
||||
}
|
||||
(Some(ok_node()), None)
|
||||
}
|
||||
// IRC keeps a read position too, where the network offers
|
||||
// `draft/read-marker` - so a channel read here is read on the
|
||||
// phone, which is the whole point of the server holding it.
|
||||
Some(buffer) if buffer.account_id.starts_with("irc:") || state.accounts.get_irc(&buffer.account_id).is_some() => {
|
||||
if state.runtime.irc_has_cap(&buffer.account_id, "draft/read-marker") {
|
||||
if let Some(sender) = state.runtime.irc_sender(&buffer.account_id) {
|
||||
let target = buffer.name.clone();
|
||||
let at = params
|
||||
.get("ts")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or_else(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64);
|
||||
// Best-effort, like the Matrix one above: failing
|
||||
// to say a channel has been read must not make
|
||||
// reading it look like an error.
|
||||
if let Err(e) = backend::irc::drafts::mark_read(&sender, &target, at) {
|
||||
tracing::debug!("irc read marker: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
(Some(ok_node()), None)
|
||||
}
|
||||
_ => (Some(ok_node()), None),
|
||||
}
|
||||
}
|
||||
@@ -2546,6 +2567,25 @@ pub async fn dispatch(
|
||||
Err(e) => (None, Some(e.to_string())),
|
||||
},
|
||||
},
|
||||
// IRC, where the network offers `draft/message-redaction`.
|
||||
// Every other service here has always had this; IRC is where
|
||||
// the menu entry existed and did nothing.
|
||||
Some(buffer) if state.accounts.get_irc(&buffer.account_id).is_some() => {
|
||||
if !state.runtime.irc_has_cap(&buffer.account_id, "draft/message-redaction") {
|
||||
return (None, Some("this network has no way to take a message back".to_string()));
|
||||
}
|
||||
let Some(sender) = state.runtime.irc_sender(&buffer.account_id) else {
|
||||
return (None, Some("account not connected".to_string()));
|
||||
};
|
||||
match backend::irc::drafts::redact(&sender, &buffer.name, msg_id, None) {
|
||||
// Not removed here: the server decides whether this
|
||||
// was allowed and says so by sending REDACT back,
|
||||
// which is what removes it. Taking it off screen now
|
||||
// would be showing a deletion that may not happen.
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
Some(_) => (None, Some("deleting isn't supported for this service".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1167,6 +1167,40 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows a channel that changed its name, carrying its history with it.
|
||||
///
|
||||
/// The buffer's identity is its name, so this is a move rather than a
|
||||
/// rename: the old buffer goes and a new one arrives holding what was
|
||||
/// said. Done in that order deliberately - creating first would show the
|
||||
/// channel twice for as long as it took to remove the old one.
|
||||
pub fn rename_irc_buffer(&self, state: &AppState, account_id: &str, old_name: &str, new_name: &str) {
|
||||
let old_id = crate::model::buffer_id(account_id, old_name);
|
||||
let new_id = crate::model::buffer_id(account_id, new_name);
|
||||
match state.store.move_buffer(&old_id, &new_id) {
|
||||
Ok(moved) => tracing::debug!("irc[{account_id}]: {old_name} renamed to {new_name}, {moved} messages moved"),
|
||||
Err(e) => tracing::warn!("irc[{account_id}]: moving {old_name} to {new_name}: {e:#}"),
|
||||
}
|
||||
self.remove_buffer(state, &old_id);
|
||||
self.ensure_buffer(state, account_id, new_name, "channel");
|
||||
self.refresh_buffer_activity(state, &new_id);
|
||||
}
|
||||
|
||||
/// Where another client of this account says a conversation was read to.
|
||||
///
|
||||
/// Passed straight on rather than stored. Which messages count as read is
|
||||
/// a question the frontend owns - it is the thing that knows what is on
|
||||
/// screen - and a daemon that kept a second answer would be keeping one
|
||||
/// that disagrees.
|
||||
pub fn note_irc_read_marker(&self, state: &AppState, account_id: &str, target: &str, at: i64) {
|
||||
state.events.emit(
|
||||
"readMarker",
|
||||
serde_json::json!({
|
||||
"bufferId": crate::model::buffer_id(account_id, target),
|
||||
"ts": at,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn remove_buffer(&self, state: &AppState, buffer_id: &str) {
|
||||
self.buffers.lock().unwrap().remove(buffer_id);
|
||||
// And what pointed at it. A Matrix room whose buffer went away but
|
||||
|
||||
@@ -385,6 +385,26 @@ impl Store {
|
||||
/// with any messages already (from a prior backfill or from having
|
||||
/// seen live traffic) is left alone rather than re-fetched, since
|
||||
/// there's no per-message dedup against Discord's own message ids here.
|
||||
/// Moves everything said in one buffer to another name.
|
||||
///
|
||||
/// A buffer's identity here is its name, so a channel that renames itself
|
||||
/// is a different buffer as far as storage is concerned - and its history
|
||||
/// would be stranded under a name nobody is in. Returns how many messages
|
||||
/// moved, which is worth logging: a rename that silently moved nothing is
|
||||
/// the shape of this going wrong.
|
||||
pub fn move_buffer(&self, old_id: &str, new_id: &str) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
// OR IGNORE rather than a plain UPDATE: the destination may already
|
||||
// hold a message with the same id if the server sent anything under
|
||||
// the new name before the rename was processed, and a primary-key
|
||||
// collision would abort the whole move.
|
||||
let moved = conn.execute(
|
||||
"UPDATE OR IGNORE messages SET buffer_id = ?2 WHERE buffer_id = ?1",
|
||||
params![old_id, new_id],
|
||||
)?;
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
pub fn has_messages(&self, buffer_id: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let exists: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM messages WHERE buffer_id = ?1)", params![buffer_id], |row| row.get(0))?;
|
||||
|
||||
Reference in New Issue
Block a user