Skip to main content

obby_client/
caps.rs

1//! Capability negotiation state.
2
3use alloc::borrow::ToOwned;
4use alloc::collections::BTreeMap;
5use alloc::string::String;
6use alloc::vec::Vec;
7
8/// The capabilities this engine knows how to use, in the order we ask for them.
9///
10/// A capability we do not name here is never requested, because requesting one we cannot handle
11/// changes what the server sends and breaks parsing. That is why `draft/whoami`,
12/// `draft/account-registration`, `draft/account-2fa`, `soju.im/bouncer-networks` with its `-notify`
13/// companion, `unrealircd.org/json-log`, `draft/bot-tools` and `draft/persistence` are absent:
14/// nothing here reads what they would make the server send.
15pub const WANTED_CAPS: &[&str] = &[
16    // parsing changes, so these come first
17    "message-tags",
18    "server-time",
19    "batch",
20    "labeled-response",
21    "echo-message",
22    "draft/multiline",
23    "multi-prefix",
24    "userhost-in-names",
25    "extended-join",
26    "account-notify",
27    "account-tag",
28    "away-notify",
29    "chghost",
30    "invite-notify",
31    "setname",
32    "cap-notify",
33    "monitor",
34    "extended-monitor",
35    // messages. `reply`, `react` and `msgid` are client tags carried by message-tags rather than
36    // capabilities, so requesting them would only earn a NAK.
37    "draft/chathistory",
38    "draft/event-playback",
39    "draft/read-marker",
40    "draft/typing",
41    "draft/message-redaction",
42    "draft/channel-rename",
43    "channel-context",
44    "draft/channel-context",
45    "standard-replies",
46    "draft/named-modes",
47    "draft/metadata-2",
48    "draft/extended-isupport",
49    "draft/extended-isupport-0.2",
50    "sasl",
51    "znc.in/playback",
52    #[cfg(feature = "obby")]
53    "obsidianirc/cmdslist",
54    #[cfg(feature = "obby")]
55    "obby.world/channel-bots",
56    #[cfg(feature = "obby")]
57    "draft/bot-cmds",
58    // without this the server falls back to unwrapped legacy WHOIS numerics
59    #[cfg(feature = "obby")]
60    "obby.world/whois",
61    #[cfg(feature = "obby")]
62    "obby.world/invitation",
63    #[cfg(feature = "obby")]
64    "draft/authtoken",
65    #[cfg(feature = "voice")]
66    "obsidianirc/voice",
67];
68
69/// One capability the server advertised, with the value it carried if any.
70#[derive(Debug, Clone, PartialEq, Eq)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
73pub struct Capability {
74    /// The capability name, without any `=value` suffix.
75    pub name: String,
76    /// The value, for capabilities like `sasl=PLAIN,EXTERNAL` that carry one.
77    pub value: Option<String>,
78}
79
80/// What the server offers and what we hold.
81#[derive(Debug, Clone, Default)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
84#[cfg_attr(feature = "ts", ts(rename = "Capabilities"))]
85pub struct Capabilities {
86    available: BTreeMap<String, Option<String>>,
87    acknowledged: BTreeMap<String, Option<String>>,
88    /// Capabilities we asked for and are still waiting on. Registration cannot finish while this is
89    /// non-empty, because `CAP END` before the last reply loses the capability.
90    pending: Vec<String>,
91}
92
93impl Capabilities {
94    /// Record one `CAP LS` or `CAP NEW` line's worth of advertisements.
95    pub fn advertise(&mut self, list: &str) {
96        for token in list.split_whitespace() {
97            let (name, value) = split_value(token);
98            self.available
99                .insert(name.to_owned(), value.map(ToOwned::to_owned));
100        }
101    }
102
103    /// Forget capabilities the server withdrew with `CAP DEL`.
104    pub fn withdraw(&mut self, list: &str) {
105        for token in list.split_whitespace() {
106            let (name, _) = split_value(token);
107            self.available.remove(name);
108            self.acknowledged.remove(name);
109        }
110    }
111
112    /// The subset of [`WANTED_CAPS`] the server offers and we have not asked for yet.
113    pub fn to_request(&self) -> Vec<String> {
114        WANTED_CAPS
115            .iter()
116            .filter(|name| self.available.contains_key(**name))
117            .filter(|name| !self.acknowledged.contains_key(**name))
118            .filter(|name| !self.pending.iter().any(|p| p == *name))
119            .map(|name| (*name).to_owned())
120            .collect()
121    }
122
123    /// Note that we sent a `CAP REQ` for these.
124    pub fn requested(&mut self, names: &[String]) {
125        self.pending.extend_from_slice(names);
126    }
127
128    /// Apply a `CAP ACK`, returning the names that were acknowledged.
129    pub fn acknowledge(&mut self, list: &str) -> Vec<String> {
130        let mut acked = Vec::new();
131        for token in list.split_whitespace() {
132            // an ack may arrive prefixed with `-`, meaning the server dropped a capability we held
133            if let Some(name) = token.strip_prefix('-') {
134                self.acknowledged.remove(name);
135                self.pending.retain(|p| p != name);
136                continue;
137            }
138            let (name, value) = split_value(token);
139            // an ACK rarely repeats the value the LS carried, so fall back to what was advertised;
140            // otherwise the SASL mechanism list disappears the moment the capability is granted
141            let value = value
142                .map(ToOwned::to_owned)
143                .or_else(|| self.available.get(name).cloned().flatten());
144            self.acknowledged.insert(name.to_owned(), value);
145            self.pending.retain(|p| p != name);
146            acked.push(name.to_owned());
147        }
148        acked
149    }
150
151    /// Apply a `CAP NAK`. Nothing is enabled, we only stop waiting.
152    pub fn reject(&mut self, list: &str) {
153        for token in list.split_whitespace() {
154            let (name, _) = split_value(token);
155            self.pending.retain(|p| p != name);
156        }
157    }
158
159    /// True when we hold this capability.
160    pub fn has(&self, name: &str) -> bool {
161        self.acknowledged.contains_key(name)
162    }
163
164    /// The value the server gave a capability, such as the SASL mechanism list.
165    pub fn value(&self, name: &str) -> Option<&str> {
166        self.acknowledged.get(name)?.as_deref()
167    }
168
169    /// True when the server advertised this capability, whether or not we hold it.
170    pub fn offers(&self, name: &str) -> bool {
171        self.available.contains_key(name)
172    }
173
174    /// True when every requested capability has been answered.
175    pub fn settled(&self) -> bool {
176        self.pending.is_empty()
177    }
178
179    /// Everything we hold, sorted by name.
180    pub fn enabled(&self) -> impl Iterator<Item = Capability> + '_ {
181        self.acknowledged.iter().map(|(name, value)| Capability {
182            name: name.clone(),
183            value: value.clone(),
184        })
185    }
186}
187
188fn split_value(token: &str) -> (&str, Option<&str>) {
189    token
190        .split_once('=')
191        .map_or((token, None), |(name, value)| (name, Some(value)))
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use alloc::string::ToString;
198
199    #[test]
200    fn requests_only_what_is_offered_and_wanted() {
201        let mut caps = Capabilities::default();
202        caps.advertise("multi-prefix sasl=PLAIN,EXTERNAL something-we-never-want");
203        let request = caps.to_request();
204        assert!(request.contains(&"multi-prefix".to_string()));
205        assert!(request.contains(&"sasl".to_string()));
206        assert!(!request.iter().any(|c| c == "something-we-never-want"));
207    }
208
209    #[test]
210    fn keeps_the_value_of_an_acknowledged_capability() {
211        let mut caps = Capabilities::default();
212        caps.advertise("sasl=PLAIN,EXTERNAL");
213        caps.requested(&["sasl".to_string()]);
214        caps.acknowledge("sasl=PLAIN,EXTERNAL");
215        assert!(caps.has("sasl"));
216        assert_eq!(caps.value("sasl"), Some("PLAIN,EXTERNAL"));
217    }
218
219    #[test]
220    fn an_ack_without_a_value_keeps_the_advertised_one() {
221        let mut caps = Capabilities::default();
222        caps.advertise("sasl=PLAIN,EXTERNAL");
223        caps.requested(&["sasl".to_string()]);
224        caps.acknowledge("sasl");
225        assert_eq!(caps.value("sasl"), Some("PLAIN,EXTERNAL"));
226    }
227
228    #[test]
229    fn is_not_settled_until_every_request_is_answered() {
230        let mut caps = Capabilities::default();
231        caps.advertise("multi-prefix away-notify");
232        let request = caps.to_request();
233        caps.requested(&request);
234        assert!(!caps.settled());
235        caps.acknowledge("multi-prefix");
236        assert!(!caps.settled());
237        caps.reject("away-notify");
238        assert!(caps.settled());
239    }
240
241    #[test]
242    fn a_negated_ack_drops_the_capability() {
243        let mut caps = Capabilities::default();
244        caps.advertise("echo-message");
245        caps.requested(&["echo-message".to_string()]);
246        caps.acknowledge("echo-message");
247        assert!(caps.has("echo-message"));
248        caps.acknowledge("-echo-message");
249        assert!(!caps.has("echo-message"));
250    }
251
252    #[test]
253    fn cap_del_removes_an_offer_and_the_hold() {
254        let mut caps = Capabilities::default();
255        caps.advertise("away-notify");
256        caps.requested(&["away-notify".to_string()]);
257        caps.acknowledge("away-notify");
258        caps.withdraw("away-notify");
259        assert!(!caps.has("away-notify"));
260        assert!(!caps.offers("away-notify"));
261    }
262
263    #[test]
264    fn does_not_re_request_what_is_already_held() {
265        let mut caps = Capabilities::default();
266        caps.advertise("multi-prefix");
267        caps.requested(&["multi-prefix".to_string()]);
268        caps.acknowledge("multi-prefix");
269        assert!(caps.to_request().is_empty());
270    }
271}