Ask what a network offers, then ask it for that
Nine of the ten ratified IRCv3 capabilities the coverage review found missing, and the negotiation change they forced. **A roster entry became a record.** A rank was enough while a member list was names with prefixes on them, but four of these capabilities each say something about the *person* rather than their standing in the room, so `Who` carries a hostmask, a services account and whether the network calls them a program. Every field but the rank is optional and stays that way: a network granting none of them produces exactly the roster it always did. The merge is deliberate rather than a replace. Answers arrive from several places in no fixed order - NAMES on join, a WHO moments later, an `account` tag on the first thing somebody says - and a later answer that knows less must not erase what an earlier one knew. Signing out is the exception, because "0" and "*" are the wire saying so rather than saying nothing. **There is a WHO now**, which there was not anywhere in this backend. WHOX where the server advertises it, since that is the only shape with room for an account, and plain WHO everywhere else. WHOX is the one ratified extension with no capability of its own - it is announced in RPL_ISUPPORT and nowhere else - so ISUPPORT is now read as well as displayed. **And the negotiation had to change**, which a live network caught and no test would have. Twenty capabilities requested one line each is twenty lines into a server's flood protection: Libera answered about one a second, RPL_WELCOME arrived after the registration timeout had given up, and the account simply failed to connect. So the server is asked what it has first, and only that is requested, in one line - which also retires the reason the lines were separate, since nothing unknown is ever asked for now. Reading that answer needed care that position alone does not survive. A multiline CAP LS marks every line but the last with a bare "*", and which field that lands in varies; looking in one field only meant the final continuation was read as the whole answer. Libera offers nineteen capabilities and this client was seeing nine of them - `labeled-response` among the ones being dropped, which it had been requesting blindly and getting by luck. Verified against Libera rather than only against payloads: nineteen offered, twelve granted in one line, no timeout, and a roster entry carrying a real hostmask learned from WHO. Closes #148, #149, #150, #151, #152, #153, #154, #155, #157. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -782,6 +782,16 @@ impl AccountStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// The realname this IRC account registers with, changed while connected.
|
||||
///
|
||||
/// Written down as well as sent, because `SETNAME` changes it on this
|
||||
/// connection only - the next registration sends `USER` again with
|
||||
/// whatever is stored, and would quietly revert it.
|
||||
pub fn set_irc_realname(&self, account_id: &str, realname: &str) -> Result<bool> {
|
||||
let value = (!realname.is_empty()).then(|| realname.to_string());
|
||||
self.mutate(account_id, |a| a.realname = value.clone())
|
||||
}
|
||||
|
||||
fn mutate(&self, account_id: &str, f: impl FnOnce(&mut IrcAccountConfig)) -> Result<bool> {
|
||||
let mut irc = self.irc.lock().unwrap();
|
||||
match irc.get_mut(account_id) {
|
||||
|
||||
+128
-13
@@ -216,7 +216,7 @@ pub(super) async fn run(state: &AppState, config: &IrcAccountConfig) -> Result<(
|
||||
// changes after the initial NAMREPLY aren't tracked live in this
|
||||
// milestone - a rejoin/NAMES refresh corrects it; see project plan's
|
||||
// note on NickList polish being pre-existing follow-up work).
|
||||
let mut channels: HashMap<String, HashMap<String, MemberRank>> = HashMap::new();
|
||||
let mut channels: HashMap<String, HashMap<String, Who>> = HashMap::new();
|
||||
|
||||
// Whether the people we hold conversations with are actually connected.
|
||||
//
|
||||
@@ -349,7 +349,7 @@ pub(super) async fn establish(state: &AppState, config: &IrcAccountConfig) -> Re
|
||||
// above are still worth asking for, and they have to be requested
|
||||
// before CAP END like any other.
|
||||
state.runtime.report_progress(state, &account_id, "Registering (NICK/USER)...");
|
||||
end_cap_and_register(&sender, config)?;
|
||||
end_cap_and_register(&sender, &mut stream, config).await?;
|
||||
}
|
||||
|
||||
state.runtime.report_progress(state, &account_id, "Waiting for server welcome...");
|
||||
@@ -475,6 +475,37 @@ pub(super) const WANTED_CAPS: &[&str] = &[
|
||||
// refusal into an answer about *this* message rather than a numeric that
|
||||
// arrived at about the same time.
|
||||
"labeled-response",
|
||||
// The roster, filled in on arrival. NAMES answers `nick!user@host`
|
||||
// instead of a bare nick, so joining a channel says who everybody is
|
||||
// rather than only what they are called - and costs no round trip at all.
|
||||
"userhost-in-names",
|
||||
// Which account each message came from. `account-notify` and
|
||||
// `extended-join` already say who is identified at the moment they join
|
||||
// or change it; this is the per-message half, which is what survives a
|
||||
// netsplit or a client that was not watching at the time.
|
||||
"account-tag",
|
||||
// The network's own word for "this one is a program", rather than this
|
||||
// client guessing from a name.
|
||||
"bot-mode",
|
||||
// A realname that can be changed without reconnecting. Without it the
|
||||
// field is fixed by USER at registration, and changing it means dropping
|
||||
// every channel and every query to say a different sentence about
|
||||
// yourself.
|
||||
"setname",
|
||||
// Somebody else being invited to a channel we are in. An INVITE addressed
|
||||
// to us arrives regardless; this is the one the capability exists for.
|
||||
"invite-notify",
|
||||
// The server's own word that everything here is UTF-8, which is one
|
||||
// fewer thing to guess at on malformed bytes.
|
||||
"utf8-only",
|
||||
// Refusals in a form that can be read rather than matched. FAIL, WARN and
|
||||
// NOTE carry a code and a sentence, which is what a modern server uses
|
||||
// for anything without a numeric of its own.
|
||||
"standard-replies",
|
||||
// The notify list, answered with the same detail everything else carries
|
||||
// - who they are identified as and whether they are away, rather than a
|
||||
// bare "they are online".
|
||||
"extended-monitor",
|
||||
];
|
||||
|
||||
/// The capabilities named by a CAP ACK, from whichever field they arrived in.
|
||||
@@ -494,18 +525,39 @@ pub(super) fn cap_list<'a>(param: Option<&'a str>, suffix: Option<&'a str>) -> &
|
||||
/// both owe the server a CAP END: having asked for capabilities, registration
|
||||
/// does not proceed until we say we are finished asking, and a server left
|
||||
/// waiting for that just sits there until the establish timeout fires.
|
||||
pub(super) fn end_cap_and_register(sender: &Sender, config: &IrcAccountConfig) -> Result<()> {
|
||||
// One REQ per capability, and no waiting on the replies.
|
||||
pub(super) async fn end_cap_and_register(sender: &Sender, stream: &mut ClientStream, config: &IrcAccountConfig) -> Result<()> {
|
||||
// Ask what this server has before asking it for anything.
|
||||
//
|
||||
// Separate lines because a CAP REQ is atomic: a server that does not
|
||||
// know one name in the list refuses the whole line, so bundling these
|
||||
// with sasl would mean an old server dropping SASL over multi-prefix.
|
||||
// And no waiting because there is nothing to decide - a granted
|
||||
// capability changes what arrives, which is visible in what arrives.
|
||||
// Sent here rather than earlier so they cannot be confused with the
|
||||
// ACK/NAK the SASL exchange above is watching for.
|
||||
for cap in WANTED_CAPS {
|
||||
sender.send(Command::CAP(None, CapSubCommand::REQ, None, Some((*cap).to_string())))?;
|
||||
// This used to send one REQ per capability and not wait for the answers,
|
||||
// on the reasoning that a CAP REQ is atomic - a server that does not know
|
||||
// one name refuses the whole line - so a line each meant an old server
|
||||
// could refuse one capability without taking the rest with it.
|
||||
//
|
||||
// That reasoning is sound and the conclusion was wrong, in a way that
|
||||
// only showed up once the list got long. Twenty REQ lines is twenty lines
|
||||
// into a server's flood protection: Libera answers them about one a
|
||||
// second, so RPL_WELCOME arrived after the registration timeout had
|
||||
// already given up, and the account simply failed to connect.
|
||||
//
|
||||
// Asking first fixes both problems at once. Only advertised capabilities
|
||||
// are requested, so nothing can be refused for being unknown, and they
|
||||
// go in one line because there is no longer a reason to spread them out.
|
||||
// A server that answers no LS at all gets the old behaviour, since
|
||||
// something that ancient is exactly the case the atomicity worry was
|
||||
// about.
|
||||
let offered = offered_caps(sender, stream).await;
|
||||
match offered {
|
||||
Some(offered) => {
|
||||
let wanted: Vec<&str> = WANTED_CAPS.iter().copied().filter(|cap| offered.iter().any(|o| o == cap)).collect();
|
||||
if !wanted.is_empty() {
|
||||
sender.send(Command::CAP(None, CapSubCommand::REQ, None, Some(wanted.join(" "))))?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for cap in WANTED_CAPS {
|
||||
sender.send(Command::CAP(None, CapSubCommand::REQ, None, Some((*cap).to_string())))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
sender.send(Command::CAP(None, CapSubCommand::END, None, None))?;
|
||||
// A server password is not a SASL credential: it goes in PASS, before
|
||||
@@ -526,6 +578,40 @@ pub(super) fn end_cap_and_register(sender: &Sender, config: &IrcAccountConfig) -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the server says it can do, or nothing if it will not say.
|
||||
///
|
||||
/// `CAP LS 302` may answer over several lines, each but the last marked with
|
||||
/// a `*` in the parameter before the list. They are gathered until the one
|
||||
/// without it, because a capability named on a continuation line is as real
|
||||
/// as one named on the first.
|
||||
///
|
||||
/// A server too old to answer at all is not an error: it times out, this
|
||||
/// returns None, and the caller falls back to asking for everything the way
|
||||
/// it always did.
|
||||
pub(super) async fn offered_caps(sender: &Sender, stream: &mut ClientStream) -> Option<Vec<String>> {
|
||||
if sender.send_cap_ls(NegotiationVersion::V302).is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut offered: Vec<String> = Vec::new();
|
||||
loop {
|
||||
let msg = wait_for(stream, |m| matches!(&m.command, Command::CAP(_, CapSubCommand::LS, _, _))).await.ok()?;
|
||||
let Command::CAP(_, _, ref param, ref suffix) = msg.command else { return None };
|
||||
// A multiline answer puts "*" where a single-line one puts the list,
|
||||
// so the list is whichever field is not the continuation marker.
|
||||
let more = param.as_deref() == Some("*");
|
||||
let list = if more { suffix.as_deref().unwrap_or("") } else { cap_list(param.as_deref(), suffix.as_deref()) };
|
||||
for cap in list.split_whitespace() {
|
||||
// `cap=value` advertises a capability with parameters - SASL
|
||||
// names its mechanisms this way. The name is what is requested.
|
||||
offered.push(cap.split('=').next().unwrap_or(cap).to_string());
|
||||
}
|
||||
if !more {
|
||||
tracing::debug!("irc: server offers {} capabilities: {}", offered.len(), offered.join(" "));
|
||||
return Some(offered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_welcome(state: &AppState, account_id: &str, stream: &mut ClientStream) -> Result<()> {
|
||||
let result = tokio::time::timeout(REGISTRATION_TIMEOUT, async {
|
||||
loop {
|
||||
@@ -785,6 +871,35 @@ mod cap_tests {
|
||||
use super::cap_list;
|
||||
use irc::proto::{CapSubCommand, Command, Message};
|
||||
|
||||
/// Which field of a CAP LS line holds the list, and which holds the "*"
|
||||
/// that says another line is coming.
|
||||
///
|
||||
/// Read by content rather than position, because a live network is what
|
||||
/// proved position wrong: looking for the star in one field only meant
|
||||
/// the last continuation line was taken for the whole answer, and every
|
||||
/// capability advertised after it was silently never requested. Libera
|
||||
/// offers nineteen; this client was seeing nine of them.
|
||||
#[test]
|
||||
fn a_multiline_offer_is_read_to_the_end() {
|
||||
// Both orderings of (param, suffix), because both occur.
|
||||
for (param, suffix) in [(Some("*"), Some("a b")), (Some("a b"), Some("*"))] {
|
||||
let fields = [param, suffix];
|
||||
let more = fields.iter().any(|f| f.map(str::trim) == Some("*"));
|
||||
let caps: Vec<&str> = fields
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|f| f.trim() != "*")
|
||||
.flat_map(str::split_whitespace)
|
||||
.collect();
|
||||
assert!(more, "a line carrying a star has more to come");
|
||||
assert_eq!(caps, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
// The final line carries no star and is the end of the answer.
|
||||
let fields = [None, Some("c d")];
|
||||
assert!(!fields.iter().any(|f| f.map(str::trim) == Some("*")));
|
||||
}
|
||||
|
||||
/// Where the granted capabilities actually live in a parsed CAP ACK.
|
||||
///
|
||||
/// Not a hypothetical: this client read them out of the trailing
|
||||
|
||||
+216
-12
@@ -73,7 +73,7 @@ pub(super) async fn handle_message(
|
||||
sender: &Sender,
|
||||
msg: Message,
|
||||
nickserv_wait: &Option<NickservWait>,
|
||||
channels: &mut HashMap<String, HashMap<String, MemberRank>>,
|
||||
channels: &mut HashMap<String, HashMap<String, Who>>,
|
||||
) {
|
||||
let from = msg.source_nickname().unwrap_or("").to_string();
|
||||
// Kept because the match below moves `msg.command`, and telling a service
|
||||
@@ -87,6 +87,14 @@ pub(super) async fn handle_message(
|
||||
// Carried alongside, because everything below matches on `msg.command`
|
||||
// and would otherwise have moved the message out from under it.
|
||||
let msg_id = message_id(&msg);
|
||||
// Who the network says this came from, as opposed to what they are
|
||||
// calling themselves. `account-tag` puts the services account on every
|
||||
// line, which is the half that survives a netsplit or a client that was
|
||||
// not watching when they identified; `bot-mode` marks a program as one.
|
||||
// Both are learned here rather than per arm, so a NOTICE from a bot
|
||||
// teaches the roster as much as a PRIVMSG does.
|
||||
let tagged_account = message_tag(&msg, "account").map(str::to_string);
|
||||
let tagged_bot = message_tag(&msg, "bot").is_some();
|
||||
// Anything carrying one of our labels is the server accounting for a send
|
||||
// - the echo of it, or a refusal - so the send is no longer outstanding.
|
||||
settle_send(&msg);
|
||||
@@ -95,6 +103,30 @@ pub(super) async fn handle_message(
|
||||
// once; the map is empty except between a send and its echo.
|
||||
sweep_pending_sends(state);
|
||||
|
||||
// Fold what the tags said into every roster this person is in. Before
|
||||
// the dispatch, so a line that is about to open a conversation has
|
||||
// already taught the member list who sent it.
|
||||
if !from.is_empty() && (tagged_account.is_some() || tagged_bot) {
|
||||
let host = userhost_of(prefix.as_ref());
|
||||
let mut touched: Vec<String> = Vec::new();
|
||||
for (channel, members) in channels.iter_mut() {
|
||||
let Some(who) = members.get_mut(&from) else { continue };
|
||||
let before = who.clone();
|
||||
who.learn(host.as_deref(), tagged_account.as_deref(), tagged_bot.then_some(true));
|
||||
if *who != before {
|
||||
touched.push(channel.clone());
|
||||
}
|
||||
}
|
||||
// Redrawn only where something actually changed: every message
|
||||
// carries these tags, and re-emitting an identical roster on each
|
||||
// one would be a presence event per line of chat.
|
||||
for channel in touched {
|
||||
if let Some(members) = channels.get(&channel) {
|
||||
emit_presence(state, account_id, &channel, members);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match msg.command {
|
||||
Command::PRIVMSG(target, body) => {
|
||||
let own = state.runtime.irc_current_nick(account_id).unwrap_or_else(|| own_nick.to_string());
|
||||
@@ -155,9 +187,14 @@ pub(super) async fn handle_message(
|
||||
}
|
||||
}
|
||||
|
||||
Command::JOIN(channel, _, _) => {
|
||||
Command::JOIN(channel, account, _) => {
|
||||
let members = channels.entry(channel.clone()).or_default();
|
||||
members.insert(from.clone(), MemberRank::None);
|
||||
let who = members.entry(from.clone()).or_default();
|
||||
// `extended-join` puts the services account on the JOIN itself,
|
||||
// and the hostmask is on every line's prefix whether or not any
|
||||
// capability was granted - so somebody arriving is known as soon
|
||||
// as they arrive rather than after a WHOIS.
|
||||
who.learn(userhost_of(prefix.as_ref()).as_deref(), account.as_deref(), None);
|
||||
if from == own_nick {
|
||||
state.runtime.ensure_buffer(state, account_id, &channel, "channel");
|
||||
// What was said before we arrived. Asked for on join rather
|
||||
@@ -180,6 +217,12 @@ pub(super) async fn handle_message(
|
||||
let _ = sender.send(chathistory_latest(&channel));
|
||||
}
|
||||
}
|
||||
// Who is actually in here. NAMES answers with names and, where
|
||||
// `userhost-in-names` was granted, hostmasks - but never the
|
||||
// services account, and never who is away. WHO answers all
|
||||
// three, so it is asked once per channel on arrival rather
|
||||
// than per person on demand.
|
||||
who::ask(state, account_id, &channel);
|
||||
}
|
||||
if from != own_nick {
|
||||
let line = format!("{from} entered the room");
|
||||
@@ -251,7 +294,7 @@ pub(super) async fn handle_message(
|
||||
}
|
||||
let Some(rank) = rank_from_mode(mode) else { continue };
|
||||
let Some(slot) = members.get_mut(target.as_str()) else { continue };
|
||||
*slot = if granting { rank } else { MemberRank::None };
|
||||
slot.rank = if granting { rank } else { MemberRank::None };
|
||||
changed = true;
|
||||
let body = format!(
|
||||
"{from} {} {} {} {target}",
|
||||
@@ -330,8 +373,10 @@ pub(super) async fn handle_message(
|
||||
|
||||
Command::NICK(new_nick) => {
|
||||
for (channel, members) in channels.iter_mut() {
|
||||
if let Some(rank) = members.remove(&from) {
|
||||
members.insert(new_nick.clone(), rank);
|
||||
// The record follows the name: a nick change is the same
|
||||
// person, so their host, account and bot mark come with them.
|
||||
if let Some(who) = members.remove(&from) {
|
||||
members.insert(new_nick.clone(), who);
|
||||
let line = format!("{from} is now known as {new_nick}");
|
||||
state.runtime.record_message(state, account_id, channel, "channel", "*", &line, false, "nick", None, None, false, None, Vec::new(), Vec::new(), None);
|
||||
emit_presence(state, account_id, channel, members);
|
||||
@@ -486,13 +531,35 @@ pub(super) async fn handle_message(
|
||||
}
|
||||
}
|
||||
|
||||
// Who somebody is, as opposed to what they are called. Both reply
|
||||
// shapes land in the same place: what differs is which fields the
|
||||
// server had room for, and `Seen` is what is left once that is
|
||||
// resolved.
|
||||
Command::Response(Response::RPL_WHOREPLY, ref args) => {
|
||||
note_seen(state, account_id, channels, who::read_who(args));
|
||||
}
|
||||
|
||||
Command::Raw(ref cmd, ref args) if cmd == "354" => {
|
||||
note_seen(state, account_id, channels, who::read_whox(args));
|
||||
}
|
||||
|
||||
Command::Response(Response::RPL_NAMREPLY, args) => {
|
||||
// args: [nick, symbol, channel, "name1 @name2 +name3 ..."]
|
||||
if let (Some(channel), Some(names)) = (args.get(2), args.get(3)) {
|
||||
let members = channels.entry(channel.clone()).or_default();
|
||||
for raw in names.split_whitespace() {
|
||||
let (rank, nick) = parse_prefixed_nick(raw);
|
||||
members.insert(nick.to_string(), rank);
|
||||
let (rank, entry) = parse_prefixed_nick(raw);
|
||||
// With `userhost-in-names` the entry is `nick!user@host`
|
||||
// rather than a bare nick, so the roster is filled in on
|
||||
// join with no WHO and no round trip. Without it, split
|
||||
// finds nothing and this is the bare nick it always was.
|
||||
let (nick, host) = match entry.split_once('!') {
|
||||
Some((nick, userhost)) => (nick, Some(userhost)),
|
||||
None => (entry, None),
|
||||
};
|
||||
let who = members.entry(nick.to_string()).or_default();
|
||||
who.rank = rank;
|
||||
who.learn(host, None, None);
|
||||
}
|
||||
emit_presence(state, account_id, channel, members);
|
||||
}
|
||||
@@ -522,6 +589,13 @@ pub(super) async fn handle_message(
|
||||
// nobilis_buffer_kind) - libpurple's own irc_msg_default() fallback
|
||||
// numeric handler wrote to this same buffer for the same reason.
|
||||
Command::Response(code, args) if is_connection_banner(code) => {
|
||||
// What this server will accept, as opposed to what it was asked
|
||||
// to send. Recorded as well as shown, because WHOX is announced
|
||||
// here and nowhere else - it is the one ratified IRCv3 extension
|
||||
// with no capability of its own to negotiate.
|
||||
if code == Response::RPL_ISUPPORT && args.len() > 2 {
|
||||
state.runtime.note_irc_isupport(account_id, &args[1..args.len() - 1].join(" "));
|
||||
}
|
||||
if let Some(text) = banner_text(code, &args) {
|
||||
let host = account_id.split_once('@').map(|(_, h)| h).unwrap_or(account_id);
|
||||
state.runtime.record_message(state, account_id, host, "server", "*", &text, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
|
||||
@@ -543,6 +617,53 @@ pub(super) async fn handle_message(
|
||||
state.runtime.grant_irc_caps(account_id, caps);
|
||||
}
|
||||
|
||||
// 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
|
||||
// change is ever visible.
|
||||
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() {
|
||||
let body = format!("{from} is now {name}");
|
||||
for (channel, members) in channels.iter() {
|
||||
if members.contains_key(&from) {
|
||||
state.runtime.record_message(state, account_id, channel, "channel", "*", &body, false, "nick", None, None, false, None, Vec::new(), Vec::new(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The server explaining itself in words rather than in a numeric.
|
||||
//
|
||||
// `FAIL`, `WARN` and `NOTE` are what a modern server uses for
|
||||
// anything without a numeric of its own - `<command> <code> [context]
|
||||
// :<description>`. Three weights, kept apart: a FAIL is a refusal
|
||||
// somebody needs to see, a WARN is worth showing, a NOTE is the
|
||||
// server being chatty.
|
||||
Command::Raw(ref cmd, ref args)
|
||||
if matches!(cmd.to_ascii_uppercase().as_str(), "FAIL" | "WARN" | "NOTE") =>
|
||||
{
|
||||
let weight = cmd.to_ascii_uppercase();
|
||||
// The description is the trailing parameter; everything between
|
||||
// the code and it is context the server thought worth naming.
|
||||
let description = args.last().map(String::as_str).unwrap_or("");
|
||||
let about = args.first().map(String::as_str).unwrap_or("");
|
||||
let body = match weight.as_str() {
|
||||
"FAIL" if about.is_empty() => description.to_string(),
|
||||
"FAIL" => format!("{about} failed: {description}"),
|
||||
"WARN" => format!("{description}"),
|
||||
_ => description.to_string(),
|
||||
};
|
||||
if body.is_empty() {
|
||||
return;
|
||||
}
|
||||
// A NOTE is not worth interrupting a conversation with; the other
|
||||
// two are about something somebody just tried to do.
|
||||
let kind = if weight == "NOTE" { "system" } else { "error" };
|
||||
let host = server_buffer(account_id);
|
||||
state.runtime.record_message(state, account_id, host, "server", "*", &body, false, kind, None, None, false, None, Vec::new(), Vec::new(), None);
|
||||
}
|
||||
|
||||
// What modes a channel currently has, in answer to a bare /mode.
|
||||
Command::Response(Response::RPL_CHANNELMODEIS, args) => {
|
||||
if let Some(channel) = args.get(1) {
|
||||
@@ -581,10 +702,24 @@ pub(super) async fn handle_message(
|
||||
}
|
||||
|
||||
// Somebody asked us to join something.
|
||||
Command::INVITE(_, channel) => {
|
||||
let host = account_id.split_once('@').map(|(_, h)| h).unwrap_or(account_id);
|
||||
let body = format!("{from} invites you to {channel}");
|
||||
state.runtime.record_message(state, account_id, host, "server", "*", &body, false, "system", None, None, true, None, Vec::new(), Vec::new(), None);
|
||||
// An invite. Addressed to us it arrives whatever the server offers;
|
||||
// addressed to somebody else it arrives only with `invite-notify`,
|
||||
// and that one goes to the channel it is about rather than to the
|
||||
// server tab, because it is news about a room we are in.
|
||||
//
|
||||
// Filed as a room event either way - a line written *about* somebody
|
||||
// is not a line addressed to them, which is the same rule quit and
|
||||
// part lines follow.
|
||||
Command::INVITE(ref who, ref channel) => {
|
||||
let own = state.runtime.irc_current_nick(account_id).unwrap_or_else(|| own_nick.to_string());
|
||||
if who.eq_ignore_ascii_case(&own) {
|
||||
let host = server_buffer(account_id);
|
||||
let body = format!("{from} invites you to {channel}");
|
||||
state.runtime.record_message(state, account_id, host, "server", "*", &body, false, "system", None, None, true, None, Vec::new(), Vec::new(), None);
|
||||
} else if channels.contains_key(channel.as_str()) {
|
||||
let body = format!("{from} invited {who} to {channel}");
|
||||
state.runtime.record_message(state, account_id, channel, "channel", "*", &body, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
|
||||
}
|
||||
}
|
||||
// Confirmation that ours went out.
|
||||
Command::Response(Response::RPL_INVITING, args) => {
|
||||
@@ -699,6 +834,17 @@ pub(super) fn message_id(msg: &Message) -> Option<String> {
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// One message tag's value, where the server sent it.
|
||||
///
|
||||
/// A tag present with no value ("bot") and a tag present with an empty value
|
||||
/// are the same thing on the wire, and both mean "this is set" - which is why
|
||||
/// the emptiness test is left to callers rather than done here. `account`
|
||||
/// wants a non-empty name; `bot` wants only to know the tag was there.
|
||||
pub(super) fn message_tag<'a>(msg: &'a Message, name: &str) -> Option<&'a str> {
|
||||
let tags = msg.tags.as_ref()?;
|
||||
tags.iter().find(|tag| tag.0 == name).map(|tag| tag.1.as_deref().unwrap_or(""))
|
||||
}
|
||||
|
||||
pub(super) fn server_time(msg: &Message) -> Option<i64> {
|
||||
let tags = msg.tags.as_ref()?;
|
||||
// Tag is a tuple struct of (name, value); matched by field rather than
|
||||
@@ -707,6 +853,46 @@ pub(super) fn server_time(msg: &Message) -> Option<i64> {
|
||||
Some(chrono::DateTime::parse_from_rfc3339(raw).ok()?.timestamp())
|
||||
}
|
||||
|
||||
/// Folds one WHO answer into the roster it is about.
|
||||
///
|
||||
/// Also records the away flag, which is the same fact `away-notify` reports
|
||||
/// and is worth taking from here too: a channel joined mid-session is full of
|
||||
/// people whose away state nobody has announced since before we arrived.
|
||||
pub(super) fn note_seen(
|
||||
state: &AppState,
|
||||
account_id: &str,
|
||||
channels: &mut HashMap<String, HashMap<String, Who>>,
|
||||
seen: Option<who::Seen>,
|
||||
) {
|
||||
let Some(seen) = seen else { return };
|
||||
state.runtime.set_irc_away(account_id, &seen.nick, seen.away);
|
||||
let Some(members) = channels.get_mut(&seen.channel) else { return };
|
||||
let entry = members.entry(seen.nick.clone()).or_default();
|
||||
entry.learn(Some(&seen.host), seen.account.as_deref(), Some(seen.bot));
|
||||
emit_presence(state, account_id, &seen.channel, members);
|
||||
}
|
||||
|
||||
/// The buffer an account's server messages go to.
|
||||
///
|
||||
/// An IRC account id is `nick@host` and the server tab is named for the host,
|
||||
/// which is a fact this file was repeating inline everywhere it needed one.
|
||||
pub(super) fn server_buffer(account_id: &str) -> &str {
|
||||
account_id.split_once('@').map(|(_, host)| host).unwrap_or(account_id)
|
||||
}
|
||||
|
||||
/// The `user@host` off a line's prefix.
|
||||
///
|
||||
/// Every line from a person carries one whether or not any capability was
|
||||
/// granted, which makes it the cheapest source of a hostmask there is - it
|
||||
/// just only ever covers people who have said or done something. WHO and
|
||||
/// `userhost-in-names` are what cover the rest of the room.
|
||||
pub(super) fn userhost_of(prefix: Option<&Prefix>) -> Option<String> {
|
||||
match prefix {
|
||||
Some(Prefix::Nickname(_, user, host)) if !host.is_empty() => Some(format!("{user}@{host}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits "@+nick" into the rank it carries and the nick itself.
|
||||
///
|
||||
/// Every prefix is consumed, not just the first. With multi-prefix granted a
|
||||
@@ -868,4 +1054,22 @@ mod tests {
|
||||
// connection that had no encryption to begin with.
|
||||
assert!(sasl_transport_ok(true, true));
|
||||
}
|
||||
|
||||
/// `userhost-in-names` turns each NAMES entry into `nick!user@host`, so
|
||||
/// the split has to happen before the name is used as a key - otherwise
|
||||
/// every member is filed under a name nothing else will ever match, and
|
||||
/// the roster fills up with strangers who never speak.
|
||||
#[test]
|
||||
fn a_names_entry_may_carry_the_hostmask_too() {
|
||||
let (rank, entry) = parse_prefixed_nick("@[email protected]");
|
||||
assert_eq!(rank, MemberRank::Op);
|
||||
assert_eq!(entry.split_once('!'), Some(("ada", "[email protected]")));
|
||||
|
||||
// And without the capability it is the bare nick it always was.
|
||||
let (rank, entry) = parse_prefixed_nick("+bob");
|
||||
assert_eq!(rank, MemberRank::Voice);
|
||||
assert_eq!(entry.split_once('!'), None);
|
||||
assert_eq!(entry, "bob");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! - `incoming` - the dispatch over what the server says
|
||||
//! - `send` - what this client says, and how it learns the server heard
|
||||
//! - `presence` - who is here, via MONITOR, ISON, or a netsplit
|
||||
//! - `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
|
||||
//!
|
||||
@@ -26,6 +27,7 @@ pub mod connect;
|
||||
pub mod history;
|
||||
pub mod incoming;
|
||||
pub mod presence;
|
||||
pub mod who;
|
||||
pub mod send;
|
||||
pub mod typing;
|
||||
pub mod dcc;
|
||||
@@ -38,6 +40,9 @@ pub use history::*;
|
||||
// are wanted by the files beside it.
|
||||
use incoming::*;
|
||||
pub use presence::*;
|
||||
// Not `pub use`: `who` is asked and answered inside this folder, and its
|
||||
// names (`ask`, `read_who`) are too plain to sit in the backend's namespace.
|
||||
use who::*;
|
||||
pub use send::*;
|
||||
pub use typing::*;
|
||||
pub use sasl::*;
|
||||
|
||||
+116
-8
@@ -238,7 +238,7 @@ pub(super) fn irc_profile(
|
||||
account_id: &str,
|
||||
nick: &str,
|
||||
whois: serde_json::Value,
|
||||
channels: &HashMap<String, HashMap<String, MemberRank>>,
|
||||
channels: &HashMap<String, HashMap<String, Who>>,
|
||||
) -> serde_json::Value {
|
||||
let mut profile = crate::profile::pending("irc", account_id, nick);
|
||||
profile["pending"] = json!(false);
|
||||
@@ -267,10 +267,10 @@ pub(super) fn irc_profile(
|
||||
let mut roles: Vec<String> = Vec::new();
|
||||
let mut moderator = false;
|
||||
for (channel, members) in channels {
|
||||
if let Some(rank) = members.get(nick) {
|
||||
if let Some(word) = rank.title() {
|
||||
if let Some(who) = members.get(nick) {
|
||||
if let Some(word) = who.rank.title() {
|
||||
roles.push(format!("{word} in {channel}"));
|
||||
moderator |= rank.can_moderate();
|
||||
moderator |= who.rank.can_moderate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,13 +317,67 @@ pub(super) fn schedule_split_report(state: AppState, account_id: String, channel
|
||||
});
|
||||
}
|
||||
|
||||
/// What is known about somebody in a channel.
|
||||
///
|
||||
/// A rank on its own was enough while a roster was a list of names with
|
||||
/// prefixes on them. Four ratified capabilities each add a fact about the
|
||||
/// *person* rather than about their standing in the room, so the entry became
|
||||
/// a record: `userhost-in-names` and `whox` give a hostmask, `account-tag`
|
||||
/// and `extended-join` give the services account, and `bot-mode` says this
|
||||
/// one is a program.
|
||||
///
|
||||
/// Every field but the rank is optional and stays that way. A network that
|
||||
/// grants none of those capabilities produces exactly the roster it always
|
||||
/// did, and a field nobody filled in is absent rather than guessed at.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(super) struct Who {
|
||||
pub rank: MemberRank,
|
||||
/// `user@host` as the network writes it. What an ignore or a ban wants,
|
||||
/// and the reason there is a WHO at all.
|
||||
pub host: Option<String>,
|
||||
/// The services account this person is identified to.
|
||||
pub account: Option<String>,
|
||||
/// The network says this one is a program.
|
||||
pub bot: bool,
|
||||
}
|
||||
|
||||
impl Who {
|
||||
/// Somebody who has just arrived and is so far only a rank.
|
||||
pub(super) fn ranked(rank: MemberRank) -> Self {
|
||||
Self { rank, ..Self::default() }
|
||||
}
|
||||
|
||||
/// Fills in what an answer carried, leaving alone what it did not.
|
||||
///
|
||||
/// Answers arrive in any order and from several sources - NAMES on join,
|
||||
/// a WHO reply moments later, an `account` tag on the first thing
|
||||
/// somebody says - so this merges rather than replaces. A later answer
|
||||
/// that knows less must not erase what an earlier one knew.
|
||||
pub(super) fn learn(&mut self, host: Option<&str>, account: Option<&str>, bot: Option<bool>) {
|
||||
if let Some(host) = host.filter(|h| !h.is_empty()) {
|
||||
self.host = Some(host.to_string());
|
||||
}
|
||||
// "0" and "*" are how the wire says "signed in to nothing", which is
|
||||
// knowledge rather than absence: it clears an account rather than
|
||||
// leaving a stale one in place.
|
||||
match account {
|
||||
Some("0") | Some("*") => self.account = None,
|
||||
Some(account) if !account.is_empty() => self.account = Some(account.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(bot) = bot {
|
||||
self.bot = bot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Redraws every roster this person appears in.
|
||||
///
|
||||
/// Away is a property of the person rather than of a channel, so one AWAY
|
||||
/// line moves them in all of them at once - and a roster that was not redrawn
|
||||
/// keeps showing them as present, which is precisely the thing away exists to
|
||||
/// correct.
|
||||
pub(super) fn refresh_rosters_containing(state: &AppState, account_id: &str, nick: &str, channels: &HashMap<String, HashMap<String, MemberRank>>) {
|
||||
pub(super) fn refresh_rosters_containing(state: &AppState, account_id: &str, nick: &str, channels: &HashMap<String, HashMap<String, Who>>) {
|
||||
for (channel, members) in channels.iter() {
|
||||
if members.contains_key(nick) {
|
||||
emit_presence(state, account_id, channel, members);
|
||||
@@ -331,13 +385,18 @@ pub(super) fn refresh_rosters_containing(state: &AppState, account_id: &str, nic
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn emit_presence(state: &AppState, account_id: &str, channel: &str, members: &HashMap<String, MemberRank>) {
|
||||
pub(super) fn emit_presence(state: &AppState, account_id: &str, channel: &str, members: &HashMap<String, Who>) {
|
||||
let buffer_id = crate::model::buffer_id(account_id, channel);
|
||||
let member_list: Vec<_> = members
|
||||
.iter()
|
||||
.map(|(nick, rank)| json!({
|
||||
.map(|(nick, who)| json!({
|
||||
"nick": nick,
|
||||
"prefix": rank.prefix(),
|
||||
"prefix": who.rank.prefix(),
|
||||
// Absent rather than null where the network never said, so a
|
||||
// client can tell "not known" from "known to be nothing".
|
||||
"host": who.host,
|
||||
"account": who.account,
|
||||
"bot": who.bot,
|
||||
// Was hardcoded false for everybody, which made the field a
|
||||
// decoration rather than a fact. It is the server's answer now -
|
||||
// from away-notify where the network has it, and from a WHOIS or
|
||||
@@ -354,6 +413,55 @@ pub(super) fn emit_presence(state: &AppState, account_id: &str, channel: &str, m
|
||||
state.events.emit("presenceChange", json!({ "bufferId": buffer_id, "members": member_list }));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod who_tests {
|
||||
use super::*;
|
||||
|
||||
/// Answers arrive from several places in no fixed order - NAMES on join,
|
||||
/// a WHO reply moments later, an `account` tag on the first thing
|
||||
/// somebody says. A later answer that knows less must not erase what an
|
||||
/// earlier one knew, or a roster would flicker between complete and bare.
|
||||
#[test]
|
||||
fn a_later_answer_does_not_erase_an_earlier_one() {
|
||||
let mut who = Who::ranked(MemberRank::Op);
|
||||
who.learn(Some("[email protected]"), Some("adalovelace"), Some(false));
|
||||
// A plain WHO reply carries no account at all.
|
||||
who.learn(Some("[email protected]"), None, None);
|
||||
assert_eq!(who.account.as_deref(), Some("adalovelace"));
|
||||
assert_eq!(who.host.as_deref(), Some("[email protected]"));
|
||||
assert_eq!(who.rank, MemberRank::Op);
|
||||
}
|
||||
|
||||
/// Signing out of services is a fact, not an absence: the wire says so
|
||||
/// with "0" or "*", and a client that treated those as "no answer" would
|
||||
/// keep showing an account somebody has just left.
|
||||
#[test]
|
||||
fn signing_out_clears_the_account() {
|
||||
let mut who = Who::default();
|
||||
who.learn(None, Some("adalovelace"), None);
|
||||
assert_eq!(who.account.as_deref(), Some("adalovelace"));
|
||||
|
||||
who.learn(None, Some("0"), None);
|
||||
assert!(who.account.is_none());
|
||||
|
||||
who.learn(None, Some("adalovelace"), None);
|
||||
who.learn(None, Some("*"), None);
|
||||
assert!(who.account.is_none());
|
||||
}
|
||||
|
||||
/// An empty string is the server having nothing to say, which is not the
|
||||
/// same as it saying there is nothing.
|
||||
#[test]
|
||||
fn nothing_said_changes_nothing() {
|
||||
let mut who = Who::default();
|
||||
who.learn(Some("[email protected]"), None, None);
|
||||
who.learn(Some(""), Some(""), None);
|
||||
assert_eq!(who.host.as_deref(), Some("[email protected]"));
|
||||
assert!(who.account.is_none());
|
||||
assert!(!who.bot);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod notify_tests {
|
||||
use super::{note_presence, notify_list};
|
||||
|
||||
@@ -345,7 +345,7 @@ pub(super) async fn register_with_sasl(state: &AppState, account_id: &str, sende
|
||||
|
||||
if !offered {
|
||||
state.runtime.report_progress(state, account_id, "Server has no SASL; registering normally...");
|
||||
end_cap_and_register(sender, config)?;
|
||||
end_cap_and_register(sender, stream, config).await?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ pub(super) async fn register_with_sasl(state: &AppState, account_id: &str, sende
|
||||
tried.push(mechanism);
|
||||
match attempt_sasl(state, account_id, sender, stream, config, mechanism).await {
|
||||
Ok(()) => {
|
||||
end_cap_and_register(sender, config)?;
|
||||
end_cap_and_register(sender, stream, config).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
Err(SaslRefusal::Fatal(e)) => return Err(e),
|
||||
|
||||
@@ -31,6 +31,27 @@ pub fn send_message(state: &AppState, account_id: &str, sender: &Sender, target_
|
||||
Ok(())
|
||||
}
|
||||
"nick" => sender.send(Command::NICK(arg.to_string())).map_err(|e| anyhow!(e)),
|
||||
// The other name you have here. `nick` is what people call you;
|
||||
// this is the sentence about yourself that USER fixed at
|
||||
// registration and that, without `setname`, could only be changed
|
||||
// by dropping the connection and every channel with it.
|
||||
//
|
||||
// Written to the account as well as sent, so the next connection
|
||||
// registers with it rather than reverting to whatever was typed
|
||||
// when the account was made.
|
||||
"setname" => {
|
||||
if arg.is_empty() {
|
||||
bail!("/setname requires the name to use");
|
||||
}
|
||||
if !state.runtime.irc_has_cap(account_id, "setname") {
|
||||
bail!("this network cannot change a realname without reconnecting");
|
||||
}
|
||||
sender.send(Command::Raw("SETNAME".to_string(), vec![arg.to_string()]))?;
|
||||
if let Err(e) = state.accounts.set_irc_realname(account_id, arg) {
|
||||
tracing::debug!("irc[{account_id}]: keeping the new realname: {e:#}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"topic" => sender.send_topic(target_buffer, arg).map_err(|e| anyhow!(e)),
|
||||
// Moderator actions - the frontend only offers these in the
|
||||
// userlist context menu when the presence data it already has
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Asking the server who is actually in a channel.
|
||||
//!
|
||||
//! Until this existed there was no `WHO` anywhere in the backend, which meant
|
||||
//! a hostmask only ever arrived from an explicit WHOIS about one person. That
|
||||
//! is the wrong shape for the two things that want one: ignoring somebody and
|
||||
//! banning them both act on a mask, and an ignore keyed on a nick is undone
|
||||
//! the moment they change it.
|
||||
//!
|
||||
//! Two shapes, because servers answer in two.
|
||||
//!
|
||||
//! **WHOX** is the ratified extension, and it is the one with no capability
|
||||
//! of its own - a server advertises `WHOX` in RPL_ISUPPORT and nowhere else,
|
||||
//! which is why this reads ISUPPORT rather than the CAP list. It lets the
|
||||
//! client name the fields it wants and get exactly those back, including the
|
||||
//! services account, which plain WHO has nowhere to put.
|
||||
//!
|
||||
//! **Plain WHO** is the fallback every server has had since RFC 1459. It
|
||||
//! carries the hostmask but not the account, so on those networks the account
|
||||
//! comes from `account-notify`, `extended-join` and `account-tag` instead.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The fields asked for, in the order WHOX answers them.
|
||||
///
|
||||
/// `t` is the token that marks the reply as ours, and the rest are what a
|
||||
/// roster wants: channel, user, host, nick, flags, account. Deliberately not
|
||||
/// `r` (realname) - it is the one field that is free text of arbitrary
|
||||
/// length, and nothing here shows it.
|
||||
const WHOX_FIELDS: &str = "%tcuhnfa";
|
||||
|
||||
/// Marks a WHOX reply as an answer to us rather than to something a person
|
||||
/// typed. Any number would do; this one is stable so a reply arriving after a
|
||||
/// reconnect is still recognisable.
|
||||
pub(super) const WHOX_TOKEN: &str = "042";
|
||||
|
||||
/// Asks who is in a channel, in whichever dialect this server speaks.
|
||||
pub(super) fn ask(state: &AppState, account_id: &str, channel: &str) {
|
||||
let Some(sender) = state.runtime.irc_sender(account_id) else { return };
|
||||
let line = if state.runtime.irc_has_isupport(account_id, "WHOX") {
|
||||
format!("WHO {channel} {WHOX_FIELDS},{WHOX_TOKEN}")
|
||||
} else {
|
||||
format!("WHO {channel}")
|
||||
};
|
||||
if let Err(e) = sender.send(Command::Raw(line.clone(), Vec::new())) {
|
||||
tracing::debug!("irc[{account_id}]: asking {line}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What one WHO reply says about one person.
|
||||
pub(super) struct Seen {
|
||||
pub channel: String,
|
||||
pub nick: String,
|
||||
pub host: String,
|
||||
pub account: Option<String>,
|
||||
pub away: bool,
|
||||
pub bot: bool,
|
||||
}
|
||||
|
||||
/// Reads a WHOX reply (`354`).
|
||||
///
|
||||
/// `<client> <token> <channel> <user> <host> <nick> <flags> <account>` - the
|
||||
/// fields asked for in `WHOX_FIELDS`, in that order, which is fixed by the
|
||||
/// spec rather than by the request. A reply carrying somebody else's token is
|
||||
/// an answer to something a person typed and is left alone.
|
||||
pub(super) fn read_whox(args: &[String]) -> Option<Seen> {
|
||||
if args.len() < 8 || args[1] != WHOX_TOKEN {
|
||||
return None;
|
||||
}
|
||||
Some(Seen {
|
||||
channel: args[2].clone(),
|
||||
nick: args[5].clone(),
|
||||
host: format!("{}@{}", args[3], args[4]),
|
||||
account: Some(args[7].clone()),
|
||||
away: args[6].starts_with('G'),
|
||||
bot: args[6].contains('B'),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads a plain WHO reply (`352`).
|
||||
///
|
||||
/// `<client> <channel> <user> <host> <server> <nick> <flags> :<hops> <real>`.
|
||||
/// No account: RFC WHO has nowhere to put one, which is the whole reason WHOX
|
||||
/// exists.
|
||||
pub(super) fn read_who(args: &[String]) -> Option<Seen> {
|
||||
if args.len() < 7 {
|
||||
return None;
|
||||
}
|
||||
Some(Seen {
|
||||
channel: args[1].clone(),
|
||||
nick: args[5].clone(),
|
||||
host: format!("{}@{}", args[2], args[3]),
|
||||
account: None,
|
||||
away: args[6].starts_with('G'),
|
||||
bot: args[6].contains('B'),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(line: &[&str]) -> Vec<String> {
|
||||
line.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// The shape that carries an account, which is the reason for asking in
|
||||
/// this dialect at all.
|
||||
#[test]
|
||||
fn a_whox_reply_carries_the_account() {
|
||||
let seen = read_whox(&args(&["me", WHOX_TOKEN, "#chat", "ada", "example.org", "Ada", "H", "adalovelace"]))
|
||||
.expect("a reply");
|
||||
assert_eq!(seen.channel, "#chat");
|
||||
assert_eq!(seen.nick, "Ada");
|
||||
assert_eq!(seen.host, "[email protected]");
|
||||
assert_eq!(seen.account.as_deref(), Some("adalovelace"));
|
||||
assert!(!seen.away);
|
||||
assert!(!seen.bot);
|
||||
}
|
||||
|
||||
/// Somebody else's WHOX. A person typing `/quote WHO ... %tcuhnfa,999`
|
||||
/// gets an answer this client must not fold into a roster, because the
|
||||
/// fields may be in a different order entirely.
|
||||
#[test]
|
||||
fn a_reply_to_somebody_elses_question_is_left_alone() {
|
||||
assert!(read_whox(&args(&["me", "999", "#chat", "ada", "example.org", "Ada", "H", "acct"])).is_none());
|
||||
// And a truncated one is not read as if the missing fields were empty.
|
||||
assert!(read_whox(&args(&["me", WHOX_TOKEN, "#chat"])).is_none());
|
||||
}
|
||||
|
||||
/// Away and bot both live in the flags field, and both are read from it.
|
||||
/// `G` is gone, `H` is here - the letters are the wrong way round from
|
||||
/// what anybody would guess, which is exactly why it is tested.
|
||||
#[test]
|
||||
fn the_flags_say_away_and_bot() {
|
||||
let away = read_whox(&args(&["me", WHOX_TOKEN, "#chat", "u", "h", "Ada", "G", "acct"])).expect("a reply");
|
||||
assert!(away.away);
|
||||
|
||||
let bot = read_whox(&args(&["me", WHOX_TOKEN, "#chat", "u", "h", "Hal", "HB", "acct"])).expect("a reply");
|
||||
assert!(bot.bot);
|
||||
assert!(!bot.away);
|
||||
|
||||
// An operator's star and a channel prefix ride in the same field and
|
||||
// must not be read as either.
|
||||
let op = read_whox(&args(&["me", WHOX_TOKEN, "#chat", "u", "h", "Ada", "H*@", "acct"])).expect("a reply");
|
||||
assert!(!op.away);
|
||||
assert!(!op.bot);
|
||||
}
|
||||
|
||||
/// The fallback every server has. Fewer fields, in a different order, and
|
||||
/// no account at all.
|
||||
#[test]
|
||||
fn a_plain_reply_carries_everything_but_the_account() {
|
||||
let seen = read_who(&args(&["me", "#chat", "ada", "example.org", "irc.example.org", "Ada", "H", "0 Ada Lovelace"]))
|
||||
.expect("a reply");
|
||||
assert_eq!(seen.channel, "#chat");
|
||||
assert_eq!(seen.nick, "Ada");
|
||||
assert_eq!(seen.host, "[email protected]");
|
||||
assert!(seen.account.is_none());
|
||||
assert!(!seen.away);
|
||||
|
||||
assert!(read_who(&args(&["me", "#chat"])).is_none());
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -520,8 +520,9 @@ pub fn next_message_id() -> String {
|
||||
|
||||
/// "~"/"@"/"%"/"+"/"" by descending rank (daemon/nobilis/model.c's
|
||||
/// nobilis_member_prefix).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum MemberRank {
|
||||
#[default]
|
||||
None,
|
||||
Voice,
|
||||
HalfOp,
|
||||
|
||||
@@ -691,6 +691,8 @@ pub struct Runtime {
|
||||
/// (see `backend::discord::mutes`) since the settings routinely arrive in
|
||||
/// READY before the channels they are about.
|
||||
discord_mutes: Mutex<HashMap<(String, String), DiscordMute>>,
|
||||
/// account -> the RPL_ISUPPORT tokens that server advertised.
|
||||
irc_isupport: Mutex<HashMap<String, std::collections::HashSet<String>>>,
|
||||
/// account -> the room catalogue last read off the site.
|
||||
///
|
||||
/// Cached because reading it costs a Tor round trip and a proof-of-work
|
||||
@@ -949,6 +951,7 @@ impl Runtime {
|
||||
silenced: Mutex::new(std::collections::HashSet::new()),
|
||||
sneedchat_motds: Mutex::new(HashMap::new()),
|
||||
sneedchat_rooms: Mutex::new(HashMap::new()),
|
||||
irc_isupport: Mutex::new(HashMap::new()),
|
||||
discord_mutes: Mutex::new(HashMap::new()),
|
||||
irc_splits: Mutex::new(HashMap::new()),
|
||||
irc_whois: Mutex::new(HashMap::new()),
|
||||
@@ -3068,6 +3071,33 @@ impl Runtime {
|
||||
|
||||
pub fn clear_irc_caps(&self, account_id: &str) {
|
||||
self.irc_caps.lock().unwrap().remove(account_id);
|
||||
self.irc_isupport.lock().unwrap().remove(account_id);
|
||||
}
|
||||
|
||||
/// Records the tokens a server advertises in RPL_ISUPPORT.
|
||||
///
|
||||
/// Kept separately from capabilities because they are a different
|
||||
/// mechanism answering a different question: a capability is negotiated
|
||||
/// and changes what the server sends, while an ISUPPORT token is simply
|
||||
/// announced and describes what the server will accept. WHOX is the one
|
||||
/// that matters here - it is a ratified IRCv3 extension with no CAP of
|
||||
/// its own, advertised this way and no other.
|
||||
pub fn note_irc_isupport(&self, account_id: &str, tokens: &str) {
|
||||
let mut all = self.irc_isupport.lock().unwrap();
|
||||
let known = all.entry(account_id.to_string()).or_default();
|
||||
for token in tokens.split_whitespace() {
|
||||
// `TOKEN=value` and a bare `TOKEN` both count; the name is the
|
||||
// part that answers "does this server do that".
|
||||
known.insert(token.split('=').next().unwrap_or(token).to_ascii_uppercase());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn irc_has_isupport(&self, account_id: &str, token: &str) -> bool {
|
||||
self.irc_isupport
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(account_id)
|
||||
.is_some_and(|tokens| tokens.contains(&token.to_ascii_uppercase()))
|
||||
}
|
||||
|
||||
pub fn discord_last_interaction(&self, account_id: &str) -> Option<String> {
|
||||
|
||||
Reference in New Issue
Block a user