didbot_policy_count/
interaction.rs1use didbot_schema::AtUri;
17use serde_json::Value;
18
19const FOLLOW: &str = "app.bsky.graph.follow";
21const LIKE: &str = "app.bsky.feed.like";
23const REPOST: &str = "app.bsky.feed.repost";
24const POST: &str = "app.bsky.feed.post";
27const MENTION: &str = "app.bsky.richtext.facet#mention";
29
30#[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
72fn 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 #[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}