diff --git a/.github/workflows/bsky-sdk.yml b/.github/workflows/bsky-sdk.yml new file mode 100644 index 00000000..09c2a5a4 --- /dev/null +++ b/.github/workflows/bsky-sdk.yml @@ -0,0 +1,30 @@ +name: bsky-sdk + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build + run: | + cargo build -p bsky-sdk --verbose + cargo build -p bsky-sdk --verbose --no-default-features + cargo build -p bsky-sdk --verbose --no-default-features --features default-client + cargo build -p bsky-sdk --verbose --no-default-features --features rich-text + cargo build -p bsky-sdk --verbose --all-features + - name: Run tests + run: | + cargo test -p bsky-sdk + cargo test -p bsky-sdk --lib --no-default-features + cargo test -p bsky-sdk --lib --no-default-features --features default-client + cargo test -p bsky-sdk --lib --no-default-features --features rich-text + cargo test -p bsky-sdk --lib --all-features diff --git a/Cargo.lock b/Cargo.lock index 49e271b6..b277e102 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,9 +91,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.80" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "assert-json-diff" @@ -118,9 +118,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.77" +version = "0.1.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" +checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ "proc-macro2", "quote", @@ -185,7 +185,6 @@ dependencies = [ "async-trait", "atrium-xrpc", "futures", - "http 1.1.0", "isahc", "mockito", "reqwest", @@ -239,6 +238,25 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +[[package]] +name = "bsky-sdk" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "atrium-api", + "atrium-xrpc-client", + "ipld-core", + "psl", + "regex", + "serde", + "serde_json", + "thiserror", + "tokio", + "toml", + "unicode-segmentation", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -915,9 +933,9 @@ dependencies = [ [[package]] name = "ipld-core" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd30b9d3019be78818b8d20691ecfa98630ae2d7fb23ffd4d668ee27ad25108" +checksum = "b4ede82a79e134f179f4b29b5fdb1eb92bd1b38c4dfea394c539051150a21b9b" dependencies = [ "cid", "serde", @@ -1338,6 +1356,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl" +version = "2.1.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71cad2f472a847f48e9b6d4712238b77ce586801c4b1702dc6c026290b25c6ff" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "quote" version = "1.0.35" @@ -1596,9 +1629,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.197" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" dependencies = [ "serde_derive", ] @@ -1614,9 +1647,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", @@ -1650,15 +1683,24 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.114" +version = "1.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" dependencies = [ "itoa", "ryu", "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1782,18 +1824,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.57" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b" +checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.57" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" +checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", @@ -1880,6 +1922,40 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.8.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e43f8cc456c9704c851ae29c67e17ef65d2c30017c17a9765b89c382dc8bba" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c127785850e8c20836d49732ae6abfa47616e60bf9d9f57c43c250361a9db96c" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -1977,6 +2053,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + [[package]] name = "unsigned-varint" version = "0.7.2" @@ -2290,6 +2372,15 @@ version = "0.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0770833d60a970638e989b3fa9fd2bb1aaadcf88963d1659fd7d9990196ed2d6" +[[package]] +name = "winnow" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86c949fede1d13936a99f14fafd3e76fd642b556dd2ce96287fbe2e0151bfac6" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" diff --git a/Cargo.toml b/Cargo.toml index 4a96f82e..878c5ca6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "atrium-cli", "atrium-xrpc", "atrium-xrpc-client", + "bsky-sdk", ] # Examples show how to use the latest published crates, not the workspace state. exclude = [ @@ -27,7 +28,7 @@ atrium-xrpc-client = { version = "0.5.4", path = "atrium-xrpc-client" } # async in traits # Can be removed once MSRV is at least 1.75.0. -async-trait = "0.1.68" +async-trait = "0.1.80" # DAG-CBOR codec ipld-core = { version = "0.4.0", default-features = false, features = ["std"] } @@ -37,9 +38,9 @@ serde_ipld_dagcbor = { version = "0.6.0", default-features = false, features = [ chrono = "0.4" langtag = "0.3" regex = "1" -serde = "1.0.160" +serde = "1.0.202" serde_bytes = "0.11.9" -serde_json = "1.0.96" +serde_json = "1.0.117" serde_html_form = "0.2.6" # Networking @@ -52,7 +53,7 @@ isahc = "1.7.2" reqwest = { version = "0.12", default-features = false } # Errors -anyhow = "1.0.80" +anyhow = "1.0.86" thiserror = "1.0" # CLI diff --git a/atrium-api/src/types/integer.rs b/atrium-api/src/types/integer.rs index 78fd85cc..cdebe31c 100644 --- a/atrium-api/src/types/integer.rs +++ b/atrium-api/src/types/integer.rs @@ -7,7 +7,7 @@ use serde::{de::Error, Deserialize}; macro_rules! uint { ($primitive:ident, $nz:ident, $lim:ident, $lim_nz:ident, $bounded:ident) => { /// An unsigned integer with a maximum value of `MAX`. - #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] + #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, Hash)] #[repr(transparent)] #[serde(transparent)] pub struct $lim($primitive); @@ -53,7 +53,7 @@ macro_rules! uint { } /// An unsigned integer with a minimum value of 1 and a maximum value of `MAX`. - #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] + #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, Hash)] #[repr(transparent)] #[serde(transparent)] pub struct $lim_nz($nz); @@ -111,7 +111,7 @@ macro_rules! uint { /// An unsigned integer with a minimum value of `MIN` and a maximum value of `MAX`. /// /// `MIN` must be non-zero. - #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] + #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, Hash)] #[repr(transparent)] #[serde(transparent)] pub struct $bounded($nz); diff --git a/atrium-api/src/types/string.rs b/atrium-api/src/types/string.rs index b2759069..beabfef1 100644 --- a/atrium-api/src/types/string.rs +++ b/atrium-api/src/types/string.rs @@ -54,7 +54,7 @@ macro_rules! string_newtype { } /// An AT Protocol identifier. -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] #[serde(untagged)] pub enum AtIdentifier { Did(Did), @@ -106,7 +106,7 @@ impl AsRef for AtIdentifier { /// A [CID in string format]. /// /// [CID in string format]: https://atproto.com/specs/data-model#link-and-cid-formats -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct Cid(cid::Cid); impl Cid { @@ -254,7 +254,7 @@ impl AsRef> for Datetime { /// A generic [DID Identifier]. /// /// [DID Identifier]: https://atproto.com/specs/did -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)] #[serde(transparent)] pub struct Did(String); string_newtype!(Did); @@ -295,7 +295,7 @@ impl Did { /// A [Handle Identifier]. /// /// [Handle Identifier]: https://atproto.com/specs/handle -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)] #[serde(transparent)] pub struct Handle(String); string_newtype!(Handle); @@ -331,7 +331,7 @@ impl Handle { /// A [Namespaced Identifier]. /// /// [Namespaced Identifier]: https://atproto.com/specs/nsid -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)] #[serde(transparent)] pub struct Nsid(String); string_newtype!(Nsid); @@ -379,7 +379,7 @@ impl Nsid { /// An [IETF Language Tag] string. /// /// [IETF Language Tag]: https://en.wikipedia.org/wiki/IETF_language_tag -#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Hash)] #[serde(transparent)] pub struct Language(LanguageTagBuf); @@ -416,7 +416,7 @@ impl Serialize for Language { /// A [Timestamp Identifier]. /// /// [Timestamp Identifier]: https://atproto.com/specs/record-key#record-key-type-tid -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)] #[serde(transparent)] pub struct Tid(String); string_newtype!(Tid); @@ -452,7 +452,7 @@ impl Tid { /// A record key (`rkey`) used to name and reference an individual record within the same /// collection of an atproto repository. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Hash)] pub struct RecordKey(String); string_newtype!(RecordKey); diff --git a/atrium-xrpc-client/Cargo.toml b/atrium-xrpc-client/Cargo.toml index cb4e5225..ac2cbcab 100644 --- a/atrium-xrpc-client/Cargo.toml +++ b/atrium-xrpc-client/Cargo.toml @@ -14,7 +14,6 @@ keywords.workspace = true [dependencies] async-trait.workspace = true atrium-xrpc.workspace = true -http.workspace = true isahc = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } diff --git a/atrium-xrpc-client/src/isahc.rs b/atrium-xrpc-client/src/isahc.rs index 0f6492b8..06f5917e 100644 --- a/atrium-xrpc-client/src/isahc.rs +++ b/atrium-xrpc-client/src/isahc.rs @@ -1,7 +1,7 @@ #![doc = "XrpcClient implementation for [isahc]"] use async_trait::async_trait; +use atrium_xrpc::http::{Request, Response}; use atrium_xrpc::{HttpClient, XrpcClient}; -use http::{Request, Response}; use isahc::{AsyncReadResponseExt, HttpClient as Client}; use std::sync::Arc; diff --git a/atrium-xrpc-client/src/reqwest.rs b/atrium-xrpc-client/src/reqwest.rs index 94195679..83039e91 100644 --- a/atrium-xrpc-client/src/reqwest.rs +++ b/atrium-xrpc-client/src/reqwest.rs @@ -1,7 +1,7 @@ #![doc = "XrpcClient implementation for [reqwest]"] use async_trait::async_trait; +use atrium_xrpc::http::{Request, Response}; use atrium_xrpc::{HttpClient, XrpcClient}; -use http::{Request, Response}; use reqwest::Client; use std::sync::Arc; diff --git a/atrium-xrpc-client/src/tests.rs b/atrium-xrpc-client/src/tests.rs index 5c9a9790..e0cda2da 100644 --- a/atrium-xrpc-client/src/tests.rs +++ b/atrium-xrpc-client/src/tests.rs @@ -1,6 +1,6 @@ +use atrium_xrpc::http::Method; use atrium_xrpc::{InputDataOrBytes, OutputDataOrBytes, XrpcClient, XrpcRequest}; use futures::future::join_all; -use http::Method; use mockito::{Matcher, Server}; use serde::{Deserialize, Serialize}; use tokio::task::JoinError; diff --git a/atrium-xrpc/src/lib.rs b/atrium-xrpc/src/lib.rs index bbc169c9..c7fb33c4 100644 --- a/atrium-xrpc/src/lib.rs +++ b/atrium-xrpc/src/lib.rs @@ -6,6 +6,7 @@ pub mod types; pub use crate::error::{Error, Result}; pub use crate::traits::{HttpClient, XrpcClient}; pub use crate::types::{InputDataOrBytes, OutputDataOrBytes, XrpcRequest}; +pub use http; #[cfg(test)] mod tests { diff --git a/bsky-sdk/Cargo.lock b/bsky-sdk/Cargo.lock new file mode 100644 index 00000000..dcbb89ca --- /dev/null +++ b/bsky-sdk/Cargo.lock @@ -0,0 +1,1515 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + +[[package]] +name = "async-trait" +version = "0.1.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.64", +] + +[[package]] +name = "atrium-api" +version = "0.22.2" +dependencies = [ + "async-trait", + "atrium-xrpc", + "chrono", + "http", + "ipld-core", + "langtag", + "regex", + "serde", + "serde_bytes", + "tokio", +] + +[[package]] +name = "atrium-xrpc" +version = "0.11.0" +dependencies = [ + "async-trait", + "http", + "serde", + "serde_html_form", + "serde_json", + "thiserror", +] + +[[package]] +name = "atrium-xrpc-client" +version = "0.5.4" +dependencies = [ + "async-trait", + "atrium-xrpc", + "http", + "reqwest", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "backtrace" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" + +[[package]] +name = "bsky-sdk" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "atrium-api", + "atrium-xrpc-client", + "http", + "ipld-core", + "serde", + "serde_json", + "thiserror", + "tokio", + "toml", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytes" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" + +[[package]] +name = "cc" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets 0.52.5", +] + +[[package]] +name = "cid" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a" +dependencies = [ + "core2", + "multibase", + "multihash", + "serde", + "serde_bytes", + "unsigned-varint 0.8.0", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "data-encoding" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" + +[[package]] +name = "data-encoding-macro" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1559b6cba622276d6d63706db152618eeb15b89b3e4041446b05876e352e639" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "332d754c0af53bc87c108fed664d121ecf59207ec4196041f04d6ab9002ad33f" +dependencies = [ + "data-encoding", + "syn 1.0.109", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "hyper" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", + "tower", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipld-core" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ede82a79e134f179f4b29b5fdb1eb92bd1b38c4dfea394c539051150a21b9b" +dependencies = [ + "cid", + "serde", + "serde_bytes", +] + +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "langtag" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed60c85f254d6ae8450cec15eedd921efbc4d1bdf6fcf6202b9a58b403f6f805" +dependencies = [ + "serde", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "multibase" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b3539ec3c1f04ac9748a260728e855f261b4977f5c3406612c884564f329404" +dependencies = [ + "base-x", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076d548d76a0e2a0d4ab471d0b1c36c577786dfc4471242035d97a12a735c492" +dependencies = [ + "core2", + "serde", + "unsigned-varint 0.7.2", +] + +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "openssl" +version = "0.10.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.64", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.64", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "proc-macro2" +version = "1.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" + +[[package]] +name = "reqwest" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +dependencies = [ + "base64", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "security-framework" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.202" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b8497c313fd43ab992087548117643f6fcd935cbf36f176ffda0aacf9591734" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.202" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.64", +] + +[[package]] +name = "serde_html_form" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de514ef58196f1fc96dcaef80fe6170a1ce6215df9687a93fe8300e773fefc5" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_json" +version = "1.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ad3dee41f36859875573074334c200d1add8e4a87bb37113ebd31d926b7b11f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "thiserror" +version = "1.0.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579e9083ca58dd9dcf91a9923bb9054071b9ebbd800b342194c9feb0ee89fc18" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2470041c06ec3ac1ab38d0356a6119054dedaea53e12fbefc0de730a1c08524" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.64", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.64", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e43f8cc456c9704c851ae29c67e17ef65d2c30017c17a9765b89c382dc8bba" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c127785850e8c20836d49732ae6abfa47616e60bf9d9f57c43c250361a9db96c" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unsigned-varint" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6889a77d49f1f013504cec6bf97a2c730394adedaeb1deb5ea08949a50541105" + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.64", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.64", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + +[[package]] +name = "web-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.5", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.5", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm 0.52.5", + "windows_aarch64_msvc 0.52.5", + "windows_i686_gnu 0.52.5", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.5", + "windows_x86_64_gnu 0.52.5", + "windows_x86_64_gnullvm 0.52.5", + "windows_x86_64_msvc 0.52.5", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" + +[[package]] +name = "winnow" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c52e9c97a68071b23e836c9380edae937f17b9c4667bd021973efc689f618d" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] diff --git a/bsky-sdk/Cargo.toml b/bsky-sdk/Cargo.toml new file mode 100644 index 00000000..848b9748 --- /dev/null +++ b/bsky-sdk/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "bsky-sdk" +version = "0.1.0" +authors = ["sugyan "] +edition.workspace = true +rust-version.workspace = true +description = "ATrium-based SDK for Bluesky" +documentation = "https://docs.rs/bsky-sdk" +readme = "README.md" +repository.workspace = true +license.workspace = true +keywords = ["atproto", "bluesky", "atrium", "sdk"] + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +atrium-api.workspace = true +atrium-xrpc-client = { workspace = true, optional = true } +ipld-core.workspace = true +psl = { version = "2.1.42", optional = true } +regex.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true +toml = { version = "0.8.13", optional = true } +unicode-segmentation = { version = "1.11.0", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[features] +default = ["default-client", "rich-text"] +default-client = ["atrium-xrpc-client"] +rich-text = ["psl", "unicode-segmentation"] +config-toml = ["toml"] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/bsky-sdk/README.md b/bsky-sdk/README.md new file mode 100644 index 00000000..b18b1f69 --- /dev/null +++ b/bsky-sdk/README.md @@ -0,0 +1,155 @@ +# Bsky SDK: [ATrium](https://github.com/sugyan/atrium)-based SDK for Bluesky. + +[![](https://img.shields.io/crates/v/bsky-sdk)](https://crates.io/crates/bsky-sdk) +[![](https://img.shields.io/docsrs/bsky-sdk)](https://docs.rs/bsky-sdk) +[![](https://img.shields.io/crates/l/bsky-sdk)](https://github.com/sugyan/atrium/blob/main/LICENSE) +[![Rust](https://github.com/sugyan/atrium/actions/workflows/bsky-sdk.yml/badge.svg?branch=main)](https://github.com/sugyan/atrium/actions/workflows/bsky-sdk.yml) + +- ✔️ APIs for ATProto and Bluesky. +- ✔️ Session management (same as `atrium-api`'s [`AtpAgent`](https://docs.rs/atrium-api/latest/atrium_api/agent/struct.AtpAgent.html)). +- ✔️ Moderation APIs. +- ✔️ A RichText library. + +## Usage + +### Session management + +Log into a server using these APIs. You'll need an active session for most methods. + +```rust,no_run +use bsky_sdk::BskyAgent; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let agent = BskyAgent::builder().build().await?; + let session = agent.login("alice@mail.com", "hunter2").await?; + Ok(()) +} +``` + +You can save the agent config (including the session) to a file and load it later: + +```rust,no_run +use bsky_sdk::agent::config::FileStore; +use bsky_sdk::BskyAgent; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let agent = BskyAgent::builder().build().await?; + agent.login("...", "...").await?; + agent + .to_config() + .await + .save(&FileStore::new("config.json")) + .await?; + Ok(()) +} +``` + +```rust,no_run +use bsky_sdk::agent::config::{Config, FileStore}; +use bsky_sdk::BskyAgent; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let agent = BskyAgent::builder() + .config(Config::load(&FileStore::new("config.json")).await?) + .build() + .await?; + let result = agent.api.com.atproto.server.get_session().await; + assert!(result.is_ok()); + Ok(()) +} +``` + +### Moderation + +The moderation APIs have almost the same functionality as the official SDK ([@atproto/api](https://www.npmjs.com/package/@atproto/api#moderation)). + +```rust,no_run +use bsky_sdk::moderation::decision::DecisionContext; +use bsky_sdk::BskyAgent; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let agent = BskyAgent::builder().build().await?; + // log in... + + // First get the user's moderation prefs and their label definitions + let preferences = agent.get_preferences(true).await?; + let moderator = agent.moderator(&preferences).await?; + + // in feeds + for feed_view_post in agent + .api + .app + .bsky + .feed + .get_timeline(atrium_api::app::bsky::feed::get_timeline::Parameters { + algorithm: None, + cursor: None, + limit: None, + }) + .await? + .feed + { + // We call the appropriate moderation function for the content + let post_mod = moderator.moderate_post(&feed_view_post.post); + // don't include in feeds? + println!( + "{:?} (filter: {})", + feed_view_post.post.cid.as_ref(), + post_mod.ui(DecisionContext::ContentList).filter() + ); + } + Ok(()) +} +``` + +### RichText + +Creating a RichText object from a string: + +```rust,no_run +use bsky_sdk::rich_text::RichText; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let rt = RichText::new_with_detect_facets( + "Hello @alice.com, check out this link: https://example.com", + ) + .await?; + + let segments = rt.segments(); + assert_eq!(segments.len(), 4); + assert!(segments[0].text == "Hello "); + assert!(segments[1].text == "@alice.com" && segments[1].mention().is_some()); + assert!(segments[2].text == ", check out this link: "); + assert!(segments[3].text == "https://example.com" && segments[3].link().is_some()); + + let post_record = atrium_api::app::bsky::feed::post::Record { + created_at: atrium_api::types::string::Datetime::now(), + embed: None, + entities: None, + facets: rt.facets, + labels: None, + langs: None, + reply: None, + tags: None, + text: rt.text, + }; + println!("{:?}", post_record); + Ok(()) +} +``` + +Calculating string lengths: + +```rust +use bsky_sdk::rich_text::RichText; + +fn main() { + let rt = RichText::new("👨‍👩‍👧‍👧", None); + assert_eq!(rt.text.len(), 25); + assert_eq!(rt.grapheme_len(), 1); +} diff --git a/bsky-sdk/src/agent.rs b/bsky-sdk/src/agent.rs new file mode 100644 index 00000000..cc4d761e --- /dev/null +++ b/bsky-sdk/src/agent.rs @@ -0,0 +1,218 @@ +//! Implementation of [`BskyAgent`] and their builders. +mod builder; +pub mod config; + +pub use self::builder::BskyAgentBuilder; +use self::config::Config; +use crate::error::Result; +use crate::moderation::util::interpret_label_value_definitions; +use crate::moderation::{ModerationPrefsLabeler, Moderator}; +use crate::preference::Preferences; +use atrium_api::agent::store::MemorySessionStore; +use atrium_api::agent::{store::SessionStore, AtpAgent}; +use atrium_api::app::bsky::actor::defs::{LabelersPref, PreferencesItem}; +use atrium_api::types::Union; +use atrium_api::xrpc::XrpcClient; +#[cfg(feature = "default-client")] +use atrium_xrpc_client::reqwest::ReqwestClient; +use ipld_core::serde::from_ipld; +use std::collections::HashMap; +use std::ops::Deref; + +/// A Bluesky agent. +/// +/// This agent is a wrapper around the [`AtpAgent`] that provides additional functionality for working with Bluesky. +/// For creating an instance of this agent, use the [`BskyAgentBuilder`]. +/// +/// # Example +/// +/// ``` +/// use bsky_sdk::BskyAgent; +/// +/// #[tokio::main] +/// async fn main() { +/// let agent = BskyAgent::builder().build().await.expect("failed to build agent"); +/// } +/// ``` + +#[cfg(feature = "default-client")] +pub struct BskyAgent +where + T: XrpcClient + Send + Sync, + S: SessionStore + Send + Sync, +{ + inner: AtpAgent, +} + +#[cfg(not(feature = "default-client"))] +pub struct BskyAgent +where + T: XrpcClient + Send + Sync, + S: SessionStore + Send + Sync, +{ + inner: AtpAgent, +} + +#[cfg_attr(docsrs, doc(cfg(feature = "default-client")))] +#[cfg(feature = "default-client")] +impl BskyAgent { + /// Create a new [`BskyAgentBuilder`] with the default client and session store. + pub fn builder() -> BskyAgentBuilder { + BskyAgentBuilder::default() + } +} + +impl BskyAgent +where + T: XrpcClient + Send + Sync, + S: SessionStore + Send + Sync, +{ + /// Get the agent's current state as a [`Config`]. + pub async fn to_config(&self) -> Config { + Config { + endpoint: self.get_endpoint().await, + session: self.get_session().await, + labelers_header: self.get_labelers_header().await, + proxy_header: self.get_proxy_header().await, + } + } + /// Get the logged-in user's [`Preferences`]. + /// + /// # Arguments + /// + /// `enable_bsky_labeler` - If `true`, the [Bluesky's moderation labeler](atrium_api::agent::bluesky::BSKY_LABELER_DID) will be included in the moderation preferences. + pub async fn get_preferences(&self, enable_bsky_labeler: bool) -> Result { + let mut prefs = Preferences::default(); + if enable_bsky_labeler { + prefs + .moderation_prefs + .labelers + .push(ModerationPrefsLabeler::default()); + } + let mut label_prefs = Vec::new(); + for pref in self + .api + .app + .bsky + .actor + .get_preferences(atrium_api::app::bsky::actor::get_preferences::Parameters {}) + .await? + .preferences + { + match pref { + Union::Refs(PreferencesItem::AdultContentPref(p)) => { + prefs.moderation_prefs.adult_content_enabled = p.enabled; + } + Union::Refs(PreferencesItem::ContentLabelPref(p)) => { + label_prefs.push(p); + } + Union::Refs(PreferencesItem::MutedWordsPref(p)) => { + prefs.moderation_prefs.muted_words = p.items; + } + Union::Refs(PreferencesItem::HiddenPostsPref(p)) => { + prefs.moderation_prefs.hidden_posts = p.items; + } + Union::Unknown(u) => { + if u.r#type == "app.bsky.actor.defs#labelersPref" { + prefs.moderation_prefs.labelers.extend( + from_ipld::(u.data)? + .labelers + .into_iter() + .map(|item| ModerationPrefsLabeler { + did: item.did, + labels: HashMap::default(), + is_default_labeler: false, + }), + ); + } + } + _ => { + // TODO + } + } + } + for pref in label_prefs { + if let Some(did) = pref.labeler_did { + if let Some(l) = prefs + .moderation_prefs + .labelers + .iter_mut() + .find(|l| l.did == did) + { + l.labels.insert( + pref.label, + pref.visibility.parse().expect("invalid visibility"), + ); + } + } else { + prefs.moderation_prefs.labels.insert( + pref.label, + pref.visibility.parse().expect("invalid visibility"), + ); + } + } + Ok(prefs) + } + /// Configure the labelers header. + /// + /// Read labelers preferences from the provided [`Preferences`] and set the labelers header up to 10 labelers. + /// + /// See details: [https://docs.bsky.app/docs/advanced-guides/moderation#labeler-subscriptions](https://docs.bsky.app/docs/advanced-guides/moderation#labeler-subscriptions) + pub fn configure_labelers_from_preferences(&self, preferences: &Preferences) { + self.configure_labelers_header(Some( + preferences + .moderation_prefs + .labelers + .iter() + .map(|labeler| (labeler.did.clone(), labeler.is_default_labeler)) + .take(10) + .collect(), + )); + } + /// Make a [`Moderator`] instance with the provided [`Preferences`]. + pub async fn moderator(&self, preferences: &Preferences) -> Result { + let labelers = self + .api + .app + .bsky + .labeler + .get_services(atrium_api::app::bsky::labeler::get_services::Parameters { + detailed: Some(true), + dids: preferences + .moderation_prefs + .labelers + .iter() + .map(|labeler| labeler.did.clone()) + .collect(), + }) + .await? + .views; + let mut label_defs = HashMap::with_capacity(labelers.len()); + for labeler in &labelers { + let Union::Refs(atrium_api::app::bsky::labeler::get_services::OutputViewsItem::AppBskyLabelerDefsLabelerViewDetailed(labeler_view)) = labeler else { + continue; + }; + label_defs.insert( + labeler_view.creator.did.clone(), + interpret_label_value_definitions(labeler_view)?, + ); + } + Ok(Moderator::new( + self.get_session().await.map(|s| s.did), + preferences.moderation_prefs.clone(), + label_defs, + )) + } +} + +impl Deref for BskyAgent +where + T: XrpcClient + Send + Sync, + S: SessionStore + Send + Sync, +{ + type Target = AtpAgent; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} diff --git a/bsky-sdk/src/agent/builder.rs b/bsky-sdk/src/agent/builder.rs new file mode 100644 index 00000000..566f8c0c --- /dev/null +++ b/bsky-sdk/src/agent/builder.rs @@ -0,0 +1,216 @@ +use super::config::Config; +use super::BskyAgent; +use crate::error::Result; +use atrium_api::agent::store::MemorySessionStore; +use atrium_api::agent::{store::SessionStore, AtpAgent}; +use atrium_api::xrpc::XrpcClient; +#[cfg(feature = "default-client")] +use atrium_xrpc_client::reqwest::ReqwestClient; + +/// A builder for creating a [`BskyAgent`]. +pub struct BskyAgentBuilder +where + T: XrpcClient + Send + Sync, + S: SessionStore + Send + Sync, +{ + config: Config, + store: S, + client: T, +} + +impl BskyAgentBuilder +where + T: XrpcClient + Send + Sync, +{ + /// Create a new builder with the given XRPC client. + pub fn new(client: T) -> Self { + Self { + config: Config::default(), + store: MemorySessionStore::default(), + client, + } + } +} + +impl BskyAgentBuilder +where + T: XrpcClient + Send + Sync, + S: SessionStore + Send + Sync, +{ + /// Set the configuration for the agent. + pub fn config(mut self, config: Config) -> Self { + self.config = config; + self + } + /// Set the session store for the agent. + /// + /// Returns a new builder with the session store set. + pub fn store(self, store: S0) -> BskyAgentBuilder + where + S0: SessionStore + Send + Sync, + { + BskyAgentBuilder { + config: self.config, + store, + client: self.client, + } + } + /// Set the XRPC client for the agent. + /// + /// Returns a new builder with the XRPC client set. + pub fn client(self, client: T0) -> BskyAgentBuilder + where + T0: XrpcClient + Send + Sync, + { + BskyAgentBuilder { + config: self.config, + store: self.store, + client, + } + } + pub async fn build(self) -> Result> { + let agent = AtpAgent::new(self.client, self.store); + agent.configure_endpoint(self.config.endpoint); + if let Some(session) = self.config.session { + agent.resume_session(session).await?; + } + if let Some(labelers) = self.config.labelers_header { + agent.configure_labelers_header(Some( + labelers + .iter() + .filter_map(|did| { + let (did, redact) = match did.split_once(';') { + Some((did, params)) if params.trim() == "redact" => (did, true), + None => (did.as_str(), false), + _ => return None, + }; + did.parse().ok().map(|did| (did, redact)) + }) + .collect(), + )); + } + if let Some(proxy) = self.config.proxy_header { + if let Some((did, service_type)) = proxy.split_once('#') { + if let Ok(did) = did.parse() { + agent.configure_proxy_header(did, service_type); + } + } + } + Ok(BskyAgent { inner: agent }) + } +} + +#[cfg_attr(docsrs, doc(cfg(feature = "default-client")))] +#[cfg(feature = "default-client")] +impl Default for BskyAgentBuilder { + /// Create a new builder with the default client and session store. + /// + /// Default client is [`ReqwestClient`] and default session store is [`MemorySessionStore`]. + fn default() -> Self { + Self::new(ReqwestClient::new(Config::default().endpoint)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use atrium_api::agent::Session; + + fn session() -> Session { + Session { + access_jwt: String::new(), + did: "did:fake:handle.test".parse().expect("invalid did"), + did_doc: None, + email: None, + email_auth_factor: None, + email_confirmed: None, + handle: "handle.test".parse().expect("invalid handle"), + refresh_jwt: String::new(), + } + } + + struct MockSessionStore; + + #[async_trait] + impl SessionStore for MockSessionStore { + async fn get_session(&self) -> Option { + Some(session()) + } + async fn set_session(&self, _: Session) {} + async fn clear_session(&self) {} + } + + #[cfg(feature = "default-client")] + #[tokio::test] + async fn default() -> Result<()> { + // default build + { + let agent = BskyAgentBuilder::default().build().await?; + assert_eq!(agent.get_endpoint().await, "https://bsky.social"); + assert_eq!(agent.get_session().await, None); + } + // with store + { + let agent = BskyAgentBuilder::default() + .store(MockSessionStore) + .build() + .await?; + assert_eq!(agent.get_endpoint().await, "https://bsky.social"); + assert_eq!( + agent.get_session().await.map(|session| session.handle), + Some("handle.test".parse().expect("invalid handle")) + ); + } + // with config + { + let agent = BskyAgentBuilder::default() + .config(Config { + endpoint: "https://example.com".to_string(), + ..Default::default() + }) + .build() + .await?; + assert_eq!(agent.get_endpoint().await, "https://example.com"); + assert_eq!(agent.get_session().await, None); + } + Ok(()) + } + + #[cfg(not(feature = "default-client"))] + #[tokio::test] + async fn custom() -> Result<()> { + use crate::tests::MockClient; + + // default build + { + let agent = BskyAgentBuilder::new(MockClient).build().await?; + assert_eq!(agent.get_endpoint().await, "https://bsky.social"); + } + // with store + { + let agent = BskyAgentBuilder::new(MockClient) + .store(MockSessionStore) + .build() + .await?; + assert_eq!(agent.get_endpoint().await, "https://bsky.social"); + assert_eq!( + agent.get_session().await.map(|session| session.handle), + Some("handle.test".parse().expect("invalid handle")) + ); + } + // with config + { + let agent = BskyAgentBuilder::new(MockClient) + .config(Config { + endpoint: "https://example.com".to_string(), + ..Default::default() + }) + .build() + .await?; + assert_eq!(agent.get_endpoint().await, "https://example.com"); + assert_eq!(agent.get_session().await, None); + } + Ok(()) + } +} diff --git a/bsky-sdk/src/agent/config.rs b/bsky-sdk/src/agent/config.rs new file mode 100644 index 00000000..2b64ad4a --- /dev/null +++ b/bsky-sdk/src/agent/config.rs @@ -0,0 +1,65 @@ +//! Configuration for the [`BskyAgent`](super::BskyAgent). +mod file; + +use crate::error::{Error, Result}; +use async_trait::async_trait; +use atrium_api::agent::Session; +pub use file::FileStore; +use serde::{Deserialize, Serialize}; + +/// Configuration data struct for the [`BskyAgent`](super::BskyAgent). +#[derive(Debug, Serialize, Deserialize)] +pub struct Config { + /// The base URL for the XRPC endpoint. + pub endpoint: String, + /// The session data. + pub session: Option, + /// The labelers header values. + pub labelers_header: Option>, + /// The proxy header for service proxying. + pub proxy_header: Option, +} + +impl Config { + /// Loads the configuration from the provided loader. + pub async fn load(loader: &impl Loader) -> Result { + loader.load().await.map_err(Error::ConfigLoad) + } + /// Saves the configuration using the provided saver. + pub async fn save(&self, saver: &impl Saver) -> Result<()> { + saver.save(self).await.map_err(Error::ConfigSave) + } +} + +impl Default for Config { + /// Creates a new default configuration. + /// + /// The default configuration uses the base URL `https://bsky.social`. + fn default() -> Self { + Self { + endpoint: String::from("https://bsky.social"), + session: None, + labelers_header: None, + proxy_header: None, + } + } +} + +/// The trait for loading configuration data. +#[async_trait] +pub trait Loader { + /// Loads the configuration data. + async fn load( + &self, + ) -> core::result::Result>; +} + +/// The trait for saving configuration data. +#[async_trait] +pub trait Saver { + /// Saves the configuration data. + async fn save( + &self, + config: &Config, + ) -> core::result::Result<(), Box>; +} diff --git a/bsky-sdk/src/agent/config/file.rs b/bsky-sdk/src/agent/config/file.rs new file mode 100644 index 00000000..681b8fbb --- /dev/null +++ b/bsky-sdk/src/agent/config/file.rs @@ -0,0 +1,54 @@ +use super::{Config, Loader, Saver}; +use anyhow::anyhow; +use async_trait::async_trait; +use std::path::{Path, PathBuf}; + +/// An implementation of [`Loader`] and [`Saver`] that reads and writes a configuration file. +pub struct FileStore { + path: PathBuf, +} + +impl FileStore { + /// Create a new [`FileStore`] with the given path. + /// + /// This `FileStore` will read and write to the file at the given path. + /// [`Config`] data will be serialized and deserialized using the file extension. + /// By default, this supports only `.json` files. + pub fn new(path: impl AsRef) -> Self { + Self { + path: path.as_ref().to_path_buf(), + } + } +} + +#[async_trait] +impl Loader for FileStore { + async fn load( + &self, + ) -> core::result::Result> { + match self.path.extension().and_then(|ext| ext.to_str()) { + Some("json") => Ok(serde_json::from_str(&std::fs::read_to_string(&self.path)?)?), + #[cfg(feature = "config-toml")] + Some("toml") => Ok(toml::from_str(&std::fs::read_to_string(&self.path)?)?), + _ => Err(anyhow!("Unsupported file format").into()), + } + } +} + +#[async_trait] +impl Saver for FileStore { + async fn save( + &self, + config: &Config, + ) -> core::result::Result<(), Box> { + match self.path.extension().and_then(|ext| ext.to_str()) { + Some("json") => Ok(std::fs::write( + &self.path, + serde_json::to_string_pretty(config)?, + )?), + #[cfg(feature = "config-toml")] + Some("toml") => Ok(std::fs::write(&self.path, toml::to_string_pretty(config)?)?), + _ => Err(anyhow!("Unsupported file format").into()), + } + } +} diff --git a/bsky-sdk/src/error.rs b/bsky-sdk/src/error.rs new file mode 100644 index 00000000..6f61aa87 --- /dev/null +++ b/bsky-sdk/src/error.rs @@ -0,0 +1,57 @@ +use atrium_api::xrpc::error::XrpcErrorKind; +use atrium_api::xrpc::http::StatusCode; +use atrium_api::xrpc::Error as XrpcError; +use thiserror::Error; + +/// Error type for this crate. +#[derive(Error, Debug)] +pub enum Error { + #[error("xrpc response error: {0}")] + Xrpc(Box), + #[error("loading config error: {0}")] + ConfigLoad(Box), + #[error("saving config error: {0}")] + ConfigSave(Box), + #[error(transparent)] + IpldSerde(#[from] ipld_core::serde::SerdeError), + #[error(transparent)] + Moderation(#[from] crate::moderation::Error), +} + +#[derive(Error, Debug)] +pub struct GenericXrpcError { + status: StatusCode, + error: Option, +} + +impl std::fmt::Display for GenericXrpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.status.as_str())?; + let Some(error) = &self.error else { + return Ok(()); + }; + if !error.is_empty() { + write!(f, " {error}")?; + } + Ok(()) + } +} + +impl From> for Error { + fn from(err: XrpcError) -> Self { + if let XrpcError::XrpcResponse(e) = err { + Self::Xrpc(Box::new(GenericXrpcError { + status: e.status, + error: e.error.map(|e| match e { + XrpcErrorKind::Custom(_) => String::from("custom error"), + XrpcErrorKind::Undefined(res) => res.to_string(), + }), + })) + } else { + err.into() + } + } +} + +/// Type alias to use this crate's [`Error`](enum@crate::Error) type in a [`Result`](core::result::Result). +pub type Result = core::result::Result; diff --git a/bsky-sdk/src/lib.rs b/bsky-sdk/src/lib.rs new file mode 100644 index 00000000..2b2b1e83 --- /dev/null +++ b/bsky-sdk/src/lib.rs @@ -0,0 +1,58 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] +#![doc = include_str!("../README.md")] +pub mod agent; +mod error; +pub mod moderation; +pub mod preference; +#[cfg_attr(docsrs, doc(cfg(feature = "rich-text")))] +#[cfg(feature = "rich-text")] +pub mod rich_text; + +pub use agent::BskyAgent; +pub use atrium_api as api; +pub use error::{Error, Result}; + +#[cfg(test)] +mod tests { + use async_trait::async_trait; + use atrium_api::xrpc::http::{Request, Response}; + use atrium_api::xrpc::types::Header; + use atrium_api::xrpc::{HttpClient, XrpcClient}; + + pub struct MockClient; + + #[async_trait] + impl HttpClient for MockClient { + async fn send_http( + &self, + request: Request>, + ) -> core::result::Result< + Response>, + Box, + > { + if let Some(handle) = request + .uri() + .query() + .and_then(|s| s.strip_prefix("handle=")) + { + Ok(Response::builder() + .status(200) + .header(Header::ContentType, "application/json") + .body( + format!(r#"{{"did": "did:fake:{}"}}"#, handle) + .as_bytes() + .to_vec(), + )?) + } else { + Ok(Response::builder().status(500).body(Vec::new())?) + } + } + } + + #[async_trait] + impl XrpcClient for MockClient { + fn base_uri(&self) -> String { + String::new() + } + } +} diff --git a/bsky-sdk/src/moderation.rs b/bsky-sdk/src/moderation.rs new file mode 100644 index 00000000..69088a05 --- /dev/null +++ b/bsky-sdk/src/moderation.rs @@ -0,0 +1,63 @@ +//! Moderation module for working with Bluesky's moderation. +pub mod decision; +mod error; +mod labels; +pub mod mutewords; +mod subjects; +mod types; +pub mod ui; +pub mod util; + +use self::decision::ModerationDecision; +pub use self::error::{Error, Result}; +pub use self::types::*; +use atrium_api::types::string::Did; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// A moderator for the different kinds of content on the Bluesky network. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Moderator { + user_did: Option, + prefs: ModerationPrefs, + label_defs: HashMap>, +} + +impl Moderator { + /// Create a new moderator. + pub fn new( + user_did: Option, + prefs: ModerationPrefs, + label_defs: HashMap>, + ) -> Self { + Self { + user_did, + prefs, + label_defs, + } + } + /// Calculate the moderation decision for an account profile. + pub fn moderate_profile(&self, profile: &SubjectProfile) -> ModerationDecision { + ModerationDecision::merge(&[self.decide_account(profile), self.decide_profile(profile)]) + } + /// Calculate the moderation decision for a post. + pub fn moderate_post(&self, post: &SubjectPost) -> ModerationDecision { + self.decide_post(post) + } + /// Calculate the moderation decision for a notification. + pub fn moderate_notification(&self) -> ModerationDecision { + todo!() + } + /// Calculate the moderation decision for a feed generator. + pub fn moderate_feed_generator(&self) -> ModerationDecision { + todo!() + } + /// Calculate the moderation decision for a user list. + pub fn moderate_user_list(&self) -> ModerationDecision { + todo!() + } +} + +#[cfg(test)] +mod tests; diff --git a/bsky-sdk/src/moderation/decision.rs b/bsky-sdk/src/moderation/decision.rs new file mode 100644 index 00000000..75799833 --- /dev/null +++ b/bsky-sdk/src/moderation/decision.rs @@ -0,0 +1,451 @@ +//! Moderation behavior decision making. +use super::types::*; +use super::{labels::KnownLabelValue, ui::ModerationUi, Moderator}; +use atrium_api::app::bsky::graph::defs::ListViewBasic; +use atrium_api::com::atproto::label::defs::Label; +use atrium_api::types::string::Did; + +/// A moderation decision context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DecisionContext { + /// A profile being listed, e.g. in search or a follower list + ProfileList, + /// A profile being viewed directly + ProfileView, + /// The user's avatar in any context + Avatar, + /// The user's banner in any context + Banner, + /// The user's display name in any context + DisplayName, + /// Content being listed, e.g. posts in a feed, posts as replies, a user list list, a feed generator list, etc + ContentList, + /// Content being viewed direct, e.g. an opened post, the user list page, the feedgen page, etc + ContentView, + /// Media inside the content, e.g. a picture embedded in a post + ContentMedia, +} + +impl DecisionContext { + pub const ALL: [DecisionContext; 8] = [ + DecisionContext::ProfileList, + DecisionContext::ProfileView, + DecisionContext::Avatar, + DecisionContext::Banner, + DecisionContext::DisplayName, + DecisionContext::ContentList, + DecisionContext::ContentView, + DecisionContext::ContentMedia, + ]; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ModerationBehaviorSeverity { + High, + Medium, + Low, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Priority { + Priority1, + Priority2, + Priority3, + Priority4, + Priority5, + Priority6, + Priority7, + Priority8, +} + +impl AsRef for Priority { + fn as_ref(&self) -> &u8 { + match self { + Priority::Priority1 => &1, + Priority::Priority2 => &2, + Priority::Priority3 => &3, + Priority::Priority4 => &4, + Priority::Priority5 => &5, + Priority::Priority6 => &6, + Priority::Priority7 => &7, + Priority::Priority8 => &8, + } + } +} + +/// A moderation decision. +#[derive(Debug)] +pub struct ModerationDecision { + did: Option, + is_me: bool, + causes: Vec, +} + +impl ModerationDecision { + /// Calculate the [`ModerationUi`] for a given context. + pub fn ui(&self, context: DecisionContext) -> ModerationUi { + let mut ui = ModerationUi { + no_override: false, + filters: Vec::new(), + blurs: Vec::new(), + alerts: Vec::new(), + informs: Vec::new(), + }; + for cause in &self.causes { + match cause { + ModerationCause::Blocking(b) + | ModerationCause::BlockedBy(b) + /* | ModerationCause::BlockOther(b) */ => { + if self.is_me { + continue; + } + if matches!( + context, + DecisionContext::ProfileList | DecisionContext::ContentList + ) { + ui.filters.push(cause.clone()) + } + if !b.downgraded { + match ModerationBehavior::BLOCK_BEHAVIOR.behavior_for(context) { + Some(BehaviorValue::Blur) => { + ui.no_override = true; + ui.blurs.push(cause.clone()); + } + Some(BehaviorValue::Alert) => { + ui.alerts.push(cause.clone()); + } + Some(BehaviorValue::Inform) => { + ui.informs.push(cause.clone()); + } + _ => {} + } + } + } + ModerationCause::Label(label) => { + if matches!( + (context, label.target), + (DecisionContext::ProfileList, LabelTarget::Account) + | ( + DecisionContext::ContentList, + LabelTarget::Account | LabelTarget::Content, + ), + ) && (label.setting == LabelPreference::Hide && !self.is_me) + { + ui.filters.push(cause.clone()) + } + if !label.downgraded { + match label.behavior.behavior_for(context) { + Some(BehaviorValue::Blur) => { + ui.blurs.push(cause.clone()); + if label.no_override && !self.is_me { + ui.no_override = true; + } + } + Some(BehaviorValue::Alert) => { + ui.alerts.push(cause.clone()); + } + Some(BehaviorValue::Inform) => { + ui.informs.push(cause.clone()); + } + _ => {} + } + } + } + ModerationCause::Muted(muted) => { + if self.is_me { + continue; + } + if matches!( + context, + DecisionContext::ProfileList | DecisionContext::ContentList + ) { + ui.filters.push(cause.clone()) + } + if !muted.downgraded { + match ModerationBehavior::MUTE_BEHAVIOR.behavior_for(context) { + Some(BehaviorValue::Blur) => { + ui.blurs.push(cause.clone()); + } + Some(BehaviorValue::Alert) => { + ui.alerts.push(cause.clone()); + } + Some(BehaviorValue::Inform) => { + ui.informs.push(cause.clone()); + } + _ => {} + } + } + } + ModerationCause::MuteWord(mute_word) => { + if self.is_me { + continue; + } + if matches!(context, DecisionContext::ContentList) { + ui.filters.push(cause.clone()) + } + if !mute_word.downgraded { + match ModerationBehavior::MUTEWORD_BEHAVIOR.behavior_for(context) { + Some(BehaviorValue::Blur) => { + ui.blurs.push(cause.clone()); + } + Some(BehaviorValue::Alert) => { + ui.alerts.push(cause.clone()); + } + Some(BehaviorValue::Inform) => { + ui.informs.push(cause.clone()); + } + _ => {} + } + } + } + ModerationCause::Hidden(hidden) => { + if matches!(context, DecisionContext::ProfileList | DecisionContext::ContentList) { + ui.filters.push(cause.clone()) + } + if !hidden.downgraded { + match ModerationBehavior::HIDE_BEHAVIOR.behavior_for(context) { + Some(BehaviorValue::Blur) => { + ui.blurs.push(cause.clone()); + } + Some(BehaviorValue::Alert) => { + ui.alerts.push(cause.clone()); + } + Some(BehaviorValue::Inform) => { + ui.informs.push(cause.clone()); + } + _ => {} + } + } + } + } + } + ui.filters.sort_by_cached_key(|c| c.priority()); + ui.blurs.sort_by_cached_key(|c| c.priority()); + ui + } + /// Check if the decision is blocking or blocked by other user. + pub fn is_blocked(&self) -> bool { + self.causes.iter().any(|c| { + matches!( + c, + ModerationCause::Blocking(_) + | ModerationCause::BlockedBy(_) + /* | ModerationCause::BlockOther(_) */ + ) + }) + } + /// Check if the decision is by muted. + pub fn is_muted(&self) -> bool { + self.causes + .iter() + .any(|c| matches!(c, ModerationCause::Muted(_))) + } + pub(crate) fn new() -> Self { + Self { + did: None, + is_me: false, + causes: Vec::new(), + } + } + pub(crate) fn merge(decisions: &[Self]) -> Self { + assert!(!decisions.is_empty()); + // assert!(decisions + // .windows(2) + // .all(|w| w[0].did == w[1].did && w[0].is_me == w[1].is_me)); + Self { + did: decisions[0].did.clone(), + is_me: decisions[0].is_me, + causes: decisions + .iter() + .flat_map(|d| d.causes.iter().cloned()) + .collect(), + } + } + pub(crate) fn set_did(&mut self, did: Did) { + self.did = Some(did); + } + pub(crate) fn set_is_me(&mut self, is_me: bool) { + self.is_me = is_me; + } + pub(crate) fn add_blocking(&mut self) { + self.causes + .push(ModerationCause::Blocking(Box::new(ModerationCauseOther { + source: ModerationCauseSource::User, + downgraded: false, + }))); + } + pub(crate) fn add_blocking_by_list(&mut self, list_view: &ListViewBasic) { + self.causes + .push(ModerationCause::Blocking(Box::new(ModerationCauseOther { + source: ModerationCauseSource::List(Box::new(list_view.clone())), + downgraded: false, + }))); + } + pub(crate) fn add_blocked_by(&mut self) { + self.causes + .push(ModerationCause::BlockedBy(Box::new(ModerationCauseOther { + source: ModerationCauseSource::User, + downgraded: false, + }))); + } + pub(crate) fn add_label(&mut self, target: LabelTarget, label: &Label, moderator: &Moderator) { + let Some(label_def) = Self::lookup_label_def(label, moderator) else { + return; + }; + let is_self = Some(&label.src) == self.did.as_ref(); + let labeler = if is_self { + None + } else { + moderator.prefs.labelers.iter().find(|l| l.did == label.src) + }; + if !is_self && labeler.is_none() { + return; // skip labelers not configured by the user + } + if is_self && label_def.flags.contains(&LabelValueDefinitionFlag::NoSelf) { + return; // skip self-labels that arent supported + } + + // establish the label preference for interpretation + let mut label_pref = label_def.default_setting; + if label_def.flags.contains(&LabelValueDefinitionFlag::Adult) + && !moderator.prefs.adult_content_enabled + { + label_pref = LabelPreference::Hide; + } else if let Some(pref) = labeler.and_then(|l| l.labels.get(&label_def.identifier)) { + label_pref = *pref; + } else if let Some(pref) = moderator.prefs.labels.get(&label_def.identifier) { + label_pref = *pref; + } + + // ignore labels the user has asked to ignore + if label_pref == LabelPreference::Ignore { + return; + } + + // ignore 'unauthed' labels when the user is authed + if label_def + .flags + .contains(&LabelValueDefinitionFlag::Unauthed) + && moderator.user_did.is_some() + { + return; + } + + let behavior = label_def.behaviors.behavior_for(target); + // establish the priority of the label + let severity = Self::measure_moderation_behavior_severity(&behavior); + let priority = if label_def + .flags + .contains(&LabelValueDefinitionFlag::NoOverride) + || (label_def.flags.contains(&LabelValueDefinitionFlag::Adult) + && !moderator.prefs.adult_content_enabled) + { + Priority::Priority1 + } else if label_pref == LabelPreference::Hide { + Priority::Priority2 + } else if severity == ModerationBehaviorSeverity::High { + // blurring profile view or content view + Priority::Priority5 + } else if severity == ModerationBehaviorSeverity::Medium { + // blurring content list or content media + Priority::Priority7 + } else { + // blurring avatar, adding alerts + Priority::Priority8 + }; + + let no_override = label_def + .flags + .contains(&LabelValueDefinitionFlag::NoOverride) + || (label_def.flags.contains(&LabelValueDefinitionFlag::Adult) + && !moderator.prefs.adult_content_enabled); + + self.causes + .push(ModerationCause::Label(Box::new(ModerationCauseLabel { + source: if is_self || labeler.is_none() { + ModerationCauseSource::User + } else { + ModerationCauseSource::Labeler(label.src.clone()) + }, + label: label.clone(), + label_def, + target, + setting: label_pref, + behavior, + no_override, + priority, + downgraded: false, + }))); + } + pub(crate) fn add_muted(&mut self) { + self.causes + .push(ModerationCause::Muted(Box::new(ModerationCauseOther { + source: ModerationCauseSource::User, + downgraded: false, + }))); + } + pub(crate) fn add_muted_by_list(&mut self, list_view: &ListViewBasic) { + self.causes + .push(ModerationCause::Muted(Box::new(ModerationCauseOther { + source: ModerationCauseSource::List(Box::new(list_view.clone())), + downgraded: false, + }))); + } + pub(crate) fn add_muted_word(&mut self) { + self.causes + .push(ModerationCause::MuteWord(Box::new(ModerationCauseOther { + source: ModerationCauseSource::User, + downgraded: false, + }))); + } + pub(crate) fn add_hidden(&mut self) { + self.causes + .push(ModerationCause::Hidden(Box::new(ModerationCauseOther { + source: ModerationCauseSource::User, + downgraded: false, + }))); + } + pub(crate) fn downgrade(&mut self) { + for cause in self.causes.iter_mut() { + cause.downgrade() + } + } + fn lookup_label_def( + label: &Label, + moderator: &Moderator, + ) -> Option { + if label + .val + .chars() + .all(|c| c.is_ascii_lowercase() || c == '-') + { + if let Some(def) = moderator + .label_defs + .get(&label.src) + .and_then(|defs| defs.iter().find(|def| def.identifier == label.val)) + { + return Some(def.clone()); + } + } + label + .val + .parse::() + .ok() + .map(|known_value| known_value.definition()) + } + fn measure_moderation_behavior_severity( + behavior: &ModerationBehavior, + ) -> ModerationBehaviorSeverity { + if behavior.profile_view == Some(ProfileViewBehavior::Blur) + || behavior.content_view == Some(ContentViewBehavior::Blur) + { + return ModerationBehaviorSeverity::High; + } + if behavior.content_list == Some(ContentListBehavior::Blur) + || behavior.content_media == Some(ContentMediaBehavior::Blur) + { + return ModerationBehaviorSeverity::Medium; + } + ModerationBehaviorSeverity::Low + } +} diff --git a/bsky-sdk/src/moderation/error.rs b/bsky-sdk/src/moderation/error.rs new file mode 100644 index 00000000..a175deeb --- /dev/null +++ b/bsky-sdk/src/moderation/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +/// Error type for this module. +#[derive(Error, Debug)] +pub enum Error { + #[error("invalid label preference")] + LabelPreference, + #[error("invalid label value definition blurs")] + LabelValueDefinitionBlurs, + #[error("invalid label value definition severity")] + LabelValueDefinitionSeverity, + #[error("invalid behavior value")] + BehaviorValue, + #[error("unknown label value")] + KnownLabelValue, +} + +/// Type alias to use this module's [`Error`](enum@self::Error) type in a [`Result`](core::result::Result). +pub type Result = std::result::Result; diff --git a/bsky-sdk/src/moderation/labels.rs b/bsky-sdk/src/moderation/labels.rs new file mode 100644 index 00000000..66da9912 --- /dev/null +++ b/bsky-sdk/src/moderation/labels.rs @@ -0,0 +1,253 @@ +use super::error::Error; +use super::types::*; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KnownLabelValue { + ReservedHide, + ReservedWarn, + ReservedNoUnauthenticated, + Porn, + Sexual, + Nudity, + GraphicMedia, +} + +impl FromStr for KnownLabelValue { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "!hide" => Ok(Self::ReservedHide), + "!warn" => Ok(Self::ReservedWarn), + "!no-unauthenticated" => Ok(Self::ReservedNoUnauthenticated), + "porn" => Ok(Self::Porn), + "sexual" => Ok(Self::Sexual), + "nudity" => Ok(Self::Nudity), + "graphic-media" => Ok(Self::GraphicMedia), + _ => Err(Error::KnownLabelValue), + } + } +} + +impl KnownLabelValue { + pub fn definition(&self) -> InterpretedLabelValueDefinition { + match self { + Self::ReservedHide => InterpretedLabelValueDefinition { + adult_only: false, + blurs: LabelValueDefinitionBlurs::Content, + default_setting: LabelPreference::Hide, + identifier: String::from("!hide"), + locales: Vec::new(), + severity: LabelValueDefinitionSeverity::Alert, + defined_by: None, + configurable: false, + flags: vec![ + LabelValueDefinitionFlag::NoOverride, + LabelValueDefinitionFlag::NoSelf, + ], + behaviors: InterpretedLabelValueDefinitionBehaviors { + account: ModerationBehavior { + profile_list: Some(ProfileListBehavior::Blur), + profile_view: Some(ProfileViewBehavior::Blur), + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + display_name: Some(DisplayNameBehavior::Blur), + content_list: Some(ContentListBehavior::Blur), + content_view: Some(ContentViewBehavior::Blur), + ..Default::default() + }, + profile: ModerationBehavior { + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + display_name: Some(DisplayNameBehavior::Blur), + ..Default::default() + }, + content: ModerationBehavior { + content_list: Some(ContentListBehavior::Blur), + content_view: Some(ContentViewBehavior::Blur), + ..Default::default() + }, + }, + }, + Self::ReservedWarn => InterpretedLabelValueDefinition { + adult_only: false, + blurs: LabelValueDefinitionBlurs::Content, + default_setting: LabelPreference::Warn, + identifier: String::from("!warn"), + locales: Vec::new(), + severity: LabelValueDefinitionSeverity::None, + defined_by: None, + configurable: false, + flags: vec![LabelValueDefinitionFlag::NoSelf], + behaviors: InterpretedLabelValueDefinitionBehaviors { + account: ModerationBehavior { + profile_list: Some(ProfileListBehavior::Blur), + profile_view: Some(ProfileViewBehavior::Blur), + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + content_list: Some(ContentListBehavior::Blur), + content_view: Some(ContentViewBehavior::Blur), + ..Default::default() + }, + profile: ModerationBehavior { + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + display_name: Some(DisplayNameBehavior::Blur), + ..Default::default() + }, + content: ModerationBehavior { + content_list: Some(ContentListBehavior::Blur), + content_view: Some(ContentViewBehavior::Blur), + ..Default::default() + }, + }, + }, + Self::ReservedNoUnauthenticated => InterpretedLabelValueDefinition { + adult_only: false, + blurs: LabelValueDefinitionBlurs::Content, + default_setting: LabelPreference::Hide, + identifier: String::from("!no-unauthenticated"), + locales: Vec::new(), + severity: LabelValueDefinitionSeverity::None, + defined_by: None, + configurable: false, + flags: vec![ + LabelValueDefinitionFlag::NoOverride, + LabelValueDefinitionFlag::Unauthed, + ], + behaviors: InterpretedLabelValueDefinitionBehaviors { + account: ModerationBehavior { + profile_list: Some(ProfileListBehavior::Blur), + profile_view: Some(ProfileViewBehavior::Blur), + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + display_name: Some(DisplayNameBehavior::Blur), + content_list: Some(ContentListBehavior::Blur), + content_view: Some(ContentViewBehavior::Blur), + ..Default::default() + }, + profile: ModerationBehavior { + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + display_name: Some(DisplayNameBehavior::Blur), + ..Default::default() + }, + content: ModerationBehavior { + content_list: Some(ContentListBehavior::Blur), + content_view: Some(ContentViewBehavior::Blur), + ..Default::default() + }, + }, + }, + Self::Porn => InterpretedLabelValueDefinition { + adult_only: false, + blurs: LabelValueDefinitionBlurs::Media, + default_setting: LabelPreference::Hide, + identifier: String::from("porn"), + locales: Vec::new(), + severity: LabelValueDefinitionSeverity::None, + defined_by: None, + configurable: true, + flags: vec![LabelValueDefinitionFlag::Adult], + behaviors: InterpretedLabelValueDefinitionBehaviors { + account: ModerationBehavior { + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + ..Default::default() + }, + profile: ModerationBehavior { + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + ..Default::default() + }, + content: ModerationBehavior { + content_media: Some(ContentMediaBehavior::Blur), + ..Default::default() + }, + }, + }, + Self::Sexual => InterpretedLabelValueDefinition { + adult_only: false, + blurs: LabelValueDefinitionBlurs::Media, + default_setting: LabelPreference::Warn, + identifier: String::from("sexual"), + locales: Vec::new(), + severity: LabelValueDefinitionSeverity::None, + defined_by: None, + configurable: true, + flags: vec![LabelValueDefinitionFlag::Adult], + behaviors: InterpretedLabelValueDefinitionBehaviors { + account: ModerationBehavior { + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + ..Default::default() + }, + profile: ModerationBehavior { + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + ..Default::default() + }, + content: ModerationBehavior { + content_media: Some(ContentMediaBehavior::Blur), + ..Default::default() + }, + }, + }, + Self::Nudity => InterpretedLabelValueDefinition { + adult_only: false, + blurs: LabelValueDefinitionBlurs::Media, + default_setting: LabelPreference::Ignore, + identifier: String::from("nudity"), + locales: Vec::new(), + severity: LabelValueDefinitionSeverity::None, + defined_by: None, + configurable: true, + flags: Vec::new(), + behaviors: InterpretedLabelValueDefinitionBehaviors { + account: ModerationBehavior { + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + ..Default::default() + }, + profile: ModerationBehavior { + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + ..Default::default() + }, + content: ModerationBehavior { + content_media: Some(ContentMediaBehavior::Blur), + ..Default::default() + }, + }, + }, + Self::GraphicMedia => InterpretedLabelValueDefinition { + adult_only: false, + blurs: LabelValueDefinitionBlurs::Media, + default_setting: LabelPreference::Warn, + identifier: String::from("graphic-media"), + locales: Vec::new(), + severity: LabelValueDefinitionSeverity::None, + defined_by: None, + configurable: true, + flags: vec![LabelValueDefinitionFlag::Adult], + behaviors: InterpretedLabelValueDefinitionBehaviors { + account: ModerationBehavior { + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + ..Default::default() + }, + profile: ModerationBehavior { + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + ..Default::default() + }, + content: ModerationBehavior { + content_media: Some(ContentMediaBehavior::Blur), + ..Default::default() + }, + }, + }, + } + } +} diff --git a/bsky-sdk/src/moderation/mutewords.rs b/bsky-sdk/src/moderation/mutewords.rs new file mode 100644 index 00000000..6ae63c2a --- /dev/null +++ b/bsky-sdk/src/moderation/mutewords.rs @@ -0,0 +1,141 @@ +//! Muteword checking logic. +use atrium_api::app::bsky::{actor::defs::MutedWord, richtext::facet::MainFeaturesItem}; +use atrium_api::types::Union; +use regex::Regex; +use std::sync::OnceLock; + +static RE_SPACE_OR_PUNCTUATION: OnceLock = OnceLock::new(); +static RE_WORD_BOUNDARY: OnceLock = OnceLock::new(); +static RE_LEADING_TRAILING_PUNCTUATION: OnceLock = OnceLock::new(); +static RE_INTERNAL_PUNCTUATION: OnceLock = OnceLock::new(); + +/** + * List of 2-letter lang codes for languages that either don't use spaces, or + * don't use spaces in a way conducive to word-based filtering. + * + * For these, we use a simple `String.includes` to check for a match. + */ +const LANGUAGE_EXCEPTIONS: [&str; 5] = [ + "ja", // Japanese + "zh", // Chinese + "ko", // Korean + "th", // Thai + "vi", // Vietnamese +]; + +/// Check if a text of facets and outline tags contains a muted word. +pub fn has_muted_word( + muted_words: &[MutedWord], + text: &str, + facets: &Option>, + outline_tags: &Option>, + langs: &Option>, +) -> bool { + let exception = langs + .as_ref() + .and_then(|langs| langs.first()) + .map_or(false, |lang| { + LANGUAGE_EXCEPTIONS.contains(&lang.as_ref().as_str()) + }); + let mut tags = Vec::new(); + if let Some(outline_tags) = outline_tags { + tags.extend(outline_tags.iter().map(|t| t.to_lowercase())); + } + if let Some(facets) = facets { + tags.extend( + facets + .iter() + .filter_map(|facet| { + facet.features.iter().find_map(|feature| { + if let Union::Refs(MainFeaturesItem::Tag(tag)) = feature { + Some(&tag.tag) + } else { + None + } + }) + }) + .map(|t| t.to_lowercase()) + .collect::>(), + ) + } + for mute in muted_words { + let muted_word = mute.value.to_lowercase(); + let post_text = text.to_lowercase(); + // `content` applies to tags as well + if tags.contains(&muted_word) { + return true; + } + // rest of the checks are for `content` only + if !mute.targets.contains(&String::from("content")) { + continue; + } + // single character or other exception, has to use includes + if (muted_word.chars().count() == 1 || exception) && post_text.contains(&muted_word) { + return true; + } + // too long + if muted_word.len() > post_text.len() { + continue; + } + // exact match + if muted_word == post_text { + return true; + } + // any muted phrase with space or punctuation + if RE_SPACE_OR_PUNCTUATION + .get_or_init(|| Regex::new(r"\s|\p{P}").expect("invalid regex")) + .is_match(&muted_word) + && post_text.contains(&muted_word) + { + return true; + } + + // check individual character groups + let words = RE_WORD_BOUNDARY + .get_or_init(|| Regex::new(r"[\s\n\t\r\f\v]+?").expect("invalid regex")) + .split(&post_text) + .collect::>(); + for word in words { + if word == muted_word { + return true; + } + // compare word without leading/trailing punctuation, but allow internal + // punctuation (such as `s@ssy`) + let word_trimmed_punctuation = RE_LEADING_TRAILING_PUNCTUATION + .get_or_init(|| Regex::new(r"^\p{P}+|\p{P}+$").expect("invalid regex")) + .replace_all(word, ""); + if muted_word == word_trimmed_punctuation { + return true; + } + if muted_word.len() > word_trimmed_punctuation.len() { + continue; + } + + let re_internal_punctuation = RE_INTERNAL_PUNCTUATION + .get_or_init(|| Regex::new(r"\p{P}").expect("invalid regex")); + if re_internal_punctuation.is_match(&word_trimmed_punctuation) { + let spaced_word = re_internal_punctuation + .replace_all(&word_trimmed_punctuation, " ") + .to_lowercase(); + if spaced_word == muted_word { + return true; + } + + let contiguous_word = spaced_word.replace(char::is_whitespace, ""); + if contiguous_word == muted_word { + return true; + } + + let word_parts = re_internal_punctuation + .split(&word_trimmed_punctuation) + .collect::>(); + for word_part in word_parts { + if word_part == muted_word { + return true; + } + } + } + } + } + false +} diff --git a/bsky-sdk/src/moderation/subjects.rs b/bsky-sdk/src/moderation/subjects.rs new file mode 100644 index 00000000..f998b650 --- /dev/null +++ b/bsky-sdk/src/moderation/subjects.rs @@ -0,0 +1,3 @@ +mod account; +mod post; +mod profile; diff --git a/bsky-sdk/src/moderation/subjects/account.rs b/bsky-sdk/src/moderation/subjects/account.rs new file mode 100644 index 00000000..128f3e09 --- /dev/null +++ b/bsky-sdk/src/moderation/subjects/account.rs @@ -0,0 +1,38 @@ +use super::super::decision::ModerationDecision; +use super::super::types::{LabelTarget, SubjectProfile}; +use super::super::Moderator; + +impl Moderator { + pub(crate) fn decide_account(&self, subject: &SubjectProfile) -> ModerationDecision { + let mut acc = ModerationDecision::new(); + acc.set_did(subject.did().clone()); + acc.set_is_me(self.user_did.as_ref() == Some(subject.did())); + if let Some(viewer) = subject.viewer() { + if viewer.muted.unwrap_or_default() { + if let Some(list_view) = &viewer.muted_by_list { + acc.add_muted_by_list(list_view); + } else { + acc.add_muted(); + } + } + if viewer.blocking.is_some() { + if let Some(list_view) = &viewer.blocking_by_list { + acc.add_blocking_by_list(list_view); + } else { + acc.add_blocking(); + } + } + if viewer.blocked_by.unwrap_or_default() { + acc.add_blocked_by(); + } + } + if let Some(labels) = subject.labels() { + for label in labels.iter().filter(|l| { + !l.uri.ends_with("/app.bsky.actor.profile/self") || l.val == "!no-unauthenticated" + }) { + acc.add_label(LabelTarget::Account, label, self); + } + } + acc + } +} diff --git a/bsky-sdk/src/moderation/subjects/post.rs b/bsky-sdk/src/moderation/subjects/post.rs new file mode 100644 index 00000000..f2b681a0 --- /dev/null +++ b/bsky-sdk/src/moderation/subjects/post.rs @@ -0,0 +1,154 @@ +use super::super::decision::ModerationDecision; +use super::super::mutewords::has_muted_word; +use super::super::types::{LabelTarget, SubjectPost}; +use super::super::Moderator; +use atrium_api::app::bsky::actor::defs::MutedWord; +use atrium_api::app::bsky::embed::record::{ViewBlocked, ViewRecord, ViewRecordRefs}; +use atrium_api::app::bsky::feed::defs::PostViewEmbedRefs; +use atrium_api::records::{KnownRecord, Record}; +use atrium_api::types::Union; + +impl Moderator { + pub(crate) fn decide_post(&self, subject: &SubjectPost) -> ModerationDecision { + let mut acc = ModerationDecision::new(); + let is_me = self.user_did.as_ref() == Some(&subject.author.did); + acc.set_did(subject.author.did.clone()); + acc.set_is_me(is_me); + if let Some(labels) = &subject.labels { + for label in labels { + acc.add_label(LabelTarget::Content, label, self); + } + } + if check_hidden_post(subject, &self.prefs.hidden_posts) { + acc.add_hidden(); + } + if !is_me && check_muted_words(subject, &self.prefs.muted_words) { + acc.add_muted_word(); + } + + let embed_acc = match &subject.embed { + Some(Union::Refs(PostViewEmbedRefs::AppBskyEmbedRecordView(view))) => { + match &view.record { + Union::Refs(ViewRecordRefs::ViewRecord(record)) => { + // quoted post + Some(self.decide_quoted_post(record)) + } + Union::Refs(ViewRecordRefs::ViewBlocked(blocked)) => { + // blocked quote post + Some(self.decide_bloked_quoted_post(blocked)) + } + _ => None, + } + } + Some(Union::Refs(PostViewEmbedRefs::AppBskyEmbedRecordWithMediaView(view))) => { + match &view.record.record { + Union::Refs(ViewRecordRefs::ViewRecord(record)) => { + // quoted post with media + Some(self.decide_quoted_post(record)) + } + Union::Refs(ViewRecordRefs::ViewBlocked(blocked)) => { + // blocked quote post with media + Some(self.decide_bloked_quoted_post(blocked)) + } + _ => None, + } + } + _ => None, + }; + + let mut decisions = vec![acc]; + if let Some(mut embed_acc) = embed_acc { + embed_acc.downgrade(); + decisions.push(embed_acc); + } + let author = subject.author.clone().into(); + decisions.extend([self.decide_account(&author), self.decide_profile(&author)]); + ModerationDecision::merge(&decisions) + } + fn decide_quoted_post(&self, subject: &ViewRecord) -> ModerationDecision { + let mut acc = ModerationDecision::new(); + acc.set_did(subject.author.did.clone()); + acc.set_is_me(self.user_did.as_ref() == Some(&subject.author.did)); + if let Some(labels) = &subject.labels { + for label in labels { + acc.add_label(LabelTarget::Content, label, self); + } + } + ModerationDecision::merge(&[ + acc, + self.decide_account(&subject.author.clone().into()), + self.decide_profile(&subject.author.clone().into()), + ]) + } + fn decide_bloked_quoted_post(&self, subject: &ViewBlocked) -> ModerationDecision { + let mut acc = ModerationDecision::new(); + acc.set_did(subject.author.did.clone()); + acc.set_is_me(self.user_did.as_ref() == Some(&subject.author.did)); + if let Some(viewer) = &subject.author.viewer { + if viewer.muted.unwrap_or_default() { + if let Some(list_view) = &viewer.muted_by_list { + acc.add_muted_by_list(list_view); + } else { + acc.add_muted(); + } + } + if viewer.blocking.is_some() { + if let Some(list_view) = &viewer.blocking_by_list { + acc.add_blocking_by_list(list_view); + } else { + acc.add_blocking(); + } + } + if viewer.blocked_by.unwrap_or_default() { + acc.add_blocked_by(); + } + } + acc + } +} + +fn check_hidden_post(subject: &SubjectPost, hidden_posts: &[String]) -> bool { + if hidden_posts.is_empty() { + return false; + } + if hidden_posts.contains(&subject.uri) { + return true; + } + match &subject.embed { + Some(Union::Refs(PostViewEmbedRefs::AppBskyEmbedRecordView(view))) => { + if let Union::Refs(ViewRecordRefs::ViewRecord(record)) = &view.record { + if hidden_posts.contains(&record.uri) { + return true; + } + } + } + Some(Union::Refs(PostViewEmbedRefs::AppBskyEmbedRecordWithMediaView(view))) => { + if let Union::Refs(ViewRecordRefs::ViewRecord(record)) = &view.record.record { + if hidden_posts.contains(&record.uri) { + return true; + } + } + } + _ => {} + } + false +} +fn check_muted_words(subject: &SubjectPost, muted_words: &[MutedWord]) -> bool { + if muted_words.is_empty() { + return false; + } + let Record::Known(KnownRecord::AppBskyFeedPost(post)) = &subject.record else { + return false; + }; + if has_muted_word( + muted_words, + &post.text, + &post.facets, + &post.tags, + &post.langs, + ) { + return true; + } + + false +} diff --git a/bsky-sdk/src/moderation/subjects/profile.rs b/bsky-sdk/src/moderation/subjects/profile.rs new file mode 100644 index 00000000..9bad728e --- /dev/null +++ b/bsky-sdk/src/moderation/subjects/profile.rs @@ -0,0 +1,20 @@ +use super::super::decision::ModerationDecision; +use super::super::types::{LabelTarget, SubjectProfile}; +use super::super::Moderator; + +impl Moderator { + pub(crate) fn decide_profile(&self, subject: &SubjectProfile) -> ModerationDecision { + let mut acc = ModerationDecision::new(); + acc.set_did(subject.did().clone()); + acc.set_is_me(self.user_did.as_ref() == Some(subject.did())); + if let Some(labels) = subject.labels() { + for label in labels + .iter() + .filter(|l| l.uri.ends_with("/app.bsky.actor.profile/self")) + { + acc.add_label(LabelTarget::Profile, label, self); + } + } + acc + } +} diff --git a/bsky-sdk/src/moderation/tests.rs b/bsky-sdk/src/moderation/tests.rs new file mode 100644 index 00000000..6b970446 --- /dev/null +++ b/bsky-sdk/src/moderation/tests.rs @@ -0,0 +1,668 @@ +mod behaviors; +mod custom_labels; +mod mutewords; +mod quoteposts; + +use crate::moderation::decision::{DecisionContext, ModerationDecision}; +use crate::moderation::types::*; +use crate::moderation::util::interpret_label_value_definition; +use crate::moderation::Moderator; +use atrium_api::app::bsky::actor::defs::ProfileViewBasic; +use atrium_api::app::bsky::feed::defs::PostView; +use atrium_api::com::atproto::label::defs::{Label, LabelValueDefinition}; +use atrium_api::records::{KnownRecord, Record}; +use atrium_api::types::string::Datetime; +use std::collections::HashMap; + +const FAKE_CID: &str = "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResultFlag { + Filter, + Blur, + Alert, + Inform, + NoOverride, +} + +#[derive(Debug, Default)] +struct ExpectedBehaviors { + profile_list: Vec, + profile_view: Vec, + avatar: Vec, + banner: Vec, + display_name: Vec, + content_list: Vec, + content_view: Vec, + content_media: Vec, +} + +impl ExpectedBehaviors { + fn expected_for(&self, context: DecisionContext) -> &Vec { + match context { + DecisionContext::ProfileList => &self.profile_list, + DecisionContext::ProfileView => &self.profile_view, + DecisionContext::Avatar => &self.avatar, + DecisionContext::Banner => &self.banner, + DecisionContext::DisplayName => &self.display_name, + DecisionContext::ContentList => &self.content_list, + DecisionContext::ContentView => &self.content_view, + DecisionContext::ContentMedia => &self.content_media, + } + } +} + +fn profile_view_basic( + handle: &str, + display_name: Option<&str>, + labels: Option>, +) -> ProfileViewBasic { + ProfileViewBasic { + associated: None, + avatar: None, + did: format!("did:web:{handle}").parse().expect("invalid did"), + display_name: display_name.map(String::from), + handle: handle.parse().expect("invalid handle"), + labels, + viewer: None, + } +} + +fn post_view(author: &ProfileViewBasic, text: &str, labels: Option>) -> PostView { + PostView { + author: author.clone(), + cid: FAKE_CID.parse().expect("invalid cid"), + embed: None, + indexed_at: Datetime::now(), + labels, + like_count: None, + record: Record::Known(KnownRecord::AppBskyFeedPost(Box::new( + atrium_api::app::bsky::feed::post::Record { + created_at: Datetime::now(), + embed: None, + entities: None, + facets: None, + labels: None, + langs: None, + reply: None, + tags: None, + text: text.into(), + }, + ))), + reply_count: None, + repost_count: None, + threadgate: None, + uri: format!("at://{}/app.bsky.feed.post/fake", author.did.as_ref()), + viewer: None, + } +} + +fn label(src: &str, uri: &str, val: &str) -> Label { + Label { + cid: None, + cts: Datetime::now(), + exp: None, + neg: None, + sig: None, + src: src.parse().expect("invalid did"), + uri: uri.into(), + val: val.into(), + ver: None, + } +} + +fn assert_ui(decision: &ModerationDecision, expected: &[ResultFlag], context: DecisionContext) { + let ui = decision.ui(context); + if expected.is_empty() { + assert!( + !ui.inform(), + "inform should be a no-op for context {context:?}" + ); + assert!( + !ui.alert(), + "alert should be a no-op for context {context:?}" + ); + assert!(!ui.blur(), "blur should be a no-op for context {context:?}"); + assert!( + !ui.filter(), + "filter should be a no-op for context {context:?}" + ); + assert!( + !ui.no_override, + "no_override should be a no-op for context {context:?}" + ); + } else { + assert_eq!( + ui.inform(), + expected.contains(&ResultFlag::Inform), + "inform should be {} for context {context:?}", + !ui.inform() + ); + assert_eq!( + ui.alert(), + expected.contains(&ResultFlag::Alert), + "alert should be {} for context {context:?}", + !ui.alert() + ); + assert_eq!( + ui.blur(), + expected.contains(&ResultFlag::Blur), + "blur should be {} for context {context:?}", + !ui.blur() + ); + assert_eq!( + ui.filter(), + expected.contains(&ResultFlag::Filter), + "filter should be {} for context {context:?}", + !ui.filter() + ); + assert_eq!( + ui.no_override, + expected.contains(&ResultFlag::NoOverride), + "no_override should be {} for context {context:?}", + !ui.no_override + ); + } +} + +#[test] +fn self_label_global() { + let profile = SubjectProfile::from(profile_view_basic( + "bob.test", + Some("Bob"), + Some(vec![label( + "did:web:bob.test", + "at://did:web:bob.test/app.bsky.actor.profile/self", + "porn", + )]), + )); + // porn (hide) + { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Hide)]), + ..Default::default() + }, + HashMap::new(), + ); + let result = moderator.moderate_profile(&profile); + assert_ui(&result, &[ResultFlag::Blur], DecisionContext::Avatar) + } + // porn (ignore) + { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Ignore)]), + ..Default::default() + }, + HashMap::new(), + ); + let result = moderator.moderate_profile(&profile); + assert_ui(&result, &[], DecisionContext::Avatar) + } +} + +#[test] +fn unsubscribed_or_ignore_labels() { + let profile = SubjectProfile::from(profile_view_basic( + "bob.test", + Some("Bob"), + Some(vec![label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.actor.profile/self", + "porn", + )]), + )); + // porn (moderator disabled) + { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Hide)]), + ..Default::default() + }, + HashMap::new(), + ); + let result = moderator.moderate_profile(&profile); + for context in DecisionContext::ALL { + assert_ui(&result, &[], context); + } + } + // porn (label group disabled) + { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Hide)]), + labelers: vec![ModerationPrefsLabeler { + did: "did:web:labeler.test".parse().expect("invalid did"), + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Ignore)]), + is_default_labeler: false, + }], + ..Default::default() + }, + HashMap::new(), + ); + let result = moderator.moderate_profile(&profile); + for context in DecisionContext::ALL { + assert_ui(&result, &[], context); + } + } +} + +#[test] +fn prioritize_filters_and_blurs() { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Hide)]), + labelers: vec![ModerationPrefsLabeler { + did: "did:web:labeler.test".parse().expect("invalid did"), + labels: HashMap::new(), + is_default_labeler: false, + }], + ..Default::default() + }, + HashMap::new(), + ); + let result = moderator.moderate_post(&post_view( + &profile_view_basic("bob.test", Some("Bob"), None), + "Hello", + Some(vec![ + label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.post/fake", + "porn", + ), + label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.post/fake", + "!hide", + ), + ]), + )); + for (cause, expected_val) in [ + (&result.ui(DecisionContext::ContentList).filters[0], "!hide"), + (&result.ui(DecisionContext::ContentList).filters[1], "porn"), + (&result.ui(DecisionContext::ContentList).blurs[0], "!hide"), + (&result.ui(DecisionContext::ContentMedia).blurs[0], "porn"), + ] { + if let ModerationCause::Label(label) = cause { + assert_eq!(label.label.val, expected_val, "unexpected label value"); + } else { + panic!("unexpected cause: {cause:?}"); + } + } +} + +#[test] +fn prioritize_custom_labels() { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Warn)]), + labelers: vec![ModerationPrefsLabeler { + did: "did:web:labeler.test".parse().expect("invalid did"), + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Warn)]), + is_default_labeler: false, + }], + ..Default::default() + }, + HashMap::from_iter([( + "did:web:labeler.test".parse().expect("invalid did"), + vec![interpret_label_value_definition( + &LabelValueDefinition { + identifier: String::from("porn"), + default_setting: Some(String::from("warn")), + severity: String::from("inform"), + blurs: String::from("none"), + adult_only: None, + locales: Vec::new(), + }, + Some("did:web:labeler.test".parse().expect("invalid did")), + ) + .expect("invalid label value definition")], + )]), + ); + let result = moderator.moderate_post(&post_view( + &profile_view_basic("bob.test", Some("Bob"), None), + "Hello", + Some(vec![label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.post/fake", + "porn", + )]), + )); + for context in DecisionContext::ALL { + let expected = match context { + DecisionContext::ContentList => vec![ResultFlag::Inform], + DecisionContext::ContentView => vec![ResultFlag::Inform], + _ => vec![], + }; + assert_ui(&result, &expected, context); + } +} + +#[test] +fn does_not_override_imperative_labels() { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::new(), + labelers: vec![ModerationPrefsLabeler { + did: "did:web:labeler.test".parse().expect("invalid did"), + labels: HashMap::new(), + is_default_labeler: false, + }], + ..Default::default() + }, + HashMap::from_iter([( + "did:web:labeler.test".parse().expect("invalid did"), + vec![interpret_label_value_definition( + &LabelValueDefinition { + identifier: String::from("!hide"), + default_setting: Some(String::from("warn")), + severity: String::from("inform"), + blurs: String::from("none"), + adult_only: None, + locales: Vec::new(), + }, + Some("did:web:labeler.test".parse().expect("invalid did")), + ) + .expect("invalid label value definition")], + )]), + ); + let result = moderator.moderate_post(&post_view( + &profile_view_basic("bob.test", Some("Bob"), None), + "Hello", + Some(vec![label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.post/fake", + "!hide", + )]), + )); + for context in DecisionContext::ALL { + let expected = match context { + DecisionContext::ContentList => { + vec![ResultFlag::Filter, ResultFlag::Blur, ResultFlag::NoOverride] + } + DecisionContext::ContentView => vec![ResultFlag::Blur, ResultFlag::NoOverride], + _ => vec![], + }; + assert_ui(&result, &expected, context); + } +} + +#[test] +fn ignore_invalid_label_value_names() { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::new(), + labelers: vec![ModerationPrefsLabeler { + did: "did:web:labeler.test".parse().expect("invalid did"), + labels: HashMap::from_iter([ + (String::from("BadLabel"), LabelPreference::Hide), + (String::from("bad/label"), LabelPreference::Hide), + ]), + is_default_labeler: false, + }], + ..Default::default() + }, + HashMap::from_iter([( + "did:web:labeler.test".parse().expect("invalid did"), + vec![ + interpret_label_value_definition( + &LabelValueDefinition { + identifier: String::from("BadLabel"), + default_setting: Some(String::from("warn")), + severity: String::from("inform"), + blurs: String::from("content"), + adult_only: None, + locales: Vec::new(), + }, + Some("did:web:labeler.test".parse().expect("invalid did")), + ) + .expect("invalid label value definition"), + interpret_label_value_definition( + &LabelValueDefinition { + identifier: String::from("bad/label"), + default_setting: Some(String::from("warn")), + severity: String::from("inform"), + blurs: String::from("content"), + adult_only: None, + locales: Vec::new(), + }, + Some("did:web:labeler.test".parse().expect("invalid did")), + ) + .expect("invalid label value definition"), + ], + )]), + ); + let result = moderator.moderate_post(&post_view( + &profile_view_basic("bob.test", Some("Bob"), None), + "Hello", + Some(vec![ + label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.post/fake", + "BadLabel", + ), + label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.post/fake", + "bad/label", + ), + ]), + )); + for context in DecisionContext::ALL { + assert_ui(&result, &[], context); + } +} + +#[test] +fn custom_labels_with_default_settings() { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::new(), + labelers: vec![ModerationPrefsLabeler { + did: "did:web:labeler.test".parse().expect("invalid did"), + labels: HashMap::new(), + is_default_labeler: false, + }], + ..Default::default() + }, + HashMap::from_iter([( + "did:web:labeler.test".parse().expect("invalid did"), + vec![ + interpret_label_value_definition( + &LabelValueDefinition { + identifier: String::from("default-hide"), + default_setting: Some(String::from("hide")), + severity: String::from("inform"), + blurs: String::from("content"), + adult_only: None, + locales: Vec::new(), + }, + Some("did:web:labeler.test".parse().expect("invalid did")), + ) + .expect("invalid label value definition"), + interpret_label_value_definition( + &LabelValueDefinition { + identifier: String::from("default-warn"), + default_setting: Some(String::from("warn")), + severity: String::from("inform"), + blurs: String::from("content"), + adult_only: None, + locales: Vec::new(), + }, + Some("did:web:labeler.test".parse().expect("invalid did")), + ) + .expect("invalid label value definition"), + interpret_label_value_definition( + &LabelValueDefinition { + identifier: String::from("default-ignore"), + default_setting: Some(String::from("ignore")), + severity: String::from("inform"), + blurs: String::from("content"), + adult_only: None, + locales: Vec::new(), + }, + Some("did:web:labeler.test".parse().expect("invalid did")), + ) + .expect("invalid label value definition"), + ], + )]), + ); + let author = profile_view_basic("bob.test", Some("Bob"), None); + { + let result = moderator.moderate_post(&post_view( + &author, + "Hello", + Some(vec![label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.post/fake", + "default-hide", + )]), + )); + for context in DecisionContext::ALL { + let expected = match context { + DecisionContext::ContentList => vec![ResultFlag::Filter, ResultFlag::Blur], + DecisionContext::ContentView => vec![ResultFlag::Inform], + _ => vec![], + }; + assert_ui(&result, &expected, context); + } + } + { + let result = moderator.moderate_post(&post_view( + &author, + "Hello", + Some(vec![label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.post/fake", + "default-warn", + )]), + )); + for context in DecisionContext::ALL { + let expected = match context { + DecisionContext::ContentList => vec![ResultFlag::Blur], + DecisionContext::ContentView => vec![ResultFlag::Inform], + _ => vec![], + }; + assert_ui(&result, &expected, context); + } + } + { + let result = moderator.moderate_post(&post_view( + &author, + "Hello", + Some(vec![label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.post/fake", + "default-ignore", + )]), + )); + for context in DecisionContext::ALL { + assert_ui(&result, &[], context) + } + } +} + +#[test] +fn custom_labels_require_adult_content_enabled() { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { + adult_content_enabled: false, + labels: HashMap::from_iter([(String::from("adult"), LabelPreference::Ignore)]), + labelers: vec![ModerationPrefsLabeler { + did: "did:web:labeler.test".parse().expect("invalid did"), + labels: HashMap::from_iter([(String::from("adult"), LabelPreference::Ignore)]), + is_default_labeler: false, + }], + ..Default::default() + }, + HashMap::from_iter([( + "did:web:labeler.test".parse().expect("invalid did"), + vec![interpret_label_value_definition( + &LabelValueDefinition { + identifier: String::from("adult"), + default_setting: Some(String::from("hide")), + severity: String::from("inform"), + blurs: String::from("content"), + adult_only: Some(true), + locales: Vec::new(), + }, + Some("did:web:labeler.test".parse().expect("invalid did")), + ) + .expect("invalid label value definition")], + )]), + ); + let result = moderator.moderate_post(&post_view( + &profile_view_basic("bob.test", Some("Bob"), None), + "Hello", + Some(vec![label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.post/fake", + "adult", + )]), + )); + for context in DecisionContext::ALL { + let expected = match context { + DecisionContext::ContentList => { + vec![ResultFlag::Filter, ResultFlag::Blur, ResultFlag::NoOverride] + } + DecisionContext::ContentView => vec![ResultFlag::Blur, ResultFlag::NoOverride], + _ => vec![], + }; + assert_ui(&result, &expected, context); + } +} + +#[test] +fn adult_content_disabled_forces_hide() { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { + adult_content_enabled: false, + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Ignore)]), + labelers: vec![ModerationPrefsLabeler { + did: "did:web:labeler.test".parse().expect("invalid did"), + labels: HashMap::new(), + is_default_labeler: false, + }], + ..Default::default() + }, + HashMap::new(), + ); + let result = moderator.moderate_post(&post_view( + &profile_view_basic("bob.test", Some("Bob"), None), + "Hello", + Some(vec![label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.post/fake", + "porn", + )]), + )); + for context in DecisionContext::ALL { + let expected = match context { + DecisionContext::ContentList => vec![ResultFlag::Filter], + DecisionContext::ContentMedia => vec![ResultFlag::Blur, ResultFlag::NoOverride], + _ => vec![], + }; + assert_ui(&result, &expected, context); + } +} diff --git a/bsky-sdk/src/moderation/tests/behaviors.rs b/bsky-sdk/src/moderation/tests/behaviors.rs new file mode 100644 index 00000000..ee8b8d26 --- /dev/null +++ b/bsky-sdk/src/moderation/tests/behaviors.rs @@ -0,0 +1,1448 @@ +use super::{assert_ui, label, post_view, profile_view_basic}; +use super::{ExpectedBehaviors, ResultFlag, FAKE_CID}; +use crate::moderation::decision::DecisionContext; +use crate::moderation::types::*; +use crate::moderation::Moderator; +use atrium_api::app::bsky::actor::defs::{ProfileViewBasic, ViewerState}; +use atrium_api::app::bsky::graph::defs::{ListPurpose, ListViewBasic}; +use atrium_api::types::string::Datetime; +use std::collections::HashMap; + +fn list_view_basic(name: &str) -> ListViewBasic { + ListViewBasic { + avatar: None, + cid: FAKE_CID.parse().expect("invalid cid"), + indexed_at: Some(Datetime::now()), + labels: None, + name: name.into(), + purpose: ListPurpose::from("app.bsky.graph.defs#modlist"), + uri: String::from("at://did:plc:fake/app.bsky.graph.list/fake"), + viewer: None, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TestConfig { + None, + AdultDisabled, + PornHide, + PornWarn, + PornIgnore, + LoggedOut, +} + +impl TestConfig { + fn labels(&self) -> HashMap { + match self { + Self::PornHide => HashMap::from_iter([(String::from("porn"), LabelPreference::Hide)]), + Self::PornWarn => HashMap::from_iter([(String::from("porn"), LabelPreference::Warn)]), + Self::PornIgnore => { + HashMap::from_iter([(String::from("porn"), LabelPreference::Ignore)]) + } + _ => HashMap::new(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TestSubject { + Profile, + Post, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TestUser { + UserSelf, + Alice, + Bob, + Carla, + Dan, + Elise, + Fern, + Georgia, +} + +impl TestUser { + fn viewer_state(&self) -> ViewerState { + #[derive(Default)] + struct Definition { + blocking: bool, + blocking_by_list: bool, + blocked_by: bool, + muted: bool, + muted_by_list: bool, + } + let def = match self { + Self::Bob => Definition { + blocking: true, + ..Default::default() + }, + Self::Carla => Definition { + blocked_by: true, + ..Default::default() + }, + Self::Dan => Definition { + muted: true, + ..Default::default() + }, + Self::Elise => Definition { + muted_by_list: true, + ..Default::default() + }, + Self::Fern => Definition { + blocking: true, + blocked_by: true, + ..Default::default() + }, + Self::Georgia => Definition { + blocking_by_list: true, + ..Default::default() + }, + _ => Definition::default(), + }; + ViewerState { + blocked_by: if def.blocked_by { Some(true) } else { None }, + blocking: if def.blocking || def.blocking_by_list { + Some(String::from( + "at://did:web:self.test/app.bsky.graph.block/fake", + )) + } else { + None + }, + blocking_by_list: if def.blocking_by_list { + Some(list_view_basic("Fake list")) + } else { + None + }, + followed_by: None, + following: None, + muted: if def.muted || def.muted_by_list { + Some(true) + } else { + None + }, + muted_by_list: if def.muted_by_list { + Some(list_view_basic("Fake list")) + } else { + None + }, + } + } +} + +impl AsRef for TestUser { + fn as_ref(&self) -> &str { + match self { + Self::UserSelf => "self", + Self::Alice => "alice", + Self::Bob => "bob", + Self::Carla => "carla", + Self::Dan => "dan", + Self::Elise => "elise", + Self::Fern => "fern", + Self::Georgia => "georgia", + } + } +} + +#[derive(Debug, Default)] +struct TestLabels { + post: Vec, + profile: Vec, + account: Vec, +} + +#[derive(Debug)] +struct Scenario { + cfg: TestConfig, + subject: TestSubject, + author: TestUser, + labels: TestLabels, + behaviors: ExpectedBehaviors, +} + +impl Scenario { + fn run(&self) { + let moderator = self.moderator(); + let result = match self.subject { + TestSubject::Profile => moderator.moderate_profile(&self.profile().into()), + TestSubject::Post => moderator.moderate_post(&self.post()), + }; + if self.subject == TestSubject::Profile { + assert_ui( + &result, + &self.behaviors.profile_list, + DecisionContext::ProfileList, + ); + assert_ui( + &result, + &self.behaviors.profile_view, + DecisionContext::ProfileView, + ); + } + for context in [ + DecisionContext::Avatar, + DecisionContext::Banner, + DecisionContext::DisplayName, + DecisionContext::ContentList, + DecisionContext::ContentView, + DecisionContext::ContentMedia, + ] { + assert_ui(&result, self.behaviors.expected_for(context), context); + } + } + fn moderator(&self) -> Moderator { + Moderator::new( + match self.cfg { + TestConfig::LoggedOut => None, + _ => Some("did:web:self.test".parse().expect("invalid did")), + }, + ModerationPrefs { + adult_content_enabled: matches!( + self.cfg, + TestConfig::PornHide | TestConfig::PornWarn | TestConfig::PornIgnore + ), + labels: self.cfg.labels(), + labelers: vec![ModerationPrefsLabeler { + did: "did:plc:fake-labeler".parse().expect("invalid did"), + labels: HashMap::new(), + is_default_labeler: false, + }], + ..Default::default() + }, + HashMap::new(), + ) + } + fn profile(&self) -> ProfileViewBasic { + let mut labels = Vec::new(); + for val in &self.labels.account { + labels.push(label( + "did:plc:fake-labeler", + &format!("did:web:{}", self.author.as_ref()), + val, + )) + } + for val in &self.labels.profile { + labels.push(label( + "did:plc:fake-labeler", + &format!( + "at://did:web:{}/app.bsky.actor.profile/self", + self.author.as_ref() + ), + val, + )) + } + let mut ret = profile_view_basic( + &format!("{}.test", self.author.as_ref()), + None, + Some(labels), + ); + ret.viewer = Some(self.author.viewer_state()); + ret + } + fn post(&self) -> SubjectPost { + let author = self.profile(); + post_view( + &author, + "Post text", + Some( + self.labels + .post + .iter() + .map(|val| { + label( + "did:plc:fake-labeler", + &format!("at://{}/app.bsky.feed.post/fake", author.did.as_ref()), + val, + ) + }) + .collect(), + ), + ) + } +} + +#[test] +fn moderation_behaviors() { + use ResultFlag::*; + let scenarios = [ + ( + "Imperative label ('!hide') on account", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + profile_list: vec![Filter, Blur, NoOverride], + profile_view: vec![Blur, NoOverride], + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!hide') on profile", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!hide') on post", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + post: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!hide') on author profile", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!hide') on author account", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!warn') on account", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + profile_list: vec![Blur], + profile_view: vec![Blur], + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!warn') on profile", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + display_name: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!warn') on post", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + post: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + content_list: vec![Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!warn') on author profile", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + display_name: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!warn') on author account", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!no-unauthenticated') on account when logged out", + Scenario { + cfg: TestConfig::LoggedOut, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + profile_list: vec![Filter, Blur, NoOverride], + profile_view: vec![Blur, NoOverride], + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!no-unauthenticated') on profile when logged out", + Scenario { + cfg: TestConfig::LoggedOut, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + profile_list: vec![Filter, Blur, NoOverride], + profile_view: vec![Blur, NoOverride], + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!no-unauthenticated') on post when logged out", + Scenario { + cfg: TestConfig::LoggedOut, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + post: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!no-unauthenticated') on author profile when logged out", + Scenario { + cfg: TestConfig::LoggedOut, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!no-unauthenticated') on author account when logged out", + Scenario { + cfg: TestConfig::LoggedOut, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!no-unauthenticated') on account when logged in", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: ExpectedBehaviors::default(), + }, + ), + ( + "Imperative label ('!no-unauthenticated') on profile when logged in", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: ExpectedBehaviors::default(), + }, + ), + ( + "Imperative label ('!no-unauthenticated') on post when logged in", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + post: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: ExpectedBehaviors::default(), + }, + ), + ( + "Imperative label ('!no-unauthenticated') on author profile when logged in", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: ExpectedBehaviors::default(), + }, + ), + ( + "Imperative label ('!no-unauthenticated') on author account when logged in", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: ExpectedBehaviors::default(), + }, + ), + ( + "Blur-media label ('porn') on account (hide)", + Scenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + profile_list: vec![Filter], + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Filter], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on profile (hide)", + Scenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on post (hide)", + Scenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + post: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + content_list: vec![Filter], + content_media: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on author profile (hide)", + Scenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on author account (hide)", + Scenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + profile_list: vec![Filter], + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Filter], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on account (warn)", + Scenario { + cfg: TestConfig::PornWarn, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on profile (warn)", + Scenario { + cfg: TestConfig::PornWarn, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on post (warn)", + Scenario { + cfg: TestConfig::PornWarn, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + post: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + content_media: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on author profile (warn)", + Scenario { + cfg: TestConfig::PornWarn, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on author account (warn)", + Scenario { + cfg: TestConfig::PornWarn, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on account (ignore)", + Scenario { + cfg: TestConfig::PornIgnore, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors::default(), + }, + ), + ( + "Blur-media label ('porn') on profile (ignore)", + Scenario { + cfg: TestConfig::PornIgnore, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors::default(), + }, + ), + ( + "Blur-media label ('porn') on post (ignore)", + Scenario { + cfg: TestConfig::PornIgnore, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + post: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors::default(), + }, + ), + ( + "Blur-media label ('porn') on author profile (ignore)", + Scenario { + cfg: TestConfig::PornIgnore, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors::default(), + }, + ), + ( + "Blur-media label ('porn') on author account (ignore)", + Scenario { + cfg: TestConfig::PornIgnore, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors::default(), + }, + ), + ( + "Adult-only label on account when adult content is disabled", + Scenario { + cfg: TestConfig::AdultDisabled, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + profile_list: vec![Filter], + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + content_list: vec![Filter], + ..Default::default() + }, + }, + ), + ( + "Adult-only label on profile when adult content is disabled", + Scenario { + cfg: TestConfig::AdultDisabled, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Adult-only label on post when adult content is disabled", + Scenario { + cfg: TestConfig::AdultDisabled, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + post: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + content_list: vec![Filter], + content_media: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Adult-only label on author profile when adult content is disabled", + Scenario { + cfg: TestConfig::AdultDisabled, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Adult-only label on author account when adult content is disabled", + Scenario { + cfg: TestConfig::AdultDisabled, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + content_list: vec![Filter], + ..Default::default() + }, + }, + ), + ( + "Self-profile: !hide on account", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::UserSelf, + labels: TestLabels { + account: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + profile_list: vec![Blur], + profile_view: vec![Blur], + avatar: vec![Blur], + banner: vec![Blur], + display_name: vec![Blur], + content_list: vec![Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Self-profile: !hide on profile", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::UserSelf, + labels: TestLabels { + profile: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + display_name: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Self-post: Imperative label ('!hide') on post", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::UserSelf, + labels: TestLabels { + post: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + content_list: vec![Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Self-post: Imperative label ('!hide') on author profile", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::UserSelf, + labels: TestLabels { + profile: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + display_name: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Self-post: Imperative label ('!hide') on author account", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::UserSelf, + labels: TestLabels { + account: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + display_name: vec![Blur], + content_list: vec![Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Self-post: Imperative label ('!warn') on post", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::UserSelf, + labels: TestLabels { + post: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + content_list: vec![Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Self-post: Imperative label ('!warn') on author profile", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::UserSelf, + labels: TestLabels { + profile: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + display_name: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Self-post: Imperative label ('!warn') on author account", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::UserSelf, + labels: TestLabels { + account: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Mute/block: Blocking user", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Bob, + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { + profile_list: vec![Filter, Blur, NoOverride], + profile_view: vec![Alert], + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Post with blocked author", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Bob, + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Post with author blocking user", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Carla, + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Mute/block: Blocking-by-list user", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Georgia, + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { + profile_list: vec![Filter, Blur, NoOverride], + profile_view: vec![Alert], + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Mute/block: Blocked by user", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Carla, + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { + profile_list: vec![Filter, Blur, NoOverride], + profile_view: vec![Alert], + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Mute/block: Muted user", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Dan, + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { + profile_list: vec![Filter, Inform], + profile_view: vec![Alert], + content_list: vec![Filter, Blur], + content_view: vec![Inform], + ..Default::default() + }, + }, + ), + ( + "Mute/block: Muted-by-list user", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Elise, + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { + profile_list: vec![Filter, Inform], + profile_view: vec![Alert], + content_list: vec![Filter, Blur], + content_view: vec![Inform], + ..Default::default() + }, + }, + ), + ( + "Merging: blocking & blocked-by user", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Fern, + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { + profile_list: vec![Filter, Blur, NoOverride], + profile_view: vec![Alert], + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Post with muted author", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Dan, + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { + content_list: vec![Filter, Blur], + content_view: vec![Inform], + ..Default::default() + }, + }, + ), + ( + "Post with muted-by-list author", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Elise, + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { + content_list: vec![Filter, Blur], + content_view: vec![Inform], + ..Default::default() + }, + }, + ), + ( + "Merging: '!hide' label on account of blocked user", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Bob, + labels: TestLabels { + account: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + profile_list: vec![Filter, Blur, NoOverride], + profile_view: vec![Blur, Alert, NoOverride], + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Merging: '!hide' and 'porn' labels on account (hide)", + Scenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("!hide"), String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + profile_list: vec![Filter, Blur, NoOverride], + profile_view: vec![Blur, NoOverride], + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Merging: '!warn' and 'porn' labels on account (hide)", + Scenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("!warn"), String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + profile_list: vec![Filter, Blur], + profile_view: vec![Blur], + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Filter, Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Merging: !hide on account, !warn on profile", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("!hide")], + profile: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + profile_list: vec![Filter, Blur, NoOverride], + profile_view: vec![Blur, NoOverride], + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Merging: !warn on account, !hide on profile", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestLabels { + account: vec![String::from("!warn")], + profile: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + profile_list: vec![Blur], + profile_view: vec![Blur], + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + content_list: vec![Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Merging: post with blocking & blocked-by author", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Fern, + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Merging: '!hide' label on post by blocked user", + Scenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Bob, + labels: TestLabels { + post: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Merging: '!hide' and 'porn' labels on post (hide)", + Scenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + post: vec![String::from("!warn"), String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + content_list: vec![Filter, Blur], + content_view: vec![Blur], + content_media: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Merging: '!warn' and 'porn' labels on post (hide)", + Scenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestLabels { + post: vec![String::from("!warn"), String::from("porn")], + ..Default::default() + }, + behaviors: ExpectedBehaviors { + content_list: vec![Filter, Blur], + content_view: vec![Blur], + content_media: vec![Blur], + ..Default::default() + }, + }, + ), + ]; + for (_, scenario) in scenarios { + scenario.run(); + } +} diff --git a/bsky-sdk/src/moderation/tests/custom_labels.rs b/bsky-sdk/src/moderation/tests/custom_labels.rs new file mode 100644 index 00000000..eea49a32 --- /dev/null +++ b/bsky-sdk/src/moderation/tests/custom_labels.rs @@ -0,0 +1,300 @@ +use super::{assert_ui, label, post_view, profile_view_basic}; +use super::{ExpectedBehaviors, ResultFlag}; +use crate::moderation::decision::DecisionContext; +use crate::moderation::error::Result; +use crate::moderation::types::*; +use crate::moderation::util::interpret_label_value_definition; +use crate::moderation::Moderator; +use atrium_api::com::atproto::label::defs::LabelValueDefinition; +use std::collections::HashMap; + +#[derive(Debug)] +struct Scenario { + blurs: LabelValueDefinitionBlurs, + severity: LabelValueDefinitionSeverity, + account: ExpectedBehaviors, + profile: ExpectedBehaviors, + post: ExpectedBehaviors, +} + +impl Scenario { + fn run(&self) { + let moderator = self.moderator().expect("failed to create moderator"); + // account + { + let result = moderator.moderate_profile( + &profile_view_basic( + "bob.test", + Some("Bob"), + Some(vec![label( + "did:web:labeler.test", + "did:web:bob.test", + "custom", + )]), + ) + .into(), + ); + for context in DecisionContext::ALL { + assert_ui(&result, self.account.expected_for(context), context); + } + } + // profile + { + let result = moderator.moderate_profile( + &profile_view_basic( + "bob.test", + Some("Bob"), + Some(vec![label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.actor.profile/self", + "custom", + )]), + ) + .into(), + ); + for context in DecisionContext::ALL { + assert_ui(&result, self.profile.expected_for(context), context); + } + } + // post + { + let result = moderator.moderate_post(&post_view( + &profile_view_basic("bob.test", Some("Bob"), None), + "Hello", + Some(vec![label( + "did:web:labeler.test", + "at://did:web:bob.test/app.bsky.feed.post/fake", + "custom", + )]), + )); + for context in DecisionContext::ALL { + assert_ui(&result, self.post.expected_for(context), context); + } + } + } + fn moderator(&self) -> Result { + Ok(Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::new(), + labelers: vec![ModerationPrefsLabeler { + did: "did:web:labeler.test".parse().expect("invalid did"), + labels: HashMap::from_iter([(String::from("custom"), LabelPreference::Hide)]), + is_default_labeler: false, + }], + muted_words: Vec::new(), + hidden_posts: Vec::new(), + }, + HashMap::from_iter([( + "did:web:labeler.test".parse().expect("invalid did"), + vec![interpret_label_value_definition( + &LabelValueDefinition { + adult_only: None, + blurs: self.blurs.as_ref().to_string(), + default_setting: Some(LabelPreference::Warn.as_ref().to_string()), + identifier: String::from("custom"), + locales: Vec::new(), + severity: self.severity.as_ref().to_string(), + }, + Some("did:web:labeler.test".parse().expect("invalid did")), + )?], + )]), + )) + } +} + +#[test] +fn moderation_custom_labels() { + use ResultFlag::*; + let scenarios = [ + Scenario { + blurs: LabelValueDefinitionBlurs::Content, + severity: LabelValueDefinitionSeverity::Alert, + account: ExpectedBehaviors { + profile_list: vec![Filter, Alert], + profile_view: vec![Alert], + content_list: vec![Filter, Blur], + content_view: vec![Alert], + ..Default::default() + }, + profile: ExpectedBehaviors { + profile_list: vec![Alert], + profile_view: vec![Alert], + ..Default::default() + }, + post: ExpectedBehaviors { + content_list: vec![Filter, Blur], + content_view: vec![Alert], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::Content, + severity: LabelValueDefinitionSeverity::Inform, + account: ExpectedBehaviors { + profile_list: vec![Filter, Inform], + profile_view: vec![Inform], + content_list: vec![Filter, Blur], + content_view: vec![Inform], + ..Default::default() + }, + profile: ExpectedBehaviors { + profile_list: vec![Inform], + profile_view: vec![Inform], + ..Default::default() + }, + post: ExpectedBehaviors { + content_list: vec![Filter, Blur], + content_view: vec![Inform], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::Content, + severity: LabelValueDefinitionSeverity::None, + account: ExpectedBehaviors { + profile_list: vec![Filter], + content_list: vec![Filter, Blur], + ..Default::default() + }, + profile: ExpectedBehaviors { + ..Default::default() + }, + post: ExpectedBehaviors { + content_list: vec![Filter, Blur], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::Media, + severity: LabelValueDefinitionSeverity::Alert, + account: ExpectedBehaviors { + profile_list: vec![Filter, Alert], + profile_view: vec![Alert], + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Filter], + ..Default::default() + }, + profile: ExpectedBehaviors { + profile_list: vec![Alert], + profile_view: vec![Alert], + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + post: ExpectedBehaviors { + content_list: vec![Filter], + content_media: vec![Blur], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::Media, + severity: LabelValueDefinitionSeverity::Inform, + account: ExpectedBehaviors { + profile_list: vec![Filter, Inform], + profile_view: vec![Inform], + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Filter], + ..Default::default() + }, + profile: ExpectedBehaviors { + profile_list: vec![Inform], + profile_view: vec![Inform], + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + post: ExpectedBehaviors { + content_list: vec![Filter], + content_media: vec![Blur], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::Media, + severity: LabelValueDefinitionSeverity::None, + account: ExpectedBehaviors { + profile_list: vec![Filter], + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Filter], + ..Default::default() + }, + profile: ExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + post: ExpectedBehaviors { + content_list: vec![Filter], + content_media: vec![Blur], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::None, + severity: LabelValueDefinitionSeverity::Alert, + account: ExpectedBehaviors { + profile_list: vec![Filter, Alert], + profile_view: vec![Alert], + content_list: vec![Filter, Alert], + content_view: vec![Alert], + ..Default::default() + }, + profile: ExpectedBehaviors { + profile_list: vec![Alert], + profile_view: vec![Alert], + ..Default::default() + }, + post: ExpectedBehaviors { + content_list: vec![Filter, Alert], + content_view: vec![Alert], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::None, + severity: LabelValueDefinitionSeverity::Inform, + account: ExpectedBehaviors { + profile_list: vec![Filter, Inform], + profile_view: vec![Inform], + content_list: vec![Filter, Inform], + content_view: vec![Inform], + ..Default::default() + }, + profile: ExpectedBehaviors { + profile_list: vec![Inform], + profile_view: vec![Inform], + ..Default::default() + }, + post: ExpectedBehaviors { + content_list: vec![Filter, Inform], + content_view: vec![Inform], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::None, + severity: LabelValueDefinitionSeverity::None, + account: ExpectedBehaviors { + profile_list: vec![Filter], + content_list: vec![Filter], + ..Default::default() + }, + profile: ExpectedBehaviors { + ..Default::default() + }, + post: ExpectedBehaviors { + content_list: vec![Filter], + ..Default::default() + }, + }, + ]; + for scenario in scenarios { + scenario.run(); + } +} diff --git a/bsky-sdk/src/moderation/tests/mutewords.rs b/bsky-sdk/src/moderation/tests/mutewords.rs new file mode 100644 index 00000000..fda662c8 --- /dev/null +++ b/bsky-sdk/src/moderation/tests/mutewords.rs @@ -0,0 +1,680 @@ +use super::{post_view, profile_view_basic}; +use crate::moderation::decision::DecisionContext; +use crate::moderation::{ModerationPrefs, Moderator}; +use atrium_api::app::bsky::actor::defs::MutedWord; +use std::collections::HashMap; + +#[cfg(feature = "rich-text")] +#[tokio::test] +async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { + use crate::moderation::mutewords::has_muted_word; + use crate::rich_text::tests::rich_text_with_detect_facets; + + // match: outline tag + { + let rt = rich_text_with_detect_facets("This is a post #inlineTag").await?; + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("tag")], + value: String::from("outlineTag"), + }], + &rt.text, + &rt.facets, + &Some(vec![String::from("outlineTag")]), + &None, + )); + } + // match: inline tag + { + let rt = rich_text_with_detect_facets("This is a post #inlineTag").await?; + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("tag")], + value: String::from("inlineTag"), + }], + &rt.text, + &rt.facets, + &Some(vec![String::from("outlineTag")]), + &None, + )); + } + // match: content target matches inline tag + { + let rt = rich_text_with_detect_facets("This is a post #inlineTag").await?; + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("inlineTag"), + }], + &rt.text, + &rt.facets, + &Some(vec![String::from("outlineTag")]), + &None, + )); + } + // no match: only tag targets + { + let rt = rich_text_with_detect_facets("This is a post").await?; + assert!(!has_muted_word( + &[MutedWord { + targets: vec![String::from("tag")], + value: String::from("post"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None, + )); + } + // match: single character 希 + { + let rt = rich_text_with_detect_facets("改善希望です").await?; + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("希"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None, + )); + } + // match: single char with length > 1 ☠︎ + { + let rt = rich_text_with_detect_facets("Idk why ☠︎ but maybe").await?; + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("☠︎"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // no match: long muted word, short post + { + let rt = rich_text_with_detect_facets("hey").await?; + assert!(!has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("politics"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // match: exact text + { + let rt = rich_text_with_detect_facets("javascript").await?; + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("javascript"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // match: word within post + { + let rt = rich_text_with_detect_facets("This is a post about javascript").await?; + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("javascript"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // no match: partial word + { + let rt = rich_text_with_detect_facets("Use your brain, Eric").await?; + assert!(!has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("ai"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // match: multiline + { + let rt = rich_text_with_detect_facets("Use your\n\tbrain, Eric").await?; + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("brain"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // match: :) + { + let rt = rich_text_with_detect_facets("So happy :)").await?; + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from(":)"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // yay! + { + let rt = rich_text_with_detect_facets("We're federating, yay!").await?; + // match: yay! + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("yay!"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: yay + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("yay"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // y!ppee!! + { + let rt = rich_text_with_detect_facets("We're federating, y!ppee!!").await?; + // match: y!ppee + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("y!ppee"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: y!ppee! + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("y!ppee!"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // apostrophes: Bluesky's + { + let rt = rich_text_with_detect_facets("Yay, Bluesky's mutewords work").await?; + // match: Bluesky's + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("Bluesky's"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: Bluesky + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("Bluesky"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: bluesky + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("bluesky"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: blueskys + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("blueskys"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // Why so S@assy? + { + let rt = rich_text_with_detect_facets("Why so S@assy?").await?; + // match: S@assy + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("S@assy"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: s@assy + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("s@assy"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // New York Times + { + let rt = rich_text_with_detect_facets("New York Times").await?; + // match: new york times + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("new york times"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // !command + { + let rt = rich_text_with_detect_facets("Idk maybe a bot !command").await?; + // match: !command + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("!command"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: command + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("command"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // no match: !command + let rt = rich_text_with_detect_facets("Idk maybe a bot command").await?; + assert!(!has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("!command"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // e/acc + { + let rt = rich_text_with_detect_facets("I'm e/acc pilled").await?; + // match: e/acc + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("e/acc"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: acc + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("acc"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // super-bad + { + let rt = rich_text_with_detect_facets("I'm super-bad").await?; + // match: super-bad + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("super-bad"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: super + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("super"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: bad + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("bad"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: super bad + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("super bad"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: superbad + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("superbad"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // idk_what_this_would_be + { + let rt = rich_text_with_detect_facets("Weird post with idk_what_this_would_be").await?; + // match: idk what this would be + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("idk what this would be"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // no match: idk what this would be for + assert!(!has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("idk what this would be for"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: idk + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("idk"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: idkwhatthiswouldbe + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("idkwhatthiswouldbe"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // parentheses + { + let rt = rich_text_with_detect_facets("Post with context(iykyk)").await?; + // match: context(iykyk) + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("context(iykyk)"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: context + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("context"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: iykyk + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("iykyk"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: (iykyk) + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("(iykyk)"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // 🦋 + { + let rt = rich_text_with_detect_facets("Post with 🦋").await?; + // match: 🦋 + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("🦋"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // phrases + { + let rt = rich_text_with_detect_facets( + "I like turtles, or how I learned to stop worrying and love the internet.", + ) + .await?; + // match: stop worrying + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("stop worrying"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + // match: turtles, or how + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("turtles, or how"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // languages without spaces + { + let rt = rich_text_with_detect_facets("私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか").await?; + // match: インターネット + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from("インターネット"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &Some(vec!["ja".parse().expect("invalid lang")],) + )); + } + Ok(()) +} + +#[test] +fn does_not_mute_own_post() { + let prefs = &ModerationPrefs { + adult_content_enabled: false, + labels: HashMap::new(), + labelers: Vec::new(), + muted_words: vec![MutedWord { + targets: vec![String::from("content")], + value: String::from("words"), + }], + hidden_posts: Vec::new(), + }; + let post = &post_view( + &profile_view_basic("bob.test", Some("Bob"), None), + "Mute words!", + None, + ); + // does mute if it isn't own post + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + prefs.clone(), + HashMap::new(), + ); + let result = moderator.moderate_post(post); + assert!( + result.ui(DecisionContext::ContentList).filter(), + "post should be filtered" + ); + // doesn't mute own post when muted word is in text + let moderator = Moderator::new( + Some("did:web:bob.test".parse().expect("invalid did")), + prefs.clone(), + HashMap::new(), + ); + let result = moderator.moderate_post(post); + assert!( + !result.ui(DecisionContext::ContentList).filter(), + "post should not be filtered" + ); +} + +#[cfg(feature = "rich-text")] +#[tokio::test] +async fn does_not_mute_own_tags() -> crate::error::Result<()> { + use crate::rich_text::tests::rich_text_with_detect_facets; + use atrium_api::records::{KnownRecord, Record}; + + let prefs = ModerationPrefs { + adult_content_enabled: false, + labels: HashMap::new(), + labelers: Vec::new(), + muted_words: vec![MutedWord { + targets: vec![String::from("tag")], + value: String::from("words"), + }], + hidden_posts: Vec::new(), + }; + let rt = rich_text_with_detect_facets("Mute #words!").await?; + let mut post = post_view( + &profile_view_basic("bob.test", Some("Bob"), None), + &rt.text, + None, + ); + if let Record::Known(KnownRecord::AppBskyFeedPost(ref mut post)) = post.record { + post.facets = rt.facets; + } + let moderator = Moderator::new( + Some("did:web:bob.test".parse().expect("invalid did")), + prefs, + HashMap::new(), + ); + let result = moderator.moderate_post(&post); + assert!( + !result.ui(DecisionContext::ContentList).filter(), + "post should not be filtered" + ); + Ok(()) +} diff --git a/bsky-sdk/src/moderation/tests/quoteposts.rs b/bsky-sdk/src/moderation/tests/quoteposts.rs new file mode 100644 index 00000000..0e5f49c5 --- /dev/null +++ b/bsky-sdk/src/moderation/tests/quoteposts.rs @@ -0,0 +1,282 @@ +use super::{assert_ui, label, post_view, profile_view_basic}; +use super::{ExpectedBehaviors, ResultFlag, FAKE_CID}; +use crate::moderation::decision::DecisionContext; +use crate::moderation::error::Result; +use crate::moderation::types::*; +use crate::moderation::util::interpret_label_value_definition; +use crate::moderation::Moderator; +use atrium_api::app::bsky::actor::defs::ProfileViewBasic; +use atrium_api::app::bsky::embed::record::{View, ViewRecord, ViewRecordRefs}; +use atrium_api::app::bsky::feed::defs::{PostView, PostViewEmbedRefs}; +use atrium_api::com::atproto::label::defs::{Label, LabelValueDefinition}; +use atrium_api::records::{KnownRecord, Record}; +use atrium_api::types::string::Datetime; +use atrium_api::types::Union; +use std::collections::HashMap; + +fn embed_record_view( + author: &ProfileViewBasic, + record: &atrium_api::app::bsky::feed::post::Record, + labels: Option>, +) -> Union { + Union::Refs(PostViewEmbedRefs::AppBskyEmbedRecordView(Box::new(View { + record: Union::Refs(ViewRecordRefs::ViewRecord(Box::new(ViewRecord { + author: author.clone(), + cid: FAKE_CID.parse().expect("invalid cid"), + embeds: None, + indexed_at: Datetime::now(), + labels, + like_count: None, + reply_count: None, + repost_count: None, + uri: format!("at://{}/app.bsky.feed.post/fake", author.did.as_ref()), + value: Record::Known(KnownRecord::AppBskyFeedPost(Box::new(record.clone()))), + }))), + }))) +} + +fn quoted_post(profile_labels: Option>, post_labels: Option>) -> PostView { + let mut quoted = post_view( + &profile_view_basic("bob.test", Some("Bob"), None), + "Hello", + None, + ); + quoted.embed = Some(embed_record_view( + &profile_view_basic("carla.test", Some("Carla"), profile_labels), + &atrium_api::app::bsky::feed::post::Record { + created_at: Datetime::now(), + embed: None, + entities: None, + facets: None, + labels: None, + langs: Some(vec!["en".parse().expect("invalid lang")]), + reply: None, + tags: None, + text: String::from("Quoted post text"), + }, + post_labels, + )); + quoted +} + +struct Scenario { + blurs: LabelValueDefinitionBlurs, + severity: LabelValueDefinitionSeverity, + account: ExpectedBehaviors, + profile: ExpectedBehaviors, + post: ExpectedBehaviors, +} + +impl Scenario { + fn run(&self) { + let moderator = self.moderator().expect("failed to create moderator"); + // account + { + let result = moderator.moderate_post("ed_post( + Some(vec![label( + "did:web:labeler.test", + "did:web:carla.test", + "custom", + )]), + None, + )); + for context in DecisionContext::ALL { + assert_ui(&result, self.account.expected_for(context), context); + } + } + // profile + { + let result = moderator.moderate_post("ed_post( + Some(vec![label( + "did:web:labeler.test", + "at://did:web:carla.test/app.bsky.actor.profile/self", + "custom", + )]), + None, + )); + for context in DecisionContext::ALL { + assert_ui(&result, self.profile.expected_for(context), context); + } + } + // post + { + let result = moderator.moderate_post("ed_post( + None, + Some(vec![label( + "did:web:labeler.test", + "at://did:web:carla.test/app.bsky.feed.post/fake", + "custom", + )]), + )); + for context in DecisionContext::ALL { + assert_ui(&result, self.post.expected_for(context), context); + } + } + } + fn moderator(&self) -> Result { + Ok(Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::new(), + labelers: vec![ModerationPrefsLabeler { + did: "did:web:labeler.test".parse().expect("invalid did"), + labels: HashMap::from_iter([(String::from("custom"), LabelPreference::Hide)]), + is_default_labeler: false, + }], + muted_words: Vec::new(), + hidden_posts: Vec::new(), + }, + HashMap::from_iter([( + "did:web:labeler.test".parse().expect("invalid did"), + vec![interpret_label_value_definition( + &LabelValueDefinition { + adult_only: None, + blurs: self.blurs.as_ref().to_string(), + default_setting: Some(LabelPreference::Warn.as_ref().to_string()), + identifier: String::from("custom"), + locales: Vec::new(), + severity: self.severity.as_ref().to_string(), + }, + Some("did:web:labeler.test".parse().expect("invalid did")), + )?], + )]), + )) + } +} + +#[test] +fn moderation_quoteposts() { + use ResultFlag::*; + let scenarios = [ + Scenario { + blurs: LabelValueDefinitionBlurs::Content, + severity: LabelValueDefinitionSeverity::Alert, + account: ExpectedBehaviors { + profile_list: vec![Filter], + content_list: vec![Filter], + ..Default::default() + }, + profile: ExpectedBehaviors::default(), + post: ExpectedBehaviors { + content_list: vec![Filter], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::Content, + severity: LabelValueDefinitionSeverity::Inform, + account: ExpectedBehaviors { + profile_list: vec![Filter], + content_list: vec![Filter], + ..Default::default() + }, + profile: ExpectedBehaviors::default(), + post: ExpectedBehaviors { + content_list: vec![Filter], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::Content, + severity: LabelValueDefinitionSeverity::None, + account: ExpectedBehaviors { + profile_list: vec![Filter], + content_list: vec![Filter], + ..Default::default() + }, + profile: ExpectedBehaviors::default(), + post: ExpectedBehaviors { + content_list: vec![Filter], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::Media, + severity: LabelValueDefinitionSeverity::Alert, + account: ExpectedBehaviors { + profile_list: vec![Filter], + content_list: vec![Filter], + ..Default::default() + }, + profile: ExpectedBehaviors::default(), + post: ExpectedBehaviors { + content_list: vec![Filter], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::Media, + severity: LabelValueDefinitionSeverity::Inform, + account: ExpectedBehaviors { + profile_list: vec![Filter], + content_list: vec![Filter], + ..Default::default() + }, + profile: ExpectedBehaviors::default(), + post: ExpectedBehaviors { + content_list: vec![Filter], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::Media, + severity: LabelValueDefinitionSeverity::None, + account: ExpectedBehaviors { + profile_list: vec![Filter], + content_list: vec![Filter], + ..Default::default() + }, + profile: ExpectedBehaviors::default(), + post: ExpectedBehaviors { + content_list: vec![Filter], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::None, + severity: LabelValueDefinitionSeverity::Alert, + account: ExpectedBehaviors { + profile_list: vec![Filter], + content_list: vec![Filter], + ..Default::default() + }, + profile: ExpectedBehaviors::default(), + post: ExpectedBehaviors { + content_list: vec![Filter], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::None, + severity: LabelValueDefinitionSeverity::Inform, + account: ExpectedBehaviors { + profile_list: vec![Filter], + content_list: vec![Filter], + ..Default::default() + }, + profile: ExpectedBehaviors::default(), + post: ExpectedBehaviors { + content_list: vec![Filter], + ..Default::default() + }, + }, + Scenario { + blurs: LabelValueDefinitionBlurs::None, + severity: LabelValueDefinitionSeverity::None, + account: ExpectedBehaviors { + profile_list: vec![Filter], + content_list: vec![Filter], + ..Default::default() + }, + profile: ExpectedBehaviors::default(), + post: ExpectedBehaviors { + content_list: vec![Filter], + ..Default::default() + }, + }, + ]; + for scenario in scenarios { + scenario.run(); + } +} diff --git a/bsky-sdk/src/moderation/types.rs b/bsky-sdk/src/moderation/types.rs new file mode 100644 index 00000000..3b076137 --- /dev/null +++ b/bsky-sdk/src/moderation/types.rs @@ -0,0 +1,650 @@ +use super::decision::{DecisionContext, Priority}; +use super::error::Error; +use atrium_api::agent::bluesky::BSKY_LABELER_DID; +use atrium_api::app::bsky::actor::defs::{ + MutedWord, ProfileView, ProfileViewBasic, ProfileViewDetailed, ViewerState, +}; +use atrium_api::app::bsky::feed::defs::PostView; +use atrium_api::app::bsky::graph::defs::ListViewBasic; +use atrium_api::com::atproto::label::defs::{Label, LabelValueDefinitionStrings}; +use atrium_api::types::string::Did; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, str::FromStr}; + +// behaviors + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BehaviorValue { + Blur, + Alert, + Inform, +} + +/// Moderation behaviors for different contexts. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModerationBehavior { + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_list: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_view: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub banner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_list: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_view: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_media: Option, +} + +impl ModerationBehavior { + pub(crate) const BLOCK_BEHAVIOR: Self = Self { + profile_list: Some(ProfileListBehavior::Blur), + profile_view: Some(ProfileViewBehavior::Alert), + avatar: Some(AvatarBehavior::Blur), + banner: Some(BannerBehavior::Blur), + display_name: None, + content_list: Some(ContentListBehavior::Blur), + content_view: Some(ContentViewBehavior::Blur), + content_media: None, + }; + pub(crate) const MUTE_BEHAVIOR: Self = Self { + profile_list: Some(ProfileListBehavior::Inform), + profile_view: Some(ProfileViewBehavior::Alert), + avatar: None, + banner: None, + display_name: None, + content_list: Some(ContentListBehavior::Blur), + content_view: Some(ContentViewBehavior::Inform), + content_media: None, + }; + pub(crate) const MUTEWORD_BEHAVIOR: Self = Self { + profile_list: None, + profile_view: None, + avatar: None, + banner: None, + display_name: None, + content_list: Some(ContentListBehavior::Blur), + content_view: Some(ContentViewBehavior::Blur), + content_media: None, + }; + pub(crate) const HIDE_BEHAVIOR: Self = Self { + profile_list: None, + profile_view: None, + avatar: None, + banner: None, + display_name: None, + content_list: Some(ContentListBehavior::Blur), + content_view: Some(ContentViewBehavior::Blur), + content_media: None, + }; + pub(crate) fn behavior_for(&self, context: DecisionContext) -> Option { + match context { + DecisionContext::ProfileList => self.profile_list.clone().map(Into::into), + DecisionContext::ProfileView => self.profile_view.clone().map(Into::into), + DecisionContext::Avatar => self.avatar.clone().map(Into::into), + DecisionContext::Banner => self.banner.clone().map(Into::into), + DecisionContext::DisplayName => self.display_name.clone().map(Into::into), + DecisionContext::ContentList => self.content_list.clone().map(Into::into), + DecisionContext::ContentView => self.content_view.clone().map(Into::into), + DecisionContext::ContentMedia => self.content_media.clone().map(Into::into), + } + } +} + +/// Moderation behaviors for the profile list. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProfileListBehavior { + Blur, + Alert, + Inform, +} + +impl From for BehaviorValue { + fn from(b: ProfileListBehavior) -> Self { + match b { + ProfileListBehavior::Blur => Self::Blur, + ProfileListBehavior::Alert => Self::Alert, + ProfileListBehavior::Inform => Self::Inform, + } + } +} + +impl TryFrom for ProfileListBehavior { + type Error = Error; + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + BehaviorValue::Alert => Ok(Self::Alert), + BehaviorValue::Inform => Ok(Self::Inform), + } + } +} + +/// Moderation behaviors for the profile view. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProfileViewBehavior { + Blur, + Alert, + Inform, +} + +impl From for BehaviorValue { + fn from(b: ProfileViewBehavior) -> Self { + match b { + ProfileViewBehavior::Blur => Self::Blur, + ProfileViewBehavior::Alert => Self::Alert, + ProfileViewBehavior::Inform => Self::Inform, + } + } +} + +impl TryFrom for ProfileViewBehavior { + type Error = Error; + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + BehaviorValue::Alert => Ok(Self::Alert), + BehaviorValue::Inform => Ok(Self::Inform), + } + } +} + +/// Moderation behaviors for the user's avatar. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AvatarBehavior { + Blur, + Alert, +} + +impl From for BehaviorValue { + fn from(b: AvatarBehavior) -> Self { + match b { + AvatarBehavior::Blur => Self::Blur, + AvatarBehavior::Alert => Self::Alert, + } + } +} + +impl TryFrom for AvatarBehavior { + type Error = Error; + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + BehaviorValue::Alert => Ok(Self::Alert), + BehaviorValue::Inform => Err(Error::BehaviorValue), + } + } +} + +/// Moderation behaviors for the user's banner. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BannerBehavior { + Blur, +} + +impl From for BehaviorValue { + fn from(b: BannerBehavior) -> Self { + match b { + BannerBehavior::Blur => Self::Blur, + } + } +} + +impl TryFrom for BannerBehavior { + type Error = Error; + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + _ => Err(Error::BehaviorValue), + } + } +} + +/// Moderation behaviors for the user's display name. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DisplayNameBehavior { + Blur, +} + +impl From for BehaviorValue { + fn from(b: DisplayNameBehavior) -> Self { + match b { + DisplayNameBehavior::Blur => Self::Blur, + } + } +} + +impl TryFrom for DisplayNameBehavior { + type Error = Error; + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + _ => Err(Error::BehaviorValue), + } + } +} + +/// Moderation behaviors for the content list. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ContentListBehavior { + Blur, + Alert, + Inform, +} + +impl From for BehaviorValue { + fn from(b: ContentListBehavior) -> Self { + match b { + ContentListBehavior::Blur => Self::Blur, + ContentListBehavior::Alert => Self::Alert, + ContentListBehavior::Inform => Self::Inform, + } + } +} + +impl TryFrom for ContentListBehavior { + type Error = Error; + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + BehaviorValue::Alert => Ok(Self::Alert), + BehaviorValue::Inform => Ok(Self::Inform), + } + } +} + +/// Moderation behaviors for the content view. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ContentViewBehavior { + Blur, + Alert, + Inform, +} + +impl From for BehaviorValue { + fn from(b: ContentViewBehavior) -> Self { + match b { + ContentViewBehavior::Blur => Self::Blur, + ContentViewBehavior::Alert => Self::Alert, + ContentViewBehavior::Inform => Self::Inform, + } + } +} + +impl TryFrom for ContentViewBehavior { + type Error = Error; + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + BehaviorValue::Alert => Ok(Self::Alert), + BehaviorValue::Inform => Ok(Self::Inform), + } + } +} + +/// Moderation behaviors for the content media. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ContentMediaBehavior { + Blur, +} + +impl From for BehaviorValue { + fn from(b: ContentMediaBehavior) -> Self { + match b { + ContentMediaBehavior::Blur => Self::Blur, + } + } +} + +impl TryFrom for ContentMediaBehavior { + type Error = Error; + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + _ => Err(Error::BehaviorValue), + } + } +} + +// labels + +/// The target of a label. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LabelTarget { + Account, + Profile, + Content, +} + +/// The preference for a label. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum LabelPreference { + Ignore, + Warn, + Hide, +} + +impl AsRef for LabelPreference { + fn as_ref(&self) -> &str { + match self { + Self::Ignore => "ignore", + Self::Warn => "warn", + Self::Hide => "hide", + } + } +} + +impl FromStr for LabelPreference { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "ignore" => Ok(Self::Ignore), + "warn" => Ok(Self::Warn), + "hide" => Ok(Self::Hide), + _ => Err(Error::LabelPreference), + } + } +} + +/// A flag for a label value definition. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum LabelValueDefinitionFlag { + NoOverride, + Adult, + Unauthed, + NoSelf, +} + +/// The blurs for a label value definition. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LabelValueDefinitionBlurs { + Content, + Media, + None, +} + +impl AsRef for LabelValueDefinitionBlurs { + fn as_ref(&self) -> &str { + match self { + Self::Content => "content", + Self::Media => "media", + Self::None => "none", + } + } +} + +impl FromStr for LabelValueDefinitionBlurs { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "content" => Ok(Self::Content), + "media" => Ok(Self::Media), + "none" => Ok(Self::None), + _ => Err(Error::LabelValueDefinitionBlurs), + } + } +} + +/// The severity for a label value definition. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LabelValueDefinitionSeverity { + Inform, + Alert, + None, +} + +impl AsRef for LabelValueDefinitionSeverity { + fn as_ref(&self) -> &str { + match self { + Self::Inform => "inform", + Self::Alert => "alert", + Self::None => "none", + } + } +} + +impl FromStr for LabelValueDefinitionSeverity { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "inform" => Ok(Self::Inform), + "alert" => Ok(Self::Alert), + "none" => Ok(Self::None), + _ => Err(Error::LabelValueDefinitionSeverity), + } + } +} + +/// A label value definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InterpretedLabelValueDefinition { + // from com.atproto.label/defs#labelValueDefinition, with type narrowing + pub adult_only: bool, + pub blurs: LabelValueDefinitionBlurs, + pub default_setting: LabelPreference, + pub identifier: String, + pub locales: Vec, + pub severity: LabelValueDefinitionSeverity, + // others + #[serde(skip_serializing_if = "Option::is_none")] + pub defined_by: Option, + pub configurable: bool, + pub flags: Vec, + pub behaviors: InterpretedLabelValueDefinitionBehaviors, +} + +/// The behaviors for a label value definition. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct InterpretedLabelValueDefinitionBehaviors { + pub account: ModerationBehavior, + pub profile: ModerationBehavior, + pub content: ModerationBehavior, +} + +impl InterpretedLabelValueDefinitionBehaviors { + pub(crate) fn behavior_for(&self, target: LabelTarget) -> ModerationBehavior { + match target { + LabelTarget::Account => self.account.clone(), + LabelTarget::Profile => self.profile.clone(), + LabelTarget::Content => self.content.clone(), + } + } +} + +// subjects + +/// A subject profile. +#[derive(Debug)] +pub enum SubjectProfile { + ProfileViewBasic(ProfileViewBasic), + ProfileView(ProfileView), + ProfileViewDetailed(ProfileViewDetailed), +} + +impl SubjectProfile { + pub(crate) fn did(&self) -> &Did { + match self { + Self::ProfileViewBasic(p) => &p.did, + Self::ProfileView(p) => &p.did, + Self::ProfileViewDetailed(p) => &p.did, + } + } + pub(crate) fn labels(&self) -> &Option> { + match self { + Self::ProfileViewBasic(p) => &p.labels, + Self::ProfileView(p) => &p.labels, + Self::ProfileViewDetailed(p) => &p.labels, + } + } + pub(crate) fn viewer(&self) -> &Option { + match self { + Self::ProfileViewBasic(p) => &p.viewer, + Self::ProfileView(p) => &p.viewer, + Self::ProfileViewDetailed(p) => &p.viewer, + } + } +} + +impl From for SubjectProfile { + fn from(p: ProfileViewBasic) -> Self { + Self::ProfileViewBasic(p) + } +} + +impl From for SubjectProfile { + fn from(p: ProfileView) -> Self { + Self::ProfileView(p) + } +} + +impl From for SubjectProfile { + fn from(p: ProfileViewDetailed) -> Self { + Self::ProfileViewDetailed(p) + } +} + +/// A subject post. +pub type SubjectPost = PostView; + +/// A cause for moderation decisions. +#[derive(Debug, Clone)] +pub enum ModerationCause { + Blocking(Box), + BlockedBy(Box), + // BlockOther(Box), + Label(Box), + Muted(Box), + MuteWord(Box), + Hidden(Box), +} + +impl ModerationCause { + pub fn priority(&self) -> u8 { + match self { + Self::Blocking(_) => *Priority::Priority3.as_ref(), + Self::BlockedBy(_) => *Priority::Priority4.as_ref(), + Self::Label(label) => *label.priority.as_ref(), + Self::Muted(_) => *Priority::Priority6.as_ref(), + Self::MuteWord(_) => *Priority::Priority6.as_ref(), + Self::Hidden(_) => *Priority::Priority6.as_ref(), + } + } + pub fn downgrade(&mut self) { + match self { + Self::Blocking(blocking) => blocking.downgraded = true, + Self::BlockedBy(blocked_by) => blocked_by.downgraded = true, + Self::Label(label) => label.downgraded = true, + Self::Muted(muted) => muted.downgraded = true, + Self::MuteWord(mute_word) => mute_word.downgraded = true, + Self::Hidden(hidden) => hidden.downgraded = true, + } + } +} + +/// The source of a moderation cause. +#[derive(Debug, Clone)] +pub enum ModerationCauseSource { + User, + List(Box), + Labeler(Did), +} + +/// A label moderation cause. +#[derive(Debug, Clone)] +pub struct ModerationCauseLabel { + pub source: ModerationCauseSource, + pub label: Label, + pub label_def: InterpretedLabelValueDefinition, + pub target: LabelTarget, + pub setting: LabelPreference, + pub behavior: ModerationBehavior, + pub no_override: bool, + pub(crate) priority: Priority, + pub downgraded: bool, +} + +/// An other moderation cause. +#[derive(Debug, Clone)] +pub struct ModerationCauseOther { + pub source: ModerationCauseSource, + pub downgraded: bool, +} + +// moderation preferences + +/// The labeler preferences for moderation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModerationPrefsLabeler { + pub did: Did, + pub labels: HashMap, + #[serde(skip_serializing)] + pub is_default_labeler: bool, +} + +impl Default for ModerationPrefsLabeler { + fn default() -> Self { + Self { + did: BSKY_LABELER_DID.parse().expect("invalid did"), + labels: HashMap::default(), + is_default_labeler: true, + } + } +} + +/// The moderation preferences. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModerationPrefs { + pub adult_content_enabled: bool, + pub labels: HashMap, + pub labelers: Vec, + pub muted_words: Vec, + pub hidden_posts: Vec, +} + +impl Default for ModerationPrefs { + fn default() -> Self { + Self { + adult_content_enabled: false, + labels: HashMap::from_iter([ + (String::from("porn"), LabelPreference::Hide), + (String::from("sexual"), LabelPreference::Warn), + (String::from("nudity"), LabelPreference::Ignore), + (String::from("graphic-media"), LabelPreference::Warn), + ]), + labelers: Vec::default(), + muted_words: Vec::default(), + hidden_posts: Vec::default(), + } + } +} diff --git a/bsky-sdk/src/moderation/ui.rs b/bsky-sdk/src/moderation/ui.rs new file mode 100644 index 00000000..2f555c40 --- /dev/null +++ b/bsky-sdk/src/moderation/ui.rs @@ -0,0 +1,35 @@ +//! UI representation of moderation. +use super::types::ModerationCause; + +/// UI representation of moderation decision results. +pub struct ModerationUi { + /// Should the UI disable opening the cover? + pub no_override: bool, + /// Reasons to filter the content + pub filters: Vec, + /// Reasons to blur the content + pub blurs: Vec, + /// Reasons to alert the content + pub alerts: Vec, + /// Reasons to inform the content + pub informs: Vec, +} + +impl ModerationUi { + /// Should the content be removed from the interface? + pub fn filter(&self) -> bool { + !self.filters.is_empty() + } + /// Should the content be put behind a cover? + pub fn blur(&self) -> bool { + !self.blurs.is_empty() + } + /// Should an alert be put on the content? (negative) + pub fn alert(&self) -> bool { + !self.alerts.is_empty() + } + /// Should an informational notice be put on the content? (neutral) + pub fn inform(&self) -> bool { + !self.informs.is_empty() + } +} diff --git a/bsky-sdk/src/moderation/util.rs b/bsky-sdk/src/moderation/util.rs new file mode 100644 index 00000000..2f44d61a --- /dev/null +++ b/bsky-sdk/src/moderation/util.rs @@ -0,0 +1,110 @@ +//! Utility functions for label value definitions. +use super::error::Result; +use super::types::*; +use atrium_api::app::bsky::labeler::defs::LabelerViewDetailed; +use atrium_api::com::atproto::label::defs::LabelValueDefinition; +use atrium_api::types::string::Did; + +pub(crate) fn interpret_label_value_definitions( + labeler_view: &LabelerViewDetailed, +) -> Result> { + let defined_by = Some(labeler_view.creator.did.clone()); + labeler_view + .policies + .label_value_definitions + .as_ref() + .unwrap_or(&Vec::new()) + .iter() + .map(|label_value_definition| { + interpret_label_value_definition(label_value_definition, defined_by.clone()) + }) + .collect() +} + +/// Create an [`InterpretedLabelValueDefinition`] from a [`LabelValueDefinition`]. +pub fn interpret_label_value_definition( + def: &LabelValueDefinition, + defined_by: Option, +) -> Result { + let adult_only = def.adult_only.unwrap_or_default(); + let blurs = def.blurs.parse()?; + let default_setting = + if let Some(pref) = def.default_setting.as_ref().and_then(|s| s.parse().ok()) { + pref + } else { + LabelPreference::Warn + }; + let severity = def.severity.parse()?; + let mut flags = vec![LabelValueDefinitionFlag::NoSelf]; + if adult_only { + flags.push(LabelValueDefinitionFlag::Adult); + } + let aoi = match severity { + LabelValueDefinitionSeverity::Alert => Some(BehaviorValue::Alert), + LabelValueDefinitionSeverity::Inform => Some(BehaviorValue::Inform), + LabelValueDefinitionSeverity::None => None, + }; + let mut behaviors = InterpretedLabelValueDefinitionBehaviors::default(); + match blurs { + LabelValueDefinitionBlurs::Content => { + // target=account, blurs=content + behaviors.account.profile_list = aoi.map(BehaviorValue::try_into).transpose()?; + behaviors.account.profile_view = aoi.map(BehaviorValue::try_into).transpose()?; + behaviors.account.content_list = Some(ContentListBehavior::Blur); + behaviors.account.content_view = if adult_only { + Some(ContentViewBehavior::Blur) + } else { + aoi.map(BehaviorValue::try_into).transpose()? + }; + // target=profile, blurs=content + behaviors.profile.profile_list = aoi.map(BehaviorValue::try_into).transpose()?; + behaviors.profile.profile_view = aoi.map(BehaviorValue::try_into).transpose()?; + // target=content, blurs=content + behaviors.content.content_list = Some(ContentListBehavior::Blur); + behaviors.content.content_view = if adult_only { + Some(ContentViewBehavior::Blur) + } else { + aoi.map(BehaviorValue::try_into).transpose()? + }; + } + LabelValueDefinitionBlurs::Media => { + // target=account, blurs=media + behaviors.account.profile_list = aoi.map(BehaviorValue::try_into).transpose()?; + behaviors.account.profile_view = aoi.map(BehaviorValue::try_into).transpose()?; + behaviors.account.avatar = Some(AvatarBehavior::Blur); + behaviors.account.banner = Some(BannerBehavior::Blur); + // target=profile, blurs=media + behaviors.profile.profile_list = aoi.map(BehaviorValue::try_into).transpose()?; + behaviors.profile.profile_view = aoi.map(BehaviorValue::try_into).transpose()?; + behaviors.profile.avatar = Some(AvatarBehavior::Blur); + behaviors.profile.banner = Some(BannerBehavior::Blur); + // target=content, blurs=media + behaviors.content.content_media = Some(ContentMediaBehavior::Blur); + } + LabelValueDefinitionBlurs::None => { + // target=account, blurs=none + behaviors.account.profile_list = aoi.map(BehaviorValue::try_into).transpose()?; + behaviors.account.profile_view = aoi.map(BehaviorValue::try_into).transpose()?; + behaviors.account.content_list = aoi.map(BehaviorValue::try_into).transpose()?; + behaviors.account.content_view = aoi.map(BehaviorValue::try_into).transpose()?; + // target=profile, blurs=none + behaviors.profile.profile_list = aoi.map(BehaviorValue::try_into).transpose()?; + behaviors.profile.profile_view = aoi.map(BehaviorValue::try_into).transpose()?; + // target=content, blurs=none + behaviors.content.content_list = aoi.map(BehaviorValue::try_into).transpose()?; + behaviors.content.content_view = aoi.map(BehaviorValue::try_into).transpose()?; + } + } + Ok(InterpretedLabelValueDefinition { + adult_only, + blurs, + default_setting, + identifier: def.identifier.clone(), + locales: def.locales.clone(), + severity, + defined_by, + configurable: true, + flags, + behaviors, + }) +} diff --git a/bsky-sdk/src/preference.rs b/bsky-sdk/src/preference.rs new file mode 100644 index 00000000..251bdaee --- /dev/null +++ b/bsky-sdk/src/preference.rs @@ -0,0 +1,10 @@ +//! Preferences for Bluesky application. +use crate::moderation::ModerationPrefs; +use serde::{Deserialize, Serialize}; + +/// Preferences for Bluesky application. +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Preferences { + pub moderation_prefs: ModerationPrefs, +} diff --git a/bsky-sdk/src/rich_text.rs b/bsky-sdk/src/rich_text.rs new file mode 100644 index 00000000..0ee15586 --- /dev/null +++ b/bsky-sdk/src/rich_text.rs @@ -0,0 +1,266 @@ +//! Rich text module for working with text that contains facets. +mod detection; + +use crate::agent::config::Config; +use crate::agent::BskyAgentBuilder; +use crate::error::Result; +use atrium_api::app::bsky::richtext::facet::{ByteSlice, Link, MainFeaturesItem, Mention, Tag}; +use atrium_api::types::Union; +use atrium_api::xrpc::XrpcClient; +use detection::{detect_facets, FacetFeaturesItem}; +use std::cmp::Ordering; +use unicode_segmentation::UnicodeSegmentation; + +const PUBLIC_API_ENDPOINT: &str = "https://public.api.bsky.app"; + +/// A segment of rich text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RichTextSegment { + pub text: String, + pub facet: Option, +} + +impl RichTextSegment { + /// Create a new rich text segment. + pub fn new( + text: impl AsRef, + facets: Option, + ) -> Self { + Self { + text: text.as_ref().into(), + facet: facets, + } + } + /// Get the mention in the segment. + pub fn mention(&self) -> Option { + self.facet.as_ref().and_then(|facet| { + facet.features.iter().find_map(|feature| match feature { + Union::Refs(MainFeaturesItem::Mention(mention)) => Some(mention.as_ref().clone()), + _ => None, + }) + }) + } + /// Get the link in the segment. + pub fn link(&self) -> Option { + self.facet.as_ref().and_then(|facet| { + facet.features.iter().find_map(|feature| match feature { + Union::Refs(MainFeaturesItem::Link(link)) => Some(link.as_ref().clone()), + _ => None, + }) + }) + } + /// Get the tag in the segment. + pub fn tag(&self) -> Option { + self.facet.as_ref().and_then(|facet| { + facet.features.iter().find_map(|feature| match feature { + Union::Refs(MainFeaturesItem::Tag(tag)) => Some(tag.as_ref().clone()), + _ => None, + }) + }) + } +} + +/// A rich text structure that contains text and facets. +#[derive(Debug, Clone)] +pub struct RichText { + pub text: String, + pub facets: Option>, +} + +impl RichText { + const BYTE_SLICE_ZERO: ByteSlice = ByteSlice { + byte_start: 0, + byte_end: 0, + }; + /// Create a new [`RichText`] with the given text and optional facets. + pub fn new( + text: impl AsRef, + facets: Option>, + ) -> Self { + RichText { + text: text.as_ref().into(), + facets, + } + } + /// Create a new [`RichText`] with the given text and automatically detect facets. + #[cfg_attr(docsrs, doc(cfg(feature = "default-client")))] + #[cfg(feature = "default-client")] + pub async fn new_with_detect_facets(text: impl AsRef) -> Result { + use atrium_xrpc_client::reqwest::ReqwestClient; + + let mut rt = Self { + text: text.as_ref().into(), + facets: None, + }; + rt.detect_facets(ReqwestClient::new(String::new())).await?; + Ok(rt) + } + /// Create a new [`RichText`] with the given text and automatically detect facets with given client. + #[cfg(not(feature = "default-client"))] + pub async fn new_with_detect_facets( + text: impl AsRef, + client: impl XrpcClient + Send + Sync, + ) -> Result { + let mut rt = Self { + text: text.as_ref().into(), + facets: None, + }; + rt.detect_facets(client).await?; + Ok(rt) + } + /// Get the number of graphemes in the text. + pub fn grapheme_len(&self) -> usize { + self.text.as_str().graphemes(true).count() + } + /// Get segments of the rich text. + pub fn segments(&self) -> Vec { + let Some(facets) = self.facets.as_ref() else { + return vec![RichTextSegment::new(&self.text, None)] + }; + let mut segments = Vec::new(); + let (mut text_cursor, mut facet_cursor) = (0, 0); + while facet_cursor < facets.len() { + let curr_facet = &facets[facet_cursor]; + match text_cursor.cmp(&curr_facet.index.byte_start) { + Ordering::Less => { + segments.push(RichTextSegment::new( + &self.text[text_cursor..curr_facet.index.byte_start], + None, + )); + } + Ordering::Greater => { + facet_cursor += 1; + continue; + } + Ordering::Equal => {} + } + if curr_facet.index.byte_start < curr_facet.index.byte_end { + let subtext = &self.text[curr_facet.index.byte_start..curr_facet.index.byte_end]; + if subtext.trim().is_empty() { + segments.push(RichTextSegment::new(subtext, None)); + } else { + segments.push(RichTextSegment::new(subtext, Some(curr_facet.clone()))); + } + } + text_cursor = curr_facet.index.byte_end; + facet_cursor += 1; + } + if text_cursor < self.text.len() { + segments.push(RichTextSegment::new(&self.text[text_cursor..], None)); + } + segments + } + /// Insert text at the given index. + pub fn insert(&mut self, index: usize, text: impl AsRef) { + self.text.insert_str(index, text.as_ref()); + if let Some(facets) = self.facets.as_mut() { + let num_chars_added = text.as_ref().len(); + for facet in facets.iter_mut() { + // scenario A (before) + if index <= facet.index.byte_start { + facet.index.byte_start += num_chars_added; + facet.index.byte_end += num_chars_added; + } + // scenario B (inner) + else if index >= facet.index.byte_start && index < facet.index.byte_end { + facet.index.byte_end += num_chars_added; + } + // scenario C (after) + // noop + } + } + } + /// Delete text between the given indices. + pub fn delete(&mut self, start_index: usize, end_index: usize) { + self.text.drain(start_index..end_index); + if let Some(facets) = self.facets.as_mut() { + let num_chars_removed = end_index - start_index; + for facet in facets.iter_mut() { + // scenario A (entirely outer) + if start_index <= facet.index.byte_start && end_index >= facet.index.byte_end { + // delete slice (will get removed in final pass) + facet.index = Self::BYTE_SLICE_ZERO; + } + // scenario B (entirely after) + else if start_index > facet.index.byte_end { + // noop + } + // scenario C (partially after) + else if start_index > facet.index.byte_start + && start_index <= facet.index.byte_end + && end_index > facet.index.byte_end + { + facet.index.byte_end = start_index; + } + // scenario D (entirely inner) + else if start_index >= facet.index.byte_start && end_index <= facet.index.byte_end + { + facet.index.byte_end -= num_chars_removed; + } + // scenario E (partially before) + else if start_index < facet.index.byte_start + && end_index >= facet.index.byte_start + && end_index <= facet.index.byte_end + { + facet.index.byte_start = start_index; + facet.index.byte_end -= num_chars_removed; + } + // scenario F (entirely before) + else if end_index < facet.index.byte_start { + facet.index.byte_start -= num_chars_removed; + facet.index.byte_end -= num_chars_removed; + } + } + // filter out any facets that were made irrelevant + facets.retain(|facet| facet.index.byte_start < facet.index.byte_end); + } + } + /// Detect facets in the text and set them. + pub async fn detect_facets(&mut self, client: impl XrpcClient + Send + Sync) -> Result<()> { + let agent = BskyAgentBuilder::new(client) + .config(Config { + endpoint: PUBLIC_API_ENDPOINT.into(), + ..Default::default() + }) + .build() + .await?; + let facets_without_resolution = detect_facets(&self.text); + self.facets = if facets_without_resolution.is_empty() { + None + } else { + let mut facets = Vec::new(); + for facet_without_resolution in facets_without_resolution { + let mut features = Vec::new(); + for feature in facet_without_resolution.features { + match feature { + FacetFeaturesItem::Mention(mention) => { + let did = agent.api.com.atproto.identity.resolve_handle( + atrium_api::com::atproto::identity::resolve_handle::Parameters { + handle: mention.handle.parse().expect("invalid handle"), + } + ).await?.did; + features.push(Union::Refs(MainFeaturesItem::Mention(Box::new( + Mention { did }, + )))); + } + FacetFeaturesItem::Link(link) => { + features.push(Union::Refs(MainFeaturesItem::Link(link))); + } + FacetFeaturesItem::Tag(tag) => { + features.push(Union::Refs(MainFeaturesItem::Tag(tag))); + } + } + } + facets.push(atrium_api::app::bsky::richtext::facet::Main { + features, + index: facet_without_resolution.index, + }); + } + Some(facets) + }; + Ok(()) + } +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/bsky-sdk/src/rich_text/detection.rs b/bsky-sdk/src/rich_text/detection.rs new file mode 100644 index 00000000..479d7603 --- /dev/null +++ b/bsky-sdk/src/rich_text/detection.rs @@ -0,0 +1,126 @@ +use atrium_api::app::bsky::richtext::facet::{ByteSlice, Link, Tag}; +use psl; +use regex::Regex; +use std::sync::OnceLock; + +static RE_MENTION: OnceLock = OnceLock::new(); +static RE_URL: OnceLock = OnceLock::new(); +static RE_ENDING_PUNCTUATION: OnceLock = OnceLock::new(); +static RE_TRAILING_PUNCTUATION: OnceLock = OnceLock::new(); +static RE_TAG: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FacetWithoutResolution { + pub features: Vec, + pub index: ByteSlice, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FacetFeaturesItem { + Mention(Box), + Link(Box), + Tag(Box), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MentionWithoutResolution { + pub handle: String, +} + +pub fn detect_facets(text: &str) -> Vec { + let mut facets = Vec::new(); + // mentions + { + let re = RE_MENTION + .get_or_init(|| Regex::new(r"(?:^|\s|\()@(([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])?)\b").expect("invalid regex")); + for capture in re.captures_iter(text) { + let Some(m) = capture.get(1) else { + continue; + }; + facets.push(FacetWithoutResolution { + features: vec![FacetFeaturesItem::Mention(Box::new( + MentionWithoutResolution { + handle: m.as_str().into(), + }, + ))], + index: ByteSlice { + byte_end: m.end(), + byte_start: m.start() - 1, + }, + }); + } + } + // links + { + let re = RE_URL.get_or_init(|| { + Regex::new( + r"(?:^|\s|\()((?:https?:\/\/[\S]+)|(?:(?[a-z][a-z0-9]*(?:\.[a-z0-9]+)+)[\S]*))", + ) + .expect("invalid regex") + }); + for capture in re.captures_iter(text) { + let m = capture.get(1).expect("invalid capture"); + let mut uri = if let Some(domain) = capture.name("domain") { + if !psl::suffix(domain.as_str().as_bytes()) + .map_or(false, |suffix| suffix.is_known()) + { + continue; + } + format!("https://{}", m.as_str()) + } else { + m.as_str().into() + }; + let mut index = ByteSlice { + byte_end: m.end(), + byte_start: m.start(), + }; + // strip ending puncuation + if (RE_ENDING_PUNCTUATION + .get_or_init(|| Regex::new(r"[.,;:!?]$").expect("invalid regex")) + .is_match(&uri)) + || (uri.ends_with(')') && !uri.contains('(')) + { + uri.pop(); + index.byte_end -= 1; + } + facets.push(FacetWithoutResolution { + features: vec![FacetFeaturesItem::Link(Box::new(Link { uri }))], + index, + }); + } + } + // tags + { + let re = RE_TAG.get_or_init(|| { + Regex::new( + r"(?:^|\s)([##])([^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*[^\d\s\p{P}\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]+[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*)?", + ) + .expect("invalid regex") + }); + for capture in re.captures_iter(text) { + if let Some(tag) = capture.get(2) { + // strip ending punctuation and any spaces + let tag = RE_TRAILING_PUNCTUATION + .get_or_init(|| Regex::new(r"\p{P}+$").expect("invalid regex")) + .replace(tag.as_str(), ""); + // look-around, including look-ahead and look-behind, is not supported in `regex` + if tag.starts_with('\u{fe0f}') { + continue; + } + if tag.len() > 64 { + continue; + } + let leading = capture.get(1).expect("invalid capture"); + let index = ByteSlice { + byte_end: leading.end() + tag.len(), + byte_start: leading.start(), + }; + facets.push(FacetWithoutResolution { + features: vec![FacetFeaturesItem::Tag(Box::new(Tag { tag: tag.into() }))], + index, + }); + } + } + } + facets +} diff --git a/bsky-sdk/src/rich_text/tests.rs b/bsky-sdk/src/rich_text/tests.rs new file mode 100644 index 00000000..eb410d28 --- /dev/null +++ b/bsky-sdk/src/rich_text/tests.rs @@ -0,0 +1,497 @@ +mod detection; + +use crate::error::Result; +use crate::rich_text::{RichText, RichTextSegment}; +use crate::tests::MockClient; +use atrium_api::app::bsky::richtext::facet::{ByteSlice, Link, Main, MainFeaturesItem, Mention}; +use atrium_api::types::{Union, UnknownData}; +use ipld_core::ipld::Ipld; + +pub async fn rich_text_with_detect_facets(text: &str) -> Result { + #[cfg(feature = "default-client")] + { + let mut rt = RichText::new(text, None); + rt.detect_facets(MockClient).await?; + Ok(rt) + } + #[cfg(not(feature = "default-client"))] + { + RichText::new_with_detect_facets(text, MockClient).await + } +} + +fn facet(byte_start: usize, byte_end: usize) -> Main { + Main { + features: vec![Union::Unknown(UnknownData { + r#type: String::new(), + data: Ipld::Null, + })], + index: ByteSlice { + byte_end, + byte_start, + }, + } +} + +#[test] +fn calculate_bytelength_and_grapheme_length() { + { + let rt = RichText::new("Hello!", None); + assert_eq!(rt.text.len(), 6); + assert_eq!(rt.grapheme_len(), 6); + } + { + let rt = RichText::new("👨‍👩‍👧‍👧", None); + assert_eq!(rt.text.len(), 25); + assert_eq!(rt.grapheme_len(), 1); + } + { + let rt = RichText::new("👨‍👩‍👧‍👧🔥 good!✅", None); + assert_eq!(rt.text.len(), 38); + assert_eq!(rt.grapheme_len(), 9); + } +} + +#[test] +fn insert() { + let input = &RichText::new("hello world", Some(vec![facet(2, 7)])); + // correctly adjusts facets (scenario A - before) + { + let mut input = input.clone(); + input.insert(0, "test"); + assert_eq!(input.text, "testhello world"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 1); + assert_eq!(facets[0].index.byte_start, 6); + assert_eq!(facets[0].index.byte_end, 11); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "llo w" + ); + } + // correctly adjusts facets (scenario B - inner) + { + let mut input = input.clone(); + input.insert(4, "test"); + assert_eq!(input.text, "helltesto world"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 1); + assert_eq!(facets[0].index.byte_start, 2); + assert_eq!(facets[0].index.byte_end, 11); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "lltesto w" + ); + } + // correctly adjusts facets (scenario C - after) + { + let mut input = input.clone(); + input.insert(8, "test"); + assert_eq!(input.text, "hello wotestrld"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 1); + assert_eq!(facets[0].index.byte_start, 2); + assert_eq!(facets[0].index.byte_end, 7); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "llo w" + ); + } +} + +#[test] +fn insert_with_fat_unicode() { + let input = &RichText::new( + "one👨‍👩‍👧‍👧 two👨‍👩‍👧‍👧 three👨‍👩‍👧‍👧", + Some(vec![facet(0, 28), facet(29, 57), facet(58, 88)]), + ); + // correctly adjusts facets (scenario A - before) + { + let mut input = input.clone(); + input.insert(0, "test"); + assert_eq!(input.text, "testone👨‍👩‍👧‍👧 two👨‍👩‍👧‍👧 three👨‍👩‍👧‍👧"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 3); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "one👨‍👩‍👧‍👧" + ); + assert_eq!( + &input.text[facets[1].index.byte_start..facets[1].index.byte_end], + "two👨‍👩‍👧‍👧" + ); + assert_eq!( + &input.text[facets[2].index.byte_start..facets[2].index.byte_end], + "three👨‍👩‍👧‍👧" + ); + } + // correctly adjusts facets (scenario B - inner) + { + let mut input = input.clone(); + input.insert(3, "test"); + assert_eq!(input.text, "onetest👨‍👩‍👧‍👧 two👨‍👩‍👧‍👧 three👨‍👩‍👧‍👧"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 3); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "onetest👨‍👩‍👧‍👧" + ); + assert_eq!( + &input.text[facets[1].index.byte_start..facets[1].index.byte_end], + "two👨‍👩‍👧‍👧" + ); + assert_eq!( + &input.text[facets[2].index.byte_start..facets[2].index.byte_end], + "three👨‍👩‍👧‍👧" + ); + } + // correctly adjusts facets (scenario C - after) + { + let mut input = input.clone(); + input.insert(28, "test"); + assert_eq!(input.text, "one👨‍👩‍👧‍👧test two👨‍👩‍👧‍👧 three👨‍👩‍👧‍👧"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 3); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "one👨‍👩‍👧‍👧" + ); + assert_eq!( + &input.text[facets[1].index.byte_start..facets[1].index.byte_end], + "two👨‍👩‍👧‍👧" + ); + assert_eq!( + &input.text[facets[2].index.byte_start..facets[2].index.byte_end], + "three👨‍👩‍👧‍👧" + ); + } +} + +#[test] +fn delete() { + let input = &RichText::new("hello world", Some(vec![facet(2, 7)])); + // correctly adjusts facets (scenario A - entirely outer) + { + let mut input = input.clone(); + input.delete(0, 9); + assert_eq!(input.text, "ld"); + let facets = input.facets.expect("facets should exist"); + assert!(facets.is_empty()); + } + // correctly adjusts facets (scenario B - entirely after) + { + let mut input = input.clone(); + input.delete(7, 11); + assert_eq!(input.text, "hello w"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 1); + assert_eq!(facets[0].index.byte_start, 2); + assert_eq!(facets[0].index.byte_end, 7); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "llo w" + ); + } + // correctly adjusts facets (scenario C - partially after) + { + let mut input = input.clone(); + input.delete(4, 11); + assert_eq!(input.text, "hell"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 1); + assert_eq!(facets[0].index.byte_start, 2); + assert_eq!(facets[0].index.byte_end, 4); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "ll" + ); + } + // correctly adjusts facets (scenario D - entirely inner) + { + let mut input = input.clone(); + input.delete(3, 5); + assert_eq!(input.text, "hel world"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 1); + assert_eq!(facets[0].index.byte_start, 2); + assert_eq!(facets[0].index.byte_end, 5); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "l w" + ); + } + // correctly adjusts facets (scenario E - partially before) + { + let mut input = input.clone(); + input.delete(1, 5); + assert_eq!(input.text, "h world"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 1); + assert_eq!(facets[0].index.byte_start, 1); + assert_eq!(facets[0].index.byte_end, 3); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + " w" + ); + } + // correctly adjusts facets (scenario F - entirely before) + { + let mut input = input.clone(); + input.delete(0, 2); + assert_eq!(input.text, "llo world"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 1); + assert_eq!(facets[0].index.byte_start, 0); + assert_eq!(facets[0].index.byte_end, 5); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "llo w" + ); + } +} + +#[test] +fn delete_with_fat_unicode() { + let input = &RichText::new( + "one👨‍👩‍👧‍👧 two👨‍👩‍👧‍👧 three👨‍👩‍👧‍👧", + Some(vec![facet(29, 57)]), + ); + // correctly adjusts facets (scenario A - entirely outer) + { + let mut input = input.clone(); + input.delete(28, 58); + assert_eq!(input.text, "one👨‍👩‍👧‍👧three👨‍👩‍👧‍👧"); + let facets = input.facets.expect("facets should exist"); + assert!(facets.is_empty()); + } + // correctly adjusts facets (scenario B - entirely after) + { + let mut input = input.clone(); + input.delete(57, 88); + assert_eq!(input.text, "one👨‍👩‍👧‍👧 two👨‍👩‍👧‍👧"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 1); + assert_eq!(facets[0].index.byte_start, 29); + assert_eq!(facets[0].index.byte_end, 57); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "two👨‍👩‍👧‍👧" + ); + } + // correctly adjusts facets (scenario C - partially after) + { + let mut input = input.clone(); + input.delete(31, 88); + assert_eq!(input.text, "one👨‍👩‍👧‍👧 tw"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 1); + assert_eq!(facets[0].index.byte_start, 29); + assert_eq!(facets[0].index.byte_end, 31); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "tw" + ); + } + // correctly adjusts facets (scenario D - entirely inner) + { + let mut input = input.clone(); + input.delete(30, 32); + assert_eq!(input.text, "one👨‍👩‍👧‍👧 t👨‍👩‍👧‍👧 three👨‍👩‍👧‍👧"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 1); + assert_eq!(facets[0].index.byte_start, 29); + assert_eq!(facets[0].index.byte_end, 55); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "t👨‍👩‍👧‍👧" + ); + } + // correctly adjusts facets (scenario E - partially before) + { + let mut input = input.clone(); + input.delete(28, 31); + assert_eq!(input.text, "one👨‍👩‍👧‍👧o👨‍👩‍👧‍👧 three👨‍👩‍👧‍👧"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 1); + assert_eq!(facets[0].index.byte_start, 28); + assert_eq!(facets[0].index.byte_end, 54); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "o👨‍👩‍👧‍👧" + ); + } + // correctly adjusts facets (scenario F - entirely before) + { + let mut input = input.clone(); + input.delete(0, 2); + assert_eq!(input.text, "e👨‍👩‍👧‍👧 two👨‍👩‍👧‍👧 three👨‍👩‍👧‍👧"); + let facets = input.facets.expect("facets should exist"); + assert_eq!(facets.len(), 1); + assert_eq!(facets[0].index.byte_start, 27); + assert_eq!(facets[0].index.byte_end, 55); + assert_eq!( + &input.text[facets[0].index.byte_start..facets[0].index.byte_end], + "two👨‍👩‍👧‍👧" + ); + } +} + +#[test] +fn segments() { + // produces an empty output for an empty input + { + let input = RichText::new("", None); + assert_eq!(input.segments(), vec![RichTextSegment::new("", None)]); + } + // produces a single segment when no facets are present + { + let input = RichText::new("hello", None); + assert_eq!(input.segments(), vec![RichTextSegment::new("hello", None)]); + } + // produces 3 segments with 1 entity in the middle + { + let input = RichText::new("one two three", Some(vec![facet(4, 7)])); + assert_eq!( + input.segments(), + vec![ + RichTextSegment::new("one ", None), + RichTextSegment::new("two", Some(facet(4, 7))), + RichTextSegment::new(" three", None), + ] + ); + } + // produces 2 segments with 1 entity in the byteStart + { + let input = RichText::new("one two three", Some(vec![facet(0, 7)])); + assert_eq!( + input.segments(), + vec![ + RichTextSegment::new("one two", Some(facet(0, 7))), + RichTextSegment::new(" three", None), + ] + ); + } + // produces 2 segments with 1 entity in the end + { + let input = RichText::new("one two three", Some(vec![facet(4, 13)])); + assert_eq!( + input.segments(), + vec![ + RichTextSegment::new("one ", None), + RichTextSegment::new("two three", Some(facet(4, 13))), + ] + ); + } + // produces 1 segments with 1 entity around the entire string + { + let input = RichText::new("one two three", Some(vec![facet(0, 13)])); + assert_eq!( + input.segments(), + vec![RichTextSegment::new("one two three", Some(facet(0, 13)))] + ); + } + // produces 5 segments with 3 facets covering each word + { + let input = RichText::new( + "one two three", + Some(vec![facet(0, 3), facet(4, 7), facet(8, 13)]), + ); + assert_eq!( + input.segments(), + vec![ + RichTextSegment::new("one", Some(facet(0, 3))), + RichTextSegment::new(" ", None), + RichTextSegment::new("two", Some(facet(4, 7))), + RichTextSegment::new(" ", None), + RichTextSegment::new("three", Some(facet(8, 13))), + ] + ); + } + // uses utf8 indices + { + let input = RichText::new( + "one👨‍👩‍👧‍👧 two👨‍👩‍👧‍👧 three👨‍👩‍👧‍👧", + Some(vec![facet(0, 28), facet(29, 57), facet(58, 88)]), + ); + assert_eq!( + input.segments(), + vec![ + RichTextSegment::new("one👨‍👩‍👧‍👧", Some(facet(0, 28))), + RichTextSegment::new(" ", None), + RichTextSegment::new("two👨‍👩‍👧‍👧", Some(facet(29, 57))), + RichTextSegment::new(" ", None), + RichTextSegment::new("three👨‍👩‍👧‍👧", Some(facet(58, 88))), + ] + ); + } + // correctly identifies mentions and links + { + let input = RichText::new( + "one two three", + Some(vec![ + Main { + features: vec![Union::Refs(MainFeaturesItem::Mention(Box::new(Mention { + did: "did:plc:123".parse().expect("invalid did"), + })))], + index: ByteSlice { + byte_end: 3, + byte_start: 0, + }, + }, + Main { + features: vec![Union::Refs(MainFeaturesItem::Link(Box::new(Link { + uri: String::from("https://example.com"), + })))], + index: ByteSlice { + byte_end: 7, + byte_start: 4, + }, + }, + facet(8, 13), + ]), + ); + let segments = input.segments(); + assert_eq!(segments.len(), 5); + assert!(segments[0].link().is_none()); + assert!(segments[0].mention().is_some()); + assert!(segments[1].link().is_none()); + assert!(segments[1].mention().is_none()); + assert!(segments[2].link().is_some()); + assert!(segments[2].mention().is_none()); + assert!(segments[3].link().is_none()); + assert!(segments[3].mention().is_none()); + assert!(segments[4].link().is_none()); + assert!(segments[4].mention().is_none()); + } + // skips facets that incorrectly overlap (left edge) + { + let input = RichText::new( + "one two three", + Some(vec![facet(0, 3), facet(2, 9), facet(8, 13)]), + ); + assert_eq!( + input.segments(), + vec![ + RichTextSegment::new("one", Some(facet(0, 3))), + RichTextSegment::new(" two ", None), + RichTextSegment::new("three", Some(facet(8, 13))), + ] + ); + } + // skips facets that incorrectly overlap (right edge) + { + let input = RichText::new( + "one two three", + Some(vec![facet(0, 3), facet(4, 9), facet(8, 13)]), + ); + assert_eq!( + input.segments(), + vec![ + RichTextSegment::new("one", Some(facet(0, 3))), + RichTextSegment::new(" ", None), + RichTextSegment::new("two t", Some(facet(4, 9))), + RichTextSegment::new("hree", None), + ] + ); + } +} diff --git a/bsky-sdk/src/rich_text/tests/detection.rs b/bsky-sdk/src/rich_text/tests/detection.rs new file mode 100644 index 00000000..826f8a84 --- /dev/null +++ b/bsky-sdk/src/rich_text/tests/detection.rs @@ -0,0 +1,449 @@ +use super::rich_text_with_detect_facets; +use crate::error::Result; +use crate::rich_text::RichTextSegment; +use atrium_api::app::bsky::richtext::facet::MainFeaturesItem; +use atrium_api::types::Union; + +#[tokio::test] +async fn detect_facets_mentions_and_links() -> Result<()> { + let test_cases = [ + ("no mention", vec![("no mention", None)]), + ( + "@handle.com middle end", + vec![ + ("@handle.com", Some("did:fake:handle.com")), + (" middle end", None), + ], + ), + ( + "start @handle.com end", + vec![ + ("start ", None), + ("@handle.com", Some("did:fake:handle.com")), + (" end", None), + ], + ), + ( + "start middle @handle.com", + vec![ + ("start middle ", None), + ("@handle.com", Some("did:fake:handle.com")), + ], + ), + ( + "@handle.com @handle.com @handle.com", + vec![ + ("@handle.com", Some("did:fake:handle.com")), + (" ", None), + ("@handle.com", Some("did:fake:handle.com")), + (" ", None), + ("@handle.com", Some("did:fake:handle.com")), + ], + ), + ( + "@full123-chars.test", + vec![("@full123-chars.test", Some("did:fake:full123-chars.test"))], + ), + ("not@right", vec![("not@right", None)]), + ( + "@handle.com!@#$chars", + vec![ + ("@handle.com", Some("did:fake:handle.com")), + ("!@#$chars", None), + ], + ), + ( + "@handle.com\n@handle.com", + vec![ + ("@handle.com", Some("did:fake:handle.com")), + ("\n", None), + ("@handle.com", Some("did:fake:handle.com")), + ], + ), + ( + "parenthetical (@handle.com)", + vec![ + ("parenthetical (", None), + ("@handle.com", Some("did:fake:handle.com")), + (")", None), + ], + ), + ( + "👨‍👩‍👧‍👧 @handle.com 👨‍👩‍👧‍👧", + vec![ + ("👨‍👩‍👧‍👧 ", None), + ("@handle.com", Some("did:fake:handle.com")), + (" 👨‍👩‍👧‍👧", None), + ], + ), + ( + "start https://middle.com end", + vec![ + ("start ", None), + ("https://middle.com", Some("https://middle.com")), + (" end", None), + ], + ), + ( + "start https://middle.com/foo/bar end", + vec![ + ("start ", None), + ( + "https://middle.com/foo/bar", + Some("https://middle.com/foo/bar"), + ), + (" end", None), + ], + ), + ( + "start https://middle.com/foo/bar?baz=bux end", + vec![ + ("start ", None), + ( + "https://middle.com/foo/bar?baz=bux", + Some("https://middle.com/foo/bar?baz=bux"), + ), + (" end", None), + ], + ), + ( + "start https://middle.com/foo/bar?baz=bux#hash end", + vec![ + ("start ", None), + ( + "https://middle.com/foo/bar?baz=bux#hash", + Some("https://middle.com/foo/bar?baz=bux#hash"), + ), + (" end", None), + ], + ), + ( + "https://start.com/foo/bar?baz=bux#hash middle end", + vec![ + ( + "https://start.com/foo/bar?baz=bux#hash", + Some("https://start.com/foo/bar?baz=bux#hash"), + ), + (" middle end", None), + ], + ), + ( + "start middle https://end.com/foo/bar?baz=bux#hash", + vec![ + ("start middle ", None), + ( + "https://end.com/foo/bar?baz=bux#hash", + Some("https://end.com/foo/bar?baz=bux#hash"), + ), + ], + ), + ( + "https://newline1.com\nhttps://newline2.com", + vec![ + ("https://newline1.com", Some("https://newline1.com")), + ("\n", None), + ("https://newline2.com", Some("https://newline2.com")), + ], + ), + ( + "👨‍👩‍👧‍👧 https://middle.com 👨‍👩‍👧‍👧", + vec![ + ("👨‍👩‍👧‍👧 ", None), + ("https://middle.com", Some("https://middle.com")), + (" 👨‍👩‍👧‍👧", None), + ], + ), + ( + "start middle.com end", + vec![ + ("start ", None), + ("middle.com", Some("https://middle.com")), + (" end", None), + ], + ), + ( + "start middle.com/foo/bar end", + vec![ + ("start ", None), + ("middle.com/foo/bar", Some("https://middle.com/foo/bar")), + (" end", None), + ], + ), + ( + "start middle.com/foo/bar?baz=bux end", + vec![ + ("start ", None), + ( + "middle.com/foo/bar?baz=bux", + Some("https://middle.com/foo/bar?baz=bux"), + ), + (" end", None), + ], + ), + ( + "start middle.com/foo/bar?baz=bux#hash end", + vec![ + ("start ", None), + ( + "middle.com/foo/bar?baz=bux#hash", + Some("https://middle.com/foo/bar?baz=bux#hash"), + ), + (" end", None), + ], + ), + ( + "start.com/foo/bar?baz=bux#hash middle end", + vec![ + ( + "start.com/foo/bar?baz=bux#hash", + Some("https://start.com/foo/bar?baz=bux#hash"), + ), + (" middle end", None), + ], + ), + ( + "start middle end.com/foo/bar?baz=bux#hash", + vec![ + ("start middle ", None), + ( + "end.com/foo/bar?baz=bux#hash", + Some("https://end.com/foo/bar?baz=bux#hash"), + ), + ], + ), + ( + "newline1.com\nnewline2.com", + vec![ + ("newline1.com", Some("https://newline1.com")), + ("\n", None), + ("newline2.com", Some("https://newline2.com")), + ], + ), + ( + "a example.com/index.php php link", + vec![ + ("a ", None), + ( + "example.com/index.php", + Some("https://example.com/index.php"), + ), + (" php link", None), + ], + ), + ( + "a trailing bsky.app: colon", + vec![ + ("a trailing ", None), + ("bsky.app", Some("https://bsky.app")), + (": colon", None), + ], + ), + ("not.. a..url ..here", vec![("not.. a..url ..here", None)]), + ("e.g.", vec![("e.g.", None)]), + ("something-cool.jpg", vec![("something-cool.jpg", None)]), + ("website.com.jpg", vec![("website.com.jpg", None)]), + ("e.g./foo", vec![("e.g./foo", None)]), + ("website.com.jpg/foo", vec![("website.com.jpg/foo", None)]), + ( + "Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/", + vec![ + ("Classic article ", None), + ( + "https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/", + Some("https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/"), + ), + ], + ), + ( + "Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/ ", + vec![ + ("Classic article ", None), + ( + "https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/", + Some("https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/"), + ), + (" ", None), + ], + ), + ( + "https://foo.com https://bar.com/whatever https://baz.com", + vec![ + ("https://foo.com", Some("https://foo.com")), + (" ", None), + ("https://bar.com/whatever", Some("https://bar.com/whatever")), + (" ", None), + ("https://baz.com", Some("https://baz.com")), + ], + ), + ( + "punctuation https://foo.com, https://bar.com/whatever; https://baz.com.", + vec![ + ("punctuation ", None), + ("https://foo.com", Some("https://foo.com")), + (", ", None), + ("https://bar.com/whatever", Some("https://bar.com/whatever")), + ("; ", None), + ("https://baz.com", Some("https://baz.com")), + (".", None), + ], + ), + ( + "parenthentical (https://foo.com)", + vec![ + ("parenthentical (", None), + ("https://foo.com", Some("https://foo.com")), + (")", None), + ], + ), + ( + "except for https://foo.com/thing_(cool)", + vec![ + ("except for ", None), + ( + "https://foo.com/thing_(cool)", + Some("https://foo.com/thing_(cool)"), + ), + ], + ), + ]; + fn segment_to_output(segment: &RichTextSegment) -> (&str, Option<&str>) { + ( + &segment.text, + segment.facet.as_ref().and_then(|facet| { + facet.features.iter().find_map(|feature| match feature { + Union::Refs(MainFeaturesItem::Mention(mention)) => Some(mention.did.as_ref()), + Union::Refs(MainFeaturesItem::Link(link)) => Some(&link.uri), + _ => None, + }) + }), + ) + } + for (input, expected) in test_cases { + let rt = rich_text_with_detect_facets(input).await?; + assert_eq!( + rt.segments() + .iter() + .map(segment_to_output) + .collect::>(), + expected + ); + } + Ok(()) +} + +#[tokio::test] +async fn detect_facets_tags() -> Result<()> { + let test_cases = [ + ("#a", vec![("a", (0, 2))]), + ("#a #b", vec![("a", (0, 2)), ("b", (3, 5))]), + ("#1", vec![]), + ("#1a", vec![("1a", (0, 3))]), + ("#tag", vec![("tag", (0, 4))]), + ("body #tag", vec![("tag", (5, 9))]), + ("#tag body", vec![("tag", (0, 4))]), + ("body #tag body", vec![("tag", (5, 9))]), + ("body #1", vec![]), + ("body #1a", vec![("1a", (5, 8))]), + ("body #a1", vec![("a1", (5, 8))]), + ("#", vec![]), + ("#?", vec![]), + ("text #", vec![]), + ("text # text", vec![]), + ( + "body #thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + vec![( + "thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + (5, 70), + )], + ), + ( + "body #thisisa65characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab", + vec![], + ), + ( + "body #thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!", + vec![( + "thisisa64characterstring_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + (5, 70), + )], + ), + ("its a #double#rainbow", vec![("double#rainbow", (6, 21))]), + ("##hashash", vec![("#hashash", (0, 9))]), + ("##", vec![]), + ("some #n0n3s@n5e!", vec![("n0n3s@n5e", (5, 15))]), + ( + "works #with,punctuation", + vec![("with,punctuation", (6, 23))], + ), + ( + "strips trailing #punctuation, #like. #this!", + vec![ + ("punctuation", (16, 28)), + ("like", (30, 35)), + ("this", (37, 42)), + ], + ), + ( + "strips #multi_trailing___...", + vec![("multi_trailing", (7, 22))], + ), + ( + "works with #🦋 emoji, and #butter🦋fly", + vec![("🦋", (11, 16)), ("butter🦋fly", (28, 42))], + ), + ( + "#same #same #but #diff", + vec![ + ("same", (0, 5)), + ("same", (6, 11)), + ("but", (12, 16)), + ("diff", (17, 22)), + ], + ), + ("this #️⃣tag should not be a tag", vec![]), + ("this ##️⃣tag should be a tag", vec![("#️⃣tag", (5, 16))]), + ("this #t\nag should be a tag", vec![("t", (5, 7))]), + #[allow(clippy::invisible_characters)] + ("no match (\\u200B): #​", vec![]), + #[allow(clippy::invisible_characters)] + ("no match (\\u200Ba): #​a", vec![]), + #[allow(clippy::invisible_characters)] + ("match (a\\u200Bb): #a​b", vec![("a", (18, 20))]), + #[allow(clippy::invisible_characters)] + ("match (ab\\u200B): #ab​", vec![("ab", (18, 21))]), + ("no match (\\u20e2tag): #⃢tag", vec![]), + ("no match (a\\u20e2b): #a⃢b", vec![("a", (21, 23))]), + ( + "match full width number sign (tag): #tag", + vec![("tag", (36, 42))], + ), + ( + "match full width number sign (tag): ##️⃣tag", + vec![("#️⃣tag", (36, 49))], + ), + ("no match 1?: #1?", vec![]), + ]; + fn segment_to_output(segment: &RichTextSegment) -> Option<(&str, (usize, usize))> { + segment.facet.as_ref().and_then(|facet| { + facet.features.iter().find_map(|feature| match feature { + Union::Refs(MainFeaturesItem::Tag(tag)) => Some(( + tag.tag.as_ref(), + (facet.index.byte_start, facet.index.byte_end), + )), + _ => None, + }) + }) + } + + for (input, expected) in test_cases { + let rt = rich_text_with_detect_facets(input).await?; + assert_eq!( + rt.segments() + .iter() + .filter_map(segment_to_output) + .collect::>(), + expected + ); + } + Ok(()) +} diff --git a/release-plz.toml b/release-plz.toml index 802048e9..bc89019a 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -32,6 +32,13 @@ git_release_enable = true git_tag_enable = true changelog_update = true +[[package]] +name = "bsky-sdk" +publish = true +git_release_enable = true +git_tag_enable = true +changelog_update = true + [changelog] commit_parsers = [ { message = "^feat", group = "added" },