Skip to main content

didbot_policy_count/
interaction.rs

1//! Which records name an account other than the one writing them, which
2//! is what a statement's `naming` narrows a count to.
3//!
4//! The gate reads no repository but this deployment's own, so "names
5//! another account" is read off the record in front of it: the account a
6//! follow names, and the authority of the at-uri a like, repost, reply or
7//! quote points at. A mention is a facet feature naming a DID.
8//!
9//! An at-uri's authority is a DID or a handle, so both of the writing
10//! account's names are compared against it.
11//!
12//! The collections below are the ones this engine knows how to read a
13//! named account out of. A statement that narrows to `anotherAccount`
14//! counts a write to any other collection as naming nobody.
15
16use didbot_schema::AtUri;
17use serde_json::Value;
18
19/// A follow names the account it follows.
20const FOLLOW: &str = "app.bsky.graph.follow";
21/// A like and a repost name the record they are about.
22const LIKE: &str = "app.bsky.feed.like";
23const REPOST: &str = "app.bsky.feed.repost";
24/// A post reaches another account by replying to one, quoting one, or
25/// carrying a mention facet.
26const POST: &str = "app.bsky.feed.post";
27/// The facet feature that notifies the account it names.
28const MENTION: &str = "app.bsky.richtext.facet#mention";
29
30/// Whether this record names an account other than the one writing it.
31///
32/// `account` is the writing account's DID and `handle` the handle it holds
33/// now, if it has one. A record naming only the writing account — a reply
34/// into its own thread, a like of its own post — is not a contact and is
35/// not counted.
36#[must_use]
37pub fn names_another_account(
38    collection: &str,
39    record: &Value,
40    account: &str,
41    handle: Option<&str>,
42) -> bool {
43    let theirs = |named: Option<&str>| match named {
44        Some(named) => named != account && Some(named) != handle,
45        None => false,
46    };
47    fn at_uri(value: Option<&Value>) -> Option<&str> {
48        value
49            .and_then(Value::as_str)
50            .and_then(|uri| AtUri::parse(uri).ok())
51            .map(AtUri::authority)
52    }
53    match collection {
54        FOLLOW => theirs(record.get("subject").and_then(Value::as_str)),
55        LIKE | REPOST => theirs(at_uri(record.pointer("/subject/uri"))),
56        POST => {
57            let pointed = [
58                "/reply/parent/uri",
59                "/reply/root/uri",
60                "/embed/record/uri",
61                "/embed/record/record/uri",
62            ];
63            pointed
64                .iter()
65                .any(|path| theirs(at_uri(record.pointer(path))))
66                || mentions(record).any(theirs)
67        }
68        _ => false,
69    }
70}
71
72/// Every DID a post's facets mention.
73fn mentions(record: &Value) -> impl Iterator<Item = Option<&str>> {
74    record
75        .get("facets")
76        .and_then(Value::as_array)
77        .map(Vec::as_slice)
78        .unwrap_or_default()
79        .iter()
80        .filter_map(|facet| facet.get("features").and_then(Value::as_array))
81        .flatten()
82        .filter(|feature| feature.get("$type").and_then(Value::as_str) == Some(MENTION))
83        .map(|feature| feature.get("did").and_then(Value::as_str))
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use serde_json::json;
90
91    const ACCOUNT: &str = "did:web:agent.pds.example";
92    const HANDLE: &str = "agent.pds.example";
93    const STRANGER: &str = "did:plc:stranger";
94
95    fn names(collection: &str, record: &Value) -> bool {
96        names_another_account(collection, record, ACCOUNT, Some(HANDLE))
97    }
98
99    #[test]
100    fn a_follow_names_whoever_it_follows() {
101        assert!(names(FOLLOW, &json!({ "subject": STRANGER })));
102        assert!(!names(FOLLOW, &json!({ "subject": ACCOUNT })));
103    }
104
105    /// The authority of the at-uri is the account, whichever of its two
106    /// names it is written with.
107    #[test]
108    fn a_like_or_repost_names_whose_record_it_is_about() {
109        let of = |did: &str| json!({ "subject": { "uri": format!("at://{did}/app.bsky.feed.post/3l") } });
110        assert!(names(LIKE, &of(STRANGER)));
111        assert!(names(REPOST, &of(STRANGER)));
112        assert!(!names(LIKE, &of(ACCOUNT)));
113        assert!(!names(LIKE, &of(HANDLE)));
114        assert!(!names(
115            LIKE,
116            &json!({ "subject": { "uri": "not an at-uri" } })
117        ));
118    }
119
120    #[test]
121    fn a_post_names_who_it_replies_to_quotes_or_mentions() {
122        let reply = |did: &str| {
123            json!({
124                "text": "hello",
125                "reply": {
126                    "root": { "uri": format!("at://{ACCOUNT}/app.bsky.feed.post/3l") },
127                    "parent": { "uri": format!("at://{did}/app.bsky.feed.post/3m") },
128                },
129            })
130        };
131        assert!(names(POST, &reply(STRANGER)));
132        assert!(!names(POST, &reply(ACCOUNT)));
133        assert!(names(
134            POST,
135            &json!({
136                "text": "look",
137                "embed": {
138                    "$type": "app.bsky.embed.record",
139                    "record": { "uri": format!("at://{STRANGER}/app.bsky.feed.post/3l") },
140                },
141            })
142        ));
143        assert!(names(
144            POST,
145            &json!({
146                "text": "hello @stranger",
147                "facets": [{
148                    "index": { "byteStart": 6, "byteEnd": 15 },
149                    "features": [{ "$type": MENTION, "did": STRANGER }],
150                }],
151            })
152        ));
153        assert!(!names(
154            POST,
155            &json!({
156                "text": "hello me",
157                "facets": [{
158                    "index": { "byteStart": 6, "byteEnd": 8 },
159                    "features": [{ "$type": MENTION, "did": ACCOUNT }],
160                }],
161            })
162        ));
163        assert!(!names(POST, &json!({ "text": "a post on its own" })));
164    }
165
166    #[test]
167    fn a_record_of_another_collection_names_nobody() {
168        assert!(!names(
169            "app.bsky.actor.profile",
170            &json!({ "description": STRANGER })
171        ));
172    }
173}