From 5e53f9a8cf6ea49755849f4ec413d1473b2b8a57 Mon Sep 17 00:00:00 2001
From: naskya <m@naskya.net>
Date: Fri, 17 May 2024 17:59:45 +0900
Subject: [PATCH] refactor (backend): port check-hit-antenna to backend-rs

---
 packages/backend-rs/index.d.ts                |  21 +-
 packages/backend-rs/index.js                  |   4 +-
 packages/backend-rs/src/database/cache.rs     |   6 +
 .../src/misc/add_note_to_antenna.rs           |  31 ---
 .../backend-rs/src/misc/check_hit_antenna.rs  | 203 ++++++++++++++++++
 .../backend-rs/src/misc/check_word_mute.rs    |  85 +-------
 .../backend-rs/src/misc/get_note_all_texts.rs |  79 +++++++
 packages/backend-rs/src/misc/mod.rs           |   3 +-
 packages/backend-rs/src/service/antenna.rs    |  48 +++++
 packages/backend-rs/src/service/mod.rs        |   1 +
 .../backend/src/misc/check-hit-antenna.ts     | 126 -----------
 packages/backend/src/services/note/create.ts  |  14 +-
 12 files changed, 362 insertions(+), 259 deletions(-)
 delete mode 100644 packages/backend-rs/src/misc/add_note_to_antenna.rs
 create mode 100644 packages/backend-rs/src/misc/check_hit_antenna.rs
 create mode 100644 packages/backend-rs/src/misc/get_note_all_texts.rs
 create mode 100644 packages/backend-rs/src/service/antenna.rs
 delete mode 100644 packages/backend/src/misc/check-hit-antenna.ts

diff --git a/packages/backend-rs/index.d.ts b/packages/backend-rs/index.d.ts
index 86c5044ac5..25ffd0b509 100644
--- a/packages/backend-rs/index.d.ts
+++ b/packages/backend-rs/index.d.ts
@@ -213,7 +213,6 @@ export function stringToAcct(acct: string): Acct
 export function acctToString(acct: Acct): string
 export function initializeRustLogger(): void
 export function showServerInfo(): void
-export function addNoteToAntenna(antennaId: string, note: Note): void
 /**
  * Checks if a server is blocked.
  *
@@ -236,15 +235,7 @@ export function isSilencedServer(host: string): Promise<boolean>
  * `host` - punycoded instance host
  */
 export function isAllowedServer(host: string): Promise<boolean>
-export interface NoteLikeForCheckWordMute {
-  fileIds: Array<string>
-  userId: string | null
-  text: string | null
-  cw: string | null
-  renoteId: string | null
-  replyId: string | null
-}
-export function checkWordMute(note: NoteLikeForCheckWordMute, mutedWords: Array<string>, mutedPatterns: Array<string>): Promise<boolean>
+export function checkWordMute(note: NoteLike, mutedWords: Array<string>, mutedPatterns: Array<string>): Promise<boolean>
 export function getFullApAccount(username: string, host?: string | undefined | null): string
 export function isSelfHost(host?: string | undefined | null): boolean
 export function isSameOrigin(uri: string): boolean
@@ -260,6 +251,15 @@ export interface ImageSize {
   height: number
 }
 export function getImageSizeFromUrl(url: string): Promise<ImageSize>
+/** TODO: handle name collisions better */
+export interface NoteLikeForAllTexts {
+  fileIds: Array<string>
+  userId: string
+  text: string | null
+  cw: string | null
+  renoteId: string | null
+  replyId: string | null
+}
 export interface NoteLikeForGetNoteSummary {
   fileIds: Array<string>
   text: string | null
@@ -1175,6 +1175,7 @@ export interface Webhook {
   latestSentAt: Date | null
   latestStatus: number | null
 }
+export function updateAntennaOnCreateNote(antenna: Antenna, note: Note, noteAuthor: Acct): Promise<void>
 export function fetchNodeinfo(host: string): Promise<Nodeinfo>
 export function nodeinfo_2_1(): Promise<any>
 export function nodeinfo_2_0(): Promise<any>
diff --git a/packages/backend-rs/index.js b/packages/backend-rs/index.js
index 71247827a9..dd64d767ff 100644
--- a/packages/backend-rs/index.js
+++ b/packages/backend-rs/index.js
@@ -310,7 +310,7 @@ if (!nativeBinding) {
   throw new Error(`Failed to load native binding`)
 }
 
-const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, initializeRustLogger, showServerInfo, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, isSafeUrl, latestVersion, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, cpuInfo, cpuUsage, memoryUsage, storageUsage, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, fetchNodeinfo, nodeinfo_2_1, nodeinfo_2_0, Protocol, Inbound, Outbound, watchNote, unwatchNote, PushNotificationKind, sendPushNotification, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, generateSecureRandomString, generateUserToken } = nativeBinding
+const { SECOND, MINUTE, HOUR, DAY, USER_ONLINE_THRESHOLD, USER_ACTIVE_THRESHOLD, FILE_TYPE_BROWSERSAFE, loadEnv, loadConfig, stringToAcct, acctToString, initializeRustLogger, showServerInfo, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getImageSizeFromUrl, getNoteSummary, isSafeUrl, latestVersion, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, removeOldAttestationChallenges, cpuInfo, cpuUsage, memoryUsage, storageUsage, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, updateAntennaOnCreateNote, fetchNodeinfo, nodeinfo_2_1, nodeinfo_2_0, Protocol, Inbound, Outbound, watchNote, unwatchNote, PushNotificationKind, sendPushNotification, publishToChannelStream, ChatEvent, publishToChatStream, ChatIndexEvent, publishToChatIndexStream, publishToBroadcastStream, publishToGroupChatStream, publishToModerationStream, getTimestamp, genId, genIdAt, generateSecureRandomString, generateUserToken } = nativeBinding
 
 module.exports.SECOND = SECOND
 module.exports.MINUTE = MINUTE
@@ -325,7 +325,6 @@ module.exports.stringToAcct = stringToAcct
 module.exports.acctToString = acctToString
 module.exports.initializeRustLogger = initializeRustLogger
 module.exports.showServerInfo = showServerInfo
-module.exports.addNoteToAntenna = addNoteToAntenna
 module.exports.isBlockedServer = isBlockedServer
 module.exports.isSilencedServer = isSilencedServer
 module.exports.isAllowedServer = isAllowedServer
@@ -370,6 +369,7 @@ module.exports.RelayStatusEnum = RelayStatusEnum
 module.exports.UserEmojimodpermEnum = UserEmojimodpermEnum
 module.exports.UserProfileFfvisibilityEnum = UserProfileFfvisibilityEnum
 module.exports.UserProfileMutingnotificationtypesEnum = UserProfileMutingnotificationtypesEnum
+module.exports.updateAntennaOnCreateNote = updateAntennaOnCreateNote
 module.exports.fetchNodeinfo = fetchNodeinfo
 module.exports.nodeinfo_2_1 = nodeinfo_2_1
 module.exports.nodeinfo_2_0 = nodeinfo_2_0
diff --git a/packages/backend-rs/src/database/cache.rs b/packages/backend-rs/src/database/cache.rs
index ba2910f254..f6f465ab00 100644
--- a/packages/backend-rs/src/database/cache.rs
+++ b/packages/backend-rs/src/database/cache.rs
@@ -6,6 +6,12 @@ use serde::{Deserialize, Serialize};
 pub enum Category {
     #[strum(serialize = "fetchUrl")]
     FetchUrl,
+    #[strum(serialize = "blocking")]
+    Block,
+    #[strum(serialize = "following")]
+    Follow,
+    #[strum(serialize = "wordMute")]
+    WordMute,
     #[cfg(test)]
     #[strum(serialize = "usedOnlyForTesting")]
     Test,
diff --git a/packages/backend-rs/src/misc/add_note_to_antenna.rs b/packages/backend-rs/src/misc/add_note_to_antenna.rs
deleted file mode 100644
index 2ce4e655d7..0000000000
--- a/packages/backend-rs/src/misc/add_note_to_antenna.rs
+++ /dev/null
@@ -1,31 +0,0 @@
-use crate::database::{redis_conn, redis_key};
-use crate::model::entity::note;
-use crate::service::stream;
-use crate::util::id::{get_timestamp, InvalidIdErr};
-use redis::{streams::StreamMaxlen, Commands, RedisError};
-
-#[derive(thiserror::Error, Debug)]
-pub enum Error {
-    #[error("Redis error: {0}")]
-    RedisErr(#[from] RedisError),
-    #[error("Invalid ID: {0}")]
-    InvalidIdErr(#[from] InvalidIdErr),
-    #[error("Stream error: {0}")]
-    StreamErr(#[from] stream::Error),
-}
-
-type Note = note::Model;
-
-#[crate::export]
-pub fn add_note_to_antenna(antenna_id: String, note: &Note) -> Result<(), Error> {
-    // for timeline API
-    redis_conn()?.xadd_maxlen(
-        redis_key(format!("antennaTimeline:{}", antenna_id)),
-        StreamMaxlen::Approx(200),
-        format!("{}-*", get_timestamp(&note.id)?),
-        &[("note", &note.id)],
-    )?;
-
-    // for streaming API
-    Ok(stream::antenna::publish(antenna_id, note)?)
-}
diff --git a/packages/backend-rs/src/misc/check_hit_antenna.rs b/packages/backend-rs/src/misc/check_hit_antenna.rs
new file mode 100644
index 0000000000..d8fa7b925f
--- /dev/null
+++ b/packages/backend-rs/src/misc/check_hit_antenna.rs
@@ -0,0 +1,203 @@
+use crate::config::CONFIG;
+use crate::database::{cache, db_conn};
+use crate::federation::acct::Acct;
+use crate::misc::check_word_mute::check_word_mute_bare;
+use crate::misc::get_note_all_texts::{all_texts, NoteLike};
+use crate::model::entity::{
+    antenna, blocking, following, note, sea_orm_active_enums::*, user_profile,
+};
+use sea_orm::{ColumnTrait, DbErr, EntityTrait, QueryFilter, QuerySelect};
+
+#[derive(thiserror::Error, Debug)]
+pub enum AntennaCheckError {
+    #[error("Database error: {0}")]
+    DbErr(#[from] DbErr),
+    #[error("Cache error: {0}")]
+    CacheErr(#[from] cache::Error),
+    #[error("User profile not found: {0}")]
+    UserProfileNotFoundErr(String),
+}
+
+fn match_all(space_separated_words: &str, text: &str, case_sensitive: bool) -> bool {
+    if case_sensitive {
+        space_separated_words
+            .split_whitespace()
+            .all(|word| text.contains(word))
+    } else {
+        space_separated_words
+            .to_lowercase()
+            .split_whitespace()
+            .all(|word| text.to_lowercase().contains(word))
+    }
+}
+
+pub async fn check_hit_antenna(
+    antenna: &antenna::Model,
+    note: note::Model,
+    note_author: &Acct,
+) -> Result<bool, AntennaCheckError> {
+    if note.visibility == NoteVisibilityEnum::Specified {
+        return Ok(false);
+    }
+
+    if antenna.with_file && note.file_ids.is_empty() {
+        return Ok(false);
+    }
+
+    if !antenna.with_replies && note.reply_id.is_some() {
+        return Ok(false);
+    }
+
+    if antenna.src == AntennaSrcEnum::Users {
+        let is_from_one_of_specified_authors = antenna
+            .users
+            .iter()
+            .map(|s| s.parse::<Acct>().unwrap())
+            .any(|acct| acct.username == note_author.username && acct.host == note_author.host);
+
+        if !is_from_one_of_specified_authors {
+            return Ok(false);
+        }
+    } else if antenna.src == AntennaSrcEnum::Instances {
+        let is_from_one_of_specified_servers = !antenna.instances.iter().any(|host| {
+            host.to_ascii_lowercase()
+                == note_author
+                    .host
+                    .clone()
+                    .unwrap_or(CONFIG.host.clone())
+                    .to_ascii_lowercase()
+        });
+
+        if !is_from_one_of_specified_servers {
+            return Ok(false);
+        }
+    }
+
+    // "Home", "Group", "List" sources are currently disabled
+
+    let note_texts = all_texts(NoteLike {
+        file_ids: note.file_ids,
+        user_id: note.user_id.clone(),
+        text: note.text,
+        cw: note.cw,
+        renote_id: note.renote_id,
+        reply_id: note.reply_id,
+    })
+    .await?;
+
+    let has_keyword = antenna.keywords.iter().any(|words| {
+        note_texts
+            .iter()
+            .any(|text| match_all(words, text, antenna.case_sensitive))
+    });
+
+    if !has_keyword {
+        return Ok(false);
+    }
+
+    let has_excluded_word = antenna.exclude_keywords.iter().any(|words| {
+        note_texts
+            .iter()
+            .any(|text| match_all(words, text, antenna.case_sensitive))
+    });
+
+    if has_excluded_word {
+        return Ok(false);
+    }
+
+    let db = db_conn().await?;
+
+    let blocked_user_ids: Vec<String> = cache::get_one(cache::Category::Block, &note.user_id)?
+        .unwrap_or({
+            // cache miss
+            let blocks = blocking::Entity::find()
+                .select_only()
+                .column(blocking::Column::BlockeeId)
+                .filter(blocking::Column::BlockerId.eq(&note.user_id))
+                .into_tuple::<String>()
+                .all(db)
+                .await?;
+            cache::set_one(cache::Category::Block, &note.user_id, &blocks, 10 * 60)?;
+            blocks
+        });
+
+    // if the antenna owner is blocked by the note author, return false
+    if blocked_user_ids.contains(&antenna.user_id) {
+        return Ok(false);
+    }
+
+    if [NoteVisibilityEnum::Home, NoteVisibilityEnum::Followers].contains(&note.visibility) {
+        let following_user_ids: Vec<String> =
+            cache::get_one(cache::Category::Follow, &antenna.user_id)?.unwrap_or({
+                // cache miss
+                let following = following::Entity::find()
+                    .select_only()
+                    .column(following::Column::FolloweeId)
+                    .filter(following::Column::FollowerId.eq(&antenna.user_id))
+                    .into_tuple::<String>()
+                    .all(db)
+                    .await?;
+                cache::set_one(
+                    cache::Category::Follow,
+                    &antenna.user_id,
+                    &following,
+                    10 * 60,
+                )?;
+                following
+            });
+
+        // if the antenna owner is not following the note author, return false
+        if !following_user_ids.contains(&note.user_id) {
+            return Ok(false);
+        }
+    }
+
+    type WordMute = (
+        Vec<String>, // muted words
+        Vec<String>, // muted patterns
+    );
+
+    let word_mute: WordMute = cache::get_one(cache::Category::WordMute, &antenna.user_id)?
+        .unwrap_or({
+            // cache miss
+            let mute = user_profile::Entity::find()
+                .select_only()
+                .columns([
+                    user_profile::Column::MutedWords,
+                    user_profile::Column::MutedPatterns,
+                ])
+                .into_tuple::<WordMute>()
+                .one(db)
+                .await?
+                .ok_or({
+                    tracing::warn!("there is no user_profile for user {}", &antenna.user_id);
+                    AntennaCheckError::UserProfileNotFoundErr(antenna.user_id.clone())
+                })?;
+            cache::set_one(cache::Category::WordMute, &antenna.user_id, &mute, 10 * 60)?;
+            mute
+        });
+
+    if check_word_mute_bare(&note_texts, &word_mute.0, &word_mute.1) {
+        return Ok(false);
+    }
+
+    Ok(true)
+}
+
+#[cfg(test)]
+mod unit_test {
+    use super::match_all;
+    use pretty_assertions::assert_eq;
+
+    #[test]
+    fn test_match_all() {
+        assert_eq!(match_all("Apple", "apple and banana", false), true);
+        assert_eq!(match_all("Apple", "apple and banana", true), false);
+        assert_eq!(match_all("Apple Banana", "apple and banana", false), true);
+        assert_eq!(match_all("Apple Banana", "apple and cinnamon", true), false);
+        assert_eq!(
+            match_all("Apple Banana", "apple and cinnamon", false),
+            false
+        );
+    }
+}
diff --git a/packages/backend-rs/src/misc/check_word_mute.rs b/packages/backend-rs/src/misc/check_word_mute.rs
index 571d0c6636..fd51765ac4 100644
--- a/packages/backend-rs/src/misc/check_word_mute.rs
+++ b/packages/backend-rs/src/misc/check_word_mute.rs
@@ -1,91 +1,14 @@
-use crate::database::db_conn;
-use crate::model::entity::{drive_file, note};
+use crate::misc::get_note_all_texts::{all_texts, NoteLike};
 use once_cell::sync::Lazy;
 use regex::Regex;
-use sea_orm::{prelude::*, QuerySelect};
-
-// TODO: handle name collisions in a better way
-#[crate::export(object, js_name = "NoteLikeForCheckWordMute")]
-pub struct NoteLike {
-    pub file_ids: Vec<String>,
-    pub user_id: Option<String>,
-    pub text: Option<String>,
-    pub cw: Option<String>,
-    pub renote_id: Option<String>,
-    pub reply_id: Option<String>,
-}
-
-async fn all_texts(note: NoteLike) -> Result<Vec<String>, DbErr> {
-    let db = db_conn().await?;
-
-    let mut texts: Vec<String> = vec![];
-
-    if let Some(text) = note.text {
-        texts.push(text);
-    }
-    if let Some(cw) = note.cw {
-        texts.push(cw);
-    }
-
-    texts.extend(
-        drive_file::Entity::find()
-            .select_only()
-            .column(drive_file::Column::Comment)
-            .filter(drive_file::Column::Id.is_in(note.file_ids))
-            .into_tuple::<Option<String>>()
-            .all(db)
-            .await?
-            .into_iter()
-            .flatten(),
-    );
-
-    if let Some(renote_id) = &note.renote_id {
-        if let Some((text, cw)) = note::Entity::find_by_id(renote_id)
-            .select_only()
-            .columns([note::Column::Text, note::Column::Cw])
-            .into_tuple::<(Option<String>, Option<String>)>()
-            .one(db)
-            .await?
-        {
-            if let Some(t) = text {
-                texts.push(t);
-            }
-            if let Some(c) = cw {
-                texts.push(c);
-            }
-        } else {
-            tracing::warn!("nonexistent renote id: {:#?}", renote_id);
-        }
-    }
-
-    if let Some(reply_id) = &note.reply_id {
-        if let Some((text, cw)) = note::Entity::find_by_id(reply_id)
-            .select_only()
-            .columns([note::Column::Text, note::Column::Cw])
-            .into_tuple::<(Option<String>, Option<String>)>()
-            .one(db)
-            .await?
-        {
-            if let Some(t) = text {
-                texts.push(t);
-            }
-            if let Some(c) = cw {
-                texts.push(c);
-            }
-        } else {
-            tracing::warn!("nonexistent reply id: {:#?}", reply_id);
-        }
-    }
-
-    Ok(texts)
-}
+use sea_orm::DbErr;
 
 fn convert_regex(js_regex: &str) -> String {
     static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/(.+)/(.*)$").unwrap());
     RE.replace(js_regex, "(?$2)$1").to_string()
 }
 
-fn check_word_mute_impl(
+pub fn check_word_mute_bare(
     texts: &[String],
     muted_words: &[String],
     muted_patterns: &[String],
@@ -112,7 +35,7 @@ pub async fn check_word_mute(
     if muted_words.is_empty() && muted_patterns.is_empty() {
         Ok(false)
     } else {
-        Ok(check_word_mute_impl(
+        Ok(check_word_mute_bare(
             &all_texts(note).await?,
             muted_words,
             muted_patterns,
diff --git a/packages/backend-rs/src/misc/get_note_all_texts.rs b/packages/backend-rs/src/misc/get_note_all_texts.rs
new file mode 100644
index 0000000000..1e4bee396e
--- /dev/null
+++ b/packages/backend-rs/src/misc/get_note_all_texts.rs
@@ -0,0 +1,79 @@
+use crate::database::db_conn;
+use crate::model::entity::{drive_file, note};
+use sea_orm::{prelude::*, QuerySelect};
+
+/// TODO: handle name collisions better
+#[crate::export(object, js_name = "NoteLikeForAllTexts")]
+pub struct NoteLike {
+    pub file_ids: Vec<String>,
+    pub user_id: String,
+    pub text: Option<String>,
+    pub cw: Option<String>,
+    pub renote_id: Option<String>,
+    pub reply_id: Option<String>,
+}
+
+pub async fn all_texts(note: NoteLike) -> Result<Vec<String>, DbErr> {
+    let db = db_conn().await?;
+
+    let mut texts: Vec<String> = vec![];
+
+    if let Some(text) = note.text {
+        texts.push(text);
+    }
+    if let Some(cw) = note.cw {
+        texts.push(cw);
+    }
+
+    texts.extend(
+        drive_file::Entity::find()
+            .select_only()
+            .column(drive_file::Column::Comment)
+            .filter(drive_file::Column::Id.is_in(note.file_ids))
+            .into_tuple::<Option<String>>()
+            .all(db)
+            .await?
+            .into_iter()
+            .flatten(),
+    );
+
+    if let Some(renote_id) = &note.renote_id {
+        if let Some((text, cw)) = note::Entity::find_by_id(renote_id)
+            .select_only()
+            .columns([note::Column::Text, note::Column::Cw])
+            .into_tuple::<(Option<String>, Option<String>)>()
+            .one(db)
+            .await?
+        {
+            if let Some(t) = text {
+                texts.push(t);
+            }
+            if let Some(c) = cw {
+                texts.push(c);
+            }
+        } else {
+            tracing::warn!("nonexistent renote id: {:#?}", renote_id);
+        }
+    }
+
+    if let Some(reply_id) = &note.reply_id {
+        if let Some((text, cw)) = note::Entity::find_by_id(reply_id)
+            .select_only()
+            .columns([note::Column::Text, note::Column::Cw])
+            .into_tuple::<(Option<String>, Option<String>)>()
+            .one(db)
+            .await?
+        {
+            if let Some(t) = text {
+                texts.push(t);
+            }
+            if let Some(c) = cw {
+                texts.push(c);
+            }
+        } else {
+            tracing::warn!("nonexistent reply id: {:#?}", reply_id);
+        }
+    }
+
+    Ok(texts)
+}
diff --git a/packages/backend-rs/src/misc/mod.rs b/packages/backend-rs/src/misc/mod.rs
index ed84f4ece2..a91dc4c862 100644
--- a/packages/backend-rs/src/misc/mod.rs
+++ b/packages/backend-rs/src/misc/mod.rs
@@ -1,4 +1,4 @@
-pub mod add_note_to_antenna;
+pub mod check_hit_antenna;
 pub mod check_server_block;
 pub mod check_word_mute;
 pub mod convert_host;
@@ -6,6 +6,7 @@ pub mod emoji;
 pub mod escape_sql;
 pub mod format_milliseconds;
 pub mod get_image_size;
+pub mod get_note_all_texts;
 pub mod get_note_summary;
 pub mod is_safe_url;
 pub mod latest_version;
diff --git a/packages/backend-rs/src/service/antenna.rs b/packages/backend-rs/src/service/antenna.rs
new file mode 100644
index 0000000000..582f2f541a
--- /dev/null
+++ b/packages/backend-rs/src/service/antenna.rs
@@ -0,0 +1,48 @@
+use crate::database::{redis_conn, redis_key};
+use crate::federation::acct::Acct;
+use crate::misc::check_hit_antenna::{check_hit_antenna, AntennaCheckError};
+use crate::model::entity::{antenna, note};
+use crate::service::stream;
+use crate::util::id::{get_timestamp, InvalidIdErr};
+use redis::{streams::StreamMaxlen, Commands, RedisError};
+
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+    #[error("Redis error: {0}")]
+    RedisErr(#[from] RedisError),
+    #[error("Invalid ID: {0}")]
+    InvalidIdErr(#[from] InvalidIdErr),
+    #[error("Stream error: {0}")]
+    StreamErr(#[from] stream::Error),
+    #[error("Failed to check if the note should be added to antenna: {0}")]
+    AntennaCheckErr(#[from] AntennaCheckError),
+}
+
+// https://github.com/napi-rs/napi-rs/issues/2060
+type Antenna = antenna::Model;
+type Note = note::Model;
+
+#[crate::export]
+pub async fn update_antenna_on_create_note(
+    antenna: &Antenna,
+    note: Note,
+    note_author: &Acct,
+) -> Result<(), Error> {
+    if check_hit_antenna(antenna, note.clone(), note_author).await? {
+        add_note_to_antenna(&antenna.id, &note)?;
+    }
+    Ok(())
+}
+
+pub fn add_note_to_antenna(antenna_id: &str, note: &Note) -> Result<(), Error> {
+    // for timeline API
+    redis_conn()?.xadd_maxlen(
+        redis_key(format!("antennaTimeline:{}", antenna_id)),
+        StreamMaxlen::Approx(200),
+        format!("{}-*", get_timestamp(&note.id)?),
+        &[("note", &note.id)],
+    )?;
+
+    // for streaming API
+    Ok(stream::antenna::publish(antenna_id.to_string(), note)?)
+}
diff --git a/packages/backend-rs/src/service/mod.rs b/packages/backend-rs/src/service/mod.rs
index 6de98f4674..4a5a0a7311 100644
--- a/packages/backend-rs/src/service/mod.rs
+++ b/packages/backend-rs/src/service/mod.rs
@@ -1,3 +1,4 @@
+pub mod antenna;
 pub mod nodeinfo;
 pub mod note;
 pub mod push_notification;
diff --git a/packages/backend/src/misc/check-hit-antenna.ts b/packages/backend/src/misc/check-hit-antenna.ts
deleted file mode 100644
index 1bdc6def79..0000000000
--- a/packages/backend/src/misc/check-hit-antenna.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import type { Antenna } from "@/models/entities/antenna.js";
-import type { Note } from "@/models/entities/note.js";
-import type { User } from "@/models/entities/user.js";
-import type { UserProfile } from "@/models/entities/user-profile.js";
-import { Blockings, Followings, UserProfiles } from "@/models/index.js";
-import { checkWordMute, getFullApAccount, stringToAcct } from "backend-rs";
-import type { Packed } from "@/misc/schema.js";
-import { Cache } from "@/misc/cache.js";
-
-const blockingCache = new Cache<User["id"][]>("blocking", 60 * 5);
-const hardMutesCache = new Cache<{
-	userId: UserProfile["userId"];
-	mutedWords: UserProfile["mutedWords"];
-	mutedPatterns: UserProfile["mutedPatterns"];
-}>("hardMutes", 60 * 5);
-const followingCache = new Cache<User["id"][]>("following", 60 * 5);
-
-export async function checkHitAntenna(
-	antenna: Antenna,
-	note: Note | Packed<"Note">,
-	noteUser: { id: User["id"]; username: string; host: string | null },
-): Promise<boolean> {
-	if (note.visibility === "specified") return false;
-	if (antenna.withFile) {
-		if (note.fileIds && note.fileIds.length === 0) return false;
-	}
-	if (!antenna.withReplies && note.replyId != null) return false;
-
-	if (antenna.src === "users") {
-		const accts = antenna.users.map((x) => {
-			const { username, host } = stringToAcct(x);
-			return getFullApAccount(username, host).toLowerCase();
-		});
-		if (
-			!accts.includes(
-				getFullApAccount(noteUser.username, noteUser.host).toLowerCase(),
-			)
-		)
-			return false;
-	} else if (antenna.src === "instances") {
-		const instances = antenna.instances
-			.filter((x) => x !== "")
-			.map((host) => {
-				return host.toLowerCase();
-			});
-		if (!instances.includes(noteUser.host?.toLowerCase() ?? "")) return false;
-	}
-
-	let text = `${note.text ?? ""} ${note.cw ?? ""}`;
-	if (note.files != null)
-		text += ` ${note.files.map((f) => f.comment ?? "").join(" ")}`;
-	text = text.trim();
-
-	if (antenna.keywords.length > 0) {
-		if (note.text == null) return false;
-
-		const matched = antenna.keywords.some((item) =>
-			item
-				.split(" ")
-				.every((keyword) =>
-					antenna.caseSensitive
-						? text.includes(keyword)
-						: text.toLowerCase().includes(keyword.toLowerCase()),
-				),
-		);
-
-		if (!matched) return false;
-	}
-
-	if (antenna.excludeKeywords.length > 0) {
-		if (note.text == null) return false;
-
-		const matched = antenna.excludeKeywords.some((item) =>
-			item
-				.split(" ")
-				.every((keyword) =>
-					antenna.caseSensitive
-						? note.text?.includes(keyword)
-						: note.text?.toLowerCase().includes(keyword.toLowerCase()),
-				),
-		);
-
-		if (matched) return false;
-	}
-
-	// アンテナ作成者がノート作成者にブロックされていたらスキップ
-	const blockings = await blockingCache.fetch(noteUser.id, () =>
-		Blockings.findBy({ blockerId: noteUser.id }).then((res) =>
-			res.map((x) => x.blockeeId),
-		),
-	);
-	if (blockings.includes(antenna.userId)) return false;
-
-	if (note.visibility === "followers" || note.visibility === "home") {
-		const following = await followingCache.fetch(antenna.userId, () =>
-			Followings.find({
-				where: { followerId: antenna.userId },
-				select: ["followeeId"],
-			}).then((relations) => relations.map((relation) => relation.followeeId)),
-		);
-		if (!following.includes(note.userId)) return false;
-	}
-
-	const mutes = await hardMutesCache.fetch(antenna.userId, () =>
-		UserProfiles.findOneByOrFail({
-			userId: antenna.userId,
-		}).then((profile) => {
-			return {
-				userId: antenna.userId,
-				mutedWords: profile.mutedWords,
-				mutedPatterns: profile.mutedPatterns,
-			};
-		}),
-	);
-	if (
-		mutes.mutedWords != null &&
-		mutes.mutedPatterns != null &&
-		antenna.userId !== note.userId &&
-		(await checkWordMute(note, mutes.mutedWords, mutes.mutedPatterns))
-	)
-		return false;
-
-	// TODO: eval expression
-
-	return true;
-}
diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts
index 0e484ffc3c..32c9647a14 100644
--- a/packages/backend/src/services/note/create.ts
+++ b/packages/backend/src/services/note/create.ts
@@ -42,9 +42,8 @@ import type { IPoll } from "@/models/entities/poll.js";
 import { Poll } from "@/models/entities/poll.js";
 import { createNotification } from "@/services/create-notification.js";
 import { isDuplicateKeyValueError } from "@/misc/is-duplicate-key-value-error.js";
-import { checkHitAntenna } from "@/misc/check-hit-antenna.js";
 import {
-	addNoteToAntenna,
+	updateAntennaOnCreateNote,
 	checkWordMute,
 	genId,
 	genIdAt,
@@ -401,12 +400,11 @@ export default async (
 
 		// Antenna
 		for (const antenna of await getAntennas()) {
-			checkHitAntenna(antenna, note, user).then((hit) => {
-				if (hit) {
-					// TODO: do this more sanely
-					addNoteToAntenna(antenna.id, toRustObject(note));
-				}
-			});
+			await updateAntennaOnCreateNote(
+				toRustObject(antenna),
+				toRustObject(note),
+				user,
+			);
 		}
 
 		// Channel