Skip to main content

obby_client/
client.rs

1//! The connection engine.
2
3use alloc::collections::VecDeque;
4use alloc::string::{String, ToString};
5use alloc::vec::Vec;
6use obby_proto::{Casemapping, Isupport, Message};
7
8use crate::batch::{Batches, ClosedBatch};
9use crate::caps::Capabilities;
10use crate::command::Command;
11use crate::label::{DEFAULT_TIMEOUT_MS, Labels};
12use crate::model::Model;
13use crate::monitor::{self, WatchList};
14use crate::sasl::{self, Credentials, SaslFailure, SaslState};
15use crate::session::{self, Change};
16use crate::timer::{
17    DEAD_LINK_MS, Deadline, Now, PING_KEEPALIVE_MS, ReconnectBackoff, TYPING_EXPIRY_MS, Timers,
18};
19
20/// How far the connection has got.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
24#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
25#[cfg_attr(feature = "ts", ts(rename = "ConnectionPhase"))]
26pub enum Phase {
27    /// No transport yet. Nothing has been written.
28    Disconnected,
29    /// The transport is up and capability negotiation is running.
30    Negotiating,
31    /// Capabilities are settled, `CAP END` and the registration lines are sent, waiting for `001`.
32    Registering,
33    /// `001` arrived. The connection is usable.
34    Registered,
35}
36
37/// What a host needs to give the engine before it can connect.
38///
39/// Every field but the nick has a default, so a host deserialising one only has to supply what it
40/// actually cares about. An empty username or realname is filled in from the nick.
41#[derive(Debug, Clone)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
44#[cfg_attr(feature = "serde", serde(default))]
45pub struct Config {
46    /// The nick to register with.
47    pub nick: String,
48    /// The username sent in `USER`. Defaults to the nick.
49    #[cfg_attr(feature = "ts", ts(as = "Option<String>", optional))]
50    pub username: String,
51    /// The realname sent in `USER`. Defaults to the nick.
52    #[cfg_attr(feature = "ts", ts(as = "Option<String>", optional))]
53    pub realname: String,
54    /// The server password, sent as `PASS` before anything else.
55    #[cfg_attr(feature = "ts", ts(optional))]
56    pub password: Option<String>,
57    /// What to authenticate with, when the server offers `sasl`.
58    #[cfg_attr(feature = "ts", ts(optional))]
59    pub sasl: Option<Credentials>,
60    /// How many messages each channel and conversation keeps.
61    #[cfg_attr(feature = "ts", ts(as = "Option<usize>", optional))]
62    pub retention: usize,
63    /// Nicks to fall back through when the server says ours is taken during registration.
64    ///
65    /// Registration stalls forever if nobody answers a 433, so the engine walks this list and then
66    /// starts appending underscores rather than leaving the connection hung.
67    #[cfg_attr(feature = "ts", ts(as = "Option<Vec<String>>", optional))]
68    pub alt_nicks: Vec<String>,
69}
70
71impl Default for Config {
72    fn default() -> Self {
73        Self::new(String::new())
74    }
75}
76
77impl Config {
78    /// A config that registers with one nick and nothing else.
79    pub fn new(nick: impl Into<String>) -> Self {
80        let nick = nick.into();
81        Self {
82            username: nick.clone(),
83            realname: nick.clone(),
84            nick,
85            password: None,
86            sasl: None,
87            retention: crate::model::DEFAULT_RETENTION,
88            alt_nicks: Vec::new(),
89        }
90    }
91}
92
93/// Something the host needs to know about.
94///
95/// Events are drained with [`Client::poll_event`] rather than delivered through a callback, because
96/// a callback needs a different lifetime, threading and re-entrancy contract in every language this
97/// engine is bound into.
98#[derive(Debug, Clone, PartialEq, Eq)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
100#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
101#[cfg_attr(feature = "ts", ts(rename = "ObbyEvent"))]
102#[cfg_attr(feature = "serde", serde(tag = "type", rename_all = "snake_case"))]
103#[non_exhaustive]
104pub enum Event {
105    /// A capability was acknowledged, and whatever it enables is now live.
106    CapabilitiesAcknowledged {
107        /// The capability names, as acknowledged.
108        names: Vec<String>,
109    },
110    /// Registration finished. `001` arrived and the nick is settled.
111    Registered {
112        /// The nick the server actually gave us, which may differ from the one we asked for.
113        nick: String,
114    },
115    /// An ISUPPORT token arrived. Emitted per token so a host can react to one without diffing.
116    IsupportToken {
117        /// The token name, such as `CHANMODES`.
118        token: String,
119        /// Its value, absent for a boolean token, present and empty for `TOKEN=`.
120        value: Option<String>,
121    },
122    /// Authentication succeeded.
123    LoggedIn {
124        /// The account the server logged us in as.
125        account: String,
126    },
127    /// Authentication did not succeed. Registration continues unauthenticated.
128    SaslFailed {
129        /// Why it ended.
130        reason: SaslFailure,
131    },
132    /// The server refused a nick and the engine is trying another.
133    NickInUse {
134        /// The nick that was refused.
135        refused: String,
136        /// What is being tried instead.
137        trying: String,
138    },
139    /// The model changed.
140    ModelChanged {
141        /// What changed.
142        change: Change,
143    },
144    /// The link went quiet for too long and should be treated as dead. The host closes its socket
145    /// and waits for [`Event::ReconnectAfter`].
146    LinkDead,
147    /// Time to open a new connection. The host dials and calls [`Client::handle_connected`].
148    ReconnectAfter {
149        /// How long the host should wait first.
150        #[cfg_attr(feature = "ts", ts(type = "number"))]
151        after_ms: u64,
152    },
153    /// Reconnecting has been given up on after too many attempts.
154    ReconnectAbandoned,
155    /// A command we labelled never got its reply.
156    CommandTimedOut {
157        /// The command that went unanswered.
158        command: String,
159    },
160    /// The set of commands the server allows us changed.
161    #[cfg(feature = "obby")]
162    AllowedCommandsChanged,
163    /// What we know about a bot changed. Read it back from [`Client::bots`].
164    #[cfg(feature = "obby")]
165    BotsChanged {
166        /// The bot the server told us about.
167        nick: String,
168    },
169    /// The server minted a bearer token for one of its services.
170    #[cfg(feature = "obby")]
171    AuthToken {
172        /// The service it is for, as the server spells it.
173        service: String,
174        /// Where to present it.
175        endpoint: String,
176        /// The token itself.
177        token: String,
178    },
179    /// A voice signalling frame arrived, and the room state has already been updated for it.
180    ///
181    /// The host handles the media plane: this carries the session descriptions and candidates it
182    /// needs, and nothing that touches a track.
183    #[cfg(feature = "voice")]
184    Voice {
185        /// The channel the room belongs to.
186        channel: String,
187        /// The frame.
188        signal: crate::voice::Signal,
189    },
190    /// Someone started or stopped composing a message.
191    TypingChanged {
192        /// The channel or conversation they are composing in.
193        target: String,
194        /// Who.
195        nick: String,
196        /// True when they are composing now.
197        active: bool,
198    },
199    /// Someone we watch came online or went offline.
200    PresenceChanged {
201        /// Who.
202        nick: String,
203        /// True when they are here now.
204        online: bool,
205    },
206    /// The server reported the outcome of something, through `standard-replies`.
207    ServerReply {
208        /// How serious it is.
209        severity: Severity,
210        /// The command it concerns, or `*` when the server did not say.
211        command: String,
212        /// The machine-readable code, such as `ACCOUNT_REQUIRED_TO_CONNECT`.
213        code: String,
214        /// Any further parameters the server gave, before the description.
215        context: Vec<String>,
216        /// The human-readable description.
217        text: String,
218    },
219    /// A line arrived that the engine has no dedicated handling for yet. Everything is surfaced, so
220    /// a host is never blind to traffic the engine does not model.
221    RawLine {
222        /// The parsed line.
223        message: Message,
224    },
225}
226
227/// One connection.
228#[derive(Debug)]
229pub struct Client {
230    config: Config,
231    phase: Phase,
232    caps: Capabilities,
233    nick: String,
234    isupport: Isupport,
235    model: Model,
236    batches: Batches,
237    monitor: WatchList,
238    /// Channel keys, so a rejoin after a reconnect is not refused for want of one.
239    channel_keys: alloc::collections::BTreeMap<obby_proto::CaseFolded, String>,
240    /// Metadata keys we subscribed to, so a reconnect can ask again.
241    metadata_subscriptions: Vec<String>,
242    #[cfg(feature = "voice")]
243    rooms: alloc::collections::BTreeMap<obby_proto::CaseFolded, crate::voice::Room>,
244    #[cfg(feature = "obby")]
245    commands: crate::extensions::Commands,
246    #[cfg(feature = "obby")]
247    bots: crate::extensions::Bots,
248    timers: Timers,
249    labels: Labels<String>,
250    backoff: ReconnectBackoff,
251    monotonic_ms: u64,
252    /// The latest instant seen, which stamps a line the server did not stamp itself.
253    latest_ms: u64,
254    /// True while we are skipping the remainder of a line that grew past what we will hold.
255    discarding: bool,
256    dropped_lines: u64,
257    sasl: SaslState,
258    scram: Option<crate::scram::Scram>,
259    nick_attempt: usize,
260    /// Bytes read from the transport that do not yet form a whole line.
261    partial: Vec<u8>,
262    outbox: VecDeque<Vec<u8>>,
263    events: VecDeque<Event>,
264}
265
266impl Client {
267    /// Build an engine that has not connected yet. Nothing is written until [`Client::handle_connected`].
268    ///
269    /// ```
270    /// use obby_client::{Client, Config, Phase};
271    ///
272    /// let mut client = Client::new(Config::new("mynick"));
273    /// assert_eq!(client.nick(), "mynick");
274    /// assert_eq!(client.phase(), Phase::Disconnected);
275    /// assert!(client.poll_transmit().is_none());
276    /// ```
277    pub fn new(config: Config) -> Self {
278        let mut config = config;
279        // a host that deserialised a partial config gets these filled in rather than registering
280        // with an empty username, which some servers refuse outright
281        if config.username.is_empty() {
282            config.username.clone_from(&config.nick);
283        }
284        if config.realname.is_empty() {
285            config.realname.clone_from(&config.nick);
286        }
287        let mut model = Model::with_retention(config.retention);
288        model.me.nick.clone_from(&config.nick);
289        Self {
290            nick: config.nick.clone(),
291            model,
292            latest_ms: 0,
293            discarding: false,
294            dropped_lines: 0,
295            config,
296            phase: Phase::Disconnected,
297            caps: Capabilities::default(),
298            isupport: Isupport::default(),
299            batches: Batches::new(),
300            monitor: WatchList::new(),
301            channel_keys: alloc::collections::BTreeMap::new(),
302            metadata_subscriptions: Vec::new(),
303            #[cfg(feature = "voice")]
304            rooms: alloc::collections::BTreeMap::new(),
305            #[cfg(feature = "obby")]
306            commands: crate::extensions::Commands::new(),
307            #[cfg(feature = "obby")]
308            bots: crate::extensions::Bots::new(),
309            timers: Timers::new(),
310            labels: Labels::new(),
311            backoff: ReconnectBackoff::default(),
312            monotonic_ms: 0,
313            sasl: SaslState::default(),
314            scram: None,
315            nick_attempt: 0,
316            partial: Vec::new(),
317            outbox: VecDeque::new(),
318            events: VecDeque::new(),
319        }
320    }
321
322    /// Tell the engine the transport is up. This queues the registration burst.
323    ///
324    /// The host calls this once the socket is open and the TLS handshake, if any, has finished. The
325    /// engine has no way to know that on its own.
326    ///
327    /// ```
328    /// # use obby_client::{Client, Config};
329    /// let mut client = Client::new(Config::new("mynick"));
330    /// client.handle_connected();
331    ///
332    /// let mut sent = Vec::new();
333    /// while let Some(bytes) = client.poll_transmit() {
334    ///     sent.extend_from_slice(&bytes);
335    /// }
336    /// assert!(sent.starts_with(b"CAP LS 302\r\n"));
337    /// assert!(sent.ends_with(b"USER mynick 0 * mynick\r\n"));
338    /// ```
339    pub fn handle_connected(&mut self) {
340        self.phase = Phase::Negotiating;
341        self.backoff.reset();
342        self.timers.clear(&Deadline::Reconnect);
343        self.arm_keepalive();
344        // CAP LS goes first so the server holds registration open while we negotiate. PASS has to
345        // precede NICK and USER, and NICK and USER may be sent during negotiation.
346        self.send_line(&Message::new("CAP", ["LS", "302"]));
347        if let Some(password) = self.config.password.clone() {
348            self.send_line(&Message::new("PASS", [password]));
349        }
350        self.send_line(&Message::new("NICK", [self.config.nick.clone()]));
351        self.send_line(&Message::new(
352            "USER",
353            [
354                self.config.username.clone(),
355                "0".to_string(),
356                "*".to_string(),
357                self.config.realname.clone(),
358            ],
359        ));
360    }
361
362    /// Tell the engine its transport died. The model survives, so a reconnect can resume from it.
363    ///
364    /// The engine decides when to try again and says so with [`Event::ReconnectAfter`]. The host owns
365    /// every socket, and this is the only thing it has to report.
366    pub fn handle_disconnected(&mut self) {
367        self.phase = Phase::Disconnected;
368        self.batches.drop_all();
369        self.caps = Capabilities::default();
370        self.sasl = SaslState::default();
371        self.monitor.forget_presence();
372        self.model.forget_whois();
373        #[cfg(feature = "obby")]
374        self.bots.forget();
375        // who was in a channel is only true while we are connected to hear about it. The messages
376        // stay, because they happened; the member lists do not, because they are a live view
377        for (_, channel) in self.model.channels_mut() {
378            channel.members.clear();
379        }
380        self.timers.clear(&Deadline::PingKeepalive);
381        self.timers.clear(&Deadline::DeadLink);
382        match self.backoff.next_delay_ms() {
383            Some(after_ms) => {
384                self.timers.set(
385                    Deadline::Reconnect,
386                    self.monotonic_ms.saturating_add(after_ms),
387                );
388                self.events.push_back(Event::ReconnectAfter { after_ms });
389            }
390            None => self.events.push_back(Event::ReconnectAbandoned),
391        }
392    }
393
394    /// Advance the clock. Whatever has fallen due happens here.
395    ///
396    /// The host supplies both clocks because the engine has neither: the monotonic one drives every
397    /// deadline, and the wall clock only stamps a message the server did not stamp itself.
398    ///
399    /// ```
400    /// # use obby_client::{Client, Config, Now};
401    /// let mut client = Client::new(Config::new("mynick"));
402    /// client.handle_connected();
403    /// while client.poll_transmit().is_some() {}
404    ///
405    /// // sleeping until exactly the next deadline is all a host's loop has to do
406    /// let due_ms = client.poll_timeout().expect("the keepalive is armed on connect");
407    /// client.tick(Now {
408    ///     monotonic_ms: due_ms,
409    ///     unix_ms: 1_788_688_800_000,
410    /// });
411    /// assert_eq!(client.poll_transmit(), Some(b"PING mynick\r\n".to_vec()));
412    /// ```
413    pub fn tick(&mut self, now: Now) {
414        self.monotonic_ms = now.monotonic_ms;
415        self.latest_ms = self.latest_ms.max(now.unix_ms);
416
417        for deadline in self.timers.expire(now) {
418            match deadline {
419                Deadline::PingKeepalive => {
420                    self.send_line(&Message::new("PING", [self.nick.clone()]));
421                    self.timers.set(
422                        Deadline::DeadLink,
423                        now.monotonic_ms.saturating_add(DEAD_LINK_MS),
424                    );
425                }
426                Deadline::DeadLink => self.events.push_back(Event::LinkDead),
427                Deadline::Reconnect => self.events.push_back(Event::ReconnectAfter { after_ms: 0 }),
428                Deadline::Typing(target, who) => self.expire_typing(&target, &who),
429            }
430        }
431
432        for command in self.labels.expire(now.monotonic_ms) {
433            self.events.push_back(Event::CommandTimedOut { command });
434        }
435    }
436
437    /// When [`Client::tick`] next has something to do, as a monotonic instant.
438    ///
439    /// A host can sleep until exactly then rather than waking on a fixed interval.
440    pub fn poll_timeout(&self) -> Option<u64> {
441        self.timers.next()
442    }
443
444    /// Join a channel, with its key when it has one.
445    ///
446    /// ```
447    /// # use obby_client::{Client, Config};
448    /// let mut client = Client::new(Config::new("mynick"));
449    /// client.join("#obby", None);
450    /// client.join("#staff", Some("hunter2".to_string()));
451    ///
452    /// assert_eq!(client.poll_transmit(), Some(b"JOIN #obby\r\n".to_vec()));
453    /// assert_eq!(client.poll_transmit(), Some(b"JOIN #staff hunter2\r\n".to_vec()));
454    /// ```
455    pub fn join(&mut self, channel: impl Into<String>, key: Option<String>) {
456        self.command(Command::Join {
457            channel: channel.into(),
458            key,
459        });
460    }
461
462    /// Leave a channel, with a reason the others in it see.
463    pub fn part(&mut self, channel: impl Into<String>, reason: Option<String>) {
464        self.command(Command::Part {
465            channel: channel.into(),
466            reason,
467        });
468    }
469
470    /// Say something to a channel or a person.
471    ///
472    /// ```
473    /// # use obby_client::{Client, Config};
474    /// let mut client = Client::new(Config::new("mynick"));
475    /// client.send_message("#obby", "hello there");
476    ///
477    /// assert_eq!(
478    ///     client.poll_transmit(),
479    ///     Some(b"PRIVMSG #obby :hello there\r\n".to_vec()),
480    /// );
481    /// ```
482    pub fn send_message(&mut self, target: impl Into<String>, text: impl Into<String>) {
483        self.command(Command::SendMessage {
484            target: target.into(),
485            text: text.into(),
486        });
487    }
488
489    /// Send a notice, which by convention must never be auto-replied to.
490    pub fn send_notice(&mut self, target: impl Into<String>, text: impl Into<String>) {
491        self.command(Command::SendNotice {
492            target: target.into(),
493            text: text.into(),
494        });
495    }
496
497    /// Send a `CTCP ACTION`, the third-person form.
498    pub fn send_action(&mut self, target: impl Into<String>, text: impl Into<String>) {
499        self.command(Command::SendAction {
500            target: target.into(),
501            text: text.into(),
502        });
503    }
504
505    /// Change our nick.
506    pub fn set_nick(&mut self, nick: impl Into<String>) {
507        self.command(Command::SetNick { nick: nick.into() });
508    }
509
510    /// Set a channel's topic, or ask for the current one with `None`.
511    pub fn set_topic(&mut self, channel: impl Into<String>, topic: Option<String>) {
512        self.command(Command::SetTopic {
513            channel: channel.into(),
514            topic,
515        });
516    }
517
518    /// Go away with a message, or come back with `None`.
519    pub fn set_away(&mut self, message: Option<String>) {
520        self.command(Command::SetAway { message });
521    }
522
523    /// Tell a target we are composing, paused, or done.
524    pub fn set_typing(&mut self, target: impl Into<String>, state: crate::command::Typing) {
525        self.command(Command::SetTyping {
526            target: target.into(),
527            state,
528        });
529    }
530
531    /// React to a message with an emoji.
532    pub fn add_reaction(
533        &mut self,
534        target: impl Into<String>,
535        msgid: impl Into<String>,
536        emoji: impl Into<String>,
537    ) {
538        self.command(Command::AddReaction {
539            target: target.into(),
540            msgid: msgid.into(),
541            emoji: emoji.into(),
542        });
543    }
544
545    /// Take one of our reactions back.
546    pub fn remove_reaction(
547        &mut self,
548        target: impl Into<String>,
549        msgid: impl Into<String>,
550        emoji: impl Into<String>,
551    ) {
552        self.command(Command::RemoveReaction {
553            target: target.into(),
554            msgid: msgid.into(),
555            emoji: emoji.into(),
556        });
557    }
558
559    /// Ask the server to redact a message.
560    pub fn redact_message(
561        &mut self,
562        target: impl Into<String>,
563        msgid: impl Into<String>,
564        reason: Option<String>,
565    ) {
566        self.command(Command::RedactMessage {
567            target: target.into(),
568            msgid: msgid.into(),
569            reason,
570        });
571    }
572
573    /// Move our read marker in a target, at a time in milliseconds since the Unix epoch.
574    pub fn mark_read(&mut self, target: impl Into<String>, at_ms: u64) {
575        self.command(Command::MarkRead {
576            target: target.into(),
577            at_ms,
578        });
579    }
580
581    /// Ask for older messages in a target, before the message with this id.
582    pub fn fetch_history(
583        &mut self,
584        target: impl Into<String>,
585        before_msgid: Option<String>,
586        limit: u16,
587    ) {
588        self.command(Command::FetchHistory {
589            target: target.into(),
590            before_msgid,
591            limit,
592        });
593    }
594
595    /// Set one of our own metadata keys, or clear it with `None`.
596    pub fn set_metadata(&mut self, key: impl Into<String>, value: Option<String>) {
597        self.command(Command::SetMetadata {
598            key: key.into(),
599            value,
600        });
601    }
602
603    /// Subscribe to the metadata keys we want told about.
604    pub fn subscribe_metadata(&mut self, keys: Vec<String>) {
605        self.command(Command::SubscribeMetadata { keys });
606    }
607
608    /// Ask the server everything it will say about someone.
609    ///
610    /// The record lands in the model under the folded nick and arrives as one
611    /// [`Change::WhoisReceived`] when the reply finishes.
612    pub fn whois(&mut self, nick: impl Into<String>) {
613        self.command(Command::Whois { nick: nick.into() });
614    }
615
616    /// Rename a channel, keeping everyone in it and everything said in it.
617    pub fn rename_channel(
618        &mut self,
619        channel: impl Into<String>,
620        new_name: impl Into<String>,
621        reason: Option<String>,
622    ) {
623        self.command(Command::RenameChannel {
624            channel: channel.into(),
625            new_name: new_name.into(),
626            reason,
627        });
628    }
629
630    /// Make an invitation link to a channel, or to the network when no channel is named.
631    pub fn create_invite_link(&mut self, channel: Option<String>, description: Option<String>) {
632        self.command(Command::CreateInviteLink {
633            channel,
634            description,
635        });
636    }
637
638    /// Ask for the invitation links we have made.
639    pub fn list_invite_links(&mut self) {
640        self.command(Command::ListInviteLinks);
641    }
642
643    /// Withdraw an invitation link.
644    pub fn delete_invite_link(&mut self, share_id: impl Into<String>) {
645        self.command(Command::DeleteInviteLink {
646            share_id: share_id.into(),
647        });
648    }
649
650    /// Redeem an invitation code. Only before registering, which is the point of it.
651    pub fn redeem_invite_code(&mut self, code: impl Into<String>) {
652        self.command(Command::RedeemInviteCode { code: code.into() });
653    }
654
655    /// Mint a bearer token for one of the network's services, such as its file host.
656    ///
657    /// The token comes back as an [`Event::AuthToken`].
658    pub fn generate_token(&mut self, service: impl Into<String>) {
659        self.command(Command::GenerateToken {
660            service: service.into(),
661        });
662    }
663
664    /// Watch nicks, so we hear when they come online.
665    pub fn watch_nicks(&mut self, nicks: Vec<String>) {
666        self.command(Command::WatchNicks { nicks });
667    }
668
669    /// Stop watching nicks.
670    pub fn unwatch_nicks(&mut self, nicks: Vec<String>) {
671        self.command(Command::UnwatchNicks { nicks });
672    }
673
674    /// Send one voice signalling frame to a room.
675    #[cfg(feature = "voice")]
676    pub fn send_voice_signal(&mut self, channel: impl Into<String>, signal: crate::voice::Signal) {
677        self.command(Command::SendVoiceSignal {
678            channel: channel.into(),
679            signal,
680        });
681    }
682
683    /// Leave the server, with a reason the others see.
684    pub fn quit(&mut self, reason: Option<String>) {
685        self.command(Command::Quit { reason });
686    }
687
688    /// Send one raw protocol line, for anything this API does not name.
689    pub fn send_raw_line(&mut self, line: impl Into<String>) {
690        self.command(Command::SendRawLine { line: line.into() });
691    }
692
693    /// Do something on this connection.
694    ///
695    /// Anything the server will echo back to us is left for that echo to record, so a message never
696    /// lands twice. Without `echo-message` there is no echo coming, so we record it ourselves from
697    /// the same line we sent, through the same path that would have handled the echo.
698    pub fn command(&mut self, command: Command) {
699        // the three that land in a conversation need the model, the rest are pure translation
700        match command {
701            Command::SendMessage { target, text } => {
702                self.say(&Message::new("PRIVMSG", [target, text]));
703            }
704            Command::SendNotice { target, text } => {
705                self.say(&Message::new("NOTICE", [target, text]));
706            }
707            Command::SendAction { target, text } => {
708                let body = alloc::format!("\u{1}ACTION {text}\u{1}");
709                self.say(&Message::new("PRIVMSG", [target, body]));
710            }
711            // both are remembered, because a reconnect has to reproduce them and the server will
712            // not tell us what we asked for last time
713            Command::Join { channel, key } => {
714                let folded = self.isupport.fold(&channel);
715                match &key {
716                    Some(key) => self.channel_keys.insert(folded, key.clone()),
717                    None => self.channel_keys.remove(&folded),
718                };
719                let line = match key {
720                    Some(key) => Message::new("JOIN", [channel, key]),
721                    None => Message::new("JOIN", [channel]),
722                };
723                self.send_line_labeled(&line);
724            }
725            Command::SubscribeMetadata { keys } => {
726                for key in &keys {
727                    if !self.metadata_subscriptions.contains(key) {
728                        self.metadata_subscriptions.push(key.clone());
729                    }
730                }
731                let mut params = alloc::vec!["*".to_string(), "SUB".to_string()];
732                params.extend(keys);
733                self.send_line_labeled(&Message::new("METADATA", params));
734            }
735            Command::WatchNicks { nicks } => self.set_watching(&nicks, true),
736            Command::UnwatchNicks { nicks } => self.set_watching(&nicks, false),
737            other => {
738                if let Some(line) = Self::wire(other) {
739                    self.send_line_labeled(&line);
740                }
741            }
742        }
743    }
744
745    /// Turn a command into the line that carries it.
746    ///
747    /// `None` for a raw line that will not parse, which must not reach the wire half-formed.
748    fn wire(command: Command) -> Option<Message> {
749        Some(match command {
750            Command::SendMessage { .. }
751            | Command::SendNotice { .. }
752            | Command::SendAction { .. }
753            | Command::WatchNicks { .. }
754            | Command::UnwatchNicks { .. } => return None,
755            Command::Join { .. } | Command::SubscribeMetadata { .. } => return None,
756            Command::Part { channel, reason } => match reason {
757                Some(reason) => Message::new("PART", [channel, reason]),
758                None => Message::new("PART", [channel]),
759            },
760            Command::SetNick { nick } => Message::new("NICK", [nick]),
761            Command::SetTopic { channel, topic } => match topic {
762                Some(topic) => Message::new("TOPIC", [channel, topic]),
763                // an empty trailing parameter clears a topic; omitting it asks what the topic is
764                None => Message::new("TOPIC", [channel, String::new()]),
765            },
766            Command::SetAway { message } => match message {
767                Some(message) => Message::new("AWAY", [message]),
768                None => Message::new("AWAY", [] as [String; 0]),
769            },
770            Command::SetTyping { target, state } => {
771                let mut line = Message::new("TAGMSG", [target]);
772                line.tags
773                    .set(obby_proto::Tag::new("+typing", state.as_str()));
774                line
775            }
776            Command::AddReaction {
777                target,
778                msgid,
779                emoji,
780            } => Self::reaction("+draft/react", &target, &msgid, &emoji),
781            Command::RemoveReaction {
782                target,
783                msgid,
784                emoji,
785            } => Self::reaction("+draft/unreact", &target, &msgid, &emoji),
786            Command::RedactMessage {
787                target,
788                msgid,
789                reason,
790            } => match reason {
791                Some(reason) => Message::new("REDACT", [target, msgid, reason]),
792                None => Message::new("REDACT", [target, msgid]),
793            },
794            Command::MarkRead { target, at_ms } => Message::new(
795                "MARKREAD",
796                [
797                    target,
798                    alloc::format!("timestamp={}", obby_proto::format_server_time(at_ms)),
799                ],
800            ),
801            Command::FetchHistory {
802                target,
803                before_msgid,
804                limit,
805            } => Self::chathistory(target, before_msgid, limit),
806            Command::Whois { nick } => Message::new("WHOIS", [nick]),
807            Command::RenameChannel {
808                channel,
809                new_name,
810                reason,
811            } => match reason {
812                Some(reason) => Message::new("RENAME", [channel, new_name, reason]),
813                None => Message::new("RENAME", [channel, new_name]),
814            },
815            Command::CreateInviteLink {
816                channel,
817                description,
818            } => Self::invite_link(channel, description),
819            Command::ListInviteLinks => Message::new("INVITELINK", ["LIST"]),
820            Command::DeleteInviteLink { share_id } => {
821                Message::new("INVITELINK", ["DELETE".to_string(), share_id])
822            }
823            Command::RedeemInviteCode { code } => Message::new("INVITECODE", [code]),
824            Command::GenerateToken { service } => {
825                Message::new("TOKEN", ["GENERATE".to_string(), service])
826            }
827            Command::SetMetadata { key, value } => match value {
828                Some(value) => {
829                    Message::new("METADATA", ["*".to_string(), "SET".to_string(), key, value])
830                }
831                // a SET with no value is how the specification spells a delete
832                None => Message::new("METADATA", ["*".to_string(), "SET".to_string(), key]),
833            },
834            #[cfg(feature = "voice")]
835            Command::SendVoiceSignal { channel, signal } => {
836                let mut line = Message::new("TAGMSG", [channel]);
837                line.tags
838                    .set(obby_proto::Tag::new("+obsidianirc/rtc", signal.to_json()));
839                line
840            }
841            Command::Quit { reason } => match reason {
842                Some(reason) => Message::new("QUIT", [reason]),
843                None => Message::new("QUIT", [] as [String; 0]),
844            },
845            Command::SendRawLine { line } => Message::parse(&line).ok()?,
846        })
847    }
848
849    /// Start or stop watching a set of nicks, in requests the server will accept whole.
850    fn set_watching(&mut self, nicks: &[String], watching: bool) {
851        let folded: Vec<obby_proto::CaseFolded> =
852            nicks.iter().map(|nick| self.isupport.fold(nick)).collect();
853        if watching {
854            self.monitor.watch(folded);
855        } else {
856            self.monitor.unwatch(&folded);
857        }
858        let limit = self.isupport.number("MONITOR").unwrap_or(100) as usize;
859        let verb = if watching { "+" } else { "-" };
860        for chunk in monitor::batched(nicks, limit) {
861            self.send_line(&Message::new("MONITOR", [verb.to_string(), chunk]));
862        }
863    }
864
865    /// Ask for a page of history: the newest messages, or the ones before a message we hold.
866    fn chathistory(target: String, before_msgid: Option<String>, limit: u16) -> Message {
867        let (selector, point) = match before_msgid {
868            Some(msgid) => ("BEFORE", alloc::format!("msgid={msgid}")),
869            None => ("LATEST", "*".to_string()),
870        };
871        Message::new(
872            "CHATHISTORY",
873            [selector.to_string(), target, point, limit.to_string()],
874        )
875    }
876
877    /// Ask for an invitation link to a channel, or to the network.
878    ///
879    /// The channel is positional, so a description with no channel in front of it needs the `*` the
880    /// server itself uses to mean the whole network.
881    fn invite_link(channel: Option<String>, description: Option<String>) -> Message {
882        let mut params = alloc::vec!["CREATE".to_string()];
883        match (channel, &description) {
884            (Some(channel), _) => params.push(channel),
885            (None, Some(_)) => params.push("*".to_string()),
886            (None, None) => {}
887        }
888        params.extend(description);
889        Message::new("INVITELINK", params)
890    }
891
892    fn reaction(tag: &str, target: &str, msgid: &str, emoji: &str) -> Message {
893        let mut line = Message::new("TAGMSG", [target]);
894        line.tags.set(obby_proto::Tag::new(tag, emoji));
895        line.tags.set(obby_proto::Tag::new("+draft/reply", msgid));
896        line
897    }
898
899    /// Send something that lands in a conversation, recording it ourselves if no echo is coming.
900    fn say(&mut self, line: &Message) {
901        self.send_line_labeled(line);
902        if self.caps.has("echo-message") {
903            return;
904        }
905        let mut echo = line.clone();
906        echo.source = Some(obby_proto::Source {
907            name: self.nick.clone(),
908            user: None,
909            host: None,
910        });
911        self.fold(&echo, false);
912    }
913
914    /// Send a command and correlate whatever the server sends back with it.
915    ///
916    /// Returns the label when `labeled-response` is in force. Without that capability the command
917    /// still goes out, unlabelled, because a server that does not support it would only be confused
918    /// by the tag.
919    pub fn send_line_labeled(&mut self, message: &Message) -> Option<String> {
920        if !self.caps.has("labeled-response") {
921            self.send_line(message);
922            return None;
923        }
924        let label = self.labels.generate();
925        let mut message = message.clone();
926        message
927            .tags
928            .set(obby_proto::Tag::new("label", label.clone()));
929        self.labels.register(
930            label.clone(),
931            self.monotonic_ms.saturating_add(DEFAULT_TIMEOUT_MS),
932            message.command.clone(),
933        );
934        self.send_line(&message);
935        Some(label)
936    }
937
938    /// Put the connection back where it was.
939    ///
940    /// The model outlived the link, so the channels we were in are still here and we rejoin every
941    /// one. On a first connection there are none and this does nothing.
942    fn resume(&mut self) {
943        let rejoining: Vec<(String, Option<String>)> = self
944            .model
945            .channels()
946            .map(|(_, channel)| {
947                (
948                    channel.name.clone(),
949                    channel.log.last().and_then(|message| message.msgid.clone()),
950                )
951            })
952            .collect();
953
954        let watched: Vec<String> = self
955            .monitor
956            .watched()
957            .map(|folded| folded.as_str().to_string())
958            .collect();
959        if !watched.is_empty() {
960            let limit = self.isupport.number("MONITOR").unwrap_or(100) as usize;
961            for chunk in monitor::batched(&watched, limit) {
962                self.send_line(&Message::new("MONITOR", ["+".to_string(), chunk]));
963            }
964        }
965
966        if !self.metadata_subscriptions.is_empty() {
967            let mut params = alloc::vec!["*".to_string(), "SUB".to_string()];
968            params.extend(self.metadata_subscriptions.clone());
969            self.send_line(&Message::new("METADATA", params));
970        }
971
972        for (name, newest) in rejoining {
973            let key = self.channel_keys.get(&self.isupport.fold(&name)).cloned();
974            let join = match key {
975                Some(key) => Message::new("JOIN", [name.clone(), key]),
976                None => Message::new("JOIN", [name.clone()]),
977            };
978            self.send_line(&join);
979            // ask only for what happened while we were gone, rather than refetching a whole window
980            // and leaning on dedup to sort it out
981            if let (true, Some(msgid)) = (self.caps.has("draft/chathistory"), newest) {
982                self.send_line(&Message::new(
983                    "CHATHISTORY",
984                    [
985                        "AFTER".to_string(),
986                        name,
987                        alloc::format!("msgid={msgid}"),
988                        "100".to_string(),
989                    ],
990                ));
991            }
992        }
993    }
994
995    fn arm_keepalive(&mut self) {
996        self.timers.clear(&Deadline::DeadLink);
997        self.timers.set(
998            Deadline::PingKeepalive,
999            self.monotonic_ms.saturating_add(PING_KEEPALIVE_MS),
1000        );
1001    }
1002
1003    /// Feed whatever the transport read. Partial lines are held until the rest arrives.
1004    ///
1005    /// ```
1006    /// # use obby_client::{Client, Config};
1007    /// let mut client = Client::new(Config::new("mynick"));
1008    ///
1009    /// // a socket read splits wherever it likes, and the engine holds the remainder
1010    /// client.handle_bytes(b"PING :ab");
1011    /// assert!(client.poll_transmit().is_none());
1012    ///
1013    /// client.handle_bytes(b"c\r\n");
1014    /// assert_eq!(client.poll_transmit(), Some(b"PONG abc\r\n".to_vec()));
1015    /// ```
1016    pub fn handle_bytes(&mut self, data: &[u8]) {
1017        self.partial.extend_from_slice(data);
1018        loop {
1019            let Some(end) = self.partial.iter().position(|b| *b == b'\n') else {
1020                if self.partial.len() > MAX_INBOUND_LINE {
1021                    self.partial.clear();
1022                    self.discarding = true;
1023                    self.dropped_lines = self.dropped_lines.saturating_add(1);
1024                }
1025                return;
1026            };
1027            let line: Vec<u8> = self.partial.drain(..=end).collect();
1028            if self.discarding {
1029                self.discarding = false;
1030                continue;
1031            }
1032            // servers are not reliably UTF-8, and one bad byte must not cost us the line
1033            let text = String::from_utf8_lossy(&line);
1034            if let Ok(message) = Message::parse(&text) {
1035                self.handle_message(message);
1036            } else {
1037                self.dropped_lines = self.dropped_lines.saturating_add(1);
1038            }
1039        }
1040    }
1041
1042    /// How many inbound lines were dropped, either unparseable or longer than we will hold.
1043    ///
1044    /// Nothing is returned to the host when a line is dropped, because there is nothing it could do
1045    /// about it. The count is here so a host can see that it is happening at all.
1046    pub fn dropped_lines(&self) -> u64 {
1047        self.dropped_lines
1048    }
1049
1050    /// Bytes the host should write to the transport, or `None` when there are none.
1051    ///
1052    /// ```
1053    /// # use obby_client::{Client, Config};
1054    /// let mut client = Client::new(Config::new("mynick"));
1055    /// client.handle_connected();
1056    ///
1057    /// let mut socket = Vec::new();
1058    /// while let Some(bytes) = client.poll_transmit() {
1059    ///     socket.extend_from_slice(&bytes); // a real host writes these to its socket
1060    /// }
1061    /// assert!(socket.starts_with(b"CAP LS 302\r\n"));
1062    /// ```
1063    pub fn poll_transmit(&mut self) -> Option<Vec<u8>> {
1064        self.outbox.pop_front()
1065    }
1066
1067    /// The next thing that happened, or `None` when the host is caught up.
1068    ///
1069    /// ```
1070    /// # use obby_client::{Client, Config, Event};
1071    /// let mut client = Client::new(Config::new("mynick"));
1072    /// client.handle_bytes(b":irc.example.org 001 mynick :Welcome\r\n");
1073    ///
1074    /// let mut registered_as = None;
1075    /// while let Some(event) = client.poll_event() {
1076    ///     if let Event::Registered { nick } = event {
1077    ///         registered_as = Some(nick);
1078    ///     }
1079    /// }
1080    /// assert_eq!(registered_as.as_deref(), Some("mynick"));
1081    /// ```
1082    pub fn poll_event(&mut self) -> Option<Event> {
1083        self.events.pop_front()
1084    }
1085
1086    /// Queue a message to be written. Use this for anything the engine does not model yet.
1087    pub fn send_line(&mut self, message: &Message) {
1088        let mut line = alloc::format!("{message}").into_bytes();
1089        line.extend_from_slice(b"\r\n");
1090        self.outbox.push_back(line);
1091    }
1092
1093    /// How far registration has got.
1094    pub fn phase(&self) -> Phase {
1095        self.phase
1096    }
1097
1098    /// The nick the server currently knows us by.
1099    pub fn nick(&self) -> &str {
1100        &self.nick
1101    }
1102
1103    /// The capabilities the server offers and the ones we hold.
1104    pub fn capabilities(&self) -> &Capabilities {
1105        &self.caps
1106    }
1107
1108    /// Everything the server advertised about itself: casemapping, prefixes, mode classes, limits.
1109    pub fn isupport(&self) -> &Isupport {
1110        &self.isupport
1111    }
1112
1113    /// The commands the server says we may currently use.
1114    ///
1115    /// Empty until the server sends its list, which it does on connect and again whenever what we
1116    /// may do changes, such as after an `OPER`.
1117    #[cfg(feature = "obby")]
1118    pub fn allowed_commands(&self) -> &crate::extensions::Commands {
1119        &self.commands
1120    }
1121
1122    /// The bots the server has told us about, keyed by their folded nick.
1123    #[cfg(feature = "obby")]
1124    pub fn bots(&self) -> &crate::extensions::Bots {
1125        &self.bots
1126    }
1127
1128    /// The voice room for a channel, once we have heard any signalling for it.
1129    ///
1130    /// Signalling and room state only. Every track, codec and peer connection is the host's, and
1131    /// nothing here knows they exist.
1132    #[cfg(feature = "voice")]
1133    pub fn voice_room(&self, channel: &obby_proto::CaseFolded) -> Option<&crate::voice::Room> {
1134        self.rooms.get(channel)
1135    }
1136
1137    /// Who we are watching for coming online, and who is here.
1138    pub fn watch_list(&self) -> &WatchList {
1139        &self.monitor
1140    }
1141
1142    /// Everything the connection knows: channels, members, conversations, messages.
1143    pub fn model(&self) -> &Model {
1144        &self.model
1145    }
1146
1147    /// The casemapping to fold nicks and channel names with, from ISUPPORT.
1148    pub fn casemapping(&self) -> Casemapping {
1149        self.isupport.casemapping()
1150    }
1151
1152    fn handle_message(&mut self, message: Message) {
1153        // any line at all proves the link is alive, so the keepalive clock starts over
1154        self.arm_keepalive();
1155        self.labels.resolve(&message);
1156        if message.is("PONG") {
1157            return;
1158        }
1159        if message.is("PING") {
1160            let token = message.trailing().unwrap_or_default().to_string();
1161            self.send_line(&Message::new("PONG", [token]));
1162            return;
1163        }
1164        if message.is("CAP") {
1165            self.handle_cap(&message);
1166            return;
1167        }
1168        if message.is("001") {
1169            self.phase = Phase::Registered;
1170            if let Some(nick) = message.param(0) {
1171                self.nick = nick.to_string();
1172            }
1173            self.model.me.nick.clone_from(&self.nick);
1174            self.events.push_back(Event::Registered {
1175                nick: self.nick.clone(),
1176            });
1177            self.resume();
1178            return;
1179        }
1180        if message.is("005") {
1181            self.handle_isupport(&message);
1182            return;
1183        }
1184        if message.is("TAGMSG") && self.handle_typing(&message) {
1185            return;
1186        }
1187        #[cfg(feature = "voice")]
1188        if message.is("TAGMSG") && self.handle_rtc(&message) {
1189            return;
1190        }
1191        #[cfg(feature = "obby")]
1192        if message.is("TAGMSG") && self.handle_bot_tags(&message) {
1193            return;
1194        }
1195        #[cfg(feature = "obby")]
1196        if message.is("TOKEN") && self.handle_token(&message) {
1197            return;
1198        }
1199        #[cfg(feature = "obby")]
1200        if message.is("CMDSLIST") {
1201            self.commands.apply(&message);
1202            self.events.push_back(Event::AllowedCommandsChanged);
1203            return;
1204        }
1205        if matches!(message.command.as_str(), "730" | "731") {
1206            self.handle_monitor(&message);
1207            return;
1208        }
1209        if let Some(severity) = severity_of(&message.command)
1210            && self.handle_standard_reply(severity, &message)
1211        {
1212            return;
1213        }
1214        if message.is("AUTHENTICATE") {
1215            self.handle_authenticate(&message);
1216            return;
1217        }
1218        if message.is("433") || message.is("432") {
1219            self.handle_nick_refused(&message);
1220            return;
1221        }
1222        if let Some(reason) = sasl_failure(&message.command) {
1223            self.finish_sasl(Some(reason));
1224            return;
1225        }
1226        // 900 carries the account name; 903 only confirms. A server sends both, so the account is
1227        // remembered from 900 and reported when 903 settles the exchange.
1228        if message.is("900") {
1229            if let Some(account) = message.param(2) {
1230                self.events.push_back(Event::LoggedIn {
1231                    account: account.to_string(),
1232                });
1233            }
1234            return;
1235        }
1236        if message.is("903") {
1237            self.finish_sasl(None);
1238            return;
1239        }
1240        if message.is("BATCH") {
1241            self.handle_batch(&message);
1242            return;
1243        }
1244        // a line inside an open batch is held until the batch closes, so history arrives as one
1245        // consistent block rather than trickling in and reordering what is already shown
1246        let Some(message) = self.batches.route(message) else {
1247            return;
1248        };
1249        self.fold(&message, false);
1250    }
1251
1252    fn handle_batch(&mut self, message: &Message) {
1253        let reference = message.param(0).unwrap_or_default();
1254        if reference.starts_with('+') {
1255            self.batches.open(message);
1256            return;
1257        }
1258        let Some(closed) = self.batches.close(message) else {
1259            return;
1260        };
1261        self.drain_batch(closed);
1262    }
1263
1264    fn drain_batch(&mut self, closed: ClosedBatch) {
1265        // an inner batch closes while its parent is still open, so the ancestor chain is what says
1266        // whether these lines are replayed history
1267        let historical = closed.kind == CHATHISTORY_BATCH
1268            || closed
1269                .parent
1270                .as_deref()
1271                .is_some_and(|parent| self.batches.is_within(parent, CHATHISTORY_BATCH));
1272        let closed = if closed.kind == MULTILINE_BATCH {
1273            join_multiline(closed)
1274        } else {
1275            closed
1276        };
1277        for message in closed.messages {
1278            self.fold(&message, historical);
1279        }
1280    }
1281
1282    /// Fold one line into the model and surface whatever it changed.
1283    fn fold(&mut self, message: &Message, historical: bool) {
1284        let changes = {
1285            let mut ctx = session::Context {
1286                model: &mut self.model,
1287                isupport: &self.isupport,
1288                latest_ms: &mut self.latest_ms,
1289                historical,
1290            };
1291            session::apply(&mut ctx, message)
1292        };
1293        if changes.is_empty() {
1294            if session::accumulates(&message.command.to_ascii_uppercase()) {
1295                return;
1296            }
1297            self.events.push_back(Event::RawLine {
1298                message: message.clone(),
1299            });
1300            return;
1301        }
1302        for change in &changes {
1303            // NAMES gives us nicks and prefixes and nothing else, so joining is when we go and ask
1304            // who these people actually are
1305            if let Change::ChannelJoined { channel } = change {
1306                self.request_who(channel);
1307            }
1308            // the key and the room are keyed by the folded name the channel no longer has, and the
1309            // key is what a reconnect needs to get back in
1310            if let Change::ChannelRenamed { from, to } = change {
1311                let (from, to) = (self.isupport.fold(from), self.isupport.fold(to));
1312                if let Some(key) = self.channel_keys.remove(&from) {
1313                    self.channel_keys.insert(to.clone(), key);
1314                }
1315                #[cfg(feature = "voice")]
1316                if let Some(room) = self.rooms.remove(&from) {
1317                    self.rooms.insert(to, room);
1318                }
1319            }
1320        }
1321        for change in changes {
1322            self.events.push_back(Event::ModelChanged { change });
1323        }
1324    }
1325
1326    /// Ask who is in a channel, in the richest form the server supports.
1327    ///
1328    /// WHOX carries the account, which a plain WHO does not, and the account is what tells a client
1329    /// that two nicks are the same person.
1330    fn request_who(&mut self, channel: &str) {
1331        if self.isupport.has("WHOX") {
1332            self.send_line(&Message::new(
1333                "WHO",
1334                [
1335                    channel.to_string(),
1336                    alloc::format!("{},{}", session::WHOX_FIELDS, session::WHOX_TOKEN),
1337                ],
1338            ));
1339        } else {
1340            self.send_line(&Message::new("WHO", [channel]));
1341        }
1342    }
1343
1344    fn handle_cap(&mut self, message: &Message) {
1345        // CAP replies are `CAP <target> <subcommand> [*] :<list>`, where the `*` marks a
1346        // continuation and the list is the last parameter either way.
1347        let Some(subcommand) = message.param(1) else {
1348            return;
1349        };
1350        let list = message.trailing().unwrap_or_default();
1351        let more_to_come = message.param(2) == Some("*");
1352
1353        match subcommand.to_ascii_uppercase().as_str() {
1354            "LS" => {
1355                self.caps.advertise(list);
1356                if !more_to_come {
1357                    self.request_wanted();
1358                }
1359            }
1360            "NEW" => {
1361                self.caps.advertise(list);
1362                self.request_wanted();
1363            }
1364            "DEL" => self.caps.withdraw(list),
1365            "ACK" => {
1366                let names = self.caps.acknowledge(list);
1367                if !names.is_empty() {
1368                    self.events
1369                        .push_back(Event::CapabilitiesAcknowledged { names });
1370                }
1371                self.finish_negotiation_if_settled();
1372            }
1373            "NAK" => {
1374                self.caps.reject(list);
1375                self.finish_negotiation_if_settled();
1376            }
1377            _ => {}
1378        }
1379    }
1380
1381    fn request_wanted(&mut self) {
1382        let wanted = self.caps.to_request();
1383        if wanted.is_empty() {
1384            self.finish_negotiation_if_settled();
1385            return;
1386        }
1387        self.caps.requested(&wanted);
1388        // one REQ per line, and the server answers each atomically: all of a REQ is acked or none of
1389        // it is, so a rejected capability never takes the rest of the line down with it
1390        for name in wanted {
1391            self.send_line(&Message::new("CAP", ["REQ".to_string(), name]));
1392        }
1393    }
1394
1395    fn finish_negotiation_if_settled(&mut self) {
1396        if self.phase != Phase::Negotiating || !self.caps.settled() {
1397            return;
1398        }
1399        if self.start_sasl_if_wanted() || self.sasl.in_flight() {
1400            return;
1401        }
1402        self.phase = Phase::Registering;
1403        self.send_line(&Message::new("CAP", ["END"]));
1404    }
1405
1406    /// Open the exchange, if there is one to open. True when we are now waiting on the server.
1407    fn start_sasl_if_wanted(&mut self) -> bool {
1408        if self.sasl != SaslState::Idle || !self.caps.has("sasl") {
1409            return false;
1410        }
1411        let Some(credentials) = self.config.sasl.clone() else {
1412            return false;
1413        };
1414        let mechanism = credentials.mechanism();
1415        if !sasl::offers(self.caps.value("sasl"), mechanism) {
1416            self.sasl = SaslState::Settled;
1417            self.events.push_back(Event::SaslFailed {
1418                reason: SaslFailure::NoSharedMechanism,
1419            });
1420            return false;
1421        }
1422        self.sasl = SaslState::Offered;
1423        self.send_line(&Message::new("AUTHENTICATE", [mechanism]));
1424        true
1425    }
1426
1427    fn handle_authenticate(&mut self, message: &Message) {
1428        let Some(challenge) = sasl::decode_challenge(message.param(0).unwrap_or("+")) else {
1429            self.finish_sasl(Some(SaslFailure::Aborted));
1430            return;
1431        };
1432        match self.sasl {
1433            SaslState::Offered => self.begin_sasl_response(),
1434            SaslState::ScramChallenged => self.answer_scram_challenge(&challenge),
1435            SaslState::ScramProved => self.verify_scram_server(&challenge),
1436            _ => {}
1437        }
1438    }
1439
1440    /// Answer the server's invitation with whatever the mechanism opens with.
1441    fn begin_sasl_response(&mut self) {
1442        let Some(credentials) = self.config.sasl.clone() else {
1443            return;
1444        };
1445        if let Credentials::Scram {
1446            username,
1447            password,
1448            nonce,
1449        } = &credentials
1450        {
1451            let scram = crate::scram::Scram::new(username, password, nonce);
1452            let first = scram.client_first();
1453            self.scram = Some(scram);
1454            self.sasl = SaslState::ScramChallenged;
1455            self.send_sasl(&first);
1456            return;
1457        }
1458        self.sasl = SaslState::Responded;
1459        self.send_sasl(&credentials.response());
1460    }
1461
1462    /// Prove we know the password, over the salt and nonce the server just sent.
1463    fn answer_scram_challenge(&mut self, challenge: &[u8]) {
1464        let Some(scram) = self.scram.as_mut() else {
1465            self.finish_sasl(Some(SaslFailure::Aborted));
1466            return;
1467        };
1468        match scram.client_final(challenge) {
1469            Ok(response) => {
1470                self.sasl = SaslState::ScramProved;
1471                self.send_sasl(&response);
1472            }
1473            Err(_) => self.finish_sasl(Some(SaslFailure::Rejected)),
1474        }
1475    }
1476
1477    /// Check that the server knows the password too.
1478    ///
1479    /// Skipping this is how a server that never knew it convinces us that it did, so a failure here
1480    /// aborts rather than continuing.
1481    fn verify_scram_server(&mut self, challenge: &[u8]) {
1482        let Some(scram) = self.scram.as_ref() else {
1483            self.finish_sasl(Some(SaslFailure::Aborted));
1484            return;
1485        };
1486        if scram.verify(challenge).is_err() {
1487            self.scram = None;
1488            self.finish_sasl(Some(SaslFailure::ServerNotVerified));
1489            return;
1490        }
1491        self.scram = None;
1492        self.sasl = SaslState::Responded;
1493        // an empty response closes the exchange and lets the server send its verdict
1494        self.send_line(&Message::new("AUTHENTICATE", ["+"]));
1495    }
1496
1497    fn send_sasl(&mut self, payload: &[u8]) {
1498        for line in sasl::encode_response(payload) {
1499            self.send_line(&Message::new("AUTHENTICATE", [line]));
1500        }
1501    }
1502
1503    /// End the exchange either way and release the `CAP END` that was waiting on it.
1504    fn finish_sasl(&mut self, failure: Option<SaslFailure>) {
1505        if self.sasl == SaslState::Settled {
1506            return;
1507        }
1508        self.sasl = SaslState::Settled;
1509        if let Some(reason) = failure {
1510            self.events.push_back(Event::SaslFailed { reason });
1511        }
1512        self.finish_negotiation_if_settled();
1513    }
1514
1515    /// Walk the fallback nicks, then append underscores. Only during registration: after `001` the
1516    /// host asked for the rename and gets to decide what to do about it.
1517    fn handle_nick_refused(&mut self, message: &Message) {
1518        if self.phase == Phase::Registered {
1519            self.events.push_back(Event::RawLine {
1520                message: message.clone(),
1521            });
1522            return;
1523        }
1524        let refused = message.param(1).unwrap_or(&self.nick).to_string();
1525        let trying = match self.config.alt_nicks.get(self.nick_attempt) {
1526            Some(alt) => alt.clone(),
1527            None => alloc::format!("{}_", self.nick),
1528        };
1529        self.nick_attempt += 1;
1530        self.nick.clone_from(&trying);
1531        self.model.me.nick.clone_from(&trying);
1532        self.events.push_back(Event::NickInUse {
1533            refused,
1534            trying: trying.clone(),
1535        });
1536        self.send_line(&Message::new("NICK", [trying]));
1537    }
1538
1539    /// Apply a voice signalling frame, if the line carries one.
1540    ///
1541    /// Signalling rides on a TAGMSG to the channel and is never a message, so it is consumed here
1542    /// rather than falling through to the conversation.
1543    #[cfg(feature = "voice")]
1544    fn handle_rtc(&mut self, message: &Message) -> bool {
1545        let Some(payload) = message.tag("+obsidianirc/rtc") else {
1546            return false;
1547        };
1548        let Some(channel) = message.param(0) else {
1549            return false;
1550        };
1551        let Some(signal) = crate::voice::Signal::from_json(payload) else {
1552            // a frame we cannot read is still signalling, and filing it as a message would put an
1553            // empty row in the channel
1554            self.dropped_lines = self.dropped_lines.saturating_add(1);
1555            return true;
1556        };
1557        let folded = self.isupport.fold(channel);
1558        let room = self
1559            .rooms
1560            .entry(folded)
1561            .or_insert_with(|| crate::voice::Room::new(channel));
1562        room.apply(&self.nick, &signal, self.isupport.casemapping());
1563        self.events.push_back(Event::Voice {
1564            channel: channel.to_string(),
1565            signal,
1566        });
1567        true
1568    }
1569
1570    /// Apply a channel-bots announcement or a bot's command list, if the line carries one.
1571    ///
1572    /// Both ride on a bodiless TAGMSG, batched on connect and unbatched afterwards, and neither is
1573    /// a message: filing one as one would put an empty row in a conversation.
1574    #[cfg(feature = "obby")]
1575    fn handle_bot_tags(&mut self, message: &Message) -> bool {
1576        use crate::extensions::{BOT_COMMANDS_TAG, BOT_INFO_TAG, BotInfo, decode_bot_commands};
1577
1578        if let Some(payload) = message.tag(BOT_INFO_TAG) {
1579            let Some(info) = BotInfo::decode(payload) else {
1580                self.dropped_lines = self.dropped_lines.saturating_add(1);
1581                return true;
1582            };
1583            let nick = info.bot.nick.clone();
1584            let key = self.isupport.fold(&nick).into_string();
1585            if info.removed {
1586                self.bots.remove(&key);
1587            } else {
1588                self.bots.insert(key.clone(), info.bot);
1589                // the announcement's own list goes through the same gate a bot's does, so a
1590                // self-registered bot cannot claim a privileged name by either route
1591                self.bots.set_commands(&key, info.commands);
1592            }
1593            self.events.push_back(Event::BotsChanged { nick });
1594            return true;
1595        }
1596
1597        let Some(payload) = message.tag(BOT_COMMANDS_TAG) else {
1598            return false;
1599        };
1600        let (Some(commands), Some(source)) = (
1601            decode_bot_commands(payload),
1602            message.source.as_ref().map(|source| source.name.clone()),
1603        ) else {
1604            self.dropped_lines = self.dropped_lines.saturating_add(1);
1605            return true;
1606        };
1607        // a list from a nick the server never announced as a bot is refused: accepting one would
1608        // let anybody put entries in the command menu
1609        if self
1610            .bots
1611            .set_commands(self.isupport.fold(&source).as_str(), commands)
1612        {
1613            self.events.push_back(Event::BotsChanged { nick: source });
1614        }
1615        true
1616    }
1617
1618    /// Surface a minted bearer token, from `TOKEN GENERATE <service> <endpoint> <token>`.
1619    ///
1620    /// Returns false for any other shape, so it still reaches the host raw.
1621    #[cfg(feature = "obby")]
1622    fn handle_token(&mut self, message: &Message) -> bool {
1623        if message.param(0).is_none_or(|sub| sub != "GENERATE") {
1624            return false;
1625        }
1626        let (Some(service), Some(endpoint), Some(token)) =
1627            (message.param(1), message.param(2), message.param(3))
1628        else {
1629            return false;
1630        };
1631        self.events.push_back(Event::AuthToken {
1632            service: service.to_string(),
1633            endpoint: endpoint.to_string(),
1634            token: token.to_string(),
1635        });
1636        true
1637    }
1638
1639    /// Apply a `+typing` tag, if the line carries one.
1640    ///
1641    /// A typing indicator is not a message and must not appear as one. It also expires on its own,
1642    /// because the `done` that would clear it can be lost, and a client that waits for it shows
1643    /// someone typing forever.
1644    fn handle_typing(&mut self, message: &Message) -> bool {
1645        let Some(state) = message.tag("+typing") else {
1646            return false;
1647        };
1648        let (Some(target), Some(source)) = (message.param(0), message.source.as_ref()) else {
1649            return false;
1650        };
1651        let who = source.name.clone();
1652        // a message to us belongs to the conversation with the sender, not to one named after us
1653        let target = if self.isupport.is_channel(target) {
1654            target.to_string()
1655        } else {
1656            who.clone()
1657        };
1658        let folded_target = self.isupport.fold(&target);
1659        let folded_who = self.isupport.fold(&who);
1660        let typing = state == "active";
1661
1662        let deadline = Deadline::Typing(
1663            folded_target.as_str().to_string(),
1664            folded_who.as_str().to_string(),
1665        );
1666        if typing {
1667            self.timers
1668                .set(deadline, self.monotonic_ms.saturating_add(TYPING_EXPIRY_MS));
1669        } else {
1670            self.timers.clear(&deadline);
1671        }
1672
1673        if self.model.set_typing(&folded_target, folded_who, typing) {
1674            self.events.push_back(Event::TypingChanged {
1675                target,
1676                nick: who,
1677                active: typing,
1678            });
1679        }
1680        true
1681    }
1682
1683    /// Stop showing someone as typing once their indicator has gone stale.
1684    fn expire_typing(&mut self, target: &str, who: &str) {
1685        let (target, who) = (
1686            obby_proto::CaseFolded::already_folded(target),
1687            obby_proto::CaseFolded::already_folded(who),
1688        );
1689        if self.model.set_typing(&target, who.clone(), false) {
1690            self.events.push_back(Event::TypingChanged {
1691                target: target.into_string(),
1692                nick: who.into_string(),
1693                active: false,
1694            });
1695        }
1696    }
1697
1698    /// Apply `730` (online) and `731` (offline), each carrying a comma-separated target list.
1699    fn handle_monitor(&mut self, message: &Message) {
1700        let online = message.is("730");
1701        let Some(list) = message.param(1) else { return };
1702        for target in monitor::split_targets(list) {
1703            // the list carries full masks, and only the nick identifies the person
1704            let nick = target.split('!').next().unwrap_or(&target).to_string();
1705            let folded = self.isupport.fold(&nick);
1706            if online {
1707                self.monitor.mark_online(folded);
1708            } else {
1709                self.monitor.mark_offline(&folded);
1710            }
1711            self.events
1712                .push_back(Event::PresenceChanged { nick, online });
1713        }
1714    }
1715
1716    /// Surface a `FAIL`, `WARN` or `NOTE`.
1717    ///
1718    /// The shape is `<command> <code> [context...] :<description>`, where the description is always
1719    /// last and everything between the code and it is context whose meaning depends on the code.
1720    /// Returns false when the line does not have the shape, so the caller still surfaces it raw.
1721    fn handle_standard_reply(&mut self, severity: Severity, message: &Message) -> bool {
1722        let command = message.param(0).unwrap_or("*").to_string();
1723        let Some(code) = message.param(1).map(ToString::to_string) else {
1724            return false;
1725        };
1726        let text = message.trailing().unwrap_or_default().to_string();
1727        let context: Vec<String> = message
1728            .params
1729            .iter()
1730            .skip(2)
1731            .take(message.params.len().saturating_sub(3))
1732            .cloned()
1733            .collect();
1734        self.events.push_back(Event::ServerReply {
1735            severity,
1736            command,
1737            code,
1738            context,
1739            text,
1740        });
1741        true
1742    }
1743
1744    fn handle_isupport(&mut self, message: &Message) {
1745        // params are `<nick> <token>... :are supported by this server`, so the first and last are
1746        // not tokens
1747        let tokens = message
1748            .params
1749            .iter()
1750            .skip(1)
1751            .take(message.params.len().saturating_sub(2));
1752        for token in tokens {
1753            let applied = self.isupport.apply(token);
1754            self.events.push_back(Event::IsupportToken {
1755                token: applied.name,
1756                value: applied.value,
1757            });
1758        }
1759    }
1760}
1761
1762/// How serious a `standard-replies` message is.
1763#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1764#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1765#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
1766#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
1767pub enum Severity {
1768    /// The command did not happen.
1769    Fail,
1770    /// The command happened, with a caveat.
1771    Warn,
1772    /// Something worth saying, with nothing gone wrong.
1773    Note,
1774}
1775
1776/// The most we will buffer while assembling one inbound line.
1777///
1778/// A stream that never sends a newline, whether hostile or merely stuck, would otherwise grow the
1779/// buffer until the process dies. This is the largest line any server may legally send: the tag
1780/// budget plus the rest of the line.
1781const MAX_INBOUND_LINE: usize = obby_proto::MAX_TAG_BYTES + obby_proto::MAX_LINE_BYTES;
1782
1783/// The batch type that carries replayed history.
1784const CHATHISTORY_BATCH: &str = "chathistory";
1785
1786/// Put a message a server split across several lines back together.
1787///
1788/// The lines arrive as separate `PRIVMSG`s inside a `draft/multiline` batch, and a host that
1789/// treated them as separate messages would show one paragraph as several. A line tagged
1790/// `draft/multiline-concat` continues the one before it with nothing between them; every other
1791/// line starts a new line of the same message.
1792fn join_multiline(closed: ClosedBatch) -> ClosedBatch {
1793    let mut joined: Vec<Message> = Vec::new();
1794    for message in closed.messages {
1795        // the tag carries no value, so its presence is the whole signal
1796        let continues = message.tags.contains(MULTILINE_CONCAT);
1797        let is_text = message.is("PRIVMSG") || message.is("NOTICE");
1798        let Some(previous) = joined.last_mut() else {
1799            joined.push(message);
1800            continue;
1801        };
1802        // a line of another kind inside the batch is not part of the text, so it stays whole
1803        if !is_text || !previous.is(&message.command) || previous.param(0) != message.param(0) {
1804            joined.push(message);
1805            continue;
1806        }
1807        let separator = if continues { "" } else { "\n" };
1808        let addition = alloc::string::String::from(message.param(1).unwrap_or_default());
1809        if let Some(text) = previous.params.get_mut(1) {
1810            text.push_str(separator);
1811            text.push_str(&addition);
1812        }
1813    }
1814    ClosedBatch {
1815        messages: joined,
1816        ..closed
1817    }
1818}
1819
1820/// The batch type that wraps one message a server split across several lines.
1821const MULTILINE_BATCH: &str = "draft/multiline";
1822
1823/// The tag on a line that continues the previous one with no line break between them.
1824const MULTILINE_CONCAT: &str = "draft/multiline-concat";
1825
1826/// Read the severity off a `standard-replies` verb.
1827fn severity_of(command: &str) -> Option<Severity> {
1828    match command.to_ascii_uppercase().as_str() {
1829        "FAIL" => Some(Severity::Fail),
1830        "WARN" => Some(Severity::Warn),
1831        "NOTE" => Some(Severity::Note),
1832        _ => None,
1833    }
1834}
1835
1836/// Map a SASL error numeric onto why it failed.
1837fn sasl_failure(command: &str) -> Option<SaslFailure> {
1838    match command {
1839        "904" => Some(SaslFailure::Rejected),
1840        "905" => Some(SaslFailure::TooLong),
1841        "906" => Some(SaslFailure::Aborted),
1842        "907" => Some(SaslFailure::AlreadyAuthenticated),
1843        _ => None,
1844    }
1845}
1846
1847#[cfg(test)]
1848mod tests {
1849    use super::*;
1850
1851    fn drain(client: &mut Client) -> String {
1852        let mut out = String::new();
1853        while let Some(bytes) = client.poll_transmit() {
1854            out.push_str(&String::from_utf8_lossy(&bytes));
1855        }
1856        out
1857    }
1858
1859    fn events(client: &mut Client) -> Vec<Event> {
1860        let mut out = Vec::new();
1861        while let Some(event) = client.poll_event() {
1862            out.push(event);
1863        }
1864        out
1865    }
1866
1867    #[test]
1868    fn registration_starts_with_cap_ls_then_nick_and_user() {
1869        let mut client = Client::new(Config::new("me"));
1870        client.handle_connected();
1871        assert_eq!(
1872            drain(&mut client),
1873            "CAP LS 302\r\nNICK me\r\nUSER me 0 * me\r\n"
1874        );
1875    }
1876
1877    #[test]
1878    fn a_password_precedes_nick() {
1879        let mut config = Config::new("me");
1880        config.password = Some("hunter2".to_string());
1881        let mut client = Client::new(config);
1882        client.handle_connected();
1883        let sent = drain(&mut client);
1884        let pass = sent.find("PASS").expect("pass is sent");
1885        let nick = sent.find("NICK").expect("nick is sent");
1886        assert!(pass < nick);
1887    }
1888
1889    #[test]
1890    fn answers_ping_with_the_same_token() {
1891        let mut client = Client::new(Config::new("me"));
1892        client.handle_bytes(b"PING :abc123\r\n");
1893        assert_eq!(drain(&mut client), "PONG abc123\r\n");
1894    }
1895
1896    #[test]
1897    fn holds_a_partial_line_until_the_rest_arrives() {
1898        let mut client = Client::new(Config::new("me"));
1899        client.handle_bytes(b"PING :ab");
1900        assert_eq!(drain(&mut client), "");
1901        client.handle_bytes(b"c\r\n");
1902        assert_eq!(drain(&mut client), "PONG abc\r\n");
1903    }
1904
1905    #[test]
1906    fn survives_a_line_that_is_not_utf8() {
1907        let mut client = Client::new(Config::new("me"));
1908        client.handle_bytes(b"PING :\xff\xfe\r\n");
1909        assert!(drain(&mut client).starts_with("PONG"));
1910    }
1911
1912    #[test]
1913    fn does_not_end_negotiation_until_every_request_is_answered() {
1914        let mut client = Client::new(Config::new("me"));
1915        client.handle_connected();
1916        drain(&mut client);
1917
1918        client.handle_bytes(b":s CAP * LS :multi-prefix away-notify\r\n");
1919        let requested = drain(&mut client);
1920        assert!(requested.contains("CAP REQ multi-prefix"));
1921        assert!(requested.contains("CAP REQ away-notify"));
1922        assert_eq!(client.phase(), Phase::Negotiating);
1923
1924        client.handle_bytes(b":s CAP * ACK :multi-prefix\r\n");
1925        assert_eq!(client.phase(), Phase::Negotiating);
1926
1927        client.handle_bytes(b":s CAP * NAK :away-notify\r\n");
1928        assert_eq!(client.phase(), Phase::Registering);
1929        assert_eq!(drain(&mut client), "CAP END\r\n");
1930    }
1931
1932    #[test]
1933    fn waits_for_the_last_line_of_a_multiline_cap_ls() {
1934        let mut client = Client::new(Config::new("me"));
1935        client.handle_connected();
1936        drain(&mut client);
1937
1938        client.handle_bytes(b":s CAP * LS * :multi-prefix\r\n");
1939        assert_eq!(
1940            drain(&mut client),
1941            "",
1942            "a continuation must not trigger a request"
1943        );
1944
1945        client.handle_bytes(b":s CAP * LS :away-notify\r\n");
1946        let requested = drain(&mut client);
1947        assert!(requested.contains("multi-prefix"));
1948        assert!(requested.contains("away-notify"));
1949    }
1950
1951    #[test]
1952    fn ends_negotiation_when_the_server_offers_nothing_we_want() {
1953        let mut client = Client::new(Config::new("me"));
1954        client.handle_connected();
1955        drain(&mut client);
1956        client.handle_bytes(b":s CAP * LS :some-cap-we-do-not-know\r\n");
1957        assert_eq!(drain(&mut client), "CAP END\r\n");
1958    }
1959
1960    #[test]
1961    fn cap_new_requests_a_late_capability() {
1962        let mut client = Client::new(Config::new("me"));
1963        client.handle_connected();
1964        drain(&mut client);
1965        client.handle_bytes(b":s CAP * LS :\r\n");
1966        drain(&mut client);
1967        client.handle_bytes(b":s CAP * NEW :away-notify\r\n");
1968        assert!(drain(&mut client).contains("CAP REQ away-notify"));
1969    }
1970
1971    #[test]
1972    fn takes_the_nick_the_server_gives_us() {
1973        let mut client = Client::new(Config::new("me"));
1974        client.handle_bytes(b":s 001 me_ :Welcome\r\n");
1975        assert_eq!(client.nick(), "me_");
1976        assert_eq!(client.phase(), Phase::Registered);
1977        assert_eq!(
1978            events(&mut client),
1979            [Event::Registered {
1980                nick: "me_".to_string()
1981            }]
1982        );
1983    }
1984
1985    #[test]
1986    fn reads_the_casemapping_from_isupport() {
1987        let mut client = Client::new(Config::new("me"));
1988        assert_eq!(client.casemapping(), Casemapping::Rfc1459);
1989        client.handle_bytes(
1990            b":s 005 me CASEMAPPING=ascii CHANTYPES=#^$ PREFIX=(qaohv)~&@%+ :are supported\r\n",
1991        );
1992        assert_eq!(client.casemapping(), Casemapping::Ascii);
1993        assert!(
1994            client.isupport().is_channel("^voice"),
1995            "obby voice channels arrive through CHANTYPES like any other"
1996        );
1997        assert_eq!(client.isupport().prefix().mode_for_char('%'), Some('h'));
1998        assert_eq!(
1999            events(&mut client)
2000                .into_iter()
2001                .map(|event| match event {
2002                    Event::IsupportToken { token, value } => (token, value),
2003                    other => panic!("expected only ISUPPORT events, got {other:?}"),
2004                })
2005                .collect::<Vec<_>>(),
2006            [
2007                ("CASEMAPPING".to_string(), Some("ascii".to_string())),
2008                ("CHANTYPES".to_string(), Some("#^$".to_string())),
2009                ("PREFIX".to_string(), Some("(qaohv)~&@%+".to_string())),
2010            ]
2011        );
2012    }
2013
2014    #[test]
2015    fn a_negated_isupport_token_restores_the_default() {
2016        let mut client = Client::new(Config::new("me"));
2017        client.handle_bytes(b":s 005 me CASEMAPPING=ascii :are supported\r\n");
2018        assert_eq!(client.casemapping(), Casemapping::Ascii);
2019        client.handle_bytes(b":s 005 me -CASEMAPPING :are supported\r\n");
2020        assert_eq!(client.casemapping(), Casemapping::Rfc1459);
2021    }
2022
2023    fn with_sasl() -> Config {
2024        let mut config = Config::new("me");
2025        config.sasl = Some(Credentials::Plain {
2026            username: "alice".to_string(),
2027            password: "hunter2".to_string(),
2028        });
2029        config
2030    }
2031
2032    /// Negotiate up to the point where only SASL is left outstanding.
2033    fn negotiated(config: Config) -> Client {
2034        let mut client = Client::new(config);
2035        client.handle_connected();
2036        drain(&mut client);
2037        client.handle_bytes(b":s CAP * LS :sasl=PLAIN,EXTERNAL\r\n");
2038        client.handle_bytes(b":s CAP * ACK :sasl\r\n");
2039        client
2040    }
2041
2042    #[test]
2043    fn authenticates_before_ending_negotiation() {
2044        let mut client = negotiated(with_sasl());
2045        let sent = drain(&mut client);
2046        assert!(sent.contains("AUTHENTICATE PLAIN"));
2047        assert!(
2048            !sent.contains("CAP END"),
2049            "CAP END during SASL makes the server abort with 906 and register us unauthenticated"
2050        );
2051        assert_eq!(client.phase(), Phase::Negotiating);
2052
2053        client.handle_bytes(b":s AUTHENTICATE +\r\n");
2054        assert_eq!(
2055            drain(&mut client),
2056            "AUTHENTICATE AGFsaWNlAGh1bnRlcjI=\r\n",
2057            "the PLAIN response is authzid, authcid and password, null separated"
2058        );
2059        assert_eq!(
2060            client.phase(),
2061            Phase::Negotiating,
2062            "still waiting on a verdict"
2063        );
2064
2065        client.handle_bytes(b":s 900 me me!u@h alice :You are now logged in\r\n");
2066        client.handle_bytes(b":s 903 me :SASL authentication successful\r\n");
2067        assert_eq!(drain(&mut client), "CAP END\r\n");
2068        assert_eq!(client.phase(), Phase::Registering);
2069    }
2070
2071    #[test]
2072    fn reports_the_account_it_logged_in_as() {
2073        let mut client = negotiated(with_sasl());
2074        client.handle_bytes(b":s AUTHENTICATE +\r\n");
2075        client.handle_bytes(b":s 900 me me!u@h alice :You are now logged in\r\n");
2076        client.handle_bytes(b":s 903 me :ok\r\n");
2077        assert_eq!(
2078            events(&mut client),
2079            [
2080                Event::CapabilitiesAcknowledged {
2081                    names: alloc::vec!["sasl".to_string()]
2082                },
2083                Event::LoggedIn {
2084                    account: "alice".to_string()
2085                },
2086            ]
2087        );
2088    }
2089
2090    #[test]
2091    fn a_rejected_login_still_releases_registration() {
2092        let mut client = negotiated(with_sasl());
2093        client.handle_bytes(b":s AUTHENTICATE +\r\n");
2094        drain(&mut client);
2095        client.handle_bytes(b":s 904 me :SASL authentication failed\r\n");
2096        assert_eq!(drain(&mut client), "CAP END\r\n");
2097        assert_eq!(client.phase(), Phase::Registering);
2098        assert!(events(&mut client).contains(&Event::SaslFailed {
2099            reason: SaslFailure::Rejected
2100        }));
2101    }
2102
2103    #[test]
2104    fn skips_authentication_when_no_mechanism_is_shared() {
2105        let mut client = Client::new(with_sasl());
2106        client.handle_connected();
2107        drain(&mut client);
2108        client.handle_bytes(b":s CAP * LS :sasl=SCRAM-SHA-256\r\n");
2109        client.handle_bytes(b":s CAP * ACK :sasl\r\n");
2110        let sent = drain(&mut client);
2111        assert!(!sent.contains("AUTHENTICATE"));
2112        assert!(sent.contains("CAP END"));
2113        assert!(events(&mut client).contains(&Event::SaslFailed {
2114            reason: SaslFailure::NoSharedMechanism
2115        }));
2116    }
2117
2118    #[test]
2119    fn does_not_authenticate_without_credentials() {
2120        let mut client = negotiated(Config::new("me"));
2121        let sent = drain(&mut client);
2122        assert!(!sent.contains("AUTHENTICATE"));
2123        assert!(sent.ends_with("CAP END\r\n"));
2124    }
2125
2126    #[test]
2127    fn walks_the_fallback_nicks_then_appends_underscores() {
2128        let mut config = Config::new("me");
2129        config.alt_nicks = alloc::vec!["me2".to_string(), "me3".to_string()];
2130        let mut client = Client::new(config);
2131        client.handle_connected();
2132        drain(&mut client);
2133
2134        client.handle_bytes(b":s 433 * me :Nickname is already in use\r\n");
2135        assert_eq!(drain(&mut client), "NICK me2\r\n");
2136        client.handle_bytes(b":s 433 * me2 :Nickname is already in use\r\n");
2137        assert_eq!(drain(&mut client), "NICK me3\r\n");
2138        client.handle_bytes(b":s 433 * me3 :Nickname is already in use\r\n");
2139        assert_eq!(drain(&mut client), "NICK me3_\r\n", "the list is exhausted");
2140        assert_eq!(client.nick(), "me3_");
2141    }
2142
2143    #[test]
2144    fn a_rename_refused_after_registration_is_the_hosts_problem() {
2145        let mut client = Client::new(Config::new("me"));
2146        client.handle_bytes(b":s 001 me :Welcome\r\n");
2147        events(&mut client);
2148        client.handle_bytes(b":s 433 me taken :Nickname is already in use\r\n");
2149        assert_eq!(
2150            drain(&mut client),
2151            "",
2152            "the engine did not ask for this rename"
2153        );
2154        assert!(matches!(client.poll_event(), Some(Event::RawLine { .. })));
2155    }
2156
2157    #[test]
2158    fn surfaces_a_line_it_does_not_model() {
2159        let mut client = Client::new(Config::new("me"));
2160        client.handle_bytes(b":s 375 me :- message of the day -\r\n");
2161        let Some(Event::RawLine { message }) = client.poll_event() else {
2162            panic!("an unmodelled line must still reach the host");
2163        };
2164        assert!(message.is("375"));
2165    }
2166
2167    #[test]
2168    fn a_message_reaches_the_model_through_the_whole_path() {
2169        let mut client = Client::new(Config::new("me"));
2170        client.handle_bytes(b":s 001 me :Welcome\r\n");
2171        client.handle_bytes(b":s 005 me CHANTYPES=# :are supported\r\n");
2172        client.handle_bytes(b":me!u@h JOIN #obby\r\n");
2173        client.handle_bytes(b"@msgid=x1 :bob!u@h PRIVMSG #obby :hello\r\n");
2174
2175        let folded = client.isupport().fold("#obby");
2176        let channel = client.model().channel(&folded).expect("we joined it");
2177        assert_eq!(channel.log.len(), 1);
2178        assert_eq!(
2179            channel.log.get("x1").map(|m| m.text.as_str()),
2180            Some("hello")
2181        );
2182        assert!(events(&mut client).iter().any(|event| matches!(
2183            event,
2184            Event::ModelChanged {
2185                change: Change::MessageAdded { .. }
2186            }
2187        )));
2188    }
2189
2190    #[test]
2191    fn registering_tells_the_model_who_we_are() {
2192        let mut client = Client::new(Config::new("me"));
2193        client.handle_bytes(b":s 001 me_ :Welcome\r\n");
2194        assert_eq!(
2195            client.model().me.nick,
2196            "me_",
2197            "the model has to agree with the connection about our own nick, or every own-message check is wrong"
2198        );
2199    }
2200}
2201
2202#[cfg(test)]
2203mod batch_tests {
2204    use super::*;
2205
2206    fn registered() -> Client {
2207        let mut client = Client::new(Config::new("me"));
2208        client.handle_bytes(b":s 001 me :Welcome\r\n");
2209        client.handle_bytes(b":s 005 me CHANTYPES=# :are supported\r\n");
2210        client.handle_bytes(b":me!u@h JOIN #obby\r\n");
2211        while client.poll_event().is_some() {}
2212        client
2213    }
2214
2215    fn texts(client: &Client) -> Vec<String> {
2216        let folded = client.isupport().fold("#obby");
2217        client
2218            .model()
2219            .channel(&folded)
2220            .expect("channel")
2221            .log
2222            .iter()
2223            .map(|m| m.text.clone())
2224            .collect()
2225    }
2226
2227    #[test]
2228    fn a_multiline_message_arrives_as_one_message() {
2229        let mut client = registered();
2230        client.handle_bytes(b":s BATCH +m draft/multiline #obby\r\n");
2231        client.handle_bytes(b"@batch=m :bob!u@h PRIVMSG #obby :first line\r\n");
2232        client.handle_bytes(b"@batch=m :bob!u@h PRIVMSG #obby :second line\r\n");
2233        client.handle_bytes(b":s BATCH -m\r\n");
2234
2235        assert_eq!(
2236            texts(&client),
2237            vec!["first line\nsecond line".to_string()],
2238            "a paragraph the server split must not reach the host as several messages"
2239        );
2240    }
2241
2242    #[test]
2243    fn a_concat_tagged_line_continues_the_one_before_it() {
2244        let mut client = registered();
2245        client.handle_bytes(b":s BATCH +m draft/multiline #obby\r\n");
2246        client.handle_bytes(b"@batch=m :bob!u@h PRIVMSG #obby :one long \r\n");
2247        client
2248            .handle_bytes(b"@batch=m;draft/multiline-concat :bob!u@h PRIVMSG #obby :sentence\r\n");
2249        client.handle_bytes(b":s BATCH -m\r\n");
2250
2251        assert_eq!(texts(&client), vec!["one long sentence".to_string()]);
2252    }
2253
2254    #[test]
2255    fn a_line_of_another_kind_inside_a_multiline_batch_stays_its_own_message() {
2256        let mut client = registered();
2257        client.handle_bytes(b":s BATCH +m draft/multiline #obby\r\n");
2258        client.handle_bytes(b"@batch=m :bob!u@h PRIVMSG #obby :spoken\r\n");
2259        client.handle_bytes(b"@batch=m :bob!u@h NOTICE #obby :noticed\r\n");
2260        client.handle_bytes(b":s BATCH -m\r\n");
2261
2262        assert_eq!(
2263            texts(&client),
2264            vec!["spoken".to_string(), "noticed".to_string()]
2265        );
2266    }
2267
2268    #[test]
2269    fn a_batched_line_is_held_until_the_batch_closes() {
2270        let mut client = registered();
2271        client.handle_bytes(b":s BATCH +h chathistory #obby\r\n");
2272        client.handle_bytes(b"@batch=h;msgid=1 :bob!u@h PRIVMSG #obby :held\r\n");
2273        assert!(
2274            texts(&client).is_empty(),
2275            "history must land as one block, not trickle in"
2276        );
2277
2278        client.handle_bytes(b":s BATCH -h\r\n");
2279        assert_eq!(texts(&client), ["held"]);
2280    }
2281
2282    #[test]
2283    fn history_is_marked_as_replayed_and_live_traffic_is_not() {
2284        let mut client = registered();
2285        client.handle_bytes(b"@msgid=live :bob!u@h PRIVMSG #obby :now\r\n");
2286        client.handle_bytes(b":s BATCH +h chathistory #obby\r\n");
2287        client.handle_bytes(b"@batch=h;msgid=old :bob!u@h PRIVMSG #obby :before\r\n");
2288        client.handle_bytes(b":s BATCH -h\r\n");
2289
2290        let folded = client.isupport().fold("#obby");
2291        let channel = client.model().channel(&folded).expect("channel");
2292        assert_eq!(channel.log.get("live").map(|m| m.historical), Some(false));
2293        assert_eq!(channel.log.get("old").map(|m| m.historical), Some(true));
2294    }
2295
2296    #[test]
2297    fn a_batch_nested_inside_history_is_still_history() {
2298        let mut client = registered();
2299        client.handle_bytes(b":s BATCH +outer chathistory #obby\r\n");
2300        client.handle_bytes(b"@batch=outer :s BATCH +inner netsplit a.net b.net\r\n");
2301        client.handle_bytes(b"@batch=inner;msgid=n1 :bob!u@h PRIVMSG #obby :inside\r\n");
2302        client.handle_bytes(b":s BATCH -inner\r\n");
2303        client.handle_bytes(b":s BATCH -outer\r\n");
2304
2305        let folded = client.isupport().fold("#obby");
2306        let channel = client.model().channel(&folded).expect("channel");
2307        assert_eq!(
2308            channel.log.get("n1").map(|m| m.historical),
2309            Some(true),
2310            "the ancestor chain decides, not the innermost batch type"
2311        );
2312    }
2313
2314    #[test]
2315    fn a_line_tagged_for_an_unknown_batch_is_still_delivered() {
2316        let mut client = registered();
2317        client.handle_bytes(b"@batch=never-opened;msgid=x :bob!u@h PRIVMSG #obby :orphan\r\n");
2318        assert_eq!(
2319            texts(&client),
2320            ["orphan"],
2321            "dropping it would lose a message whenever we miss a BATCH open"
2322        );
2323    }
2324
2325    #[test]
2326    fn history_replayed_twice_is_stored_once() {
2327        let mut client = registered();
2328        for _ in 0..2 {
2329            client.handle_bytes(b":s BATCH +h chathistory #obby\r\n");
2330            client.handle_bytes(b"@batch=h;msgid=dup :bob!u@h PRIVMSG #obby :again\r\n");
2331            client.handle_bytes(b":s BATCH -h\r\n");
2332        }
2333        assert_eq!(texts(&client), ["again"]);
2334    }
2335}
2336
2337#[cfg(test)]
2338mod time_tests {
2339    use super::*;
2340
2341    fn at(ms: u64) -> Now {
2342        Now {
2343            monotonic_ms: ms,
2344            unix_ms: 1_700_000_000_000 + ms,
2345        }
2346    }
2347
2348    fn sent(client: &mut Client) -> String {
2349        let mut out = String::new();
2350        while let Some(bytes) = client.poll_transmit() {
2351            out.push_str(&String::from_utf8_lossy(&bytes));
2352        }
2353        out
2354    }
2355
2356    fn events(client: &mut Client) -> Vec<Event> {
2357        let mut out = Vec::new();
2358        while let Some(event) = client.poll_event() {
2359            out.push(event);
2360        }
2361        out
2362    }
2363
2364    #[test]
2365    fn a_quiet_link_gets_a_keepalive_ping() {
2366        let mut client = Client::new(Config::new("me"));
2367        client.handle_connected();
2368        sent(&mut client);
2369
2370        client.tick(at(PING_KEEPALIVE_MS - 1));
2371        assert_eq!(sent(&mut client), "", "nothing is due yet");
2372
2373        client.tick(at(PING_KEEPALIVE_MS));
2374        assert_eq!(sent(&mut client), "PING me\r\n");
2375    }
2376
2377    #[test]
2378    fn a_ping_with_no_answer_declares_the_link_dead() {
2379        let mut client = Client::new(Config::new("me"));
2380        client.handle_connected();
2381        client.tick(at(PING_KEEPALIVE_MS));
2382        events(&mut client);
2383
2384        client.tick(at(PING_KEEPALIVE_MS + DEAD_LINK_MS - 1));
2385        assert!(events(&mut client).is_empty());
2386
2387        client.tick(at(PING_KEEPALIVE_MS + DEAD_LINK_MS));
2388        assert_eq!(events(&mut client), [Event::LinkDead]);
2389    }
2390
2391    #[test]
2392    fn any_traffic_at_all_proves_the_link_is_alive() {
2393        let mut client = Client::new(Config::new("me"));
2394        client.handle_connected();
2395        client.tick(at(PING_KEEPALIVE_MS));
2396        sent(&mut client);
2397
2398        client.handle_bytes(b":s PONG me :me\r\n");
2399        client.tick(at(PING_KEEPALIVE_MS + DEAD_LINK_MS));
2400        assert!(
2401            !events(&mut client).contains(&Event::LinkDead),
2402            "a pong clears the dead-link deadline"
2403        );
2404    }
2405
2406    #[test]
2407    fn the_next_deadline_is_reported_so_a_host_can_sleep_exactly() {
2408        let mut client = Client::new(Config::new("me"));
2409        assert_eq!(
2410            client.poll_timeout(),
2411            None,
2412            "nothing is pending before connecting"
2413        );
2414        client.handle_connected();
2415        assert_eq!(client.poll_timeout(), Some(PING_KEEPALIVE_MS));
2416    }
2417
2418    #[test]
2419    fn a_dropped_link_keeps_the_model_and_backs_off() {
2420        let mut client = Client::new(Config::new("me"));
2421        client.handle_bytes(b":s 001 me :Welcome\r\n");
2422        client.handle_bytes(b":s 005 me CHANTYPES=# :are supported\r\n");
2423        client.handle_bytes(b":me!u@h JOIN #obby\r\n");
2424        client.handle_bytes(b"@msgid=x :bob!u@h PRIVMSG #obby :hello\r\n");
2425        events(&mut client);
2426
2427        client.handle_disconnected();
2428        let folded = client.isupport().fold("#obby");
2429        assert!(
2430            client.model().channel(&folded).is_some(),
2431            "losing the link must not lose the scrollback"
2432        );
2433
2434        let first = events(&mut client);
2435        assert!(matches!(first.as_slice(), [Event::ReconnectAfter { .. }]));
2436
2437        client.handle_connected();
2438        client.handle_disconnected();
2439        assert!(
2440            matches!(events(&mut client).as_slice(), [Event::ReconnectAfter { after_ms }] if *after_ms == ReconnectBackoff::DEFAULT_BASE_MS),
2441            "a successful connection resets the backoff"
2442        );
2443    }
2444
2445    #[test]
2446    fn backing_off_doubles_while_the_link_stays_down() {
2447        let mut client = Client::new(Config::new("me"));
2448        let mut delays = Vec::new();
2449        for _ in 0..3 {
2450            client.handle_disconnected();
2451            for event in events(&mut client) {
2452                if let Event::ReconnectAfter { after_ms } = event {
2453                    delays.push(after_ms);
2454                }
2455            }
2456        }
2457        assert!(
2458            delays.windows(2).all(|pair| pair[1] > pair[0]),
2459            "each attempt waits longer than the last, got {delays:?}"
2460        );
2461    }
2462
2463    #[test]
2464    fn a_command_is_labelled_only_when_the_server_can_correlate_it() {
2465        let mut client = Client::new(Config::new("me"));
2466        assert_eq!(
2467            client.send_line_labeled(&Message::new("WHO", ["#obby"])),
2468            None,
2469            "labelling a server that never agreed to it only confuses it"
2470        );
2471        assert_eq!(sent(&mut client), "WHO #obby\r\n");
2472
2473        client.handle_connected();
2474        sent(&mut client);
2475        client.handle_bytes(b":s CAP * LS :labeled-response\r\n");
2476        client.handle_bytes(b":s CAP * ACK :labeled-response\r\n");
2477        sent(&mut client);
2478
2479        let label = client
2480            .send_line_labeled(&Message::new("WHO", ["#obby"]))
2481            .expect("the capability is in force");
2482        assert_eq!(
2483            sent(&mut client),
2484            alloc::format!("@label={label} WHO #obby\r\n")
2485        );
2486    }
2487
2488    #[test]
2489    fn a_labelled_command_that_is_never_answered_times_out() {
2490        let mut client = Client::new(Config::new("me"));
2491        client.handle_connected();
2492        client.handle_bytes(b":s CAP * LS :labeled-response\r\n");
2493        client.handle_bytes(b":s CAP * ACK :labeled-response\r\n");
2494        client.send_line_labeled(&Message::new("WHO", ["#obby"]));
2495        events(&mut client);
2496
2497        client.tick(at(DEFAULT_TIMEOUT_MS));
2498        assert_eq!(
2499            events(&mut client),
2500            [Event::CommandTimedOut {
2501                command: "WHO".to_string()
2502            }]
2503        );
2504    }
2505
2506    #[test]
2507    fn an_answered_command_does_not_time_out() {
2508        let mut client = Client::new(Config::new("me"));
2509        client.handle_connected();
2510        client.handle_bytes(b":s CAP * LS :labeled-response\r\n");
2511        client.handle_bytes(b":s CAP * ACK :labeled-response\r\n");
2512        let label = client
2513            .send_line_labeled(&Message::new("WHO", ["#obby"]))
2514            .expect("labelled");
2515        client.handle_bytes(alloc::format!("@label={label} :s 315 me #obby :End\r\n").as_bytes());
2516        events(&mut client);
2517
2518        client.tick(at(DEFAULT_TIMEOUT_MS));
2519        assert!(
2520            !events(&mut client)
2521                .iter()
2522                .any(|e| matches!(e, Event::CommandTimedOut { .. }))
2523        );
2524    }
2525}
2526
2527/// Regression tests for defects found in review. Each names the input that used to break it.
2528#[cfg(test)]
2529mod hostile_input_tests {
2530    use super::*;
2531
2532    fn registered() -> Client {
2533        let mut client = Client::new(Config::new("me"));
2534        client.handle_bytes(b":s 001 me :Welcome\r\n");
2535        client.handle_bytes(b":s 005 me CHANTYPES=# STATUSMSG=@+ PREFIX=(ov)@+ :are supported\r\n");
2536        while client.poll_event().is_some() {}
2537        client
2538    }
2539
2540    #[test]
2541    fn a_stream_with_no_newline_does_not_grow_without_bound() {
2542        let mut client = Client::new(Config::new("me"));
2543        for _ in 0..32 {
2544            client.handle_bytes(&[b'A'; 8192]);
2545        }
2546        assert!(
2547            client.dropped_lines() > 0,
2548            "an oversized line has to be given up on, not accumulated"
2549        );
2550
2551        client.handle_bytes(b"\r\n:s 001 me :Welcome\r\n");
2552        assert_eq!(
2553            client.phase(),
2554            Phase::Registered,
2555            "the connection keeps working once the oversized line is behind us"
2556        );
2557    }
2558
2559    #[test]
2560    fn an_absurd_server_time_does_not_take_the_process_down() {
2561        let mut client = registered();
2562        client.handle_bytes(
2563            b"@time=99999999999999999-01-01T00:00:00.000Z :bob!u@h PRIVMSG me :hi\r\n",
2564        );
2565        let folded = client.isupport().fold("bob");
2566        assert_eq!(
2567            client.model().conversation(&folded).map(|q| q.log.len()),
2568            Some(1),
2569            "an unusable timestamp falls back to our own, it does not panic or lose the message"
2570        );
2571    }
2572
2573    #[test]
2574    fn a_channel_we_never_joined_is_never_invented() {
2575        let mut client = registered();
2576        client.handle_bytes(b":bob!u@h JOIN #ghost\r\n");
2577        client.handle_bytes(b":bob!u@h TOPIC #ghost :not ours\r\n");
2578        client.handle_bytes(b":s 353 me = #ghost :@bob\r\n");
2579        client.handle_bytes(b":s 332 me #ghost :still not ours\r\n");
2580        client.handle_bytes(b":bob!u@h MODE #ghost +m\r\n");
2581        assert_eq!(
2582            client.model().channels().count(),
2583            0,
2584            "a server naming channels we are not in would otherwise grow the model without limit"
2585        );
2586    }
2587
2588    #[test]
2589    fn a_status_message_still_belongs_to_its_channel() {
2590        let mut client = registered();
2591        client.handle_bytes(b":me!u@h JOIN #obby\r\n");
2592        client.handle_bytes(b"@msgid=s1 :bob!u@h PRIVMSG @#obby :ops only\r\n");
2593
2594        let channel = client.isupport().fold("#obby");
2595        assert_eq!(
2596            client.model().channel(&channel).map(|c| c.log.len()),
2597            Some(1),
2598            "a message to the operators of a channel is still a message in that channel"
2599        );
2600        assert!(
2601            client
2602                .model()
2603                .conversation(&client.isupport().fold("bob"))
2604                .is_none(),
2605            "it must not become a private conversation"
2606        );
2607    }
2608}
2609
2610#[cfg(test)]
2611mod command_tests {
2612    use super::*;
2613    use crate::command::Typing;
2614
2615    fn ready(caps: &[u8]) -> Client {
2616        let mut client = Client::new(Config::new("me"));
2617        client.handle_connected();
2618        client.handle_bytes(b":s CAP * LS :echo-message labeled-response\r\n");
2619        client.handle_bytes(caps);
2620        client.handle_bytes(b":s 001 me :Welcome\r\n");
2621        client.handle_bytes(b":s 005 me CHANTYPES=# :are supported\r\n");
2622        client.handle_bytes(b":me!u@h JOIN #obby\r\n");
2623        while client.poll_event().is_some() {}
2624        while client.poll_transmit().is_some() {}
2625        client
2626    }
2627
2628    fn sent(client: &mut Client) -> String {
2629        let mut out = String::new();
2630        while let Some(bytes) = client.poll_transmit() {
2631            out.push_str(&String::from_utf8_lossy(&bytes));
2632        }
2633        out
2634    }
2635
2636    fn events(client: &mut Client) -> Vec<Event> {
2637        let mut out = Vec::new();
2638        while let Some(event) = client.poll_event() {
2639            out.push(event);
2640        }
2641        out
2642    }
2643
2644    fn log_len(client: &Client, channel: &str) -> usize {
2645        client
2646            .model()
2647            .channel(&client.isupport().fold(channel))
2648            .map_or(0, |c| c.log.len())
2649    }
2650
2651    #[test]
2652    fn without_echo_message_we_record_our_own_message_ourselves() {
2653        let mut client = ready(b":s CAP * NAK :echo-message\r\n");
2654        client.command(Command::SendMessage {
2655            target: "#obby".to_string(),
2656            text: "hello".to_string(),
2657        });
2658        assert!(sent(&mut client).contains("PRIVMSG #obby hello"));
2659        assert_eq!(
2660            log_len(&client, "#obby"),
2661            1,
2662            "no echo is coming, so nothing else will record it"
2663        );
2664        let channel = client
2665            .model()
2666            .channel(&client.isupport().fold("#obby"))
2667            .expect("channel");
2668        assert!(channel.log.last().expect("message").own);
2669    }
2670
2671    #[test]
2672    fn with_echo_message_we_wait_for_the_server_rather_than_double_up() {
2673        let mut client = ready(b":s CAP * ACK :echo-message\r\n");
2674        client.command(Command::SendMessage {
2675            target: "#obby".to_string(),
2676            text: "hello".to_string(),
2677        });
2678        assert_eq!(
2679            log_len(&client, "#obby"),
2680            0,
2681            "recording it now and again on the echo is how a message appears twice"
2682        );
2683
2684        client.handle_bytes(b"@msgid=e1 :me!u@h PRIVMSG #obby :hello\r\n");
2685        assert_eq!(log_len(&client, "#obby"), 1);
2686    }
2687
2688    #[test]
2689    fn an_action_is_wrapped_as_ctcp_and_read_back_as_one() {
2690        let mut client = ready(b":s CAP * NAK :echo-message\r\n");
2691        client.command(Command::SendAction {
2692            target: "#obby".to_string(),
2693            text: "waves".to_string(),
2694        });
2695        assert!(sent(&mut client).contains("\u{1}ACTION waves\u{1}"));
2696        let channel = client
2697            .model()
2698            .channel(&client.isupport().fold("#obby"))
2699            .expect("channel");
2700        let message = channel.log.last().expect("message");
2701        assert_eq!(
2702            message.kind,
2703            crate::model::MessageKind::Ctcp {
2704                command: "ACTION".to_string()
2705            }
2706        );
2707        assert_eq!(message.text, "waves");
2708    }
2709
2710    #[test]
2711    fn joining_and_parting_go_out_as_written() {
2712        let mut client = ready(b":s CAP * NAK :echo-message\r\n");
2713        client.command(Command::Join {
2714            channel: "#secret".to_string(),
2715            key: Some("hunter2".to_string()),
2716        });
2717        client.command(Command::Part {
2718            channel: "#obby".to_string(),
2719            reason: Some("see you".to_string()),
2720        });
2721        let sent = sent(&mut client);
2722        assert!(sent.contains("JOIN #secret hunter2"));
2723        assert!(
2724            sent.contains("PART #obby :see you"),
2725            "a reason with a space needs its marker"
2726        );
2727    }
2728
2729    #[test]
2730    fn clearing_a_topic_is_distinguishable_from_asking_for_one() {
2731        let mut client = ready(b":s CAP * NAK :echo-message\r\n");
2732        client.command(Command::SetTopic {
2733            channel: "#obby".to_string(),
2734            topic: None,
2735        });
2736        assert!(
2737            sent(&mut client).contains("TOPIC #obby :"),
2738            "an empty trailing parameter clears the topic; omitting it would only ask what it is"
2739        );
2740    }
2741
2742    #[test]
2743    fn a_reaction_names_the_message_it_reacts_to() {
2744        let mut client = ready(b":s CAP * NAK :echo-message\r\n");
2745        client.command(Command::AddReaction {
2746            target: "#obby".to_string(),
2747            msgid: "m1".to_string(),
2748            emoji: "👍".to_string(),
2749        });
2750        let sent = sent(&mut client);
2751        assert!(sent.contains("+draft/react=👍"));
2752        assert!(sent.contains("+draft/reply=m1"));
2753        assert!(sent.contains("TAGMSG #obby"));
2754    }
2755
2756    #[test]
2757    fn typing_says_how_far_along_we_are() {
2758        let mut client = ready(b":s CAP * NAK :echo-message\r\n");
2759        client.command(Command::SetTyping {
2760            target: "#obby".to_string(),
2761            state: Typing::Active,
2762        });
2763        assert!(sent(&mut client).contains("+typing=active"));
2764    }
2765
2766    #[test]
2767    fn a_read_marker_travels_as_a_server_time() {
2768        let mut client = ready(b":s CAP * NAK :echo-message\r\n");
2769        client.command(Command::MarkRead {
2770            target: "#obby".to_string(),
2771            at_ms: 1_788_688_800_123,
2772        });
2773        assert!(
2774            sent(&mut client).contains("MARKREAD #obby timestamp=2026-09-06T10:00:00.123Z"),
2775            "the host counts in milliseconds and the engine writes what the wire wants"
2776        );
2777    }
2778
2779    #[test]
2780    fn asking_for_history_pages_backwards_from_a_message() {
2781        let mut client = ready(b":s CAP * NAK :echo-message\r\n");
2782        client.command(Command::FetchHistory {
2783            target: "#obby".to_string(),
2784            before_msgid: None,
2785            limit: 50,
2786        });
2787        assert!(sent(&mut client).contains("CHATHISTORY LATEST #obby * 50"));
2788
2789        client.command(Command::FetchHistory {
2790            target: "#obby".to_string(),
2791            before_msgid: Some("m1".to_string()),
2792            limit: 50,
2793        });
2794        assert!(sent(&mut client).contains("CHATHISTORY BEFORE #obby msgid=m1 50"));
2795    }
2796
2797    #[test]
2798    fn an_invitation_to_the_network_still_carries_its_description() {
2799        let mut client = ready(b":s CAP * NAK :echo-message\r\n");
2800        client.command(Command::CreateInviteLink {
2801            channel: None,
2802            description: Some("for the team".to_string()),
2803        });
2804        assert!(
2805            sent(&mut client).contains("INVITELINK CREATE * :for the team"),
2806            "the channel is positional, so the network needs the star the server itself uses"
2807        );
2808
2809        client.command(Command::CreateInviteLink {
2810            channel: None,
2811            description: None,
2812        });
2813        assert!(sent(&mut client).contains("INVITELINK CREATE\r\n"));
2814
2815        client.command(Command::CreateInviteLink {
2816            channel: Some("#obby".to_string()),
2817            description: None,
2818        });
2819        assert!(sent(&mut client).contains("INVITELINK CREATE #obby"));
2820    }
2821
2822    #[test]
2823    fn the_rest_of_the_invitation_commands_travel_whole() {
2824        let mut client = ready(b":s CAP * NAK :echo-message\r\n");
2825        client.command(Command::ListInviteLinks);
2826        client.command(Command::DeleteInviteLink {
2827            share_id: "abc123".to_string(),
2828        });
2829        client.command(Command::RedeemInviteCode {
2830            code: "abc123".to_string(),
2831        });
2832        client.command(Command::Whois {
2833            nick: "bob".to_string(),
2834        });
2835        client.command(Command::RenameChannel {
2836            channel: "#obby".to_string(),
2837            new_name: "#obby-world".to_string(),
2838            reason: Some("tidying up".to_string()),
2839        });
2840        let sent = sent(&mut client);
2841        assert!(sent.contains("INVITELINK LIST"));
2842        assert!(sent.contains("INVITELINK DELETE abc123"));
2843        assert!(sent.contains("INVITECODE abc123"));
2844        assert!(sent.contains("WHOIS bob"));
2845        assert!(sent.contains("RENAME #obby #obby-world :tidying up"));
2846    }
2847
2848    #[test]
2849    fn a_minted_token_reaches_the_host() {
2850        let mut client = ready(b":s CAP * NAK :echo-message\r\n");
2851        client.command(Command::GenerateToken {
2852            service: "FILEHOST".to_string(),
2853        });
2854        assert!(sent(&mut client).contains("TOKEN GENERATE FILEHOST"));
2855
2856        client.handle_bytes(b":s TOKEN GENERATE filehost https://irc.example.org tok3n\r\n");
2857        assert!(events(&mut client).iter().any(|event| matches!(
2858            event,
2859            Event::AuthToken { service, endpoint, token }
2860                if service == "filehost" && endpoint == "https://irc.example.org" && token == "tok3n"
2861        )));
2862    }
2863
2864    #[test]
2865    fn a_token_line_we_cannot_read_still_reaches_the_host_raw() {
2866        let mut client = ready(b":s CAP * NAK :echo-message\r\n");
2867        client.handle_bytes(b":s TOKEN REVOKED filehost\r\n");
2868        assert!(
2869            events(&mut client)
2870                .iter()
2871                .any(|event| matches!(event, Event::RawLine { .. }))
2872        );
2873    }
2874
2875    #[test]
2876    fn a_raw_line_that_will_not_parse_is_not_sent() {
2877        let mut client = ready(b":s CAP * NAK :echo-message\r\n");
2878        client.command(Command::SendRawLine {
2879            line: String::new(),
2880        });
2881        assert_eq!(
2882            sent(&mut client),
2883            "",
2884            "a malformed line must not reach the wire"
2885        );
2886    }
2887}
2888
2889#[cfg(test)]
2890mod resume_tests {
2891    use super::*;
2892
2893    fn sent(client: &mut Client) -> String {
2894        let mut out = String::new();
2895        while let Some(bytes) = client.poll_transmit() {
2896            out.push_str(&String::from_utf8_lossy(&bytes));
2897        }
2898        out
2899    }
2900
2901    /// A client that has been around: registered, in two channels, with history.
2902    fn established() -> Client {
2903        let mut client = Client::new(Config::new("me"));
2904        client.handle_connected();
2905        client.handle_bytes(b":s CAP * LS :draft/chathistory\r\n");
2906        client.handle_bytes(b":s CAP * ACK :draft/chathistory\r\n");
2907        client.handle_bytes(b":s 001 me :Welcome\r\n");
2908        client.handle_bytes(b":s 005 me CHANTYPES=# :are supported\r\n");
2909        client.handle_bytes(b":me!u@h JOIN #obby\r\n");
2910        client.handle_bytes(b":me!u@h JOIN #other\r\n");
2911        client.handle_bytes(b":bob!u@h JOIN #obby\r\n");
2912        client.handle_bytes(b"@msgid=last :bob!u@h PRIVMSG #obby :before the drop\r\n");
2913        while client.poll_event().is_some() {}
2914        sent(&mut client);
2915        client
2916    }
2917
2918    #[test]
2919    fn a_reconnect_rejoins_every_channel_and_asks_only_for_what_it_missed() {
2920        let mut client = established();
2921        client.handle_disconnected();
2922        client.handle_connected();
2923        sent(&mut client);
2924
2925        client.handle_bytes(b":s CAP * LS :draft/chathistory\r\n");
2926        client.handle_bytes(b":s CAP * ACK :draft/chathistory\r\n");
2927        client.handle_bytes(b":s 001 me :Welcome back\r\n");
2928        let replay = sent(&mut client);
2929
2930        assert!(replay.contains("JOIN #obby"));
2931        assert!(replay.contains("JOIN #other"));
2932        assert!(
2933            replay.contains("CHATHISTORY AFTER #obby msgid=last 100"),
2934            "resuming from the newest message we hold beats refetching a window, got: {replay}"
2935        );
2936        assert!(
2937            !replay.contains("CHATHISTORY AFTER #other"),
2938            "a channel we have no message for has no point to resume from"
2939        );
2940    }
2941
2942    #[test]
2943    fn the_scrollback_survives_but_the_member_list_does_not() {
2944        let mut client = established();
2945        let folded = client.isupport().fold("#obby");
2946        assert_eq!(
2947            client.model().channel(&folded).map(|c| c.members.len()),
2948            Some(1)
2949        );
2950
2951        client.handle_disconnected();
2952
2953        let channel = client
2954            .model()
2955            .channel(&folded)
2956            .expect("the channel outlives the link");
2957        assert_eq!(channel.log.len(), 1, "the messages happened, so they stay");
2958        assert!(
2959            channel.members.is_empty(),
2960            "who is in a channel is only knowable while connected, so a stale list is a lie"
2961        );
2962    }
2963
2964    #[test]
2965    fn a_first_connection_replays_nothing() {
2966        let mut client = Client::new(Config::new("me"));
2967        client.handle_connected();
2968        sent(&mut client);
2969        client.handle_bytes(b":s 001 me :Welcome\r\n");
2970        assert_eq!(sent(&mut client), "", "there is nothing to resume into");
2971    }
2972
2973    #[test]
2974    fn capabilities_are_renegotiated_rather_than_assumed_to_survive() {
2975        let mut client = established();
2976        assert!(client.capabilities().has("draft/chathistory"));
2977        client.handle_disconnected();
2978        assert!(
2979            !client.capabilities().has("draft/chathistory"),
2980            "the new link is a new negotiation; assuming otherwise sends commands the server never agreed to"
2981        );
2982    }
2983}
2984
2985#[cfg(test)]
2986mod standard_reply_tests {
2987    use super::*;
2988
2989    fn first_reply(raw: &[u8]) -> Event {
2990        let mut client = Client::new(Config::new("me"));
2991        client.handle_bytes(raw);
2992        client.poll_event().expect("a reply reaches the host")
2993    }
2994
2995    #[test]
2996    fn a_failure_carries_its_code_and_description() {
2997        assert_eq!(
2998            first_reply(b":s FAIL JOIN CHANNEL_FULL #obby :Channel is full\r\n"),
2999            Event::ServerReply {
3000                severity: Severity::Fail,
3001                command: "JOIN".to_string(),
3002                code: "CHANNEL_FULL".to_string(),
3003                context: alloc::vec!["#obby".to_string()],
3004                text: "Channel is full".to_string(),
3005            }
3006        );
3007    }
3008
3009    #[test]
3010    fn a_reply_with_no_context_still_parses() {
3011        assert_eq!(
3012            first_reply(b":s WARN * ACCOUNT_REQUIRED :MessageLog in for more\r\n"),
3013            Event::ServerReply {
3014                severity: Severity::Warn,
3015                command: "*".to_string(),
3016                code: "ACCOUNT_REQUIRED".to_string(),
3017                context: Vec::new(),
3018                text: "MessageLog in for more".to_string(),
3019            }
3020        );
3021    }
3022
3023    #[test]
3024    fn a_note_is_reported_without_being_treated_as_an_error() {
3025        let Event::ServerReply { severity, .. } = first_reply(b":s NOTE * HELLO :Welcome\r\n")
3026        else {
3027            panic!("a NOTE is a standard reply");
3028        };
3029        assert_eq!(severity, Severity::Note);
3030    }
3031
3032    #[test]
3033    fn a_reply_missing_its_code_is_dropped_rather_than_half_reported() {
3034        let mut client = Client::new(Config::new("me"));
3035        client.handle_bytes(b":s FAIL\r\n");
3036        assert!(matches!(client.poll_event(), Some(Event::RawLine { .. })));
3037    }
3038}
3039
3040#[cfg(test)]
3041mod typing_tests {
3042    use super::*;
3043
3044    fn at(ms: u64) -> Now {
3045        Now {
3046            monotonic_ms: ms,
3047            unix_ms: 1_700_000_000_000 + ms,
3048        }
3049    }
3050
3051    fn joined() -> Client {
3052        let mut client = Client::new(Config::new("me"));
3053        client.handle_bytes(b":s 001 me :Welcome\r\n");
3054        client.handle_bytes(b":s 005 me CHANTYPES=# :are supported\r\n");
3055        client.handle_bytes(b":me!u@h JOIN #obby\r\n");
3056        while client.poll_event().is_some() {}
3057        client
3058    }
3059
3060    fn typing_in(client: &Client, channel: &str) -> usize {
3061        client
3062            .model()
3063            .channel(&client.isupport().fold(channel))
3064            .map_or(0, |c| c.typing.len())
3065    }
3066
3067    #[test]
3068    fn a_typing_indicator_is_not_a_message() {
3069        let mut client = joined();
3070        client.handle_bytes(b"@+typing=active :bob!u@h TAGMSG #obby\r\n");
3071        assert_eq!(
3072            client
3073                .model()
3074                .channel(&client.isupport().fold("#obby"))
3075                .map(|c| c.log.len()),
3076            Some(0),
3077            "showing it as a message would put an empty row in the conversation"
3078        );
3079        assert_eq!(typing_in(&client, "#obby"), 1);
3080    }
3081
3082    #[test]
3083    fn typing_stops_when_they_say_so() {
3084        let mut client = joined();
3085        client.handle_bytes(b"@+typing=active :bob!u@h TAGMSG #obby\r\n");
3086        client.handle_bytes(b"@+typing=done :bob!u@h TAGMSG #obby\r\n");
3087        assert_eq!(typing_in(&client, "#obby"), 0);
3088    }
3089
3090    #[test]
3091    fn typing_goes_stale_on_its_own() {
3092        let mut client = joined();
3093        client.handle_bytes(b"@+typing=active :bob!u@h TAGMSG #obby\r\n");
3094        while client.poll_event().is_some() {}
3095
3096        client.tick(at(TYPING_EXPIRY_MS - 1));
3097        assert_eq!(typing_in(&client, "#obby"), 1);
3098
3099        client.tick(at(TYPING_EXPIRY_MS));
3100        assert_eq!(
3101            typing_in(&client, "#obby"),
3102            0,
3103            "the done that would clear it can be lost, so it must expire by itself"
3104        );
3105        assert!(
3106            client
3107                .poll_event()
3108                .is_some_and(|event| matches!(event, Event::TypingChanged { active: false, .. }))
3109        );
3110    }
3111
3112    #[test]
3113    fn a_repeated_indicator_does_not_report_twice() {
3114        let mut client = joined();
3115        client.handle_bytes(b"@+typing=active :bob!u@h TAGMSG #obby\r\n");
3116        while client.poll_event().is_some() {}
3117        client.handle_bytes(b"@+typing=active :bob!u@h TAGMSG #obby\r\n");
3118        assert!(
3119            client.poll_event().is_none(),
3120            "the indicator repeats on a timer, and reporting each repeat makes it flicker"
3121        );
3122    }
3123
3124    #[test]
3125    fn typing_at_us_belongs_to_the_conversation_with_the_sender() {
3126        let mut client = joined();
3127        client.handle_bytes(b":bob!u@h PRIVMSG me :hi\r\n");
3128        client.handle_bytes(b"@+typing=active :bob!u@h TAGMSG me\r\n");
3129        assert_eq!(
3130            client
3131                .model()
3132                .conversation(&client.isupport().fold("bob"))
3133                .map(|q| q.typing.len()),
3134            Some(1),
3135            "a conversation named after ourselves would be nobody"
3136        );
3137    }
3138}
3139
3140#[cfg(all(test, feature = "obby"))]
3141mod bot_tests {
3142    use super::*;
3143
3144    fn joined() -> Client {
3145        let mut client = Client::new(Config::new("me"));
3146        client.handle_bytes(b":s 001 me :Welcome\r\n");
3147        client.handle_bytes(b":s 005 me CHANTYPES=# CASEMAPPING=ascii :are supported\r\n");
3148        client.handle_bytes(b":me!u@h JOIN #obby\r\n");
3149        while client.poll_event().is_some() {}
3150        client
3151    }
3152
3153    /// One bodiless TAGMSG carrying a base64 JSON tag, with any extra tags in front of it.
3154    fn tagged(extra: &str, tag: &str, json: &str, from: &str) -> alloc::vec::Vec<u8> {
3155        use base64::Engine as _;
3156        let payload = base64::engine::general_purpose::STANDARD.encode(json);
3157        alloc::format!("@{extra}{tag}={payload} :{from} TAGMSG me\r\n").into_bytes()
3158    }
3159
3160    #[test]
3161    fn a_discovery_batch_fills_the_registry_without_filling_the_conversation() {
3162        let mut client = joined();
3163        client.handle_bytes(b":s BATCH +b obby.world/channel-bots\r\n");
3164        client.handle_bytes(&tagged(
3165            "batch=b;",
3166            "obby.world/bot-info",
3167            r#"{"event":"add","bot_id":"b1","nick":"WeatherBot","from_config":false,"commands":[{"name":"forecast"},{"name":"identify"}]}"#,
3168            "s",
3169        ));
3170        client.handle_bytes(b":s BATCH -b\r\n");
3171
3172        let bot = client.bots().get("weatherbot").expect("the bot");
3173        assert_eq!(bot.nick, "WeatherBot");
3174        assert_eq!(bot.id.as_deref(), Some("b1"));
3175        assert_eq!(
3176            bot.commands.len(),
3177            1,
3178            "a bot that registered itself must not shadow a privileged name"
3179        );
3180        assert_eq!(bot.commands[0].name, "forecast");
3181        assert!(
3182            client
3183                .model()
3184                .conversation(&client.isupport().fold("s"))
3185                .is_none(),
3186            "an announcement is not a message"
3187        );
3188    }
3189
3190    #[test]
3191    fn a_withdrawal_forgets_the_bot() {
3192        let mut client = joined();
3193        client.handle_bytes(&tagged(
3194            "",
3195            "obby.world/bot-info",
3196            r#"{"event":"add","nick":"weatherbot"}"#,
3197            "s",
3198        ));
3199        client.handle_bytes(&tagged(
3200            "",
3201            "obby.world/bot-info",
3202            r#"{"event":"remove","nick":"weatherbot"}"#,
3203            "s",
3204        ));
3205        assert!(client.bots().is_empty());
3206    }
3207
3208    #[test]
3209    fn a_command_list_from_a_nick_we_were_never_told_about_is_refused() {
3210        let mut client = joined();
3211        client.handle_bytes(&tagged(
3212            "",
3213            "+draft/bot-cmds",
3214            r#"{"commands":[{"name":"forecast"}]}"#,
3215            "stranger!u@h",
3216        ));
3217        assert!(
3218            client.bots().is_empty(),
3219            "anyone could otherwise put entries in the command menu"
3220        );
3221    }
3222
3223    #[test]
3224    fn a_command_list_from_an_announced_bot_is_kept() {
3225        let mut client = joined();
3226        client.handle_bytes(&tagged(
3227            "",
3228            "obby.world/bot-info",
3229            r#"{"event":"add","nick":"weatherbot","from_config":true}"#,
3230            "s",
3231        ));
3232        client.handle_bytes(&tagged(
3233            "",
3234            "+draft/bot-cmds",
3235            r#"{"prefix":"/","commands":[{"name":"identify"}]}"#,
3236            "WeatherBot!u@bot.obby.world",
3237        ));
3238        let bot = client.bots().get("weatherbot").expect("the bot");
3239        assert_eq!(
3240            bot.commands.len(),
3241            1,
3242            "a bot an operator configured may claim a privileged name"
3243        );
3244    }
3245
3246    #[test]
3247    fn an_announcement_we_cannot_read_is_counted_rather_than_shown() {
3248        let mut client = joined();
3249        client.handle_bytes(b"@obby.world/bot-info=not-base64-$$$ :s TAGMSG me\r\n");
3250        assert!(client.bots().is_empty());
3251        assert!(client.dropped_lines() > 0);
3252    }
3253}
3254
3255#[cfg(all(test, feature = "voice"))]
3256mod voice_tests {
3257    use super::*;
3258
3259    fn joined() -> Client {
3260        let mut client = Client::new(Config::new("me"));
3261        client.handle_bytes(b":s 001 me :Welcome\r\n");
3262        client.handle_bytes(b":s 005 me CHANTYPES=#^$ :are supported\r\n");
3263        client.handle_bytes(b":me!u@h JOIN ^general\r\n");
3264        while client.poll_event().is_some() {}
3265        client
3266    }
3267
3268    #[test]
3269    fn signalling_is_never_a_message_in_the_channel() {
3270        let mut client = joined();
3271        client.handle_bytes(
3272            br#"@+obsidianirc/rtc={"type":"join","channel":"^general"} :alice!u@h TAGMSG ^general"#,
3273        );
3274        client.handle_bytes(b"\r\n");
3275        assert_eq!(
3276            client
3277                .model()
3278                .channel(&client.isupport().fold("^general"))
3279                .map(|c| c.log.len()),
3280            Some(0),
3281            "a room full of signalling would otherwise fill the conversation with empty rows"
3282        );
3283        assert!(
3284            client
3285                .poll_event()
3286                .is_some_and(|e| matches!(e, Event::Voice { .. }))
3287        );
3288    }
3289
3290    #[test]
3291    fn a_frame_we_cannot_read_is_counted_rather_than_shown() {
3292        let mut client = joined();
3293        client.handle_bytes(b"@+obsidianirc/rtc=not-json :alice!u@h TAGMSG ^general\r\n");
3294        assert_eq!(
3295            client
3296                .model()
3297                .channel(&client.isupport().fold("^general"))
3298                .map(|c| c.log.len()),
3299            Some(0)
3300        );
3301        assert!(client.dropped_lines() > 0);
3302    }
3303
3304    #[test]
3305    fn an_outbound_frame_rides_the_rtc_tag() {
3306        let mut client = joined();
3307        while client.poll_transmit().is_some() {}
3308        client.command(Command::SendVoiceSignal {
3309            channel: "^general".to_string(),
3310            signal: crate::voice::Signal::Join {
3311                channel: "^general".to_string(),
3312            },
3313        });
3314        let mut sent = String::new();
3315        while let Some(bytes) = client.poll_transmit() {
3316            sent.push_str(&String::from_utf8_lossy(&bytes));
3317        }
3318        assert!(sent.contains(r#"+obsidianirc/rtc={"type":"join","channel":"^general"}"#));
3319        assert!(sent.contains("TAGMSG ^general"));
3320    }
3321}
3322
3323#[cfg(test)]
3324mod scram_tests {
3325    use super::*;
3326    use base64::Engine as _;
3327
3328    /// The published RFC 7677 exchange, so the engine is checked against the specification's own
3329    /// bytes rather than against our implementation agreeing with itself.
3330    const SERVER_FIRST: &str =
3331        "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096";
3332    const SERVER_FINAL: &str = "v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=";
3333
3334    fn b64(text: &str) -> String {
3335        base64::engine::general_purpose::STANDARD.encode(text)
3336    }
3337
3338    fn negotiated() -> Client {
3339        let mut config = Config::new("me");
3340        config.sasl = Some(Credentials::Scram {
3341            username: "user".to_string(),
3342            password: "pencil".to_string(),
3343            nonce: "rOprNGfwEbeRWgbNEkqO".to_string(),
3344        });
3345        let mut client = Client::new(config);
3346        client.handle_connected();
3347        client.handle_bytes(b":s CAP * LS :sasl=SCRAM-SHA-256,PLAIN\r\n");
3348        client.handle_bytes(b":s CAP * ACK :sasl\r\n");
3349        client
3350    }
3351
3352    fn sent(client: &mut Client) -> String {
3353        let mut out = String::new();
3354        while let Some(bytes) = client.poll_transmit() {
3355            out.push_str(&String::from_utf8_lossy(&bytes));
3356        }
3357        out
3358    }
3359
3360    #[test]
3361    fn the_whole_exchange_runs_and_only_then_ends_negotiation() {
3362        let mut client = negotiated();
3363        assert!(sent(&mut client).contains("AUTHENTICATE SCRAM-SHA-256"));
3364
3365        client.handle_bytes(b":s AUTHENTICATE +\r\n");
3366        assert_eq!(
3367            sent(&mut client),
3368            alloc::format!(
3369                "AUTHENTICATE {}\r\n",
3370                b64("n,,n=user,r=rOprNGfwEbeRWgbNEkqO")
3371            ),
3372        );
3373        assert_eq!(client.phase(), Phase::Negotiating);
3374
3375        client.handle_bytes(alloc::format!(":s AUTHENTICATE {}\r\n", b64(SERVER_FIRST)).as_bytes());
3376        let proof = sent(&mut client);
3377        assert!(proof.contains(&b64(
3378            "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ="
3379        )));
3380        assert_eq!(
3381            client.phase(),
3382            Phase::Negotiating,
3383            "CAP END before the server has proven itself abandons the check entirely"
3384        );
3385
3386        client.handle_bytes(alloc::format!(":s AUTHENTICATE {}\r\n", b64(SERVER_FINAL)).as_bytes());
3387        assert_eq!(sent(&mut client), "AUTHENTICATE +\r\n");
3388
3389        client.handle_bytes(b":s 903 me :SASL authentication successful\r\n");
3390        assert_eq!(sent(&mut client), "CAP END\r\n");
3391        assert_eq!(client.phase(), Phase::Registering);
3392    }
3393
3394    #[test]
3395    fn a_server_that_cannot_prove_it_knows_the_password_is_refused() {
3396        let mut client = negotiated();
3397        client.handle_bytes(b":s AUTHENTICATE +\r\n");
3398        client.handle_bytes(alloc::format!(":s AUTHENTICATE {}\r\n", b64(SERVER_FIRST)).as_bytes());
3399        sent(&mut client);
3400        while client.poll_event().is_some() {}
3401
3402        let forged = b64("v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
3403        client.handle_bytes(alloc::format!(":s AUTHENTICATE {forged}\r\n").as_bytes());
3404
3405        let mut events = Vec::new();
3406        while let Some(event) = client.poll_event() {
3407            events.push(event);
3408        }
3409        assert!(
3410            events.contains(&Event::SaslFailed {
3411                reason: SaslFailure::ServerNotVerified
3412            }),
3413            "a server that never knew the password must not be able to convince us it did"
3414        );
3415        assert_eq!(
3416            client.phase(),
3417            Phase::Registering,
3418            "registration still continues, unauthenticated"
3419        );
3420    }
3421
3422    #[test]
3423    fn a_malformed_challenge_ends_the_exchange_rather_than_hanging_it() {
3424        let mut client = negotiated();
3425        client.handle_bytes(b":s AUTHENTICATE +\r\n");
3426        sent(&mut client);
3427        client.handle_bytes(alloc::format!(":s AUTHENTICATE {}\r\n", b64("nonsense")).as_bytes());
3428        assert_eq!(sent(&mut client), "CAP END\r\n");
3429        assert_eq!(client.phase(), Phase::Registering);
3430    }
3431
3432    #[test]
3433    fn plain_is_skipped_when_the_server_will_not_take_it() {
3434        let mut config = Config::new("me");
3435        config.sasl = Some(Credentials::Plain {
3436            username: "user".to_string(),
3437            password: "pencil".to_string(),
3438        });
3439        let mut client = Client::new(config);
3440        client.handle_connected();
3441        client.handle_bytes(b":s CAP * LS :sasl=SCRAM-SHA-256\r\n");
3442        client.handle_bytes(b":s CAP * ACK :sasl\r\n");
3443        let sent = sent(&mut client);
3444        assert!(!sent.contains("AUTHENTICATE"));
3445        assert!(sent.contains("CAP END"));
3446    }
3447}
3448
3449#[cfg(all(test, feature = "serde"))]
3450mod config_tests {
3451    use super::*;
3452
3453    #[test]
3454    fn a_host_only_has_to_supply_the_nick() {
3455        let config: Config =
3456            serde_json::from_str(r#"{"nick":"me"}"#).expect("a partial config should deserialise");
3457        assert_eq!(config.nick, "me");
3458        assert_eq!(config.retention, crate::model::DEFAULT_RETENTION);
3459        assert!(config.alt_nicks.is_empty());
3460        assert!(config.sasl.is_none());
3461    }
3462
3463    #[test]
3464    fn a_blank_username_is_filled_in_from_the_nick() {
3465        let config: Config = serde_json::from_str(r#"{"nick":"me"}"#).expect("deserialise");
3466        let mut client = Client::new(config);
3467        client.handle_connected();
3468        let mut sent = String::new();
3469        while let Some(bytes) = client.poll_transmit() {
3470            sent.push_str(&String::from_utf8_lossy(&bytes));
3471        }
3472        assert!(
3473            sent.contains("USER me 0 * me"),
3474            "registering with an empty username is refused outright by some servers, got: {sent}"
3475        );
3476    }
3477}
3478
3479#[cfg(test)]
3480mod live_view_tests {
3481    use super::*;
3482
3483    #[test]
3484    fn a_renamed_channel_keeps_the_key_a_reconnect_needs() {
3485        let mut client = Client::new(Config::new("me"));
3486        client.handle_bytes(b":s 001 me :Welcome\r\n");
3487        client.join("#obby", Some("hunter2".to_string()));
3488        client.handle_bytes(b":me!u@h JOIN #obby\r\n");
3489        while client.poll_transmit().is_some() {}
3490
3491        client.handle_bytes(b":s RENAME #obby #obby-world :spring clean\r\n");
3492        client.handle_disconnected();
3493        client.handle_connected();
3494        // the rejoin rides on registration finishing, not on the socket coming up
3495        client.handle_bytes(b":s 001 me :Welcome\r\n");
3496
3497        let mut sent = alloc::string::String::new();
3498        while let Some(bytes) = client.poll_transmit() {
3499            sent.push_str(&alloc::string::String::from_utf8_lossy(&bytes));
3500        }
3501        assert!(
3502            sent.contains("JOIN #obby-world hunter2"),
3503            "a rejoin without the key is refused by the server, and the key is filed under the \
3504             name the channel had: {sent}"
3505        );
3506    }
3507
3508    #[test]
3509    fn a_dead_link_forgets_what_only_held_while_connected() {
3510        let mut client = Client::new(Config::new("me"));
3511        client.handle_bytes(b":s 001 me :Welcome\r\n");
3512        client.handle_bytes(b":s 311 me alice ident host.example * :Alice\r\n");
3513        client.handle_bytes(b":s 318 me alice :End of /WHOIS\r\n");
3514        assert!(
3515            client
3516                .model()
3517                .whois(&client.isupport().fold("alice"))
3518                .is_some()
3519        );
3520
3521        client.handle_disconnected();
3522
3523        assert!(
3524            client
3525                .model()
3526                .whois(&client.isupport().fold("alice"))
3527                .is_none(),
3528            "a record from a connection that ended must not merge into the next one"
3529        );
3530    }
3531
3532    #[test]
3533    fn a_server_naming_endless_nicks_cannot_grow_the_model_without_bound() {
3534        let mut client = Client::new(Config::new("me"));
3535        client.handle_bytes(b":s 001 me :Welcome\r\n");
3536        for index in 0..500 {
3537            let line = alloc::format!(":s 311 me nick{index} ident host * :Someone\r\n");
3538            client.handle_bytes(line.as_bytes());
3539        }
3540
3541        assert!(
3542            client.model().whois_records().count() <= 64,
3543            "the model holds what a host asked for, not what a server volunteered"
3544        );
3545    }
3546}