1use serde_json::Value;
27
28pub const MAX_DEPTH: usize = 10;
37
38#[must_use]
46pub fn named_accounts(record: &Value) -> Vec<String> {
47 let mut found = Vec::new();
48 walk(record, 0, &mut found);
49 found
50}
51
52fn walk(value: &Value, depth: usize, found: &mut Vec<String>) {
53 if depth > MAX_DEPTH {
54 return;
55 }
56 match value {
57 Value::String(text) => {
58 if let Some(did) = did_in(text) {
59 if !found.iter().any(|held| held == did) {
60 found.push(did.to_owned());
61 }
62 }
63 }
64 Value::Array(items) => {
65 for item in items {
66 walk(item, depth + 1, found);
67 }
68 }
69 Value::Object(fields) => {
70 for (name, field) in fields {
71 if name == "$type" {
72 continue;
73 }
74 walk(field, depth + 1, found);
75 }
76 }
77 _ => {}
78 }
79}
80
81#[must_use]
92pub fn named_handles(record: &Value) -> Vec<(String, String)> {
93 let mut found = Vec::new();
94 walk_handles(record, 0, &mut found);
95 found
96}
97
98fn walk_handles(value: &Value, depth: usize, found: &mut Vec<(String, String)>) {
99 if depth > MAX_DEPTH {
100 return;
101 }
102 match value {
103 Value::Array(items) => {
104 for item in items {
105 walk_handles(item, depth + 1, found);
106 }
107 }
108 Value::Object(fields) => {
109 if let (Some(Value::String(text)), Some(Value::Array(facets))) =
110 (fields.get("text"), fields.get("facets"))
111 {
112 for facet in facets {
113 mentioned(text, facet, found);
114 }
115 }
116 for (name, field) in fields {
117 if name != "$type" {
118 walk_handles(field, depth + 1, found);
119 }
120 }
121 }
122 _ => {}
123 }
124}
125
126fn mentioned(text: &str, facet: &Value, found: &mut Vec<(String, String)>) {
128 let index = facet.get("index");
129 let start = index
130 .and_then(|i| i.get("byteStart"))
131 .and_then(Value::as_u64);
132 let end = index.and_then(|i| i.get("byteEnd")).and_then(Value::as_u64);
133 let (Some(start), Some(end)) = (start, end) else {
134 return;
135 };
136 let (Ok(start), Ok(end)) = (usize::try_from(start), usize::try_from(end)) else {
137 return;
138 };
139 let Some(span) = text.get(start..end) else {
140 return;
141 };
142 let Some(handle) = span.strip_prefix('@') else {
143 return;
144 };
145 if didbot_identity::validate_handle(handle).is_err() {
146 return;
147 }
148 let Some(Value::Array(features)) = facet.get("features") else {
149 return;
150 };
151 for feature in features {
152 let Some(did) = feature.get("did").and_then(Value::as_str) else {
153 continue;
154 };
155 if didbot_identity::validate_did(did).is_err() || found.iter().any(|(held, _)| held == did)
156 {
157 continue;
158 }
159 found.push((did.to_owned(), handle.to_owned()));
160 }
161}
162
163fn did_in(text: &str) -> Option<&str> {
165 let candidate = match text.strip_prefix("at://") {
166 Some(rest) => rest.split('/').next().unwrap_or(rest),
167 None => text,
168 };
169 didbot_identity::validate_did(candidate)
170 .is_ok()
171 .then_some(candidate)
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use serde_json::json;
178
179 #[test]
182 fn a_mention_names_its_account_by_the_handle_its_text_spans() {
183 let post = json!({
184 "text": "hi @wren.example, see https://x.example",
185 "facets": [
186 {
187 "index": { "byteStart": 3, "byteEnd": 16 },
188 "features": [{ "$type": "app.bsky.richtext.facet#mention", "did": "did:web:wren.example" }],
189 },
190 {
191 "index": { "byteStart": 22, "byteEnd": 39 },
192 "features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://x.example" }],
193 },
194 {
195 "index": { "byteStart": 0, "byteEnd": 2 },
196 "features": [{ "did": "did:web:not-a-handle.example" }],
197 },
198 ],
199 });
200 assert_eq!(
201 named_handles(&post),
202 [("did:web:wren.example".to_owned(), "wren.example".to_owned())]
203 );
204 }
205
206 #[test]
209 fn a_post_names_its_parents_its_quote_and_its_mentions() {
210 let post = json!({
211 "$type": "app.bsky.feed.post",
212 "text": "@wren.example",
213 "createdAt": "2026-09-11T00:00:00.000Z",
214 "reply": {
215 "root": { "uri": "at://did:web:root.example/app.bsky.feed.post/3lb", "cid": "bafyfake" },
216 "parent": { "uri": "at://did:web:parent.example/app.bsky.feed.post/3lc", "cid": "bafyfake" },
217 },
218 "embed": {
219 "$type": "app.bsky.embed.record",
220 "record": { "uri": "at://did:web:quoted.example/app.bsky.feed.post/3ld", "cid": "bafyfake" },
221 },
222 "facets": [{
223 "index": { "byteStart": 0, "byteEnd": 13 },
224 "features": [{ "$type": "app.bsky.richtext.facet#mention", "did": "did:web:wren.example" }],
225 }],
226 });
227 assert_eq!(
228 named_accounts(&post),
229 [
230 "did:web:root.example",
231 "did:web:parent.example",
232 "did:web:quoted.example",
233 "did:web:wren.example",
234 ]
235 );
236 }
237
238 #[test]
241 fn a_follow_a_like_and_a_repost_each_name_one_account() {
242 for (record, named) in [
243 (
244 json!({ "$type": "app.bsky.graph.follow", "subject": "did:web:wren.example" }),
245 "did:web:wren.example",
246 ),
247 (
248 json!({
249 "$type": "app.bsky.feed.like",
250 "subject": { "uri": "at://did:web:wren.example/app.bsky.feed.post/3lb", "cid": "bafyfake" },
251 }),
252 "did:web:wren.example",
253 ),
254 (
255 json!({
256 "$type": "app.bsky.feed.repost",
257 "subject": { "uri": "at://did:web:wren.example/app.bsky.feed.post/3lb", "cid": "bafyfake" },
258 }),
259 "did:web:wren.example",
260 ),
261 ] {
262 assert_eq!(named_accounts(&record), [named], "{record}");
263 }
264 }
265
266 #[test]
269 fn a_record_carrying_no_did_names_nobody() {
270 let post = json!({
271 "$type": "app.bsky.feed.post",
272 "text": "the tide is out",
273 "createdAt": "2026-09-11T00:00:00.000Z",
274 "facets": [{
275 "index": { "byteStart": 0, "byteEnd": 6 },
276 "features": [{ "$type": "app.bsky.richtext.facet#tag", "tag": "tides" }],
277 }],
278 });
279 assert!(named_accounts(&post).is_empty());
280 }
281
282 #[test]
285 fn a_type_is_never_an_account_and_the_walk_is_bounded() {
286 let mut nested = json!("did:web:deep.example");
287 for _ in 0..MAX_DEPTH + 2 {
288 nested = json!({ "inner": nested });
289 }
290 assert!(named_accounts(&nested).is_empty());
291
292 let shallow =
293 json!({ "$type": "did:web:not.an.account", "subject": "did:web:wren.example" });
294 assert_eq!(named_accounts(&shallow), ["did:web:wren.example"]);
295 }
296}