1use alloc::collections::BTreeMap;
7use alloc::string::{String, ToString};
8use alloc::vec::Vec;
9use obby_proto::{CaseFolded, Casemapping, Isupport, Message as Line, parse_channel_modes};
10
11use crate::model::{ChatMessage, MessageKind, Model};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
17#[cfg_attr(feature = "ts", ts(rename = "ModelChange"))]
18#[cfg_attr(feature = "serde", serde(tag = "type", rename_all = "snake_case"))]
19#[non_exhaustive]
20pub enum Change {
21 MessageAdded {
23 target: String,
25 key: crate::model::MessageKey,
27 },
28 ChannelJoined {
30 channel: String,
32 },
33 ChannelParted {
35 channel: String,
37 },
38 ChannelMembersChanged {
40 channel: String,
42 },
43 ChannelTopicChanged {
45 channel: String,
47 },
48 NickChanged {
50 from: String,
52 to: String,
54 },
55 ChannelModesChanged {
57 channel: String,
59 },
60 ReadMarkerMoved {
62 target: String,
64 },
65 MessageReacted {
67 target: String,
69 msgid: String,
71 },
72 MessageRedacted {
74 target: String,
76 msgid: String,
78 },
79 MetadataChanged {
81 target: String,
83 key: String,
85 },
86 WhoisReceived {
88 nick: String,
90 },
91 ChannelRenamed {
93 from: String,
95 to: String,
97 },
98}
99
100pub(crate) const WHOX_TOKEN: &str = "332";
102
103pub(crate) const WHOX_FIELDS: &str = "%tcuhnfar";
107
108fn is_nick_char(c: char) -> bool {
113 c.is_alphanumeric() || "[]\\`_^{|}-".contains(c)
114}
115
116fn mentions(casemapping: Casemapping, text: &str, nick: &str) -> bool {
122 if nick.is_empty() {
123 return false;
124 }
125 let folded_nick = casemapping.fold(nick);
126 let needle = folded_nick.as_str();
127 let folded: String = text.chars().map(|c| casemapping.fold_char(c)).collect();
128
129 let mut from = 0;
130 while let Some(offset) = folded.get(from..).and_then(|rest| rest.find(needle)) {
131 let start = from + offset;
132 let end = start + needle.len();
133 let before = folded.get(..start).and_then(|s| s.chars().next_back());
134 let after = folded.get(end..).and_then(|s| s.chars().next());
135 if !before.is_some_and(is_nick_char) && !after.is_some_and(is_nick_char) {
136 return true;
137 }
138 from = end;
139 }
140 false
141}
142
143pub(crate) struct Context<'a> {
145 pub(crate) model: &'a mut Model,
146 pub(crate) isupport: &'a Isupport,
147 pub(crate) latest_ms: &'a mut u64,
149 pub(crate) historical: bool,
151}
152
153impl Context<'_> {
154 fn casemapping(&self) -> Casemapping {
155 self.isupport.casemapping()
156 }
157
158 fn fold(&self, name: &str) -> CaseFolded {
159 self.isupport.fold(name)
160 }
161
162 fn stamp(&mut self, line: &Line) -> u64 {
167 let stamped = line
168 .tag("time")
169 .and_then(obby_proto::parse_server_time)
170 .unwrap_or(*self.latest_ms);
171 *self.latest_ms = (*self.latest_ms).max(stamped);
172 stamped
173 }
174
175 fn is_me(&self, nick: &str) -> bool {
176 self.casemapping().eq(&self.model.me.nick, nick)
177 }
178
179 fn sender(line: &Line) -> String {
180 line.source
181 .as_ref()
182 .map_or_else(String::new, |source| source.name.clone())
183 }
184}
185
186pub(crate) fn apply(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
188 let command = line.command.to_ascii_uppercase();
189 match command.as_str() {
190 "PRIVMSG" | "NOTICE" | "TAGMSG" => message(ctx, line, &command),
191 "JOIN" => join(ctx, line),
192 "PART" => part(ctx, line),
193 "QUIT" => quit(ctx, line),
194 "KICK" => kick(ctx, line),
195 "NICK" => nick(ctx, line),
196 "RENAME" => rename_channel(ctx, line),
197 "TOPIC" => topic(ctx, line),
198 "MODE" => mode(ctx, line),
199 "332" => topic_reply(ctx, line),
200 "MARKREAD" => mark_read(ctx, line),
201 "REDACT" => redact(ctx, line),
202 "AWAY" => away(ctx, line),
203 "ACCOUNT" => account(ctx, line),
204 "METADATA" => metadata(ctx, line, 0),
206 "760" | "761" | "766" => metadata(ctx, line, 1),
208 "311" | "312" | "313" | "317" | "318" | "319" | "330" | "338" | "378" | "671" => {
210 whois(ctx, line, &command)
211 }
212 "353" => names(ctx, line),
213 "352" => who_reply(ctx, line),
214 "354" => whox_reply(ctx, line),
215 "PROP" => prop(ctx, line),
216 "961" => prop_list(ctx, line),
217 _ => Vec::new(),
218 }
219}
220
221fn message(ctx: &mut Context<'_>, line: &Line, command: &str) -> Vec<Change> {
222 let Some(target) = line.param(0) else {
223 return Vec::new();
224 };
225 if command == "TAGMSG"
226 && let Some(changes) = reaction(ctx, line)
227 {
228 return changes;
229 }
230 #[cfg(feature = "obby")]
231 if command == "TAGMSG"
232 && let Some(changes) = link_preview(ctx, line)
233 {
234 return changes;
235 }
236
237 let sender = Context::sender(line);
238 let time_ms = ctx.stamp(line);
239 let key = ctx.model.next_message_key(time_ms);
240 let body = if command == "TAGMSG" {
241 String::new()
242 } else {
243 line.param(1).unwrap_or_default().to_string()
244 };
245
246 let (kind, text) = match command {
247 "TAGMSG" => (MessageKind::Tagmsg, body),
248 "NOTICE" => (MessageKind::Notice, body),
249 _ => match obby_proto::parse_ctcp(&body) {
250 Some(ctcp) => (
251 MessageKind::Ctcp {
252 command: ctcp.command.to_ascii_uppercase(),
253 },
254 ctcp.params,
255 ),
256 None => (MessageKind::Privmsg, body),
257 },
258 };
259
260 let mut message = ChatMessage::new(key, sender.clone(), kind);
261 message.text = text;
262 message.msgid = line.tag("msgid").map(ToString::to_string);
263 message.account = line.tag("account").map(ToString::to_string);
264 message.own = ctx.is_me(&sender);
265 message.historical = ctx.historical;
266 message.reply_to = line
269 .tag("+reply")
270 .or_else(|| line.tag("+draft/reply"))
271 .map(ToString::to_string);
272
273 let addressed = target.trim_start_matches(|c| ctx.isupport.is_statusmsg(c));
276 let (name, is_channel) = if ctx.isupport.is_channel(addressed) {
279 (addressed.to_string(), true)
280 } else if message.own {
281 (target.to_string(), false)
282 } else {
283 (sender, false)
284 };
285
286 let counts = !message.own && !message.historical;
288 let addressed = counts && mentions(ctx.casemapping(), &message.text, &ctx.model.me.nick);
289
290 let folded = ctx.fold(&name);
291 if is_channel && ctx.model.channel(&folded).is_none() {
294 return Vec::new();
295 }
296 let accepted = if is_channel {
297 let channel = ctx.model.channel_or_insert(folded, &name);
298 let accepted = channel.log.insert(message);
299 if accepted && counts {
300 channel.unread = channel.unread.saturating_add(1);
301 if addressed {
302 channel.mentions = channel.mentions.saturating_add(1);
303 }
304 }
305 accepted
306 } else {
307 let query = ctx.model.conversation_or_insert(folded, &name);
308 let accepted = query.log.insert(message);
309 if accepted && counts {
310 query.unread = query.unread.saturating_add(1);
312 }
313 accepted
314 };
315
316 if accepted {
317 alloc::vec![Change::MessageAdded { target: name, key }]
318 } else {
319 Vec::new()
320 }
321}
322
323fn join(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
324 let Some(channel) = line.param(0) else {
325 return Vec::new();
326 };
327 let who = Context::sender(line);
328 let name = channel.to_string();
329 let folded = ctx.fold(&name);
330
331 if ctx.is_me(&who) {
332 ctx.model.channel_or_insert(folded, &name);
333 return alloc::vec![Change::ChannelJoined { channel: name }];
334 }
335
336 let account = line.param(1).filter(|a| *a != "*").map(ToString::to_string);
337 let key = ctx.isupport.fold(&who);
340 if ctx.model.channel(&folded).is_none() {
341 return Vec::new();
342 }
343 let person = ctx.model.person_or_insert(key.clone(), &who);
344 person.nick = who;
345 if account.is_some() {
347 person.account = account;
348 }
349 if let Some(channel) = ctx.model.channel_mut(&folded) {
350 channel.members.entry(key).or_default();
351 }
352 alloc::vec![Change::ChannelMembersChanged { channel: name }]
353}
354
355fn part(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
356 let Some(channel) = line.param(0) else {
357 return Vec::new();
358 };
359 let who = Context::sender(line);
360 let name = channel.to_string();
361 let folded = ctx.fold(&name);
362
363 if ctx.is_me(&who) {
364 ctx.model.remove_channel(&folded);
365 return alloc::vec![Change::ChannelParted { channel: name }];
366 }
367 if let Some(channel) = ctx.model.channel_mut(&folded) {
368 channel.members.remove(&ctx.isupport.fold(&who));
369 }
370 ctx.model.forget_strangers();
371 alloc::vec![Change::ChannelMembersChanged { channel: name }]
372}
373
374fn quit(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
375 let who = Context::sender(line);
376 let folded = ctx.fold(&who);
377 let left = ctx.model.remove_everywhere(&folded);
378 ctx.model.forget_strangers();
379 left.into_iter()
380 .filter_map(|key| {
381 ctx.model
382 .channel(&key)
383 .map(|channel| Change::ChannelMembersChanged {
384 channel: channel.name.clone(),
385 })
386 })
387 .collect()
388}
389
390fn kick(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
391 let (Some(channel), Some(target)) = (line.param(0), line.param(1)) else {
392 return Vec::new();
393 };
394 let name = channel.to_string();
395 let folded = ctx.fold(&name);
396
397 if ctx.is_me(target) {
398 ctx.model.remove_channel(&folded);
399 return alloc::vec![Change::ChannelParted { channel: name }];
400 }
401 let removed = ctx.isupport.fold(target);
402 if let Some(channel) = ctx.model.channel_mut(&folded) {
403 channel.members.remove(&removed);
404 }
405 ctx.model.forget_strangers();
406 alloc::vec![Change::ChannelMembersChanged { channel: name }]
407}
408
409fn nick(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
410 let Some(to) = line.param(0) else {
411 return Vec::new();
412 };
413 let from = Context::sender(line);
414 ctx.model.rename(ctx.isupport.casemapping(), &from, to);
415 alloc::vec![Change::NickChanged {
416 from,
417 to: to.to_string()
418 }]
419}
420
421fn rename_channel(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
423 let (Some(from), Some(to)) = (line.param(0), line.param(1)) else {
424 return Vec::new();
425 };
426 if !ctx.model.rename_channel(ctx.casemapping(), from, to) {
427 return Vec::new();
428 }
429 alloc::vec![Change::ChannelRenamed {
430 from: from.to_string(),
431 to: to.to_string(),
432 }]
433}
434
435pub(crate) fn accumulates(command: &str) -> bool {
445 matches!(
446 command,
447 "311" | "312" | "313" | "317" | "319" | "330" | "338" | "378" | "671"
448 )
449}
450
451fn whois(ctx: &mut Context<'_>, line: &Line, command: &str) -> Vec<Change> {
452 let Some(nick) = line.param(1) else {
453 return Vec::new();
454 };
455 let key = ctx.fold(nick);
456 if command == "318" {
457 let Some(record) = ctx.model.whois_mut(&key) else {
458 return Vec::new();
459 };
460 record.complete = true;
461 return alloc::vec![Change::WhoisReceived {
462 nick: nick.to_string(),
463 }];
464 }
465
466 let text = line.trailing().map(ToString::to_string);
467 let record = ctx.model.whois_or_insert(key, nick);
468 match command {
469 "311" => {
470 record.username = line.param(2).map(ToString::to_string);
471 record.host = line.param(3).map(ToString::to_string);
472 record.realname = text;
473 }
474 "312" => {
475 record.server = line.param(2).map(ToString::to_string);
476 record.server_info = text;
477 }
478 "313" => record.operator = text,
479 "317" => {
480 record.idle_secs = line.param(2).and_then(|secs| secs.parse().ok());
481 record.signon_ms = line
482 .param(3)
483 .and_then(|at| at.parse::<u64>().ok())
484 .map(|seconds| seconds.saturating_mul(1000));
485 }
486 "319" => {
487 record.channels = line
488 .param(2)
489 .unwrap_or_default()
490 .split_whitespace()
491 .map(ToString::to_string)
492 .collect();
493 }
494 "330" => record.account = line.param(2).map(ToString::to_string),
495 "338" | "378" => {
497 record.actual_host = match line.param(3) {
498 Some(_) => line.param(2).map(ToString::to_string),
499 None => text,
500 };
501 }
502 "671" => record.secure = true,
503 _ => {}
504 }
505 Vec::new()
506}
507
508fn topic(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
509 let Some(channel) = line.param(0) else {
510 return Vec::new();
511 };
512 let name = channel.to_string();
513 let folded = ctx.fold(&name);
514 let who = Context::sender(line);
515 let text = line.param(1).unwrap_or_default().to_string();
516 let Some(entry) = ctx.model.channel_mut(&folded) else {
517 return Vec::new();
518 };
519 entry.topic = (!text.is_empty()).then_some(text);
520 entry.topic_by = Some(who);
521 alloc::vec![Change::ChannelTopicChanged { channel: name }]
522}
523
524fn topic_reply(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
525 let Some(channel) = line.param(1) else {
526 return Vec::new();
527 };
528 let name = channel.to_string();
529 let folded = ctx.fold(&name);
530 let text = line.param(2).unwrap_or_default().to_string();
531 let Some(entry) = ctx.model.channel_mut(&folded) else {
532 return Vec::new();
533 };
534 entry.topic = (!text.is_empty()).then_some(text);
535 alloc::vec![Change::ChannelTopicChanged { channel: name }]
536}
537
538fn names(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
539 let Some(channel) = line.param(2) else {
541 return Vec::new();
542 };
543 let name = channel.to_string();
544 let folded = ctx.fold(&name);
545 let entries: Vec<String> = line
546 .trailing()
547 .unwrap_or_default()
548 .split_whitespace()
549 .map(ToString::to_string)
550 .collect();
551
552 for entry in entries {
553 let (prefixes, nick) = ctx.isupport.prefix().split(&entry);
554 let nick = nick.split('!').next().unwrap_or(nick);
556 if nick.is_empty() {
557 continue;
558 }
559 let (prefixes, nick, key) = (
560 prefixes.to_string(),
561 nick.to_string(),
562 ctx.isupport.fold(nick),
563 );
564 if ctx.model.channel(&folded).is_none() {
565 return Vec::new();
566 }
567 ctx.model
568 .person_or_insert(key.clone(), &nick)
569 .nick
570 .clone_from(&nick);
571 if let Some(channel) = ctx.model.channel_mut(&folded) {
572 channel.members.entry(key).or_default().prefixes = prefixes;
573 }
574 }
575 alloc::vec![Change::ChannelMembersChanged { channel: name }]
576}
577
578fn reaction(ctx: &mut Context<'_>, line: &Line) -> Option<Vec<Change>> {
582 let (tag, adding) = match (line.tag("+draft/react"), line.tag("+draft/unreact")) {
583 (Some(emoji), _) => (emoji, true),
584 (None, Some(emoji)) => (emoji, false),
585 (None, None) => return None,
586 };
587 let emoji = tag.to_string();
588 let msgid = line
590 .tag("+reply")
591 .or_else(|| line.tag("+draft/reply"))?
592 .to_string();
593 let sender = Context::sender(line);
594 let who = ctx.fold(&sender).into_string();
597 let target = line.param(0)?;
598 let name = target.to_string();
599 let folded = ctx.fold(&name);
600
601 let log = match ctx.model.channel_mut(&folded) {
602 Some(channel) => &mut channel.log,
603 None => &mut ctx.model.conversation_mut(&folded)?.log,
604 };
605 let message = log.get_mut(&msgid)?;
606 let reactors = message.reactions.entry(emoji.clone()).or_default();
607 if adding {
608 if !reactors.contains(&who) {
609 reactors.push(who);
610 }
611 } else {
612 reactors.retain(|reactor| *reactor != who);
613 if reactors.is_empty() {
614 message.reactions.remove(&emoji);
615 }
616 }
617 Some(alloc::vec![Change::MessageReacted {
618 target: name,
619 msgid
620 }])
621}
622
623#[cfg(feature = "obby")]
628fn link_preview(ctx: &mut Context<'_>, line: &Line) -> Option<Vec<Change>> {
629 let (msgid, preview) = crate::extensions::LinkPreview::parse(line)?;
630 let mut changes = Vec::new();
634 if let Some(name) = line.param(0) {
635 let name = name.to_string();
636 let folded = ctx.fold(&name);
637 let log = match ctx.model.channel_mut(&folded) {
638 Some(channel) => Some(&mut channel.log),
639 None => ctx
640 .model
641 .conversation_mut(&folded)
642 .map(|query| &mut query.log),
643 };
644 if let Some(log) = log
645 && let Some(message) = log.get_mut(&msgid)
646 {
647 message.link_preview = Some(preview);
648 let key = message.key;
649 changes.push(Change::MessageAdded { target: name, key });
650 }
651 }
652 Some(changes)
653}
654
655fn redact(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
660 let (Some(target), Some(msgid)) = (line.param(0), line.param(1)) else {
661 return Vec::new();
662 };
663 let name = target.to_string();
664 let folded = ctx.fold(&name);
665 let msgid = msgid.to_string();
666
667 let Some(log) = (match ctx.model.channel_mut(&folded) {
668 Some(channel) => Some(&mut channel.log),
669 None => ctx.model.conversation_mut(&folded).map(|q| &mut q.log),
670 }) else {
671 return Vec::new();
672 };
673 let Some(message) = log.get_mut(&msgid) else {
674 return Vec::new();
675 };
676 message.redacted = true;
677 alloc::vec![Change::MessageRedacted {
678 target: name,
679 msgid
680 }]
681}
682
683fn metadata(ctx: &mut Context<'_>, line: &Line, offset: usize) -> Vec<Change> {
690 let (Some(target), Some(key)) = (line.param(offset), line.param(offset + 1)) else {
691 return Vec::new();
692 };
693 let target = if target == "*" {
695 ctx.model.me.nick.clone()
696 } else {
697 target.to_string()
698 };
699 let key = key.to_string();
700 let value = line
702 .param(offset + 3)
703 .filter(|_| !line.is("766"))
704 .map(ToString::to_string);
705 let folded = ctx.fold(&target);
706
707 let apply = |store: &mut BTreeMap<String, String>| match &value {
708 Some(value) => {
709 store.insert(key.clone(), value.clone());
710 }
711 None => {
712 store.remove(&key);
713 }
714 };
715
716 if ctx.isupport.is_channel(&target) {
717 let Some(channel) = ctx.model.channel_mut(&folded) else {
718 return Vec::new();
719 };
720 apply(&mut channel.metadata);
721 return alloc::vec![Change::MetadataChanged { target, key }];
722 }
723
724 apply(&mut ctx.model.person_or_insert(folded, &target).metadata);
725 if ctx.is_me(&target) {
726 apply(&mut ctx.model.me.metadata);
727 }
728 alloc::vec![Change::MetadataChanged { target, key }]
729}
730
731fn away(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
733 let reason = line.param(0).map(ToString::to_string);
734 person_changed(ctx, line, |person| person.away.clone_from(&reason))
735}
736
737fn account(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
739 let name = line.param(0).filter(|a| *a != "*").map(ToString::to_string);
740 person_changed(ctx, line, |person| person.account.clone_from(&name))
741}
742
743fn person_changed(
745 ctx: &mut Context<'_>,
746 line: &Line,
747 change: impl FnOnce(&mut crate::model::Person),
748) -> Vec<Change> {
749 let who = Context::sender(line);
750 let key = ctx.fold(&who);
751 change(ctx.model.person_or_insert(key.clone(), &who));
752 if ctx.is_me(&who) {
753 let person = ctx.model.person(&key).cloned().unwrap_or_default();
754 ctx.model.me.away = person.away;
755 ctx.model.me.account = person.account;
756 }
757 ctx.model
758 .channels()
759 .filter(|(_, channel)| channel.members.contains_key(&key))
760 .map(|(_, channel)| Change::ChannelMembersChanged {
761 channel: channel.name.clone(),
762 })
763 .collect()
764}
765
766fn mark_read(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
771 let (Some(target), Some(timestamp)) = (line.param(0), line.param(1)) else {
772 return Vec::new();
773 };
774 let marker = timestamp
775 .strip_prefix("timestamp=")
776 .unwrap_or(timestamp)
777 .to_string();
778 let folded = ctx.fold(target);
779 let name = target.to_string();
780
781 if let Some(channel) = ctx.model.channel_mut(&folded) {
782 channel.read_marker = Some(marker);
783 channel.unread = 0;
784 channel.mentions = 0;
785 return alloc::vec![Change::ReadMarkerMoved { target: name }];
786 }
787 if let Some(query) = ctx.model.conversation_mut(&folded) {
788 query.read_marker = Some(marker);
789 query.unread = 0;
790 return alloc::vec![Change::ReadMarkerMoved { target: name }];
791 }
792 Vec::new()
793}
794
795fn who_reply(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
800 let (Some(channel), Some(user), Some(host), Some(nick), Some(flags)) = (
801 line.param(1),
802 line.param(2),
803 line.param(3),
804 line.param(5),
805 line.param(6),
806 ) else {
807 return Vec::new();
808 };
809 let realname = line
811 .trailing()
812 .and_then(|trailing| trailing.split_once(' '))
813 .map(|(_, realname)| realname.to_string());
814 apply_who(
815 ctx,
816 WhoRow {
817 channel,
818 nick,
819 user,
820 host,
821 flags,
822 account: Account::Unknown,
823 realname,
824 },
825 )
826}
827
828fn whox_reply(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
832 if line.param(1) != Some(WHOX_TOKEN) {
833 return Vec::new();
834 }
835 let (Some(channel), Some(user), Some(host), Some(nick), Some(flags), Some(account)) = (
836 line.param(2),
837 line.param(3),
838 line.param(4),
839 line.param(5),
840 line.param(6),
841 line.param(7),
842 ) else {
843 return Vec::new();
844 };
845 let account = if account == "0" || account == "*" {
846 Account::LoggedOut
847 } else {
848 Account::As(account.to_string())
849 };
850 apply_who(
851 ctx,
852 WhoRow {
853 channel,
854 nick,
855 user,
856 host,
857 flags,
858 account,
859 realname: line.param(8).map(ToString::to_string),
860 },
861 )
862}
863
864struct WhoRow<'a> {
866 channel: &'a str,
867 nick: &'a str,
868 user: &'a str,
869 host: &'a str,
870 flags: &'a str,
871 account: Account,
872 realname: Option<String>,
873}
874
875enum Account {
880 Unknown,
882 LoggedOut,
884 As(String),
886}
887
888fn apply_who(ctx: &mut Context<'_>, row: WhoRow<'_>) -> Vec<Change> {
890 let key = ctx.fold(row.nick);
891 let prefixes: String = row
892 .flags
893 .chars()
894 .filter(|c| ctx.isupport.prefix().rank(*c).is_some())
895 .collect();
896 let away = row.flags.starts_with('G');
897
898 let person = ctx.model.person_or_insert(key.clone(), row.nick);
899 person.nick = row.nick.to_string();
900 person.username = Some(row.user.to_string());
901 person.host = Some(row.host.to_string());
902 person.operator = row.flags.contains('*');
903 person.bot = row.flags.contains('B');
904 if let Some(realname) = row.realname {
905 person.realname = Some(realname);
906 }
907 if away {
910 person.away.get_or_insert_with(String::new);
911 } else {
912 person.away = None;
913 }
914 match row.account {
915 Account::Unknown => {}
916 Account::LoggedOut => person.account = None,
917 Account::As(account) => person.account = Some(account),
918 }
919
920 if !ctx.isupport.is_channel(row.channel) {
921 return Vec::new();
922 }
923 let name = row.channel.to_string();
924 let folded = ctx.fold(&name);
925 let Some(entry) = ctx.model.channel_mut(&folded) else {
926 return Vec::new();
927 };
928 entry.members.entry(key).or_default().prefixes = prefixes;
929 alloc::vec![Change::ChannelMembersChanged { channel: name }]
930}
931
932fn prop(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
938 let Some(target) = line.param(0) else {
939 return Vec::new();
940 };
941 let name = target.to_string();
942 let folded = ctx.fold(&name);
943 let changes: Vec<String> = line.params.iter().skip(1).cloned().collect();
944 if !apply_named(ctx, &folded, changes.iter().map(String::as_str)) {
945 return Vec::new();
946 }
947 alloc::vec![Change::ChannelModesChanged { channel: name }]
948}
949
950fn prop_list(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
954 let Some(target) = line.param(1) else {
955 return Vec::new();
956 };
957 let name = target.to_string();
958 let folded = ctx.fold(&name);
959 let entries: Vec<String> = line
960 .params
961 .iter()
962 .skip(2)
963 .flat_map(|param| param.split_whitespace())
964 .map(|entry| alloc::format!("+{entry}"))
965 .collect();
966 if !apply_named(ctx, &folded, entries.iter().map(String::as_str)) {
967 return Vec::new();
968 }
969 alloc::vec![Change::ChannelModesChanged { channel: name }]
970}
971
972fn apply_named<'a>(
977 ctx: &mut Context<'_>,
978 folded: &CaseFolded,
979 entries: impl Iterator<Item = &'a str>,
980) -> bool {
981 let Some(channel) = ctx.model.channel_mut(folded) else {
982 return false;
983 };
984 for entry in entries {
985 if let Some(rest) = entry.strip_prefix('+') {
986 let (name, param) = rest.split_once('=').map_or((rest, None), |(name, param)| {
987 (name, Some(param.to_string()))
988 });
989 channel.named_modes.insert(name.to_string(), param);
990 } else if let Some(name) = entry.strip_prefix('-') {
991 let name = name.split('=').next().unwrap_or(name);
993 channel.named_modes.remove(name);
994 }
995 }
996 true
997}
998
999fn mode(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
1000 let Some(target) = line.param(0) else {
1001 return Vec::new();
1002 };
1003 if !ctx.isupport.is_channel(target) {
1004 for change in obby_proto::parse_user_modes(line.param(1).unwrap_or_default()) {
1005 if change.set {
1006 if !ctx.model.me.modes.contains(change.mode) {
1007 ctx.model.me.modes.push(change.mode);
1008 }
1009 } else {
1010 ctx.model.me.modes.retain(|m| m != change.mode);
1011 }
1012 }
1013 return Vec::new();
1014 }
1015
1016 let name = target.to_string();
1017 let folded = ctx.fold(&name);
1018 let args: Vec<String> = line.params.iter().skip(1).cloned().collect();
1019 let changes = parse_channel_modes(ctx.isupport, &args);
1020 let order = ctx.isupport.prefix().clone();
1021
1022 let mut touched_members = false;
1023 for change in changes {
1024 if change.membership {
1025 let Some(nick) = change.arg else { continue };
1026 let Some(prefix) = order.char_for_mode(change.mode) else {
1027 continue;
1028 };
1029 let key = ctx.isupport.fold(&nick);
1030 if let Some(member) = ctx
1031 .model
1032 .channel_mut(&folded)
1033 .and_then(|channel| channel.members.get_mut(&key))
1034 {
1035 if change.set {
1036 member.grant(prefix, &order);
1037 } else {
1038 member.revoke(prefix);
1039 }
1040 touched_members = true;
1041 }
1042 continue;
1043 }
1044 let Some(channel) = ctx.model.channel_mut(&folded) else {
1045 continue;
1046 };
1047 if change.set {
1048 channel.modes.insert(change.mode, change.arg);
1049 } else {
1050 channel.modes.remove(&change.mode);
1051 }
1052 }
1053
1054 if touched_members {
1055 alloc::vec![
1056 Change::ChannelModesChanged {
1057 channel: name.clone()
1058 },
1059 Change::ChannelMembersChanged { channel: name },
1060 ]
1061 } else {
1062 alloc::vec![Change::ChannelModesChanged { channel: name }]
1063 }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068 use super::*;
1069 use crate::model::MessageKind;
1070
1071 struct Harness {
1072 model: Model,
1073 isupport: Isupport,
1074 latest_ms: u64,
1075 historical: bool,
1076 }
1077
1078 impl Harness {
1079 fn new() -> Self {
1080 let mut isupport = Isupport::default();
1081 isupport.apply("CHANTYPES=#^$");
1082 isupport.apply("PREFIX=(qaohv)~&@%+");
1083 isupport.apply("CHANMODES=beI,k,l,imnpst");
1084 let mut model = Model::default();
1085 model.me.nick = "me".to_string();
1086 Self {
1087 model,
1088 isupport,
1089 latest_ms: 1000,
1090 historical: false,
1091 }
1092 }
1093
1094 fn joined(mut self, channel: &str) -> Self {
1096 self.feed(&alloc::format!(":me!u@h JOIN {channel}"));
1097 self
1098 }
1099
1100 fn feed(&mut self, raw: &str) -> Vec<Change> {
1101 let line = Line::parse(raw).expect("the test line should parse");
1102 let mut ctx = Context {
1103 model: &mut self.model,
1104 isupport: &self.isupport,
1105 latest_ms: &mut self.latest_ms,
1106 historical: self.historical,
1107 };
1108 apply(&mut ctx, &line)
1109 }
1110
1111 fn channel(&self, name: &str) -> &crate::model::Channel {
1112 self.model
1113 .channel(&self.isupport.fold(name))
1114 .expect("the channel should exist")
1115 }
1116
1117 fn person(&self, nick: &str) -> &crate::model::Person {
1118 self.model
1119 .person(&self.isupport.fold(nick))
1120 .expect("we should know this person")
1121 }
1122
1123 fn conversation(&self, nick: &str) -> &crate::model::Conversation {
1124 self.model
1125 .conversation(&self.isupport.fold(nick))
1126 .expect("the conversation should exist")
1127 }
1128 }
1129
1130 impl Harness {
1131 fn whois(&self, nick: &str) -> &crate::model::Whois {
1132 self.model
1133 .whois(&self.isupport.fold(nick))
1134 .expect("the whois record should exist")
1135 }
1136 }
1137
1138 #[test]
1139 fn a_whois_reply_is_reported_once_it_is_whole() {
1140 let mut h = Harness::new();
1141 for line in [
1142 ":s 311 me bob ident host.example * :Bob Smith",
1143 ":s 312 me bob irc.example.org :ObbyIRCd",
1144 ":s 313 me bob :is an IRC Operator",
1145 ":s 378 me bob :is connecting from *@h4ks.local 172.18.0.1",
1146 ":s 671 me bob :is using a Secure Connection",
1147 ":s 319 me bob :~@#obby +#other",
1148 ":s 330 me bob bob_acct :is logged in as",
1149 ":s 317 me bob 42 1787477054 :seconds idle, signon time",
1150 ] {
1151 assert!(
1152 h.feed(line).is_empty(),
1153 "a partial record would redraw the card once per numeric"
1154 );
1155 }
1156
1157 let changes = h.feed(":s 318 me bob :End of /WHOIS list.");
1158 assert!(matches!(changes.first(), Some(Change::WhoisReceived { nick }) if nick == "bob"));
1159
1160 let whois = h.whois("BOB");
1161 assert_eq!(whois.username.as_deref(), Some("ident"));
1162 assert_eq!(whois.host.as_deref(), Some("host.example"));
1163 assert_eq!(whois.realname.as_deref(), Some("Bob Smith"));
1164 assert_eq!(whois.server.as_deref(), Some("irc.example.org"));
1165 assert_eq!(whois.server_info.as_deref(), Some("ObbyIRCd"));
1166 assert_eq!(whois.operator.as_deref(), Some("is an IRC Operator"));
1167 assert_eq!(whois.account.as_deref(), Some("bob_acct"));
1168 assert_eq!(whois.channels, ["~@#obby", "+#other"]);
1169 assert_eq!(whois.idle_secs, Some(42));
1170 assert_eq!(whois.signon_ms, Some(1_787_477_054_000));
1171 assert!(whois.secure);
1172 assert!(whois.complete);
1173 }
1174
1175 #[test]
1176 fn a_second_whois_replaces_the_first_rather_than_merging_into_it() {
1177 let mut h = Harness::new();
1178 h.feed(":s 313 me bob :is an IRC Operator");
1179 h.feed(":s 671 me bob :is using a Secure Connection");
1180 h.feed(":s 318 me bob :End of /WHOIS list.");
1181
1182 h.feed(":s 311 me bob ident host.example * :Bob Smith");
1183 h.feed(":s 318 me bob :End of /WHOIS list.");
1184 let whois = h.whois("bob");
1185 assert_eq!(
1186 whois.operator, None,
1187 "a deopered user sends no 313 to say they lost it"
1188 );
1189 assert!(!whois.secure);
1190 }
1191
1192 #[test]
1193 fn the_end_of_a_whois_for_a_nick_with_no_record_reports_nothing() {
1194 let mut h = Harness::new();
1195 assert!(h.feed(":s 318 me nobody :End of /WHOIS list.").is_empty());
1196 }
1197
1198 #[test]
1199 fn a_338_names_the_host_in_its_own_parameter() {
1200 let mut h = Harness::new();
1201 h.feed(":s 338 me bob real.host.example :actually using host");
1202 assert_eq!(
1203 h.whois("bob").actual_host.as_deref(),
1204 Some("real.host.example")
1205 );
1206 }
1207
1208 #[test]
1209 fn a_rename_moves_the_channel_and_keeps_what_is_in_it() {
1210 let mut h = Harness::new().joined("#obby");
1211 h.feed(":bob!u@h JOIN #obby");
1212 h.feed(":bob!u@h PRIVMSG #obby :hello");
1213
1214 let changes = h.feed(":s RENAME #obby #obby-world :tidying up");
1215 assert!(
1216 matches!(changes.first(), Some(Change::ChannelRenamed { from, to }) if from == "#obby" && to == "#obby-world")
1217 );
1218 assert!(
1219 h.model.channel(&h.isupport.fold("#obby")).is_none(),
1220 "the old name must not still resolve"
1221 );
1222 let renamed = h.channel("#obby-world");
1223 assert_eq!(renamed.name, "#obby-world");
1224 assert!(renamed.members.contains_key(&h.isupport.fold("bob")));
1225 assert_eq!(renamed.log.last().map(|m| m.text.as_str()), Some("hello"));
1226 }
1227
1228 #[test]
1229 fn a_rename_of_a_channel_we_are_not_in_changes_nothing() {
1230 let mut h = Harness::new();
1231 assert!(h.feed(":s RENAME #theirs #theirs-too").is_empty());
1232 assert_eq!(h.model.channels().count(), 0);
1233 }
1234
1235 #[test]
1236 fn a_message_to_a_channel_we_never_joined_invents_nothing() {
1237 let mut h = Harness::new();
1238 assert!(h.feed(":bob!u@h PRIVMSG #ghost :hello").is_empty());
1239 assert_eq!(
1240 h.model.channels().count(),
1241 0,
1242 "a server naming channels we are not in would otherwise grow the model without limit"
1243 );
1244 }
1245
1246 #[test]
1247 fn a_channel_message_lands_in_that_channel() {
1248 let mut h = Harness::new().joined("#obby");
1249 let changes = h.feed(":bob!u@h PRIVMSG #obby :hello");
1250 assert!(
1251 matches!(changes.first(), Some(Change::MessageAdded { target, .. }) if target == "#obby")
1252 );
1253 let message = h.channel("#obby").log.last().expect("a message");
1254 assert_eq!(message.text, "hello");
1255 assert_eq!(message.sender, "bob");
1256 assert!(!message.own);
1257 }
1258
1259 #[test]
1260 fn a_private_message_is_filed_under_the_sender_not_under_us() {
1261 let mut h = Harness::new();
1262 h.feed(":bob!u@h PRIVMSG me :psst");
1263 assert_eq!(h.conversation("bob").log.len(), 1);
1264 assert!(
1265 h.model.conversation(&h.isupport.fold("me")).is_none(),
1266 "a conversation named after ourselves would be nobody"
1267 );
1268 }
1269
1270 #[test]
1271 fn our_own_private_message_is_filed_under_who_we_sent_it_to() {
1272 let mut h = Harness::new();
1273 h.feed(":me!u@h PRIVMSG bob :hi there");
1274 let message = h.conversation("bob").log.last().expect("a message");
1275 assert!(message.own);
1276 assert_eq!(message.text, "hi there");
1277 }
1278
1279 #[test]
1280 fn joining_creates_the_channel_and_others_joining_add_members() {
1281 let mut h = Harness::new();
1282 assert_eq!(
1283 h.feed(":me!u@h JOIN #obby"),
1284 [Change::ChannelJoined {
1285 channel: "#obby".to_string()
1286 }]
1287 );
1288 h.feed(":bob!u@h JOIN #obby");
1289 assert_eq!(h.channel("#obby").members.len(), 1);
1290 assert_eq!(h.person("bob").nick.as_str(), "bob");
1291 }
1292
1293 #[test]
1294 fn extended_join_carries_the_account_without_a_who() {
1295 let mut h = Harness::new().joined("#obby");
1296 h.feed(":bob!u@h JOIN #obby alice :Bob");
1297 assert_eq!(h.person("bob").account.as_deref(), Some("alice"));
1298 }
1299
1300 #[test]
1301 fn an_unauthenticated_join_records_no_account() {
1302 let mut h = Harness::new().joined("#obby");
1303 h.feed(":bob!u@h JOIN #obby * :Bob");
1304 assert_eq!(
1305 h.person("bob").account,
1306 None,
1307 "a star means logged in as nobody"
1308 );
1309 }
1310
1311 #[test]
1312 fn leaving_forgets_the_channel_but_someone_else_leaving_does_not() {
1313 let mut h = Harness::new().joined("#obby");
1314 h.feed(":bob!u@h JOIN #obby");
1315 h.feed(":bob!u@h PART #obby :bye");
1316 assert!(h.channel("#obby").members.is_empty());
1317
1318 h.feed(":me!u@h PART #obby :bye");
1319 assert!(h.model.channel(&h.isupport.fold("#obby")).is_none());
1320 }
1321
1322 #[test]
1323 fn being_kicked_forgets_the_channel() {
1324 let mut h = Harness::new().joined("#obby");
1325 h.feed(":bob!u@h JOIN #obby");
1326 h.feed(":op!u@h KICK #obby bob :rude");
1327 assert!(h.channel("#obby").members.is_empty());
1328
1329 h.feed(":op!u@h KICK #obby me :rude");
1330 assert!(h.model.channel(&h.isupport.fold("#obby")).is_none());
1331 }
1332
1333 #[test]
1334 fn a_quit_reports_every_channel_they_were_in() {
1335 let mut h = Harness::new().joined("#a").joined("#b");
1336 for name in ["#a", "#b"] {
1337 h.feed(&alloc::format!(":bob!u@h JOIN {name}"));
1338 }
1339 let changes = h.feed(":bob!u@h QUIT :gone");
1340 assert_eq!(changes.len(), 2);
1341 assert!(h.channel("#a").members.is_empty());
1342 assert!(h.channel("#b").members.is_empty());
1343 }
1344
1345 #[test]
1346 fn a_rename_follows_the_servers_casemapping() {
1347 let mut h = Harness::new().joined("#obby");
1348 h.feed(":[bob]!u@h JOIN #obby");
1349 h.feed(":[bob]!u@h NICK {BOB}");
1350 let channel = h.channel("#obby");
1351 assert_eq!(
1352 channel.members.len(),
1353 1,
1354 "rfc1459 folds braces onto brackets, so this is one person"
1355 );
1356 assert!(channel.members.contains_key(&h.isupport.fold("{bob}")));
1357 assert_eq!(h.person("{bob}").nick, "{BOB}");
1358 }
1359
1360 #[test]
1361 fn names_splits_prefixes_and_hostmasks() {
1362 let mut h = Harness::new().joined("#obby");
1363 h.feed(":s 353 me = #obby :~&@alice +bob carol!u@host");
1364 let channel = h.channel("#obby");
1365 assert_eq!(channel.members.len(), 3);
1366 assert_eq!(
1367 channel
1368 .members
1369 .get(&h.isupport.fold("alice"))
1370 .map(|m| m.prefixes.as_str()),
1371 Some("~&@")
1372 );
1373 assert_eq!(
1374 channel
1375 .members
1376 .get(&h.isupport.fold("bob"))
1377 .map(|m| m.prefixes.as_str()),
1378 Some("+")
1379 );
1380 assert!(
1381 channel.members.contains_key(&h.isupport.fold("carol")),
1382 "userhost-in-names must not become part of the nick"
1383 );
1384 }
1385
1386 #[test]
1387 fn a_membership_mode_moves_a_prefix_and_a_channel_mode_does_not() {
1388 let mut h = Harness::new().joined("#obby");
1389 h.feed(":bob!u@h JOIN #obby");
1390 h.feed(":op!u@h MODE #obby +o bob");
1391 assert_eq!(
1392 h.channel("#obby")
1393 .members
1394 .get(&h.isupport.fold("bob"))
1395 .map(|m| m.prefixes.as_str()),
1396 Some("@")
1397 );
1398
1399 h.feed(":op!u@h MODE #obby +b nuisance!*@*");
1400 assert_eq!(
1401 h.channel("#obby")
1402 .members
1403 .get(&h.isupport.fold("bob"))
1404 .map(|m| m.prefixes.as_str()),
1405 Some("@"),
1406 "a ban must not consume the nick argument of a membership mode"
1407 );
1408 assert!(h.channel("#obby").modes.contains_key(&'b'));
1409
1410 h.feed(":op!u@h MODE #obby -o bob");
1411 assert_eq!(
1412 h.channel("#obby")
1413 .members
1414 .get(&h.isupport.fold("bob"))
1415 .map(|m| m.prefixes.as_str()),
1416 Some("")
1417 );
1418 }
1419
1420 #[test]
1421 fn our_own_user_modes_accumulate() {
1422 let mut h = Harness::new();
1423 h.feed(":s MODE me +iw");
1424 assert_eq!(h.model.me.modes, "iw");
1425 h.feed(":s MODE me -i");
1426 assert_eq!(h.model.me.modes, "w");
1427 }
1428
1429 #[test]
1430 fn the_topic_arrives_from_a_command_or_a_numeric() {
1431 let mut h = Harness::new().joined("#obby");
1432 h.feed(":s 332 me #obby :from the numeric");
1433 assert_eq!(
1434 h.channel("#obby").topic.as_deref(),
1435 Some("from the numeric")
1436 );
1437 h.feed(":bob!u@h TOPIC #obby :from the command");
1438 assert_eq!(
1439 h.channel("#obby").topic.as_deref(),
1440 Some("from the command")
1441 );
1442 assert_eq!(h.channel("#obby").topic_by.as_deref(), Some("bob"));
1443 }
1444
1445 #[test]
1446 fn clearing_the_topic_leaves_none_rather_than_an_empty_string() {
1447 let mut h = Harness::new().joined("#obby");
1448 h.feed(":bob!u@h TOPIC #obby :something");
1449 h.feed(":bob!u@h TOPIC #obby :");
1450 assert_eq!(h.channel("#obby").topic, None);
1451 }
1452
1453 #[test]
1454 fn server_time_decides_when_a_message_happened() {
1455 let mut h = Harness::new().joined("#obby");
1456 h.feed("@time=2026-09-06T10:00:00.000Z :bob!u@h PRIVMSG #obby :stamped");
1457 let message = h.channel("#obby").log.last().expect("a message");
1458 assert_eq!(message.key.time_ms, 1_788_688_800_000);
1459 }
1460
1461 #[test]
1462 fn an_unstamped_line_reuses_the_latest_instant_seen() {
1463 let mut h = Harness::new().joined("#obby");
1464 h.feed("@time=2026-09-06T10:00:00.000Z :bob!u@h PRIVMSG #obby :stamped");
1465 h.feed(":bob!u@h PRIVMSG #obby :unstamped");
1466 let messages: Vec<u64> = h
1467 .channel("#obby")
1468 .log
1469 .iter()
1470 .map(|m| m.key.time_ms)
1471 .collect();
1472 assert_eq!(
1473 messages,
1474 [1_788_688_800_000, 1_788_688_800_000],
1475 "an unstamped line must not sort back before the stamped ones"
1476 );
1477 }
1478
1479 #[test]
1480 fn a_ctcp_keeps_its_command_and_loses_its_wrapper() {
1481 let mut h = Harness::new().joined("#obby");
1482 h.feed(":bob!u@h PRIVMSG #obby :\u{1}ACTION waves\u{1}");
1483 let message = h.channel("#obby").log.last().expect("a message");
1484 assert_eq!(
1485 message.kind,
1486 MessageKind::Ctcp {
1487 command: "ACTION".to_string()
1488 }
1489 );
1490 assert_eq!(message.text, "waves");
1491 }
1492
1493 #[test]
1494 fn both_spellings_of_the_reply_tag_are_accepted() {
1495 let mut h = Harness::new().joined("#obby");
1496 h.feed("@+reply=abc :bob!u@h PRIVMSG #obby :one");
1497 h.feed("@+draft/reply=def :bob!u@h PRIVMSG #obby :two");
1498 let replies: Vec<Option<&str>> = h
1499 .channel("#obby")
1500 .log
1501 .iter()
1502 .map(|m| m.reply_to.as_deref())
1503 .collect();
1504 assert_eq!(
1505 replies,
1506 [Some("abc"), Some("def")],
1507 "the server sends one spelling from filehost and the other from pushbot"
1508 );
1509 }
1510
1511 #[test]
1512 fn a_repeated_msgid_is_only_stored_once() {
1513 let mut h = Harness::new().joined("#obby");
1514 h.feed("@msgid=x1 :bob!u@h PRIVMSG #obby :hello");
1515 h.feed("@msgid=x1 :bob!u@h PRIVMSG #obby :hello");
1516 assert_eq!(h.channel("#obby").log.len(), 1);
1517 }
1518
1519 #[test]
1520 fn a_mention_needs_a_whole_nick_not_a_substring() {
1521 let map = Casemapping::Rfc1459;
1522 assert!(mentions(map, "hey me, look", "me"));
1523 assert!(mentions(map, "me: look", "me"));
1524 assert!(mentions(map, "me", "me"));
1525 assert!(
1526 !mentions(map, "spameda", "me"),
1527 "a nick inside a word is not a mention"
1528 );
1529 assert!(!mentions(map, "me-too", "me"), "a dash is a nick character");
1530 assert!(!mentions(map, "", "me"));
1531 assert!(!mentions(map, "anything", ""));
1532 }
1533
1534 #[test]
1535 fn a_mention_folds_the_way_the_server_does() {
1536 assert!(
1537 mentions(Casemapping::Rfc1459, "hey {NICK} there", "[nick]"),
1538 "rfc1459 folds braces onto brackets, so this addresses us"
1539 );
1540 assert!(
1541 !mentions(Casemapping::Ascii, "hey {NICK} there", "[nick]"),
1542 "an ascii server treats them as different people"
1543 );
1544 assert!(mentions(Casemapping::Ascii, "hey NICK there", "nick"));
1545 }
1546
1547 #[test]
1548 fn a_channel_message_counts_as_unread_and_a_mention_only_when_it_names_us() {
1549 let mut h = Harness::new().joined("#obby");
1550 h.feed(":bob!u@h PRIVMSG #obby :morning all");
1551 assert_eq!(
1552 (h.channel("#obby").unread, h.channel("#obby").mentions),
1553 (1, 0)
1554 );
1555
1556 h.feed(":bob!u@h PRIVMSG #obby :me: got a second?");
1557 assert_eq!(
1558 (h.channel("#obby").unread, h.channel("#obby").mentions),
1559 (2, 1)
1560 );
1561 }
1562
1563 #[test]
1564 fn our_own_words_and_replayed_history_are_never_unread() {
1565 let mut h = Harness::new().joined("#obby");
1566 h.feed(":me!u@h PRIVMSG #obby :me talking about me");
1567 assert_eq!(
1568 (h.channel("#obby").unread, h.channel("#obby").mentions),
1569 (0, 0)
1570 );
1571
1572 h.historical = true;
1573 h.feed(":bob!u@h PRIVMSG #obby :me, an old message");
1574 assert_eq!(
1575 (h.channel("#obby").unread, h.channel("#obby").mentions),
1576 (0, 0),
1577 "scrolling back must not light up the unread badge"
1578 );
1579 }
1580
1581 #[test]
1582 fn every_private_message_is_addressed_to_us_by_existing() {
1583 let mut h = Harness::new();
1584 h.feed(":bob!u@h PRIVMSG me :no nick needed");
1585 assert_eq!(h.conversation("bob").unread, 1);
1586 }
1587
1588 #[test]
1589 fn the_server_confirming_a_read_marker_clears_what_it_covers() {
1590 let mut h = Harness::new().joined("#obby");
1591 h.feed(":bob!u@h PRIVMSG #obby :me: hello");
1592 assert_eq!(
1593 (h.channel("#obby").unread, h.channel("#obby").mentions),
1594 (1, 1)
1595 );
1596
1597 let changes = h.feed(":s MARKREAD #obby timestamp=2026-09-06T10:00:00.000Z");
1598 assert_eq!(
1599 changes,
1600 [Change::ReadMarkerMoved {
1601 target: "#obby".to_string()
1602 }]
1603 );
1604 assert_eq!(
1605 (h.channel("#obby").unread, h.channel("#obby").mentions),
1606 (0, 0)
1607 );
1608 assert_eq!(
1609 h.channel("#obby").read_marker.as_deref(),
1610 Some("2026-09-06T10:00:00.000Z")
1611 );
1612 }
1613
1614 #[test]
1615 fn a_reaction_attaches_to_the_message_rather_than_becoming_one() {
1616 let mut h = Harness::new().joined("#obby");
1617 h.feed("@msgid=m1 :bob!u@h PRIVMSG #obby :something");
1618 let changes = h.feed("@+draft/react=👍;+draft/reply=m1 :carol!u@h TAGMSG #obby");
1619 assert_eq!(
1620 changes,
1621 [Change::MessageReacted {
1622 target: "#obby".to_string(),
1623 msgid: "m1".to_string()
1624 }]
1625 );
1626 assert_eq!(
1627 h.channel("#obby").log.len(),
1628 1,
1629 "a reaction is not a message in the log"
1630 );
1631 let message = h.channel("#obby").log.get("m1").expect("the message");
1632 assert_eq!(
1633 message.reactions.get("👍").map(Vec::as_slice),
1634 Some(&["carol".to_string()][..])
1635 );
1636 }
1637
1638 #[test]
1639 fn reacting_twice_counts_once_and_taking_it_back_removes_it() {
1640 let mut h = Harness::new().joined("#obby");
1641 h.feed("@msgid=m1 :bob!u@h PRIVMSG #obby :something");
1642 h.feed("@+draft/react=👍;+reply=m1 :carol!u@h TAGMSG #obby");
1643 h.feed("@+draft/react=👍;+reply=m1 :carol!u@h TAGMSG #obby");
1644 assert_eq!(
1645 h.channel("#obby")
1646 .log
1647 .get("m1")
1648 .expect("message")
1649 .reactions
1650 .get("👍")
1651 .map(Vec::len),
1652 Some(1)
1653 );
1654
1655 h.feed("@+draft/unreact=👍;+reply=m1 :carol!u@h TAGMSG #obby");
1656 assert!(
1657 h.channel("#obby")
1658 .log
1659 .get("m1")
1660 .expect("message")
1661 .reactions
1662 .is_empty(),
1663 "the last reactor leaving takes the emoji with them"
1664 );
1665 }
1666
1667 #[test]
1668 fn a_redaction_marks_the_message_and_keeps_what_it_said() {
1669 let mut h = Harness::new().joined("#obby");
1670 h.feed("@msgid=m1 :bob!u@h PRIVMSG #obby :regrettable");
1671 let changes = h.feed(":op!u@h REDACT #obby m1 :spam");
1672 assert_eq!(
1673 changes,
1674 [Change::MessageRedacted {
1675 target: "#obby".to_string(),
1676 msgid: "m1".to_string()
1677 }]
1678 );
1679 let message = h.channel("#obby").log.get("m1").expect("the message stays");
1680 assert!(message.redacted);
1681 assert_eq!(
1682 message.text, "regrettable",
1683 "discarding the content leaves no way to show who deleted what"
1684 );
1685 }
1686
1687 #[test]
1688 fn going_away_shows_on_every_member_list_they_are_in() {
1689 let mut h = Harness::new().joined("#a").joined("#b");
1690 h.feed(":bob!u@h JOIN #a");
1691 h.feed(":bob!u@h JOIN #b");
1692 h.feed(":bob!u@h AWAY :back later");
1693 for name in ["#a", "#b"] {
1694 assert!(
1695 h.channel(name)
1696 .members
1697 .contains_key(&h.isupport.fold("bob"))
1698 );
1699 }
1700 assert_eq!(
1701 h.person("bob").away.as_deref(),
1702 Some("back later"),
1703 "away is recorded once, not once per channel"
1704 );
1705
1706 h.feed(":bob!u@h AWAY");
1707 assert_eq!(
1708 h.model
1709 .person(&h.isupport.fold("bob"))
1710 .and_then(|p| p.away.as_deref()),
1711 None
1712 );
1713 }
1714
1715 #[test]
1716 fn logging_into_an_account_updates_everywhere_they_are() {
1717 let mut h = Harness::new().joined("#obby");
1718 h.feed(":bob!u@h JOIN #obby");
1719 h.feed(":bob!u@h ACCOUNT alice");
1720 assert_eq!(
1721 h.model
1722 .person(&h.isupport.fold("bob"))
1723 .and_then(|p| p.account.as_deref()),
1724 Some("alice")
1725 );
1726
1727 h.feed(":bob!u@h ACCOUNT *");
1728 assert_eq!(
1729 h.model
1730 .person(&h.isupport.fold("bob"))
1731 .and_then(|p| p.account.as_deref()),
1732 None,
1733 "a star means logged out"
1734 );
1735 }
1736
1737 #[test]
1738 fn our_own_away_and_account_land_on_us() {
1739 let mut h = Harness::new();
1740 h.feed(":me!u@h AWAY :lunch");
1741 assert_eq!(h.model.me.away.as_deref(), Some("lunch"));
1742 h.feed(":me!u@h ACCOUNT myaccount");
1743 assert_eq!(h.model.me.account.as_deref(), Some("myaccount"));
1744 }
1745
1746 #[test]
1747 fn a_tagmsg_carrying_nothing_we_model_is_still_a_message() {
1748 let mut h = Harness::new().joined("#obby");
1749 h.feed("@+example.org/thing=1 :bob!u@h TAGMSG #obby");
1750 assert_eq!(h.channel("#obby").log.len(), 1);
1751 }
1752
1753 #[test]
1754 fn metadata_about_a_person_is_held_once_for_them() {
1755 let mut h = Harness::new().joined("#a").joined("#b");
1756 h.feed(":bob!u@h JOIN #a");
1757 h.feed(":bob!u@h JOIN #b");
1758 let changes = h.feed(":s METADATA bob display-name * :Bobby Tables");
1759 assert_eq!(
1760 changes,
1761 [Change::MetadataChanged {
1762 target: "bob".to_string(),
1763 key: "display-name".to_string()
1764 }]
1765 );
1766 assert_eq!(
1767 h.person("bob")
1768 .metadata
1769 .get("display-name")
1770 .map(String::as_str),
1771 Some("Bobby Tables"),
1772 "one person, one record, however many channels we share"
1773 );
1774 }
1775
1776 #[test]
1777 fn a_key_with_no_value_clears_it() {
1778 let mut h = Harness::new();
1779 h.feed(":s METADATA bob avatar * :https://example.org/a.png");
1780 assert!(h.person("bob").metadata.contains_key("avatar"));
1781 h.feed(":s METADATA bob avatar *");
1782 assert!(
1783 !h.person("bob").metadata.contains_key("avatar"),
1784 "a push with no value is how the server deletes a key"
1785 );
1786 }
1787
1788 #[test]
1789 fn the_not_set_reply_clears_a_key_too() {
1790 let mut h = Harness::new();
1791 h.feed(":s 761 me bob color * :#ff0000");
1792 assert_eq!(
1793 h.person("bob").metadata.get("color").map(String::as_str),
1794 Some("#ff0000")
1795 );
1796 h.feed(":s 766 me bob color :no matching key");
1797 assert!(!h.person("bob").metadata.contains_key("color"));
1798 }
1799
1800 #[test]
1801 fn a_numeric_reply_reads_past_our_own_nick() {
1802 let mut h = Harness::new();
1803 h.feed(":s 761 me bob display-name * :Bobby");
1804 assert_eq!(
1805 h.person("bob")
1806 .metadata
1807 .get("display-name")
1808 .map(String::as_str),
1809 Some("Bobby"),
1810 "the numeric puts our nick first, so the target is one parameter along"
1811 );
1812 }
1813
1814 #[test]
1815 fn channel_metadata_lands_on_the_channel_not_on_a_person() {
1816 let mut h = Harness::new().joined("#obby");
1817 h.feed(":s METADATA #obby avatar * :https://example.org/c.png");
1818 assert_eq!(
1819 h.channel("#obby")
1820 .metadata
1821 .get("avatar")
1822 .map(String::as_str),
1823 Some("https://example.org/c.png")
1824 );
1825 assert!(h.model.person(&h.isupport.fold("#obby")).is_none());
1826 }
1827
1828 #[test]
1829 fn a_star_target_means_us() {
1830 let mut h = Harness::new();
1831 h.feed(":s METADATA * display-name * :LocalUser Myself");
1832 assert_eq!(
1833 h.model.me.metadata.get("display-name").map(String::as_str),
1834 Some("LocalUser Myself")
1835 );
1836 }
1837
1838 #[cfg(feature = "obby")]
1839 #[test]
1840 fn a_link_preview_attaches_to_its_message_instead_of_becoming_one() {
1841 let mut h = Harness::new().joined("#obby");
1842 h.feed("@msgid=m1 :bob!u@h PRIVMSG #obby :look at https://example.org");
1843 h.feed(
1844 "@+reply=m1;obsidianirc/link-preview-title=Example;obsidianirc/link-preview-snippet=A\\spage :s TAGMSG #obby",
1845 );
1846 assert_eq!(
1847 h.channel("#obby").log.len(),
1848 1,
1849 "the preview arrives long after the message, and must not become a second one"
1850 );
1851 let preview = h
1852 .channel("#obby")
1853 .log
1854 .get("m1")
1855 .expect("the message")
1856 .link_preview
1857 .as_ref()
1858 .expect("a preview");
1859 assert_eq!(preview.title, "Example");
1860 assert_eq!(preview.snippet.as_deref(), Some("A page"));
1861 }
1862
1863 #[cfg(feature = "obby")]
1864 #[test]
1865 fn a_preview_for_a_message_we_never_saw_is_dropped() {
1866 let mut h = Harness::new().joined("#obby");
1867 h.feed("@+reply=gone;obsidianirc/link-preview-title=Example :s TAGMSG #obby");
1868 assert_eq!(
1869 h.channel("#obby").log.len(),
1870 0,
1871 "there is nothing to attach it to, and it is not a message of its own"
1872 );
1873 }
1874
1875 #[test]
1876 fn a_who_reply_fills_in_who_someone_actually_is() {
1877 let mut h = Harness::new().joined("#obby");
1878 h.feed(":s 352 me #obby ident host.example irc.example bob H@ :0 Bob Smith");
1879 let person = h.person("bob");
1880 assert_eq!(person.username.as_deref(), Some("ident"));
1881 assert_eq!(person.host.as_deref(), Some("host.example"));
1882 assert_eq!(person.realname.as_deref(), Some("Bob Smith"));
1883 assert_eq!(person.away, None, "H means here");
1884 assert_eq!(
1885 h.channel("#obby")
1886 .members
1887 .get(&h.isupport.fold("bob"))
1888 .map(|m| m.prefixes.as_str()),
1889 Some("@")
1890 );
1891 }
1892
1893 #[test]
1894 fn a_plain_who_never_signs_anybody_out() {
1895 let mut h = Harness::new().joined("#obby");
1896 h.feed(":bob!u@h JOIN #obby alice_acct :Bob");
1897 assert_eq!(h.person("bob").account.as_deref(), Some("alice_acct"));
1898
1899 h.feed(":s 352 me #obby ident host.example irc.example bob H :0 Bob");
1900 assert_eq!(
1901 h.person("bob").account.as_deref(),
1902 Some("alice_acct"),
1903 "a plain WHO carries no account field, which is not the same as an empty one"
1904 );
1905 }
1906
1907 #[test]
1908 fn a_whox_reply_carries_the_account() {
1909 let mut h = Harness::new().joined("#obby");
1910 h.feed(":s 354 me 332 #obby ident host.example bob H+ alice_acct :Bob Smith");
1911 let person = h.person("bob");
1912 assert_eq!(person.account.as_deref(), Some("alice_acct"));
1913 assert_eq!(person.realname.as_deref(), Some("Bob Smith"));
1914 assert_eq!(
1915 h.channel("#obby")
1916 .members
1917 .get(&h.isupport.fold("bob"))
1918 .map(|m| m.prefixes.as_str()),
1919 Some("+")
1920 );
1921 }
1922
1923 #[test]
1924 fn a_whox_reply_can_sign_somebody_out() {
1925 let mut h = Harness::new().joined("#obby");
1926 h.feed(":s 354 me 332 #obby ident host bob H acct :Bob");
1927 assert!(h.person("bob").account.is_some());
1928 h.feed(":s 354 me 332 #obby ident host bob H 0 :Bob");
1929 assert_eq!(
1930 h.person("bob").account,
1931 None,
1932 "a zero in the account field is how WHOX spells logged out"
1933 );
1934 }
1935
1936 #[test]
1937 fn a_whox_reply_to_somebody_elses_request_is_ignored() {
1938 let mut h = Harness::new().joined("#obby");
1939 h.feed(":s 354 me 999 #obby ident host bob H acct :Bob");
1940 assert!(
1941 h.model.person(&h.isupport.fold("bob")).is_none(),
1942 "the token is what tells our reply apart from another client's"
1943 );
1944 }
1945
1946 #[test]
1947 fn who_reports_away_and_operator_and_bot_flags() {
1948 let mut h = Harness::new().joined("#obby");
1949 h.feed(":s 352 me #obby ident host irc bob G*B@ :0 Bob");
1950 let person = h.person("bob");
1951 assert!(person.away.is_some(), "G means gone");
1952 assert!(person.operator);
1953 assert!(person.bot);
1954 }
1955
1956 #[test]
1957 fn coming_back_from_away_clears_it_but_going_away_keeps_a_known_reason() {
1958 let mut h = Harness::new().joined("#obby");
1959 h.feed(":bob!u@h AWAY :at lunch");
1960 h.feed(":s 352 me #obby ident host irc bob G :0 Bob");
1961 assert_eq!(
1962 h.person("bob").away.as_deref(),
1963 Some("at lunch"),
1964 "WHO says only that they are away, so a reason we already have survives"
1965 );
1966 h.feed(":s 352 me #obby ident host irc bob H :0 Bob");
1967 assert_eq!(h.person("bob").away, None);
1968 }
1969
1970 #[test]
1971 fn a_named_mode_is_recorded_by_its_name_not_its_letter() {
1972 let mut h = Harness::new().joined("#obby");
1973 let changes = h.feed(":s PROP #obby +obsidianirc/censor +obsidianirc/history=30d");
1974 assert_eq!(
1975 changes,
1976 [Change::ChannelModesChanged {
1977 channel: "#obby".to_string()
1978 }]
1979 );
1980 let modes = &h.channel("#obby").named_modes;
1981 assert_eq!(modes.get("obsidianirc/censor"), Some(&None));
1982 assert_eq!(
1983 modes.get("obsidianirc/history"),
1984 Some(&Some("30d".to_string())),
1985 "a letter means nothing without the server saying what it does; the name is stable"
1986 );
1987 }
1988
1989 #[test]
1990 fn removing_a_named_mode_ignores_the_parameter_it_carries() {
1991 let mut h = Harness::new().joined("#obby");
1992 h.feed(":s PROP #obby +obsidianirc/history=30d");
1993 h.feed(":s PROP #obby -obsidianirc/history=30d");
1994 assert!(
1995 !h.channel("#obby")
1996 .named_modes
1997 .contains_key("obsidianirc/history")
1998 );
1999 }
2000
2001 #[test]
2002 fn a_prop_listing_reports_state_rather_than_a_change() {
2003 let mut h = Harness::new().joined("#obby");
2004 h.feed(":s 961 me #obby obsidianirc/censor obsidianirc/history=7d");
2005 let modes = &h.channel("#obby").named_modes;
2006 assert_eq!(
2007 modes.len(),
2008 2,
2009 "a listing has no signs, so every entry is set"
2010 );
2011 assert_eq!(
2012 modes.get("obsidianirc/history"),
2013 Some(&Some("7d".to_string()))
2014 );
2015 }
2016
2017 #[test]
2018 fn a_prop_for_a_channel_we_never_joined_invents_nothing() {
2019 let mut h = Harness::new();
2020 assert!(h.feed(":s PROP #ghost +obsidianirc/censor").is_empty());
2021 assert_eq!(h.model.channels().count(), 0);
2022 }
2023
2024 #[test]
2025 fn the_same_change_arriving_as_mode_and_as_prop_is_harmless() {
2026 let mut h = Harness::new().joined("#obby");
2027 h.feed(":op!u@h MODE #obby +t");
2029 h.feed(":s PROP #obby +topiclock");
2030 assert!(h.channel("#obby").modes.contains_key(&'t'));
2031 assert!(h.channel("#obby").named_modes.contains_key("topiclock"));
2032 }
2033
2034 #[test]
2035 fn a_notice_is_not_an_ordinary_message() {
2036 let mut h = Harness::new().joined("#obby");
2037 h.feed(":bob!u@h NOTICE #obby :careful");
2038 assert_eq!(
2039 h.channel("#obby").log.last().map(|m| m.kind.clone()),
2040 Some(MessageKind::Notice)
2041 );
2042 }
2043}