1use alloc::borrow::ToOwned;
4use alloc::collections::BTreeMap;
5use alloc::string::String;
6use alloc::vec::Vec;
7
8pub const WANTED_CAPS: &[&str] = &[
16 "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 "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 #[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#[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 pub name: String,
76 pub value: Option<String>,
78}
79
80#[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 pending: Vec<String>,
91}
92
93impl Capabilities {
94 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 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 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 pub fn requested(&mut self, names: &[String]) {
125 self.pending.extend_from_slice(names);
126 }
127
128 pub fn acknowledge(&mut self, list: &str) -> Vec<String> {
130 let mut acked = Vec::new();
131 for token in list.split_whitespace() {
132 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 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 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 pub fn has(&self, name: &str) -> bool {
161 self.acknowledged.contains_key(name)
162 }
163
164 pub fn value(&self, name: &str) -> Option<&str> {
166 self.acknowledged.get(name)?.as_deref()
167 }
168
169 pub fn offers(&self, name: &str) -> bool {
171 self.available.contains_key(name)
172 }
173
174 pub fn settled(&self) -> bool {
176 self.pending.is_empty()
177 }
178
179 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}