Somebody on IRC can have a face, where the network carries one
IRC itself carries a nick and a realname and nothing else, which is why moho drew a face for everyone on Discord and Matrix and nobody on IRC. Metadata is the mechanism IRC grew for exactly that, and nothing here asked for it. Both spellings are requested - `draft/metadata` is what the networks that shipped it first still advertise, `metadata-2` is the name it was given on the way to being standardised, and the wire format is the same in both. A network that granted either is sent one `METADATA * SUB` after registration for the four keys with somewhere to go: avatar and display-name onto the profile card as a face and a name, status and homepage as lines beneath it. Subscribing rather than asking per person because the point is the roster, and a GET each for everybody in a busy channel is a lot of round trips to draw one column. Values arrive two ways and both are read by one parser: `METADATA` as a command is how a change reaches a subscriber, and numeric 761 is how the same four fields come back from an explicit GET. The numeric carries our own nick in front, stripped where that is known rather than guessed at in the parser. A key with no value has been cleared, which is somebody taking their avatar down rather than never having had one. It removes the entry instead of storing an empty string, so nothing renders as a broken picture. Nicks are stored lowercased, because the same person is `Alice` in a roster and `alice` in a notification. The whole table is dropped on reconnect for the same reason capabilities are: the next session subscribes again and is told afresh, and stale answers would show an avatar that is gone. The subscription is sent only where the capability was granted - it is an unknown command otherwise, and an error in the server buffer is noise for a feature nobody asked for. Closes #179. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -432,6 +432,27 @@ pub(super) async fn establish(state: &AppState, config: &IrcAccountConfig) -> Re
|
||||
state.runtime.set_conn_state(state, &account_id, ConnState::Connected, None);
|
||||
state.runtime.ensure_buffer(state, &account_id, &config.host, "server");
|
||||
|
||||
// Ask to be told what people publish about themselves.
|
||||
//
|
||||
// Only where the network granted it - the capability was requested during
|
||||
// negotiation, and the ACK recording it has arrived by now. Sending this
|
||||
// to a network that has no metadata is an unknown command and an error
|
||||
// reply in the server buffer, which is noise for a feature nobody asked
|
||||
// for. Subscribing rather than asking per person because the point is the
|
||||
// roster: a GET each for everybody in a busy channel is a lot of round
|
||||
// trips to draw one column.
|
||||
if state.runtime.irc_has_cap(&account_id, "draft/metadata") || state.runtime.irc_has_cap(&account_id, "metadata-2") {
|
||||
if let Err(e) = sender.send(Command::Raw(
|
||||
"METADATA".to_string(),
|
||||
std::iter::once("*".to_string())
|
||||
.chain(std::iter::once("SUB".to_string()))
|
||||
.chain(metadata::WANTED_KEYS.iter().map(|k| (*k).to_string()))
|
||||
.collect(),
|
||||
)) {
|
||||
tracing::warn!("irc[{account_id}]: subscribing to metadata failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Keyed on whether SASL actually authenticated rather than on whether it
|
||||
// was asked for. A server that turned out not to offer it leaves the
|
||||
// account unidentified, and skipping NickServ there would silently drop
|
||||
@@ -577,6 +598,13 @@ pub(super) const WANTED_CAPS: &[&str] = &[
|
||||
// A channel changing its name without becoming a second channel with the
|
||||
// first one's history stranded in it.
|
||||
"draft/channel-rename",
|
||||
// The avatar, display name and status a network lets somebody publish.
|
||||
// IRC carries a nick and a realname and nothing else, so this is the only
|
||||
// way a face on an IRC roster is ever anything but a placeholder. Both
|
||||
// spellings, because networks that shipped it first still advertise the
|
||||
// draft one - see metadata.rs.
|
||||
"draft/metadata",
|
||||
"metadata-2",
|
||||
];
|
||||
|
||||
/// The capabilities named by a CAP ACK, from whichever field they arrived in.
|
||||
|
||||
@@ -761,6 +761,30 @@ pub(super) async fn handle_message(
|
||||
// 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
|
||||
// change is ever visible.
|
||||
// Somebody's published facts, as they change.
|
||||
//
|
||||
// `METADATA <target> <key> <visibility> [:<value>]`, which arrives
|
||||
// because of the `METADATA * SUB` sent at registration. The same four
|
||||
// fields come back from an explicit GET as numeric 761, handled just
|
||||
// below by the same parser.
|
||||
Command::Raw(ref cmd, ref args) if cmd.eq_ignore_ascii_case("METADATA") => {
|
||||
if let Some((target, key, value)) = metadata::parse_keyvalue(args) {
|
||||
state.runtime.set_irc_metadata(account_id, &target, &key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// The same thing, as the answer to a GET rather than as news.
|
||||
//
|
||||
// Numerics are addressed to us, so the client's own nick sits in
|
||||
// front of the fields the command form starts with - dropped here,
|
||||
// where it is known to be there, rather than guessed at in the
|
||||
// parser.
|
||||
Command::Raw(ref cmd, ref args) if cmd == "761" => {
|
||||
if let Some((target, key, value)) = metadata::parse_keyvalue(args.get(1..).unwrap_or(&[])) {
|
||||
state.runtime.set_irc_metadata(account_id, &target, &key, value);
|
||||
}
|
||||
}
|
||||
|
||||
Command::Raw(ref cmd, ref args) if cmd.eq_ignore_ascii_case("SETNAME") => {
|
||||
let name = args.last().map(String::as_str).unwrap_or("");
|
||||
if !from.is_empty() && !name.is_empty() {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! IRCv3 metadata - the avatar, display name and status a network lets
|
||||
//! somebody publish about themselves.
|
||||
//!
|
||||
//! IRC has no profile of its own. A nick and a realname is the whole of what
|
||||
//! the protocol carries, which is why moho can draw a face for everyone on
|
||||
//! Discord and Matrix and nobody on IRC. Metadata is the mechanism IRC grew
|
||||
//! for exactly that, and on a network that offers it the facts are there for
|
||||
//! the asking.
|
||||
//!
|
||||
//! Two spellings, and both are asked for. `draft/metadata` is what the
|
||||
//! networks that shipped it first still advertise, and `metadata-2` is the
|
||||
//! name it was given on the way to being standardised. A network offering
|
||||
//! either is a network this understands; one offering both is answered the
|
||||
//! same way, because the wire format below is the same in both.
|
||||
//!
|
||||
//! Values arrive two ways and both are handled. `METADATA` as a command is
|
||||
//! how a change reaches a subscriber after the fact, and `RPL_KEYVALUE`
|
||||
//! (761) is how the same thing comes back from an explicit `GET`. They carry
|
||||
//! the same four fields in the same order, so they are read by one parser.
|
||||
//!
|
||||
//! A key with no value has been *cleared*, and that is not the same as a key
|
||||
//! that was never set - the network is saying somebody took their avatar
|
||||
//! down. Both end up absent here, which is right, but the distinction is why
|
||||
//! an empty value removes the entry instead of storing an empty string.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// What is worth subscribing to.
|
||||
///
|
||||
/// Deliberately short. A subscription is a standing request for every change
|
||||
/// to these keys for everybody visible, so asking for keys nothing renders
|
||||
/// would be traffic spent on nothing. These are the ones with somewhere to
|
||||
/// go: the first two onto the profile card as a face and a name, and the
|
||||
/// rest as the lines beneath it.
|
||||
pub const WANTED_KEYS: &[&str] = &["avatar", "display-name", "homepage", "status"];
|
||||
|
||||
/// One person's published facts, by key.
|
||||
pub type Metadata = BTreeMap<String, String>;
|
||||
|
||||
/// Reads the `<target> <key> <visibility> [:<value>]` shape that both
|
||||
/// `METADATA` and `RPL_KEYVALUE` use.
|
||||
///
|
||||
/// Returns the target, the key, and the value - `None` for a key that has
|
||||
/// been cleared. Visibility is parsed past rather than kept: it says who else
|
||||
/// can see the value, which is the network's business and not a fact about
|
||||
/// the person.
|
||||
///
|
||||
/// The numeric form is prefixed with the client's own nick, the way every
|
||||
/// numeric is. That is stripped by the caller, which knows whether it is
|
||||
/// reading a command or a reply, rather than being guessed at here.
|
||||
pub fn parse_keyvalue(args: &[String]) -> Option<(String, String, Option<String>)> {
|
||||
let target = args.first()?.clone();
|
||||
let key = args.get(1)?.clone();
|
||||
// Three args is a cleared key: target, key, visibility and nothing after.
|
||||
let value = args.get(3).filter(|v| !v.is_empty()).cloned();
|
||||
Some((target, key, value))
|
||||
}
|
||||
|
||||
/// Whether a capability name is one of the two spellings of metadata.
|
||||
pub fn is_metadata_cap(cap: &str) -> bool {
|
||||
matches!(cap, "draft/metadata" | "metadata-2")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_value_somebody_published() {
|
||||
let got = parse_keyvalue(&args(&["alice", "avatar", "*", "https://example.net/a.png"]));
|
||||
assert_eq!(got, Some(("alice".into(), "avatar".into(), Some("https://example.net/a.png".into()))));
|
||||
}
|
||||
|
||||
/// A cleared key is the network saying somebody took their avatar down,
|
||||
/// which has to be distinguishable from never having had one.
|
||||
#[test]
|
||||
fn a_key_with_no_value_has_been_cleared() {
|
||||
assert_eq!(parse_keyvalue(&args(&["alice", "avatar", "*"])), Some(("alice".into(), "avatar".into(), None)));
|
||||
// An explicitly empty trailing parameter means the same thing.
|
||||
assert_eq!(parse_keyvalue(&args(&["alice", "avatar", "*", ""])), Some(("alice".into(), "avatar".into(), None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_too_short_to_mean_anything_is_not_read() {
|
||||
assert_eq!(parse_keyvalue(&args(&["alice"])), None);
|
||||
assert_eq!(parse_keyvalue(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_spellings_of_the_capability_count() {
|
||||
assert!(is_metadata_cap("draft/metadata"));
|
||||
assert!(is_metadata_cap("metadata-2"));
|
||||
assert!(!is_metadata_cap("metadata"));
|
||||
assert!(!is_metadata_cap("away-notify"));
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ pub mod send;
|
||||
pub mod typing;
|
||||
pub mod dcc;
|
||||
pub mod nickserv;
|
||||
pub mod metadata;
|
||||
pub mod sts;
|
||||
pub mod sasl;
|
||||
|
||||
|
||||
@@ -250,6 +250,23 @@ pub(super) fn irc_profile(
|
||||
crate::profile::set(&mut profile, "away", whois.get("away").cloned());
|
||||
crate::profile::set(&mut profile, "channels", whois.get("channels").cloned());
|
||||
|
||||
// What they have published about themselves, where the network carries
|
||||
// it at all. This is the only source of a face on IRC: the protocol
|
||||
// itself has a nick and a realname and nothing else - see metadata.rs.
|
||||
let published = state.runtime.irc_metadata(account_id, nick);
|
||||
if let Some(avatar) = published.get("avatar") {
|
||||
crate::profile::set(&mut profile, "avatarUrl", Some(json!(avatar)));
|
||||
}
|
||||
if let Some(shown) = published.get("display-name") {
|
||||
crate::profile::set(&mut profile, "displayName", Some(json!(shown)));
|
||||
}
|
||||
if let Some(status) = published.get("status") {
|
||||
crate::profile::note(&mut profile, "Status", status);
|
||||
}
|
||||
if let Some(home) = published.get("homepage") {
|
||||
crate::profile::note(&mut profile, "Homepage", home);
|
||||
}
|
||||
|
||||
if let Some(real) = whois.get("realName").and_then(serde_json::Value::as_str) {
|
||||
crate::profile::note(&mut profile, "Name", real);
|
||||
}
|
||||
|
||||
@@ -453,6 +453,11 @@ pub struct Runtime {
|
||||
/// refuses that line. Asking for history from a server that never granted
|
||||
/// it is a command it will answer with an error in the server tab.
|
||||
irc_caps: Mutex<HashMap<String, std::collections::HashSet<String>>>,
|
||||
/// What people on IRC have published about themselves, by account and
|
||||
/// then by lowercased nick - see backend/irc/metadata.rs. Lowercased
|
||||
/// because IRC nicks are matched case-insensitively and the same person
|
||||
/// is `Alice` in a roster and `alice` in a metadata notification.
|
||||
irc_metadata: Mutex<HashMap<String, HashMap<String, crate::backend::irc::metadata::Metadata>>>,
|
||||
/// The conversation each Discord account last acted in, for an answer
|
||||
/// that arrives without saying where it belongs - see the modal dispatch.
|
||||
discord_last_interaction: Mutex<HashMap<String, String>>,
|
||||
@@ -922,6 +927,7 @@ impl Runtime {
|
||||
matrix_poll_votes: Mutex::new(HashMap::new()),
|
||||
irc_channel_lists: Mutex::new(HashMap::new()),
|
||||
irc_caps: Mutex::new(HashMap::new()),
|
||||
irc_metadata: Mutex::new(HashMap::new()),
|
||||
irc_monitor: Mutex::new(std::collections::HashSet::new()),
|
||||
matrix_stickers: Mutex::new(HashMap::new()),
|
||||
matrix_call_members: Mutex::new(HashMap::new()),
|
||||
@@ -3130,8 +3136,45 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records one published fact. An absent value clears the key, which is
|
||||
/// somebody taking their avatar down rather than never having had one.
|
||||
pub fn set_irc_metadata(&self, account_id: &str, nick: &str, key: &str, value: Option<String>) {
|
||||
let mut all = self.irc_metadata.lock().unwrap();
|
||||
let people = all.entry(account_id.to_string()).or_default();
|
||||
let nick = nick.to_ascii_lowercase();
|
||||
match value {
|
||||
Some(v) => {
|
||||
people.entry(nick).or_default().insert(key.to_string(), v);
|
||||
}
|
||||
None => {
|
||||
if let Some(theirs) = people.get_mut(&nick) {
|
||||
theirs.remove(key);
|
||||
if theirs.is_empty() {
|
||||
people.remove(&nick);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything this person has published, or nothing.
|
||||
pub fn irc_metadata(&self, account_id: &str, nick: &str) -> crate::backend::irc::metadata::Metadata {
|
||||
self.irc_metadata
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(account_id)
|
||||
.and_then(|people| people.get(&nick.to_ascii_lowercase()))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn clear_irc_caps(&self, account_id: &str) {
|
||||
self.irc_caps.lock().unwrap().remove(account_id);
|
||||
// Published facts are per connection in the same way capabilities
|
||||
// are: the next session subscribes again and is told afresh, and
|
||||
// keeping the old answers would show an avatar somebody has since
|
||||
// taken down.
|
||||
self.irc_metadata.lock().unwrap().remove(account_id);
|
||||
self.irc_isupport.lock().unwrap().remove(account_id);
|
||||
// Rosters are per connection: a reconnect rejoins every channel and
|
||||
// the answers from the last session are about people who may not be
|
||||
@@ -4193,6 +4236,53 @@ mod irc_cap_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod irc_metadata_tests {
|
||||
use super::Runtime;
|
||||
|
||||
/// The same person is `Alice` in a roster and `alice` in a notification,
|
||||
/// and IRC matches nicks case-insensitively - so one of those must not be
|
||||
/// a different person.
|
||||
#[test]
|
||||
fn a_nick_is_the_same_nick_in_any_case() {
|
||||
let rt = Runtime::new();
|
||||
rt.set_irc_metadata("a", "Alice", "avatar", Some("https://example.net/a.png".into()));
|
||||
assert_eq!(rt.irc_metadata("a", "alice").get("avatar").map(String::as_str), Some("https://example.net/a.png"));
|
||||
assert_eq!(rt.irc_metadata("a", "ALICE").get("avatar").map(String::as_str), Some("https://example.net/a.png"));
|
||||
}
|
||||
|
||||
/// A cleared key is somebody taking their avatar down, and has to leave
|
||||
/// nothing behind rather than an empty string that would render as a
|
||||
/// broken picture.
|
||||
#[test]
|
||||
fn clearing_a_key_removes_it() {
|
||||
let rt = Runtime::new();
|
||||
rt.set_irc_metadata("a", "alice", "avatar", Some("x".into()));
|
||||
rt.set_irc_metadata("a", "alice", "status", Some("away".into()));
|
||||
rt.set_irc_metadata("a", "alice", "avatar", None);
|
||||
let theirs = rt.irc_metadata("a", "alice");
|
||||
assert!(!theirs.contains_key("avatar"));
|
||||
assert_eq!(theirs.get("status").map(String::as_str), Some("away"));
|
||||
}
|
||||
|
||||
/// Published facts are per connection, like capabilities: the next
|
||||
/// session subscribes again and is told afresh, and keeping the old
|
||||
/// answers would show an avatar somebody has since taken down.
|
||||
#[test]
|
||||
fn a_reconnect_forgets_what_was_published() {
|
||||
let rt = Runtime::new();
|
||||
rt.set_irc_metadata("a", "alice", "avatar", Some("x".into()));
|
||||
rt.clear_irc_caps("a");
|
||||
assert!(rt.irc_metadata("a", "alice").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn somebody_who_published_nothing_has_nothing() {
|
||||
let rt = Runtime::new();
|
||||
assert!(rt.irc_metadata("a", "nobody").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rename_tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user