Skip to main content

obby_client/
extensions.rs

1//! The Obby vendored extensions.
2//!
3//! Implemented against the wire the running server and client speak, rather than against the
4//! published <https://github.com/obbyworld/extensions>. The two disagree in
5//! about twenty places: the repository names a batch type and an attribution tag for channel-bots
6//! that do not exist on the wire, and describes a `manage-bots` permission that exists in neither
7//! the server nor the client.
8
9use 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/// A preview of a link someone posted, built by the server and attached to the message.
17///
18/// The server fetches the page; a client never does. There is no capability to negotiate, and the
19/// server refuses these tags from any sender but itself, so a peer cannot forge one.
20#[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    /// The page title. Always present when a preview exists at all.
25    pub title: String,
26    /// A description of the page, when the page offered one.
27    pub snippet: Option<String>,
28    /// An image, already re-hosted by the server when it has a filehost configured.
29    pub image: Option<String>,
30}
31
32impl LinkPreview {
33    /// Read a preview off a TAGMSG, with the message it describes.
34    ///
35    /// The tags carry no capability and arrive on a bare TAGMSG whose `+reply` names the message
36    /// being previewed. Both spellings of the reply tag are accepted, because the server sends one
37    /// from its filehost module and the other from its bot module.
38    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/// The set of commands the server says we may currently use.
60///
61/// The server pushes this on connect and again whenever it changes, such as after an `OPER`. A
62/// batch carries additions and removals together, so both are applied at once.
63#[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    /// Know about no commands yet.
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Apply one `CMDSLIST` line's worth of `+name` and `-name` tokens.
78    ///
79    /// A long list is split across several lines inside one batch, so this is called per line and
80    /// the effects accumulate.
81    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    /// True when the server says we may use this command.
96    pub fn contains(&self, name: &str) -> bool {
97        self.available.contains(&name.to_ascii_uppercase())
98    }
99
100    /// Every command we may use, in name order.
101    pub fn iter(&self) -> impl Iterator<Item = &str> {
102        self.available.iter().map(String::as_str)
103    }
104
105    /// How many commands we may use.
106    pub fn len(&self) -> usize {
107        self.available.len()
108    }
109
110    /// True when the server has told us nothing yet.
111    pub fn is_empty(&self) -> bool {
112        self.available.is_empty()
113    }
114}
115
116/// An invitation link to the network or to one channel.
117#[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    /// The identifier used to delete it.
122    pub share_id: String,
123    /// The channel it joins, or nothing when it invites to the network.
124    pub channel: Option<String>,
125    /// The link itself.
126    pub url: String,
127    /// When it was made, as the server spells it.
128    pub created: Option<String>,
129    /// How many people have used it.
130    pub redeemed: u32,
131    /// What it is for.
132    pub description: Option<String>,
133}
134
135impl Invitation {
136    /// Read the `INVITELINK` reply that follows a create.
137    ///
138    /// The shape is `INVITELINK <share-id> <channel|*> :<url>`.
139    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    /// Read one line of an `INVITELINK LIST` reply.
155    ///
156    /// The shape is `INVITELINK ENTRY <share-id> <channel|*> <created> <redeemed> <url>
157    /// [:<description>]`.
158    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            // a count we cannot read is not a reason to drop the whole invitation
167            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
174/// `*` in place of a channel means the invitation is to the network rather than to one channel.
175fn channel_or_network(value: &str) -> Option<String> {
176    (value != "*").then(|| value.to_string())
177}
178
179/// What the server knows about a bot in a channel.
180#[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    /// The bot's nick.
185    pub nick: String,
186    /// The identifier the server gave it.
187    pub id: Option<String>,
188    /// True when an operator configured this bot, rather than it registering itself.
189    ///
190    /// Only a configured bot may claim a privileged command name. A bot that registered itself has
191    /// those names stripped, so it cannot shadow `oper` or `identify`.
192    pub from_config: bool,
193    /// The commands it offers.
194    pub commands: Vec<BotCommand>,
195}
196
197/// One command a bot offers.
198#[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    /// What to type, without its leading slash.
203    pub name: String,
204    /// What it does.
205    pub description: Option<String>,
206}
207
208/// Command names a self-registered bot must never be allowed to claim.
209///
210/// Letting one shadow these lets it collect a password or impersonate a server service. Only a bot
211/// an operator configured may register them.
212pub const PRIVILEGED_COMMANDS: &[&str] = &[
213    "oper", "identify", "nickserv", "chanserv", "ns", "cs", "register", "pass", "auth", "login",
214];
215
216/// The tag an announcement of a bot rides on, on a bodiless `TAGMSG`.
217///
218/// The published extensions repository names the batch `obby.world/bot-list` and puts the payload
219/// under `draft/bot-cmds`; the wire uses the capability name as the batch type and this tag.
220pub(crate) const BOT_INFO_TAG: &str = "obby.world/bot-info";
221
222/// The tag a bot answers a command-list query with, on a bodiless `TAGMSG`.
223pub(crate) const BOT_COMMANDS_TAG: &str = "+draft/bot-cmds";
224
225/// One announcement the server made about a bot.
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub(crate) struct BotInfo {
228    /// True when the server is withdrawing the bot.
229    pub removed: bool,
230    /// The bot, with no commands on it yet: those go through [`Bots::set_commands`], which is where
231    /// the privileged-name rule lives.
232    pub bot: Bot,
233    /// The commands the announcement carried.
234    pub commands: Vec<BotCommand>,
235}
236
237impl BotInfo {
238    /// Read the base64 JSON an `obby.world/bot-info` tag carries.
239    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
255/// Read the base64 JSON a `+draft/bot-cmds` tag carries.
256///
257/// The payload is `{prefix?, commands}` on one line, never batch-wrapped, whatever the published
258/// specification's example shows.
259pub(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
271/// The `commands` array of a bot payload, skipping any entry with no name to type.
272fn 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/// The bots we know about, keyed by their folded nick.
288#[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    /// Know about no bots.
298    pub fn new() -> Self {
299        Self::default()
300    }
301
302    /// Record a bot the server told us about.
303    pub fn insert(&mut self, key: String, bot: Bot) {
304        self.known.insert(key, bot);
305    }
306
307    /// Forget a bot.
308    pub fn remove(&mut self, key: &str) -> Option<Bot> {
309        self.known.remove(key)
310    }
311
312    /// Forget every bot.
313    ///
314    /// Which bots exist is a live view: a server replays what it knows on connect and says nothing
315    /// about one that was removed while we were away.
316    pub fn forget(&mut self) {
317        self.known.clear();
318    }
319
320    /// A bot we know about.
321    pub fn get(&self, key: &str) -> Option<&Bot> {
322        self.known.get(key)
323    }
324
325    /// Every bot, in folded nick order.
326    pub fn iter(&self) -> impl Iterator<Item = (&String, &Bot)> {
327        self.known.iter()
328    }
329
330    /// How many bots we know about.
331    pub fn len(&self) -> usize {
332        self.known.len()
333    }
334
335    /// True when we know of none.
336    pub fn is_empty(&self) -> bool {
337        self.known.is_empty()
338    }
339
340    /// Record the commands a bot offers, dropping any name it is not entitled to.
341    ///
342    /// A bot we have never been told about is refused outright. Accepting a command list from an
343    /// arbitrary nick would let anyone put entries in the command menu, and the reference client
344    /// has exactly that gap on its parallel workflow protocol.
345    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
361/// True when this command name may only be claimed by a bot an operator configured.
362pub 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}