1use 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#[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 Disconnected,
29 Negotiating,
31 Registering,
33 Registered,
35}
36
37#[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 pub nick: String,
48 #[cfg_attr(feature = "ts", ts(as = "Option<String>", optional))]
50 pub username: String,
51 #[cfg_attr(feature = "ts", ts(as = "Option<String>", optional))]
53 pub realname: String,
54 #[cfg_attr(feature = "ts", ts(optional))]
56 pub password: Option<String>,
57 #[cfg_attr(feature = "ts", ts(optional))]
59 pub sasl: Option<Credentials>,
60 #[cfg_attr(feature = "ts", ts(as = "Option<usize>", optional))]
62 pub retention: usize,
63 #[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 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#[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 CapabilitiesAcknowledged {
107 names: Vec<String>,
109 },
110 Registered {
112 nick: String,
114 },
115 IsupportToken {
117 token: String,
119 value: Option<String>,
121 },
122 LoggedIn {
124 account: String,
126 },
127 SaslFailed {
129 reason: SaslFailure,
131 },
132 NickInUse {
134 refused: String,
136 trying: String,
138 },
139 ModelChanged {
141 change: Change,
143 },
144 LinkDead,
147 ReconnectAfter {
149 #[cfg_attr(feature = "ts", ts(type = "number"))]
151 after_ms: u64,
152 },
153 ReconnectAbandoned,
155 CommandTimedOut {
157 command: String,
159 },
160 #[cfg(feature = "obby")]
162 AllowedCommandsChanged,
163 #[cfg(feature = "obby")]
165 BotsChanged {
166 nick: String,
168 },
169 #[cfg(feature = "obby")]
171 AuthToken {
172 service: String,
174 endpoint: String,
176 token: String,
178 },
179 #[cfg(feature = "voice")]
184 Voice {
185 channel: String,
187 signal: crate::voice::Signal,
189 },
190 TypingChanged {
192 target: String,
194 nick: String,
196 active: bool,
198 },
199 PresenceChanged {
201 nick: String,
203 online: bool,
205 },
206 ServerReply {
208 severity: Severity,
210 command: String,
212 code: String,
214 context: Vec<String>,
216 text: String,
218 },
219 RawLine {
222 message: Message,
224 },
225}
226
227#[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: alloc::collections::BTreeMap<obby_proto::CaseFolded, String>,
240 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 latest_ms: u64,
254 discarding: bool,
256 dropped_lines: u64,
257 sasl: SaslState,
258 scram: Option<crate::scram::Scram>,
259 nick_attempt: usize,
260 partial: Vec<u8>,
262 outbox: VecDeque<Vec<u8>>,
263 events: VecDeque<Event>,
264}
265
266impl Client {
267 pub fn new(config: Config) -> Self {
278 let mut config = config;
279 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 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 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 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 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 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 pub fn poll_timeout(&self) -> Option<u64> {
441 self.timers.next()
442 }
443
444 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 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 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 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 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 pub fn set_nick(&mut self, nick: impl Into<String>) {
507 self.command(Command::SetNick { nick: nick.into() });
508 }
509
510 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 pub fn set_away(&mut self, message: Option<String>) {
520 self.command(Command::SetAway { message });
521 }
522
523 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 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 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 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 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 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 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 pub fn subscribe_metadata(&mut self, keys: Vec<String>) {
605 self.command(Command::SubscribeMetadata { keys });
606 }
607
608 pub fn whois(&mut self, nick: impl Into<String>) {
613 self.command(Command::Whois { nick: nick.into() });
614 }
615
616 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 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 pub fn list_invite_links(&mut self) {
640 self.command(Command::ListInviteLinks);
641 }
642
643 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 pub fn redeem_invite_code(&mut self, code: impl Into<String>) {
652 self.command(Command::RedeemInviteCode { code: code.into() });
653 }
654
655 pub fn generate_token(&mut self, service: impl Into<String>) {
659 self.command(Command::GenerateToken {
660 service: service.into(),
661 });
662 }
663
664 pub fn watch_nicks(&mut self, nicks: Vec<String>) {
666 self.command(Command::WatchNicks { nicks });
667 }
668
669 pub fn unwatch_nicks(&mut self, nicks: Vec<String>) {
671 self.command(Command::UnwatchNicks { nicks });
672 }
673
674 #[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 pub fn quit(&mut self, reason: Option<String>) {
685 self.command(Command::Quit { reason });
686 }
687
688 pub fn send_raw_line(&mut self, line: impl Into<String>) {
690 self.command(Command::SendRawLine { line: line.into() });
691 }
692
693 pub fn command(&mut self, command: Command) {
699 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn dropped_lines(&self) -> u64 {
1047 self.dropped_lines
1048 }
1049
1050 pub fn poll_transmit(&mut self) -> Option<Vec<u8>> {
1064 self.outbox.pop_front()
1065 }
1066
1067 pub fn poll_event(&mut self) -> Option<Event> {
1083 self.events.pop_front()
1084 }
1085
1086 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 pub fn phase(&self) -> Phase {
1095 self.phase
1096 }
1097
1098 pub fn nick(&self) -> &str {
1100 &self.nick
1101 }
1102
1103 pub fn capabilities(&self) -> &Capabilities {
1105 &self.caps
1106 }
1107
1108 pub fn isupport(&self) -> &Isupport {
1110 &self.isupport
1111 }
1112
1113 #[cfg(feature = "obby")]
1118 pub fn allowed_commands(&self) -> &crate::extensions::Commands {
1119 &self.commands
1120 }
1121
1122 #[cfg(feature = "obby")]
1124 pub fn bots(&self) -> &crate::extensions::Bots {
1125 &self.bots
1126 }
1127
1128 #[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 pub fn watch_list(&self) -> &WatchList {
1139 &self.monitor
1140 }
1141
1142 pub fn model(&self) -> &Model {
1144 &self.model
1145 }
1146
1147 pub fn casemapping(&self) -> Casemapping {
1149 self.isupport.casemapping()
1150 }
1151
1152 fn handle_message(&mut self, message: Message) {
1153 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 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 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 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 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 if let Change::ChannelJoined { channel } = change {
1306 self.request_who(channel);
1307 }
1308 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 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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 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 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 #[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 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 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 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 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 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 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 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#[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 Fail,
1770 Warn,
1772 Note,
1774}
1775
1776const MAX_INBOUND_LINE: usize = obby_proto::MAX_TAG_BYTES + obby_proto::MAX_LINE_BYTES;
1782
1783const CHATHISTORY_BATCH: &str = "chathistory";
1785
1786fn join_multiline(closed: ClosedBatch) -> ClosedBatch {
1793 let mut joined: Vec<Message> = Vec::new();
1794 for message in closed.messages {
1795 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 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
1820const MULTILINE_BATCH: &str = "draft/multiline";
1822
1823const MULTILINE_CONCAT: &str = "draft/multiline-concat";
1825
1826fn 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
1836fn 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 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#[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 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 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 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 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}