Sneedchat whispers: the right command, and in the room

The command was `/whisper`. The site's is `/w`, and the failure was the bad
kind the comment above that constant had always warned about: an
unrecognised command is not rejected, it is posted as ordinary text. Every
whisper sent from here went to the whole room with "/whisper @name"
written in front of it. That is also why none ever came back - there was
never a conversation to reply to.

Inbound, whispers arrive as ordinary messages with no room attached, and
that absence is the only thing marking them - which the protocol notes
have said all along while the code waited for a separate `Whisper` frame
that does not come. Both are handled now; only one connection records
each, since every room's socket sees it.

They go in the room rather than a buffer of their own. The separate buffer
was the "DM window" in the report, and it was the same mistake in a worse
form: a private message you only see by going somewhere else to look, and
a conversation that pulled you out of the room you were talking in. A
whisper belongs to no room and the site shows it wherever you are, so it
is written into every room this account is in - the store keys on
(buffer, id), so each holds it once.

Highlighted in the first room only, because highlighting is what raises an
alert: one whisper is one notification however many rooms carry it. The
copies still read as whispers, since the client colours them by kind.

Our own whisper is recorded from us with the "@name" the site writes, not
filed under the recipient - where it read as something they had said.

And `/w @name message` typed into the box now does what it says. Passed
through it would have been a real whisper this client never saw, since the
site echoes none back, so the line vanished as it was sent.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-01 22:59:09 -04:00
co-authored by Claude Opus 5
parent 6237ad9292
commit 615c2ac96b
2 changed files with 187 additions and 39 deletions
+115 -30
View File
@@ -439,6 +439,30 @@ async fn handle_frame(state: &AppState, http: &http::HttpClient, host: &str, acc
Some(raw) => cached_avatar_path(http, host, &m.author.id, raw).await,
None => None,
};
// A message belonging to no room is not a room's message. The site
// delivers a whisper down the same channel as everything else and
// marks it only by that absence - which is why whispers arrived as
// nothing at all: the separate `Whisper` frame this used to wait for
// is not how they come.
//
// Handled here rather than only in that frame, and both are kept,
// because one of them is known to work and the other is what the
// protocol notes have always said. Every room's socket sees the same
// whisper, so only one connection records it.
if m.room_id.is_none() {
if !is_primary {
continue;
}
tracing::debug!("sockchat[{account_id}]: whisper from {} (no room_id)", m.author.username);
let msg_id = (!m.message_uuid.is_empty()).then(|| m.message_uuid.clone());
for buffer in record_whisper(state, account_id, &m.author.username, &body, msg_id, avatar_url, true) {
if !m.message_uuid.is_empty() {
spawn_attachment_resolve(state.clone(), http.clone(), buffer, m.message_uuid.clone(), body.clone());
}
}
continue;
}
// There's no separate "edit" event on this wire - the server just
// re-sends the same message_uuid with a bumped edit date. Try to
// update an already-stored row first; only if that uuid was never
@@ -472,13 +496,10 @@ async fn handle_frame(state: &AppState, http: &http::HttpClient, host: &str, acc
None => None,
};
let msg_id = (!w.message_uuid.is_empty()).then(|| w.message_uuid.clone());
// Marked as a whisper rather than an ordinary message, and
// highlighted like a mention: somebody has spoken to you
// directly and privately, which is at least as worth noticing
// as being named in a room.
let is_new = state.runtime.record_message(state, account_id, "Whispers", "dm", &w.author.username, &body, false, "whisper", None, msg_id, true, avatar_url, Vec::new(), Vec::new(), None);
if is_new && !w.message_uuid.is_empty() {
spawn_attachment_resolve(state.clone(), http.clone(), crate::model::buffer_id(account_id, "Whispers"), w.message_uuid.clone(), body);
for buffer in record_whisper(state, account_id, &w.author.username, &body, msg_id, avatar_url, true) {
if !w.message_uuid.is_empty() {
spawn_attachment_resolve(state.clone(), http.clone(), buffer, w.message_uuid.clone(), body.clone());
}
}
}
}
@@ -815,6 +836,67 @@ fn room_sender_for_buffer(state: &AppState, account_id: &str, buffer_name: &str)
state.runtime.sockchat_sender(account_id, room.id).ok_or_else(|| anyhow!("not currently connected to this room"))
}
/// Puts a whisper in front of whoever is reading, whichever room that is.
///
/// A whisper belongs to no room, and the site shows it wherever you happen to
/// be looking. Nothing here can know which room a frontend has open, so it is
/// recorded in every room this account is in rather than one being picked and
/// hoped for.
///
/// That replaces a buffer of its own, which is what the report called "a DM
/// window". The separate buffer was the same mistake in a worse form: a
/// private message you only see by going somewhere else to look, and a
/// conversation that pulled you out of the room you were in to have it.
///
/// Storing the same message under several buffers is fine and not a
/// duplicate - the store keys on (buffer, id), so each room holds it once and
/// none of them holds it twice however many sockets saw it.
#[allow(clippy::too_many_arguments)]
fn record_whisper(
state: &AppState,
account_id: &str,
from: &str,
body: &str,
msg_id: Option<String>,
avatar_url: Option<String>,
// `notify`: whether this one should raise an alert. True for a whisper
// somebody sent us; false for one we sent, which needs no telling.
notify: bool,
) -> Vec<String> {
let Some(cfg) = state.accounts.get_sockchat(account_id) else { return Vec::new() };
let mut fresh = Vec::new();
for (index, room) in effective_rooms(&cfg).into_iter().enumerate() {
// Highlighted in the first room only, and highlighting is what raises
// an alert - so one whisper is one notification however many rooms it
// is written into. The copies are still whispers and are still drawn
// as whispers, because the client colours them by kind rather than by
// highlight; what they are not is a second time somebody's desktop
// says the same thing.
let alert = notify && index == 0;
let is_new = state.runtime.record_message(
state,
account_id,
&room.name,
"channel",
from,
body,
false,
"whisper",
None,
msg_id.clone(),
alert,
avatar_url.clone(),
Vec::new(),
Vec::new(),
None,
);
if is_new {
fresh.push(crate::model::buffer_id(account_id, &room.name));
}
}
fresh
}
/// Any live room connection for this account.
///
/// A whisper belongs to no room, so it does not matter which carries it - but
@@ -858,6 +940,13 @@ fn effective_rooms(config: &SockChatAccountConfig) -> Vec<SockChatRoom> {
/// know the convention - and skipped when the message already opens with it,
/// so replying twice to the same person does not stack them up.
pub fn send_message(state: &AppState, account_id: &str, buffer_name: &str, body: &str, reply_to: Option<&str>) -> Result<()> {
// Typed rather than chosen from a menu, which is what somebody used to the
// site will do. Routed through the same path either way, so it is recorded
// as a whisper here too - passed straight through it would be a real
// whisper that this client never saw, since the site echoes none back.
if let Some((target, text)) = protocol::parse_whisper_command(body) {
return send_whisper(state, account_id, &target, &text);
}
let body = as_reply(body, reply_to);
let Some(text) = protocol::prepare_outgoing(&body) else { return Ok(()) };
let sender = room_sender_for_buffer(state, account_id, buffer_name)?;
@@ -868,39 +957,35 @@ pub fn send_message(state: &AppState, account_id: &str, buffer_name: &str, body:
/// Sends a private message to one person.
///
/// Goes out on whichever room connection is to hand: a whisper is not part of
/// any room's conversation - which is also why the replies arrive in their own
/// buffer rather than wherever you were - so any open socket carries it.
/// any room's conversation, so any open socket carries it.
///
/// Recorded locally as well as sent. Unlike a room message, the site does not
/// echo a whisper back to whoever sent it, so without this a conversation
/// would show only one side of itself.
/// echo a whisper back to whoever sent it, so without this you would see only
/// one side of your own conversation.
///
/// Recorded from *us*, with the target written into the line. There is no
/// field on a message for "and this went to Someone", and a whisper filed
/// under the recipient's name reads as something they said - which is what it
/// used to do. The `@name` is what the site puts there too, so the line reads
/// the way the same whisper reads on the site.
pub fn send_whisper(state: &AppState, account_id: &str, target: &str, body: &str) -> Result<()> {
if target.trim().is_empty() {
let target = target.trim();
if target.is_empty() {
bail!("no one to whisper to");
}
let Some(text) = protocol::prepare_outgoing(body) else { return Ok(()) };
let sender = any_room_sender(state, account_id)?;
sender
.send(protocol::prepare_whisper(target.trim(), &text))
.send(protocol::prepare_whisper(target, &text))
.map_err(|_| anyhow!("chat socket closed"))?;
state.runtime.record_message(
state,
account_id,
"Whispers",
"dm",
target.trim(),
&text,
false,
"whisper",
None,
None,
false,
None,
Vec::new(),
Vec::new(),
None,
);
let me = state
.accounts
.get_sockchat(account_id)
.map(|c| c.display_name.filter(|n| !n.is_empty()).unwrap_or(c.username))
.unwrap_or_default();
let at = if target.starts_with('@') { "" } else { "@" };
record_whisper(state, account_id, &me, &format!("{at}{target} {text}"), None, None, false);
Ok(())
}
+72 -9
View File
@@ -315,13 +315,18 @@ pub fn prepare_edit(uuid: &str, new_body: &str) -> String {
/// How a whisper is addressed on the wire.
///
/// The one thing about it that had to be confirmed rather than derived: the
/// site's own client is what defines this, and sending the wrong word does not
/// fail quietly - an unrecognised command is posted as ordinary text, which
/// puts a message meant for one person in front of the whole room.
pub const WHISPER_COMMAND: &str = "/whisper";
/// `/w`, which is what the site's own client sends. This said `/whisper` and
/// that is not a command the site knows - and the failure is the bad kind,
/// exactly as the line below this one has always warned: an unrecognised
/// command is not rejected, it is posted as ordinary text. Every whisper sent
/// from here went to the whole room with `/whisper @name` written in front of
/// it.
///
/// So this is the one constant here worth being certain of rather than
/// plausible about, and it is confirmed against the site rather than derived.
pub const WHISPER_COMMAND: &str = "/w";
/// `/whisper @username <message>`.
/// `/w @username <message>`.
///
/// The `@` is part of the address, not decoration: the site reads the target
/// as a mention. Added here rather than expected from the caller, since every
@@ -332,6 +337,32 @@ pub const WHISPER_COMMAND: &str = "/whisper";
/// The username goes through unquoted because that is what the site accepts;
/// a name with a space in it cannot be whispered, which is a limit of the
/// protocol rather than of this.
/// Reads a whisper somebody typed themselves, as `/w @name message`.
///
/// Supported because it is what the site's own users type, and because
/// handing it straight through would send a real whisper that this client
/// then knew nothing about - the site does not echo one back, so the line
/// would vanish as it was sent. Recognising it means the same text does the
/// same thing whether it was typed or chosen from a menu.
///
/// `/whisper` is accepted here too. It is not a command the site knows, but it
/// is the one somebody will type after reading about this feature anywhere
/// else, and taking it means a message meant for one person cannot go to the
/// room because of the word chosen for it.
pub fn parse_whisper_command(text: &str) -> Option<(String, String)> {
let rest = text
.strip_prefix("/w ")
.or_else(|| text.strip_prefix("/whisper "))
.or_else(|| text.strip_prefix("/W "))?;
let mut parts = rest.trim_start().splitn(2, char::is_whitespace);
let target = parts.next()?.trim_start_matches('@').trim();
let body = parts.next().unwrap_or("").trim();
if target.is_empty() || body.is_empty() {
return None;
}
Some((target.to_string(), body.to_string()))
}
pub fn prepare_whisper(target: &str, body: &str) -> String {
let target = target.trim();
let at = if target.starts_with('@') { "" } else { "@" };
@@ -481,9 +512,41 @@ mod tests {
assert_eq!(prepare_delete("abc-123"), "/delete abc-123");
// The site reads the target as a mention, so the @ is part of
// addressing it rather than something a caller has to remember.
assert_eq!(prepare_whisper("Someone", "hello there"), "/whisper @Someone hello there");
assert_eq!(prepare_whisper("Someone", "hello there"), "/w @Someone hello there");
// A name that already carries one does not get a second.
assert_eq!(prepare_whisper("@Someone", "hi"), "/whisper @Someone hi");
assert_eq!(prepare_whisper(" Spaced ", "hi"), "/whisper @Spaced hi");
assert_eq!(prepare_whisper("@Someone", "hi"), "/w @Someone hi");
assert_eq!(prepare_whisper(" Spaced ", "hi"), "/w @Spaced hi");
// The command itself, spelled out rather than built from the constant:
// a test that reads the constant back cannot notice it changing, and
// the wrong word here is not a failed send - it is the message posted
// to the whole room as ordinary text.
assert!(prepare_whisper("Someone", "hi").starts_with("/w "));
}
#[test]
fn reads_a_whisper_somebody_typed() {
assert_eq!(
parse_whisper_command("/w @Someone hello there"),
Some(("Someone".into(), "hello there".into()))
);
// Without the @, which the site accepts and people leave off.
assert_eq!(parse_whisper_command("/w Someone hi"), Some(("Someone".into(), "hi".into())));
// The word somebody will type having read about this anywhere else.
assert_eq!(parse_whisper_command("/whisper @Someone hi"), Some(("Someone".into(), "hi".into())));
}
#[test]
fn leaves_alone_what_is_not_one() {
// Half a command is not a command: sending "/w @Someone" as a whisper
// with an empty body would be a whisper nobody wrote, and passing it
// through as ordinary text is what the site does with it anyway.
assert_eq!(parse_whisper_command("/w @Someone"), None);
assert_eq!(parse_whisper_command("/w "), None);
assert_eq!(parse_whisper_command("/w"), None);
// A word that merely starts the same way. The trailing space in the
// prefix is what makes this safe, and is the reason it is tested.
assert_eq!(parse_whisper_command("/wait for it"), None);
assert_eq!(parse_whisper_command("just talking"), None);
assert_eq!(parse_whisper_command("/me waves"), None);
}
}