🐸 Frogue(Fed)

~ololduck/Frogue

Raw

// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later

//! ActivityPub mention and hashtag extraction, resolution, and rendering.
//!
//! Mentions are written as `@username` for local users and `@user@host` for
//! remote actors. Hashtags are written as `#tag`.
//!
//! Extraction works on the markdown source and skips code spans, code blocks,
//! and existing links. Resolution only uses the local database: local users
//! and remote actors that are already cached. Unknown remote actors are left
//! unresolved rather than triggering network requests from a document builder.

use std::collections::BTreeSet;

use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};

use crate::activitypub::types::Tag;

/// A mention as it was written in the source.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct MentionRequest {
    /// Username portion, without the leading `@`.
    pub username: String,
    /// Remote host, or `None` for a local mention.
    pub host: Option<String>,
}

impl MentionRequest {
    /// The normalized handle, e.g. `@alice` or `@alice@example.org`.
    pub fn handle(&self) -> String {
        match &self.host {
            Some(host) => format!("@{}@{}", self.username, host),
            None => format!("@{}", self.username),
        }
    }
}

/// A mention that could be resolved to an actor URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedMention {
    pub handle: String,
    pub href: String,
}

/// Extracts mentions and hashtags from a markdown source.
///
/// Returns unique mentions in source order and unique hashtags in source order.
pub fn parse(source: &str) -> (Vec<MentionRequest>, Vec<String>) {
    use pulldown_cmark::{Event, Parser, Tag as MdTag, TagEnd};

    let mut mentions = Vec::new();
    let mut seen_mentions = BTreeSet::new();
    let mut hashtags = Vec::new();
    let mut seen_hashtags = BTreeSet::new();
    let mut code_depth = 0usize;
    let mut link_depth = 0usize;

    for event in Parser::new_ext(source, crate::markdown::options()) {
        match event {
            Event::Start(MdTag::CodeBlock(_)) => code_depth += 1,
            Event::End(TagEnd::CodeBlock) => code_depth = code_depth.saturating_sub(1),
            Event::Code(_) => {}
            Event::Start(MdTag::Link { .. }) => link_depth += 1,
            Event::End(TagEnd::Link) => link_depth = link_depth.saturating_sub(1),
            Event::Text(text) if code_depth == 0 && link_depth == 0 => {
                scan_text(
                    text.as_ref(),
                    &mut mentions,
                    &mut seen_mentions,
                    &mut hashtags,
                    &mut seen_hashtags,
                );
            }
            _ => {}
        }
    }

    (mentions, hashtags)
}

fn scan_text(
    text: &str,
    mentions: &mut Vec<MentionRequest>,
    seen_mentions: &mut BTreeSet<MentionRequest>,
    hashtags: &mut Vec<String>,
    seen_hashtags: &mut BTreeSet<String>,
) {
    let bytes = text.as_bytes();
    let mut pos = 0;

    while pos < bytes.len() {
        match bytes[pos] {
            b'@' => {
                let before_ok = pos == 0 || !is_word_byte(bytes[pos - 1]);
                if !before_ok {
                    pos += 1;
                    continue;
                }

                let username_start = pos + 1;
                let username_end = scan_while(text, username_start, is_username_byte);
                if username_end == username_start {
                    pos += 1;
                    continue;
                }

                let username = &text[username_start..username_end];
                let (host, end) = if bytes.get(username_end) == Some(&b'@') {
                    let host_start = username_end + 1;
                    let host_end = scan_while(text, host_start, is_host_byte);
                    if host_end == host_start {
                        (None, username_end)
                    } else {
                        (Some(text[host_start..host_end].to_string()), host_end)
                    }
                } else {
                    (None, username_end)
                };

                let request = MentionRequest {
                    username: username.to_string(),
                    host,
                };
                if seen_mentions.insert(request.clone()) {
                    mentions.push(request);
                }
                pos = end;
            }
            b'#' => {
                let before_ok = pos == 0 || !is_word_byte(bytes[pos - 1]);
                if !before_ok {
                    pos += 1;
                    continue;
                }

                let tag_start = pos + 1;
                let tag_end = scan_while(text, tag_start, is_hashtag_byte);
                // `#123` is a ticket shorthand, not a hashtag.
                if tag_end == tag_start
                    || text[tag_start..tag_end].bytes().all(|b| b.is_ascii_digit())
                {
                    pos += 1;
                    continue;
                }

                let tag = text[tag_start..tag_end].to_string();
                if seen_hashtags.insert(tag.clone()) {
                    hashtags.push(tag);
                }
                pos = tag_end;
            }
            _ => pos += 1,
        }
    }
}

fn scan_while(text: &str, start: usize, predicate: fn(u8) -> bool) -> usize {
    let bytes = text.as_bytes();
    let mut end = start;
    while end < bytes.len() && predicate(bytes[end]) {
        end += 1;
    }
    end
}

fn is_word_byte(byte: u8) -> bool {
    byte.is_ascii_alphanumeric() || byte == b'_'
}

fn is_username_byte(byte: u8) -> bool {
    byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')
}

fn is_host_byte(byte: u8) -> bool {
    byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')
}

fn is_hashtag_byte(byte: u8) -> bool {
    byte.is_ascii_alphanumeric() || byte == b'_'
}

/// Resolves mentions against local users and cached remote actors.
///
/// Unresolvable mentions are omitted.
pub async fn resolve(
    db: &DatabaseConnection,
    public_url: &str,
    requests: &[MentionRequest],
) -> Vec<ResolvedMention> {
    use crate::entities::prelude::{ApRemoteActors, Users};
    use crate::entities::{ap_remote_actors, users};

    let mut resolved = Vec::new();

    for request in requests {
        let Some(host) = &request.host else {
            let user = Users::find()
                .filter(users::Column::Name.eq(request.username.as_str()))
                .filter(users::Column::DeletedAt.is_null())
                .one(db)
                .await
                .ok()
                .flatten();
            if let Some(user) = user {
                resolved.push(ResolvedMention {
                    handle: request.handle(),
                    href: crate::activitypub::ApUrls::new(public_url).actor(&user.name),
                });
            }
            continue;
        };

        let actor = ApRemoteActors::find()
            .filter(ap_remote_actors::Column::PreferredUsername.eq(request.username.as_str()))
            .all(db)
            .await
            .ok()
            .unwrap_or_default()
            .into_iter()
            .find(|actor| actor_host(&actor.actor_url).as_deref() == Some(host.as_str()));
        if let Some(actor) = actor {
            resolved.push(ResolvedMention {
                handle: request.handle(),
                href: actor.actor_url,
            });
        }
    }

    resolved
}

fn actor_host(actor_url: &str) -> Option<String> {
    url::Url::parse(actor_url)
        .ok()
        .and_then(|url| url.host_str().map(str::to_string))
}

/// Builds the ActivityStreams `tag` list for resolved mentions and hashtags.
pub fn tags(mentions: &[ResolvedMention], hashtags: &[String], public_url: &str) -> Vec<Tag> {
    let mut tags = Vec::new();
    for mention in mentions {
        tags.push(Tag {
            kind: "Mention".to_string(),
            href: mention.href.clone(),
            name: Some(mention.handle.clone()),
        });
    }
    for hashtag in hashtags {
        tags.push(Tag {
            kind: "Hashtag".to_string(),
            href: format!("{}/tags/{}", public_url.trim_end_matches('/'), hashtag),
            name: Some(format!("#{hashtag}")),
        });
    }
    tags
}

/// Adds resolved mention actor URLs to an addressing list, avoiding duplicates.
pub fn add_mention_targets(cc: &mut Vec<String>, mentions: &[ResolvedMention]) {
    for mention in mentions {
        if !cc.contains(&mention.href) {
            cc.push(mention.href.clone());
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_local_and_remote_mentions() {
        let (mentions, hashtags) = parse("Hi @alice and @bob@example.org, see #ActivityPub!");

        assert_eq!(
            mentions,
            vec![
                MentionRequest {
                    username: "alice".into(),
                    host: None,
                },
                MentionRequest {
                    username: "bob".into(),
                    host: Some("example.org".into()),
                },
            ]
        );
        assert_eq!(hashtags, vec!["ActivityPub"]);
    }

    #[test]
    fn ignores_mentions_in_code_and_links() {
        let (mentions, hashtags) =
            parse("`@alice` and [@bob](https://example.org) and\n\n```\n@carol #rust\n```\n");

        assert!(mentions.is_empty(), "{mentions:?}");
        assert!(hashtags.is_empty(), "{hashtags:?}");
    }

    #[test]
    fn does_not_treat_ticket_refs_as_hashtags() {
        let (_, hashtags) = parse("Fixes #12 and #ActivityPub #123abc");

        assert_eq!(hashtags, vec!["ActivityPub", "123abc"]);
    }

    #[test]
    fn deduplicates_mentions_and_hashtags() {
        let (mentions, hashtags) = parse("@alice @alice #rust #rust");

        assert_eq!(mentions.len(), 1);
        assert_eq!(hashtags, vec!["rust"]);
    }

    #[test]
    fn ignores_email_local_part() {
        let (mentions, _) = parse("mail me at bob@example.org");

        assert!(mentions.is_empty(), "{mentions:?}");
    }
}