1use alloc::collections::BTreeMap;
10use alloc::string::{String, ToString};
11use alloc::vec::Vec;
12use obby_proto::Message as Line;
13
14use crate::json::{Json, field_string};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
23pub struct LinkPreview {
24 pub title: String,
26 pub snippet: Option<String>,
28 pub image: Option<String>,
30}
31
32impl LinkPreview {
33 pub fn parse(line: &Line) -> Option<(String, Self)> {
39 let title = line.tag("obsidianirc/link-preview-title")?.to_string();
40 let msgid = line
41 .tag("+reply")
42 .or_else(|| line.tag("+draft/reply"))?
43 .to_string();
44 Some((
45 msgid,
46 Self {
47 title,
48 snippet: line
49 .tag("obsidianirc/link-preview-snippet")
50 .map(ToString::to_string),
51 image: line
52 .tag("obsidianirc/link-preview-meta")
53 .map(ToString::to_string),
54 },
55 ))
56 }
57}
58
59#[derive(Debug, Clone, Default)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
66#[cfg_attr(feature = "ts", ts(rename = "AllowedCommands"))]
67pub struct Commands {
68 available: alloc::collections::BTreeSet<String>,
69}
70
71impl Commands {
72 pub fn new() -> Self {
74 Self::default()
75 }
76
77 pub fn apply(&mut self, line: &Line) {
82 for token in line
83 .params
84 .iter()
85 .flat_map(|param| param.split_whitespace())
86 {
87 if let Some(name) = token.strip_prefix('+') {
88 self.available.insert(name.to_ascii_uppercase());
89 } else if let Some(name) = token.strip_prefix('-') {
90 self.available.remove(&name.to_ascii_uppercase());
91 }
92 }
93 }
94
95 pub fn contains(&self, name: &str) -> bool {
97 self.available.contains(&name.to_ascii_uppercase())
98 }
99
100 pub fn iter(&self) -> impl Iterator<Item = &str> {
102 self.available.iter().map(String::as_str)
103 }
104
105 pub fn len(&self) -> usize {
107 self.available.len()
108 }
109
110 pub fn is_empty(&self) -> bool {
112 self.available.is_empty()
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
120pub struct Invitation {
121 pub share_id: String,
123 pub channel: Option<String>,
125 pub url: String,
127 pub created: Option<String>,
129 pub redeemed: u32,
131 pub description: Option<String>,
133}
134
135impl Invitation {
136 pub fn parse_created(line: &Line) -> Option<Self> {
140 let share_id = line.param(0)?;
141 if share_id.eq_ignore_ascii_case("ENTRY") {
142 return None;
143 }
144 Some(Self {
145 share_id: share_id.to_string(),
146 channel: channel_or_network(line.param(1)?),
147 url: line.param(2)?.to_string(),
148 created: None,
149 redeemed: 0,
150 description: None,
151 })
152 }
153
154 pub fn parse_entry(line: &Line) -> Option<Self> {
159 if !line.param(0)?.eq_ignore_ascii_case("ENTRY") {
160 return None;
161 }
162 Some(Self {
163 share_id: line.param(1)?.to_string(),
164 channel: channel_or_network(line.param(2)?),
165 created: Some(line.param(3)?.to_string()),
166 redeemed: line.param(4).and_then(|n| n.parse().ok()).unwrap_or(0),
168 url: line.param(5)?.to_string(),
169 description: line.param(6).map(ToString::to_string),
170 })
171 }
172}
173
174fn channel_or_network(value: &str) -> Option<String> {
176 (value != "*").then(|| value.to_string())
177}
178
179#[derive(Debug, Clone, Default, PartialEq, Eq)]
181#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
182#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
183pub struct Bot {
184 pub nick: String,
186 pub id: Option<String>,
188 pub from_config: bool,
193 pub commands: Vec<BotCommand>,
195}
196
197#[derive(Debug, Clone, Default, PartialEq, Eq)]
199#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
200#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
201pub struct BotCommand {
202 pub name: String,
204 pub description: Option<String>,
206}
207
208pub const PRIVILEGED_COMMANDS: &[&str] = &[
213 "oper", "identify", "nickserv", "chanserv", "ns", "cs", "register", "pass", "auth", "login",
214];
215
216pub(crate) const BOT_INFO_TAG: &str = "obby.world/bot-info";
221
222pub(crate) const BOT_COMMANDS_TAG: &str = "+draft/bot-cmds";
224
225#[derive(Debug, Clone, PartialEq, Eq)]
227pub(crate) struct BotInfo {
228 pub removed: bool,
230 pub bot: Bot,
233 pub commands: Vec<BotCommand>,
235}
236
237impl BotInfo {
238 pub(crate) fn decode(payload: &str) -> Option<Self> {
240 let value = decode_tag_json(payload)?;
241 let nick = field_string(&value, "nick")?;
242 Some(Self {
243 removed: field_string(&value, "event").as_deref() == Some("remove"),
244 bot: Bot {
245 nick,
246 id: field_string(&value, "bot_id"),
247 from_config: value.field("from_config") == Some(&Json::Bool(true)),
248 commands: Vec::new(),
249 },
250 commands: bot_commands(&value),
251 })
252 }
253}
254
255pub(crate) fn decode_bot_commands(payload: &str) -> Option<Vec<BotCommand>> {
260 Some(bot_commands(&decode_tag_json(payload)?))
261}
262
263fn decode_tag_json(payload: &str) -> Option<Json> {
264 use base64::Engine as _;
265 let bytes = base64::engine::general_purpose::STANDARD
266 .decode(payload)
267 .ok()?;
268 Json::parse(core::str::from_utf8(&bytes).ok()?)
269}
270
271fn bot_commands(value: &Json) -> Vec<BotCommand> {
273 value
274 .field("commands")
275 .and_then(Json::as_array)
276 .unwrap_or_default()
277 .iter()
278 .filter_map(|command| {
279 Some(BotCommand {
280 name: field_string(command, "name")?,
281 description: field_string(command, "description"),
282 })
283 })
284 .collect()
285}
286
287#[derive(Debug, Clone, Default)]
289#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
290#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
291#[cfg_attr(feature = "ts", ts(rename = "BotRegistry"))]
292pub struct Bots {
293 known: BTreeMap<String, Bot>,
294}
295
296impl Bots {
297 pub fn new() -> Self {
299 Self::default()
300 }
301
302 pub fn insert(&mut self, key: String, bot: Bot) {
304 self.known.insert(key, bot);
305 }
306
307 pub fn remove(&mut self, key: &str) -> Option<Bot> {
309 self.known.remove(key)
310 }
311
312 pub fn forget(&mut self) {
317 self.known.clear();
318 }
319
320 pub fn get(&self, key: &str) -> Option<&Bot> {
322 self.known.get(key)
323 }
324
325 pub fn iter(&self) -> impl Iterator<Item = (&String, &Bot)> {
327 self.known.iter()
328 }
329
330 pub fn len(&self) -> usize {
332 self.known.len()
333 }
334
335 pub fn is_empty(&self) -> bool {
337 self.known.is_empty()
338 }
339
340 pub fn set_commands(&mut self, key: &str, commands: Vec<BotCommand>) -> bool {
346 let Some(bot) = self.known.get_mut(key) else {
347 return false;
348 };
349 bot.commands = if bot.from_config {
350 commands
351 } else {
352 commands
353 .into_iter()
354 .filter(|command| !is_privileged(&command.name))
355 .collect()
356 };
357 true
358 }
359}
360
361pub fn is_privileged(name: &str) -> bool {
363 PRIVILEGED_COMMANDS
364 .iter()
365 .any(|reserved| name.eq_ignore_ascii_case(reserved))
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 fn line(raw: &str) -> Line {
373 Line::parse(raw).expect("the test line should parse")
374 }
375
376 #[test]
377 fn a_preview_names_the_message_it_describes() {
378 let (msgid, preview) = LinkPreview::parse(&line(
379 "@+reply=m1;obsidianirc/link-preview-title=Example;obsidianirc/link-preview-snippet=A\\spage;obsidianirc/link-preview-meta=https://h/i.png :s TAGMSG #obby",
380 ))
381 .expect("a preview");
382 assert_eq!(msgid, "m1");
383 assert_eq!(preview.title, "Example");
384 assert_eq!(preview.snippet.as_deref(), Some("A page"));
385 assert_eq!(preview.image.as_deref(), Some("https://h/i.png"));
386 }
387
388 #[test]
389 fn a_preview_needs_only_a_title() {
390 let (_, preview) = LinkPreview::parse(&line(
391 "@+draft/reply=m1;obsidianirc/link-preview-title=Bare :s TAGMSG #obby",
392 ))
393 .expect("a preview");
394 assert_eq!(preview.snippet, None);
395 assert_eq!(preview.image, None);
396 }
397
398 #[test]
399 fn a_preview_with_nothing_to_attach_to_is_not_one() {
400 assert!(
401 LinkPreview::parse(&line(
402 "@obsidianirc/link-preview-title=Orphan :s TAGMSG #obby"
403 ))
404 .is_none(),
405 "without a reply tag there is no message to hang it on"
406 );
407 assert!(LinkPreview::parse(&line("@+reply=m1 :s TAGMSG #obby")).is_none());
408 }
409
410 #[test]
411 fn the_command_list_adds_and_removes_in_one_pass() {
412 let mut commands = Commands::new();
413 commands.apply(&line(":s CMDSLIST +JOIN +PART +OPER"));
414 assert_eq!(commands.len(), 3);
415 assert!(
416 commands.contains("join"),
417 "command names are case-insensitive"
418 );
419
420 commands.apply(&line(":s CMDSLIST -OPER +TOPIC"));
421 assert!(!commands.contains("OPER"));
422 assert!(commands.contains("TOPIC"));
423 assert_eq!(commands.len(), 3);
424 }
425
426 #[test]
427 fn a_command_list_split_across_lines_accumulates() {
428 let mut commands = Commands::new();
429 commands.apply(&line(":s CMDSLIST +JOIN +PART"));
430 commands.apply(&line(":s CMDSLIST +TOPIC"));
431 assert_eq!(
432 commands.len(),
433 3,
434 "a long list arrives as several lines in one batch"
435 );
436 }
437
438 #[test]
439 fn a_created_invitation_reads_back() {
440 let invitation = Invitation::parse_created(&line(
441 ":s INVITELINK abc123 #obby :https://obby.example/i/abc123",
442 ))
443 .expect("an invitation");
444 assert_eq!(invitation.share_id, "abc123");
445 assert_eq!(invitation.channel.as_deref(), Some("#obby"));
446 assert_eq!(invitation.url, "https://obby.example/i/abc123");
447 }
448
449 #[test]
450 fn a_star_means_the_invitation_is_to_the_network() {
451 let invitation =
452 Invitation::parse_created(&line(":s INVITELINK abc123 * :https://obby.example/i/abc"))
453 .expect("an invitation");
454 assert_eq!(invitation.channel, None);
455 }
456
457 #[test]
458 fn a_list_entry_carries_its_history() {
459 let invitation = Invitation::parse_entry(&line(
460 ":s INVITELINK ENTRY abc123 #obby 2026-09-06T10:00:00Z 4 https://obby.example/i/abc :for the team",
461 ))
462 .expect("an entry");
463 assert_eq!(invitation.share_id, "abc123");
464 assert_eq!(invitation.created.as_deref(), Some("2026-09-06T10:00:00Z"));
465 assert_eq!(invitation.redeemed, 4);
466 assert_eq!(invitation.description.as_deref(), Some("for the team"));
467 }
468
469 #[test]
470 fn an_unreadable_count_does_not_lose_the_invitation() {
471 let invitation = Invitation::parse_entry(&line(
472 ":s INVITELINK ENTRY abc123 * 2026-09-06T10:00:00Z lots https://obby.example/i/abc",
473 ))
474 .expect("an entry");
475 assert_eq!(invitation.redeemed, 0);
476 assert_eq!(invitation.url, "https://obby.example/i/abc");
477 }
478
479 #[test]
480 fn a_created_reply_is_not_mistaken_for_a_list_entry() {
481 assert!(
482 Invitation::parse_created(&line(":s INVITELINK ENTRY a * 2026 0 https://u")).is_none()
483 );
484 assert!(Invitation::parse_entry(&line(":s INVITELINK abc * :https://u")).is_none());
485 }
486
487 #[test]
488 fn a_self_registered_bot_cannot_claim_a_privileged_name() {
489 let mut bots = Bots::new();
490 bots.insert(
491 "helper".to_string(),
492 Bot {
493 nick: "helper".to_string(),
494 from_config: false,
495 ..Bot::default()
496 },
497 );
498 assert!(bots.set_commands(
499 "helper",
500 alloc::vec![
501 BotCommand {
502 name: "weather".to_string(),
503 description: None
504 },
505 BotCommand {
506 name: "IdentIfy".to_string(),
507 description: None
508 },
509 ]
510 ));
511 let commands = &bots.get("helper").expect("the bot").commands;
512 assert_eq!(commands.len(), 1);
513 assert_eq!(commands[0].name, "weather");
514 }
515
516 #[test]
517 fn a_configured_bot_may_claim_one() {
518 let mut bots = Bots::new();
519 bots.insert(
520 "services".to_string(),
521 Bot {
522 nick: "services".to_string(),
523 from_config: true,
524 ..Bot::default()
525 },
526 );
527 bots.set_commands(
528 "services",
529 alloc::vec![BotCommand {
530 name: "identify".to_string(),
531 description: None
532 }],
533 );
534 assert_eq!(bots.get("services").expect("the bot").commands.len(), 1);
535 }
536
537 #[test]
538 fn commands_from_a_nick_we_never_heard_of_are_refused() {
539 let mut bots = Bots::new();
540 assert!(
541 !bots.set_commands("stranger", alloc::vec![]),
542 "anyone could otherwise put entries in the command menu"
543 );
544 assert!(bots.is_empty());
545 }
546
547 fn base64(text: &str) -> String {
548 use base64::Engine as _;
549 base64::engine::general_purpose::STANDARD.encode(text)
550 }
551
552 #[test]
553 fn a_bot_announcement_reads_back() {
554 let info = BotInfo::decode(&base64(
555 r#"{"event":"add","bot_id":"b1","nick":"weatherbot","from_config":true,"commands":[{"name":"forecast","description":"the weather"},{"name":"nodesc"}]}"#,
556 ))
557 .expect("an announcement");
558 assert!(!info.removed);
559 assert_eq!(info.bot.nick, "weatherbot");
560 assert_eq!(info.bot.id.as_deref(), Some("b1"));
561 assert!(info.bot.from_config);
562 assert_eq!(info.commands.len(), 2);
563 assert_eq!(info.commands[0].description.as_deref(), Some("the weather"));
564 assert_eq!(info.commands[1].description, None);
565 }
566
567 #[test]
568 fn a_withdrawal_says_so() {
569 let info = BotInfo::decode(&base64(r#"{"event":"remove","nick":"weatherbot"}"#))
570 .expect("an announcement");
571 assert!(info.removed);
572 assert!(
573 !info.bot.from_config,
574 "a bot must opt in to the trusted set"
575 );
576 }
577
578 #[test]
579 fn an_announcement_with_no_nick_is_not_one() {
580 assert!(BotInfo::decode(&base64(r#"{"event":"add","bot_id":"b1"}"#)).is_none());
581 assert!(BotInfo::decode("not base64 at all $$$").is_none());
582 assert!(BotInfo::decode(&base64("{not json")).is_none());
583 }
584
585 #[test]
586 fn a_command_list_arrives_on_its_own_line() {
587 let commands = decode_bot_commands(&base64(
588 r#"{"prefix":"/","commands":[{"name":"forecast"}]}"#,
589 ))
590 .expect("a command list");
591 assert_eq!(commands.len(), 1);
592 assert_eq!(commands[0].name, "forecast");
593 }
594
595 #[test]
596 fn privileged_names_are_matched_regardless_of_case() {
597 assert!(is_privileged("OPER"));
598 assert!(is_privileged("NickServ"));
599 assert!(!is_privileged("weather"));
600 }
601}