use atproto_identity::resolve::IdentityResolver;
use atproto_record::lexicon::app::bsky::richtext::facet::{
ByteSlice, Facet, FacetFeature, Link, Mention, Tag,
};
use regex::bytes::Regex;
#[derive(Debug, Clone, Copy)]
pub struct FacetLimits {
pub mentions_max: usize,
pub tags_max: usize,
pub links_max: usize,
pub max: usize,
}
impl Default for FacetLimits {
fn default() -> Self {
Self {
mentions_max: 5,
tags_max: 5,
links_max: 5,
max: 10,
}
}
}
pub async fn parse_mentions(
text: &str,
identity_resolver: &dyn IdentityResolver,
limits: &FacetLimits,
) -> Vec<Facet> {
let mut facets = Vec::new();
let url_facets = parse_urls(text);
let mention_regex = Regex::new(
r"(?:^|[^\w])(@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)",
)
.unwrap();
let text_bytes = text.as_bytes();
let mut mention_count = 0;
for capture in mention_regex.captures_iter(text_bytes) {
if mention_count >= limits.mentions_max {
break;
}
if let Some(mention_match) = capture.get(1) {
let start = mention_match.start();
let end = mention_match.end();
let overlaps_url = url_facets.iter().any(|facet| {
(start >= facet.index.byte_start && start < facet.index.byte_end)
|| (end > facet.index.byte_start && end <= facet.index.byte_end)
});
if !overlaps_url {
let handle = std::str::from_utf8(&mention_match.as_bytes()[1..])
.unwrap_or_default()
.to_string();
let at_uri = format!("at://{}", handle);
let did_result = match identity_resolver.resolve(&at_uri).await {
Ok(doc) => Ok(doc),
Err(_) => identity_resolver.resolve(&handle).await,
};
if let Ok(did_doc) = did_result {
facets.push(Facet {
index: ByteSlice {
byte_start: start,
byte_end: end,
},
features: vec![FacetFeature::Mention(Mention {
did: did_doc.id.to_string(),
})],
});
mention_count += 1;
}
}
}
}
facets
}
pub fn parse_urls(text: &str) -> Vec<Facet> {
let mut facets = Vec::new();
let url_regex = Regex::new(
r"(?:^|[^\w])(https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]+\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?)"
).unwrap();
let text_bytes = text.as_bytes();
for capture in url_regex.captures_iter(text_bytes) {
if let Some(url_match) = capture.get(1) {
let url = std::str::from_utf8(url_match.as_bytes())
.unwrap_or_default()
.to_string();
facets.push(Facet {
index: ByteSlice {
byte_start: url_match.start(),
byte_end: url_match.end(),
},
features: vec![FacetFeature::Link(Link { uri: url })],
});
}
}
facets
}
pub fn parse_tags(text: &str) -> Vec<Facet> {
let mut facets = Vec::new();
let tag_regex = Regex::new(r"(?:^|[^\w])([#\xEF\xBC\x83])([\w]+(?:[\w]*)*)").unwrap();
let text_bytes = text.as_bytes();
for capture in tag_regex.captures_iter(text_bytes) {
if let (Some(full_match), Some(hash_match), Some(tag_match)) =
(capture.get(0), capture.get(1), capture.get(2))
{
let match_start = full_match.start();
let hash_offset = hash_match.start() - full_match.start();
let start = match_start + hash_offset;
let end = match_start + hash_offset + hash_match.len() + tag_match.len();
let tag = std::str::from_utf8(tag_match.as_bytes()).unwrap_or_default();
if !tag.chars().all(|c| c.is_ascii_digit()) {
facets.push(Facet {
index: ByteSlice {
byte_start: start,
byte_end: end,
},
features: vec![FacetFeature::Tag(Tag {
tag: tag.to_string(),
})],
});
}
}
}
facets
}
pub async fn parse_facets_from_text(
text: &str,
identity_resolver: &dyn IdentityResolver,
limits: &FacetLimits,
) -> Option<Vec<Facet>> {
let mut facets = Vec::new();
let mention_facets = parse_mentions(text, identity_resolver, limits).await;
facets.extend(mention_facets);
let url_facets = parse_urls(text);
for (idx, facet) in url_facets.into_iter().enumerate() {
if idx >= limits.links_max {
break;
}
facets.push(facet);
}
let tag_facets = parse_tags(text);
for (idx, facet) in tag_facets.into_iter().enumerate() {
if idx >= limits.tags_max {
break;
}
facets.push(facet);
}
if facets.len() > limits.max {
facets.truncate(limits.max);
}
if !facets.is_empty() {
Some(facets)
} else {
None
}
}
#[cfg(test)]
mod tests {
use async_trait::async_trait;
use atproto_identity::model::Document;
use std::collections::HashMap;
use super::*;
struct MockIdentityResolver {
handles_to_dids: HashMap<String, String>,
}
impl MockIdentityResolver {
fn new() -> Self {
let mut handles_to_dids = HashMap::new();
handles_to_dids.insert(
"alice.bsky.social".to_string(),
"did:plc:alice123".to_string(),
);
handles_to_dids.insert(
"at://alice.bsky.social".to_string(),
"did:plc:alice123".to_string(),
);
Self { handles_to_dids }
}
fn add_identity(&mut self, handle: &str, did: &str) {
self.handles_to_dids
.insert(handle.to_string(), did.to_string());
self.handles_to_dids
.insert(format!("at://{}", handle), did.to_string());
}
}
#[async_trait]
impl IdentityResolver for MockIdentityResolver {
async fn resolve(&self, handle: &str) -> anyhow::Result<Document> {
let handle_key = handle.to_string();
if let Some(did) = self.handles_to_dids.get(&handle_key) {
Ok(Document {
context: vec![],
id: did.clone(),
also_known_as: vec![format!("at://{}", handle_key.trim_start_matches("at://"))],
verification_method: vec![],
service: vec![],
extra: HashMap::new(),
})
} else {
Err(anyhow::anyhow!("Handle not found"))
}
}
}
#[tokio::test]
async fn test_parse_facets_from_text_comprehensive() {
let mut resolver = MockIdentityResolver::new();
resolver.add_identity("bob.test.com", "did:plc:bob456");
let limits = FacetLimits::default();
let text = "Join @alice.bsky.social and @bob.test.com at https://example.com #rust #golang";
let facets = parse_facets_from_text(text, &resolver, &limits).await;
assert!(facets.is_some());
let facets = facets.unwrap();
assert_eq!(facets.len(), 5);
assert_eq!(facets[0].index.byte_start, 5);
assert_eq!(facets[0].index.byte_end, 23);
if let FacetFeature::Mention(ref mention) = facets[0].features[0] {
assert_eq!(mention.did, "did:plc:alice123");
} else {
panic!("Expected Mention feature");
}
assert_eq!(facets[1].index.byte_start, 28);
assert_eq!(facets[1].index.byte_end, 41);
if let FacetFeature::Mention(mention) = &facets[1].features[0] {
assert_eq!(mention.did, "did:plc:bob456");
} else {
panic!("Expected Mention feature");
}
assert_eq!(facets[2].index.byte_start, 45);
assert_eq!(facets[2].index.byte_end, 64);
if let FacetFeature::Link(link) = &facets[2].features[0] {
assert_eq!(link.uri, "https://example.com");
} else {
panic!("Expected Link feature");
}
assert_eq!(facets[3].index.byte_start, 65);
assert_eq!(facets[3].index.byte_end, 70);
if let FacetFeature::Tag(tag) = &facets[3].features[0] {
assert_eq!(tag.tag, "rust");
} else {
panic!("Expected Tag feature");
}
assert_eq!(facets[4].index.byte_start, 71);
assert_eq!(facets[4].index.byte_end, 78);
if let FacetFeature::Tag(tag) = &facets[4].features[0] {
assert_eq!(tag.tag, "golang");
} else {
panic!("Expected Tag feature");
}
}
#[tokio::test]
async fn test_parse_facets_from_text_with_unresolvable_mention() {
let resolver = MockIdentityResolver::new();
let limits = FacetLimits::default();
let text = "Contact @unknown.handle.com for details #rust";
let facets = parse_facets_from_text(text, &resolver, &limits).await;
assert!(facets.is_some());
let facets = facets.unwrap();
assert_eq!(facets.len(), 1);
if let FacetFeature::Tag(tag) = &facets[0].features[0] {
assert_eq!(tag.tag, "rust");
} else {
panic!("Expected Tag feature");
}
}
#[tokio::test]
async fn test_parse_facets_from_text_empty() {
let resolver = MockIdentityResolver::new();
let limits = FacetLimits::default();
let text = "No mentions, URLs, or hashtags here";
let facets = parse_facets_from_text(text, &resolver, &limits).await;
assert!(facets.is_none());
}
#[tokio::test]
async fn test_parse_facets_from_text_url_with_at_mention() {
let resolver = MockIdentityResolver::new();
let limits = FacetLimits::default();
let text = "Tangled https://tangled.org/@smokesignal.events";
let facets = parse_facets_from_text(text, &resolver, &limits).await;
assert!(facets.is_some());
let facets = facets.unwrap();
assert_eq!(
facets.len(),
1,
"Expected 1 facet (URL only), got {}",
facets.len()
);
if let FacetFeature::Link(link) = &facets[0].features[0] {
assert_eq!(link.uri, "https://tangled.org/@smokesignal.events");
} else {
panic!("Expected Link feature, got Mention or Tag instead");
}
}
#[tokio::test]
async fn test_parse_facets_with_mention_limit() {
let mut resolver = MockIdentityResolver::new();
resolver.add_identity("bob.test.com", "did:plc:bob456");
resolver.add_identity("charlie.test.com", "did:plc:charlie789");
let limits = FacetLimits {
mentions_max: 2,
tags_max: 5,
links_max: 5,
max: 10,
};
let text = "Join @alice.bsky.social @bob.test.com @charlie.test.com";
let facets = parse_facets_from_text(text, &resolver, &limits).await;
assert!(facets.is_some());
let facets = facets.unwrap();
assert_eq!(facets.len(), 2);
for facet in &facets {
assert!(matches!(facet.features[0], FacetFeature::Mention(_)));
}
}
#[tokio::test]
async fn test_parse_facets_with_global_limit() {
let mut resolver = MockIdentityResolver::new();
resolver.add_identity("bob.test.com", "did:plc:bob456");
let limits = FacetLimits {
mentions_max: 5,
tags_max: 5,
links_max: 5,
max: 3, };
let text =
"Join @alice.bsky.social @bob.test.com at https://example.com #rust #golang #python";
let facets = parse_facets_from_text(text, &resolver, &limits).await;
assert!(facets.is_some());
let facets = facets.unwrap();
assert_eq!(facets.len(), 3);
}
#[test]
fn test_parse_urls_multiple_links() {
let text = "IETF124 is happening in Montreal, Nov 1st to 7th https://www.ietf.org/meeting/124/\n\nWe're confirmed for two days of ATProto community sessions on Monday, Nov 3rd & Tuesday, Mov 4th at ECTO Co-Op. Many of us will also be participating in the free-to-attend IETF hackathon on Sunday, Nov 2nd.\n\nLatest updates and attendees in the forum https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164";
let facets = parse_urls(text);
assert_eq!(
facets.len(),
2,
"Expected 2 URLs but found {}",
facets.len()
);
if let Some(FacetFeature::Link(link)) = facets[0].features.first() {
assert_eq!(link.uri, "https://www.ietf.org/meeting/124/");
} else {
panic!("Expected Link feature");
}
if let Some(FacetFeature::Link(link)) = facets[1].features.first() {
assert_eq!(
link.uri,
"https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164"
);
} else {
panic!("Expected Link feature");
}
}
#[test]
fn test_parse_urls_with_html_entity() {
let text = "IETF124 is happening in Montreal, Nov 1st to 7th https://www.ietf.org/meeting/124/\n\nWe're confirmed for two days of ATProto community sessions on Monday, Nov 3rd & Tuesday, Mov 4th at ECTO Co-Op. Many of us will also be participating in the free-to-attend IETF hackathon on Sunday, Nov 2nd.\n\nLatest updates and attendees in the forum https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164";
let facets = parse_urls(text);
assert_eq!(
facets.len(),
2,
"Expected 2 URLs but found {}",
facets.len()
);
if let Some(FacetFeature::Link(link)) = facets[0].features.first() {
assert_eq!(link.uri, "https://www.ietf.org/meeting/124/");
} else {
panic!("Expected Link feature");
}
if let Some(FacetFeature::Link(link)) = facets[1].features.first() {
assert_eq!(
link.uri,
"https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164"
);
} else {
panic!("Expected Link feature");
}
}
#[test]
fn test_byte_offset_with_html_entities() {
let original_with_url = "Nov 3rd & Tuesday https://example.com";
let escaped_with_url = "Nov 3rd & Tuesday https://example.com";
let original_facets = parse_urls(original_with_url);
let escaped_facets = parse_urls(escaped_with_url);
assert_eq!(original_facets.len(), 1);
assert_eq!(escaped_facets.len(), 1);
assert_eq!(original_facets[0].index.byte_start, 18); assert_eq!(escaped_facets[0].index.byte_start, 22); }
#[test]
fn test_parse_urls_from_atproto_record_text() {
let text = "Dev, Power Users, and Generally inquisitive folks get a completely unprofessionally amateur interview. Just a yap sesh where chat is part of the call!\n\n✨the daniel✨ & I will be on a Zoom call and I will stream out to https://stream.place/psingletary.com\n\nSubscribe to the publications! https://atprotocalls.leaflet.pub/";
let facets = parse_urls(text);
assert_eq!(facets.len(), 2, "Should find 2 URLs");
assert_eq!(facets[0].index.byte_start, 221);
assert_eq!(facets[0].index.byte_end, 257);
if let Some(FacetFeature::Link(link)) = facets[0].features.first() {
assert_eq!(link.uri, "https://stream.place/psingletary.com");
}
assert_eq!(facets[1].index.byte_start, 290);
assert_eq!(facets[1].index.byte_end, 323);
if let Some(FacetFeature::Link(link)) = facets[1].features.first() {
assert_eq!(link.uri, "https://atprotocalls.leaflet.pub/");
}
let text_bytes = text.as_bytes();
assert_eq!(
std::str::from_utf8(&text_bytes[221..257]).unwrap(),
"https://stream.place/psingletary.com"
);
assert_eq!(
std::str::from_utf8(&text_bytes[290..323]).unwrap(),
"https://atprotocalls.leaflet.pub/"
);
}
#[tokio::test]
async fn test_parse_mentions_basic() {
let resolver = MockIdentityResolver::new();
let limits = FacetLimits::default();
let text = "Hello @alice.bsky.social!";
let facets = parse_mentions(text, &resolver, &limits).await;
assert_eq!(facets.len(), 1);
assert_eq!(facets[0].index.byte_start, 6);
assert_eq!(facets[0].index.byte_end, 24);
if let Some(FacetFeature::Mention(mention)) = facets[0].features.first() {
assert_eq!(mention.did, "did:plc:alice123");
} else {
panic!("Expected Mention feature");
}
}
#[tokio::test]
async fn test_parse_mentions_multiple() {
let mut resolver = MockIdentityResolver::new();
resolver.add_identity("bob.example.com", "did:plc:bob456");
let limits = FacetLimits::default();
let text = "CC @alice.bsky.social and @bob.example.com";
let facets = parse_mentions(text, &resolver, &limits).await;
assert_eq!(facets.len(), 2);
if let Some(FacetFeature::Mention(mention)) = facets[0].features.first() {
assert_eq!(mention.did, "did:plc:alice123");
}
if let Some(FacetFeature::Mention(mention)) = facets[1].features.first() {
assert_eq!(mention.did, "did:plc:bob456");
}
}
#[tokio::test]
async fn test_parse_mentions_unresolvable() {
let resolver = MockIdentityResolver::new();
let limits = FacetLimits::default();
let text = "Hello @unknown.handle.com!";
let facets = parse_mentions(text, &resolver, &limits).await;
assert_eq!(facets.len(), 0);
}
#[tokio::test]
async fn test_parse_mentions_in_url_excluded() {
let resolver = MockIdentityResolver::new();
let limits = FacetLimits::default();
let text = "Check https://tangled.org/@smokesignal.events";
let facets = parse_mentions(text, &resolver, &limits).await;
assert_eq!(facets.len(), 0);
}
#[test]
fn test_parse_tags_basic() {
let text = "Learning #rust today!";
let facets = parse_tags(text);
assert_eq!(facets.len(), 1);
assert_eq!(facets[0].index.byte_start, 9);
assert_eq!(facets[0].index.byte_end, 14);
if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() {
assert_eq!(tag.tag, "rust");
} else {
panic!("Expected Tag feature");
}
}
#[test]
fn test_parse_tags_multiple() {
let text = "#rust #golang #python are great!";
let facets = parse_tags(text);
assert_eq!(facets.len(), 3);
if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() {
assert_eq!(tag.tag, "rust");
}
if let Some(FacetFeature::Tag(tag)) = facets[1].features.first() {
assert_eq!(tag.tag, "golang");
}
if let Some(FacetFeature::Tag(tag)) = facets[2].features.first() {
assert_eq!(tag.tag, "python");
}
}
#[test]
fn test_parse_tags_excludes_numeric() {
let text = "Item #42 is special #test123";
let facets = parse_tags(text);
assert_eq!(facets.len(), 1);
if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() {
assert_eq!(tag.tag, "test123");
}
}
#[test]
fn test_parse_urls_basic() {
let text = "Visit https://example.com today!";
let facets = parse_urls(text);
assert_eq!(facets.len(), 1);
assert_eq!(facets[0].index.byte_start, 6);
assert_eq!(facets[0].index.byte_end, 25);
if let Some(FacetFeature::Link(link)) = facets[0].features.first() {
assert_eq!(link.uri, "https://example.com");
}
}
#[test]
fn test_parse_urls_with_path() {
let text = "Check https://example.com/path/to/page?query=1#section";
let facets = parse_urls(text);
assert_eq!(facets.len(), 1);
if let Some(FacetFeature::Link(link)) = facets[0].features.first() {
assert_eq!(link.uri, "https://example.com/path/to/page?query=1#section");
}
}
#[test]
fn test_facet_limits_default() {
let limits = FacetLimits::default();
assert_eq!(limits.mentions_max, 5);
assert_eq!(limits.tags_max, 5);
assert_eq!(limits.links_max, 5);
assert_eq!(limits.max, 10);
}
}