From 1f544e69e2e831624cb671dfb7014bb39ac0571d Mon Sep 17 00:00:00 2001 From: sugyan Date: Wed, 29 May 2024 23:40:07 +0900 Subject: [PATCH 01/29] Add bsky-sdk --- Cargo.lock | 101 +- Cargo.toml | 9 +- bsky-sdk/Cargo.lock | 1515 +++++++++++++++++++++++++++ bsky-sdk/Cargo.toml | 30 + bsky-sdk/README.md | 0 bsky-sdk/src/agent.rs | 211 ++++ bsky-sdk/src/agent/config.rs | 50 + bsky-sdk/src/agent/config/file.rs | 48 + bsky-sdk/src/error.rs | 54 + bsky-sdk/src/lib.rs | 8 + bsky-sdk/src/moderation.rs | 518 +++++++++ bsky-sdk/src/moderation/decision.rs | 284 +++++ bsky-sdk/src/moderation/labels.rs | 90 ++ bsky-sdk/src/moderation/types.rs | 463 ++++++++ bsky-sdk/src/moderation/ui.rs | 24 + bsky-sdk/src/preference.rs | 8 + 16 files changed, 3393 insertions(+), 20 deletions(-) create mode 100644 bsky-sdk/Cargo.lock create mode 100644 bsky-sdk/Cargo.toml create mode 100644 bsky-sdk/README.md create mode 100644 bsky-sdk/src/agent.rs create mode 100644 bsky-sdk/src/agent/config.rs create mode 100644 bsky-sdk/src/agent/config/file.rs create mode 100644 bsky-sdk/src/error.rs create mode 100644 bsky-sdk/src/lib.rs create mode 100644 bsky-sdk/src/moderation.rs create mode 100644 bsky-sdk/src/moderation/decision.rs create mode 100644 bsky-sdk/src/moderation/labels.rs create mode 100644 bsky-sdk/src/moderation/types.rs create mode 100644 bsky-sdk/src/moderation/ui.rs create mode 100644 bsky-sdk/src/preference.rs diff --git a/Cargo.lock b/Cargo.lock index 49e271b..3ecd39e 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", @@ -239,6 +239,23 @@ 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", + "http 1.1.0", + "ipld-core", + "serde", + "serde_json", + "thiserror", + "tokio", + "toml", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -915,9 +932,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", @@ -1596,9 +1613,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 +1631,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 +1667,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 +1808,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 +1906,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" @@ -2290,6 +2350,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 4a96f82..878c5ca 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/bsky-sdk/Cargo.lock b/bsky-sdk/Cargo.lock new file mode 100644 index 0000000..dcbb89c --- /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 0000000..2a3e104 --- /dev/null +++ b/bsky-sdk/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "bsky-sdk" +version = "0.1.0" +authors = ["sugyan "] +edition.workspace = true +rust-version.workspace = true +description = "ATrium-based SDK for Bluesky" +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 +http.workspace = true +ipld-core.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true +toml = { version = "0.8.13", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[features] +default = [] +config-toml = ["toml"] diff --git a/bsky-sdk/README.md b/bsky-sdk/README.md new file mode 100644 index 0000000..e69de29 diff --git a/bsky-sdk/src/agent.rs b/bsky-sdk/src/agent.rs new file mode 100644 index 0000000..c3b5946 --- /dev/null +++ b/bsky-sdk/src/agent.rs @@ -0,0 +1,211 @@ +pub mod config; + +use self::config::Config; +use crate::error::Result; +use crate::moderation::ModerationPrefsLabeler; +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; +use atrium_xrpc_client::reqwest::ReqwestClient; +use ipld_core::serde::from_ipld; +use std::collections::HashMap; +use std::ops::Deref; + +pub struct BskyAgent +where + S: SessionStore + Send + Sync, + T: XrpcClient + Send + Sync, +{ + inner: AtpAgent, +} + +impl BskyAgent { + pub fn builder() -> BskyAgentBuilder { + BskyAgentBuilder::default() + } +} + +impl BskyAgent +where + S: SessionStore + Send + Sync, + T: XrpcClient + Send + Sync, +{ + 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, + } + } + 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::ContentLabelPref(p)) => { + label_prefs.push(p); + } + 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) + } + 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(), + )); + } +} + +impl Deref for BskyAgent +where + S: SessionStore + Send + Sync, + T: XrpcClient + Send + Sync, +{ + type Target = AtpAgent; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +pub struct BskyAgentBuilder +where + S: SessionStore + Send + Sync, + T: XrpcClient + Send + Sync, +{ + config: Config, + store: S, + client: T, +} + +impl BskyAgentBuilder +where + S: SessionStore + Send + Sync, + T: XrpcClient + Send + Sync, +{ + pub fn config(mut self, config: Config) -> Self { + self.config = config; + self + } + pub fn store(self, store: S0) -> BskyAgentBuilder + where + S0: SessionStore + Send + Sync, + { + BskyAgentBuilder { + config: self.config, + store, + client: self.client, + } + } + 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 }) + } +} + +impl Default for BskyAgentBuilder { + fn default() -> Self { + Self { + config: Config::default(), + client: ReqwestClient::new(Config::default().endpoint), + store: MemorySessionStore::default(), + } + } +} diff --git a/bsky-sdk/src/agent/config.rs b/bsky-sdk/src/agent/config.rs new file mode 100644 index 0000000..e17c6e1 --- /dev/null +++ b/bsky-sdk/src/agent/config.rs @@ -0,0 +1,50 @@ +pub 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}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Config { + pub endpoint: String, + pub session: Option, + pub labelers_header: Option>, + pub proxy_header: Option, +} + +impl Config { + pub async fn load(loader: &impl Loader) -> Result { + loader.load().await.map_err(Error::ConfigLoad) + } + pub async fn save(&self, saver: &impl Saver) -> Result<()> { + saver.save(self).await.map_err(Error::ConfigSave) + } +} + +impl Default for Config { + fn default() -> Self { + Self { + endpoint: String::from("https://bsky.social"), + session: None, + labelers_header: None, + proxy_header: None, + } + } +} + +#[async_trait] +pub trait Loader: Sized { + async fn load( + &self, + ) -> core::result::Result>; +} + +#[async_trait] +pub trait Saver { + 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 0000000..902b6f3 --- /dev/null +++ b/bsky-sdk/src/agent/config/file.rs @@ -0,0 +1,48 @@ +use super::{Config, Loader, Saver}; +use anyhow::anyhow; +use async_trait::async_trait; +use std::path::{Path, PathBuf}; + +pub struct FileStore { + path: PathBuf, +} + +impl FileStore { + 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 0000000..450e6cc --- /dev/null +++ b/bsky-sdk/src/error.rs @@ -0,0 +1,54 @@ +use atrium_api::xrpc; +use http::StatusCode; +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), +} + +#[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: xrpc::Error) -> Self { + if let xrpc::Error::XrpcResponse(e) = err { + Self::Xrpc(Box::new(GenericXrpcError { + status: e.status, + error: e.error.map(|e| match e { + xrpc::error::XrpcErrorKind::Custom(_) => String::from("custom error"), + xrpc::error::XrpcErrorKind::Undefined(res) => res.to_string(), + }), + })) + } else { + err.into() + } + } +} + +/// Type alias to use this crate's [`Error`] type in a [`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 0000000..93320ef --- /dev/null +++ b/bsky-sdk/src/lib.rs @@ -0,0 +1,8 @@ +pub mod agent; +mod error; +pub mod moderation; +pub mod preference; + +pub use agent::BskyAgent; +pub use atrium_api as api; +pub use error::{Error, Result}; diff --git a/bsky-sdk/src/moderation.rs b/bsky-sdk/src/moderation.rs new file mode 100644 index 0000000..c05a7ec --- /dev/null +++ b/bsky-sdk/src/moderation.rs @@ -0,0 +1,518 @@ +pub mod decision; +mod labels; +mod types; +pub mod ui; + +use self::decision::{LabelTarget, ModerationDecision}; +pub use self::types::*; +use atrium_api::types::{string::Did, Union}; +use std::collections::HashMap; + +#[derive(Debug)] +pub struct Moderator { + user_did: Option, + prefs: ModerationPrefs, + label_defs: Option>>, +} + +impl Moderator { + pub fn moderate_profile(&self, profile: &SubjectProfile) -> ModerationDecision { + ModerationDecision::merge(&[ + self.account_decision(profile), + self.profile_decision(profile), + ]) + } + pub fn moderate_post(&self, post: &SubjectPost) -> ModerationDecision { + self.post_decision(post) + } + fn account_decision(&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())); + // TODO: muted? + // TODO: blocked? + 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 + } + fn profile_decision(&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 + } + fn post_decision(&self, subject: &SubjectPost) -> 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); + } + } + // TODO: hidden? + // TODO: muted words? + + let embed_acc = Option::::None; + if let Some(Union::Refs(embed)) = &subject.embed { + todo!() + } + + let mut decisions = vec![acc]; + if let Some(mut embed_acc) = embed_acc { + embed_acc.downgrade(); + decisions.push(embed_acc); + } + decisions.extend([ + self.account_decision(&subject.author.clone().into()), + self.profile_decision(&subject.author.clone().into()), + ]); + ModerationDecision::merge(&decisions) + } +} + +#[cfg(test)] +mod tests { + use super::decision::DecisionContext; + use super::*; + use atrium_api::app::bsky::actor::defs::ProfileViewBasic; + use atrium_api::app::bsky::feed::defs::PostView; + use atrium_api::com::atproto::label::defs::Label; + use atrium_api::records::{KnownRecord, Record}; + use atrium_api::types::string::Datetime; + + const FAKE_CID: &str = "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq"; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum ModerationTestResultFlag { + Filter, + Blur, + Alert, + Inform, + NoOverride, + } + + 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 interpreted_label_value_definition( + identifier: &str, + default_setting: LabelPreference, + severity: &str, + blurs: &str, + ) -> InterpretedLabelValueDefinition { + let flags = vec![LabelValueDefinitionFlag::NoSelf]; + let alert_or_inform = match severity { + "alert" => BehaviorValue::Alert, + "inform" => BehaviorValue::Inform, + _ => unreachable!(), + }; + let mut behaviors = InterpretedLabelValueDefinitionBehaviors::default(); + match blurs { + "content" => { + todo!() + } + "media" => { + todo!() + } + "none" => { + // target=account, blurs=none + behaviors.account.profile_list = Some(alert_or_inform.try_into().unwrap()); + behaviors.account.profile_view = Some(alert_or_inform.try_into().unwrap()); + behaviors.account.content_list = Some(alert_or_inform.try_into().unwrap()); + behaviors.account.content_view = Some(alert_or_inform.try_into().unwrap()); + // target=profile, blurs=none + behaviors.profile.profile_list = Some(alert_or_inform.try_into().unwrap()); + behaviors.profile.profile_view = Some(alert_or_inform.try_into().unwrap()); + // target=content, blurs=none + behaviors.content.content_list = Some(alert_or_inform.try_into().unwrap()); + behaviors.content.content_view = Some(alert_or_inform.try_into().unwrap()); + } + _ => unreachable!(), + } + InterpretedLabelValueDefinition { + identifier: identifier.into(), + default_setting, + flags, + behaviors, + } + } + + fn assert_ui( + decision: &ModerationDecision, + expected: &[ModerationTestResultFlag], + context: DecisionContext, + ) { + let ui = decision.ui(context); + println!("{:?}", ui.inform()); + println!("{:?}", ui.blur()); + 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(&ModerationTestResultFlag::Inform), + "inform should be {} for context {context:?}", + !ui.inform() + ); + assert_eq!( + ui.alert(), + expected.contains(&ModerationTestResultFlag::Alert), + "alert should be {} for context {context:?}", + !ui.alert() + ); + assert_eq!( + ui.blur(), + expected.contains(&ModerationTestResultFlag::Blur), + "blur should be {} for context {context:?}", + !ui.blur() + ); + assert_eq!( + ui.filter(), + expected.contains(&ModerationTestResultFlag::Filter), + "filter should be {} for context {context:?}", + !ui.filter() + ); + assert_eq!( + ui.no_override, + expected.contains(&ModerationTestResultFlag::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 { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Hide)]), + ..Default::default() + }, + label_defs: None, + }; + let result = moderator.moderate_profile(&profile); + assert_ui( + &result, + &[ModerationTestResultFlag::Blur], + DecisionContext::Avatar, + ) + } + // porn (ignore) + { + let moderator = Moderator { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Ignore)]), + ..Default::default() + }, + label_defs: None, + }; + 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 { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Hide)]), + ..Default::default() + }, + label_defs: None, + }; + let result = moderator.moderate_profile(&profile); + for context in DecisionContext::ALL { + assert_ui(&result, &[], context); + } + } + // porn (label group disabled) + { + let moderator = Moderator { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: None, + }; + let result = moderator.moderate_profile(&profile); + for context in DecisionContext::ALL { + assert_ui(&result, &[], context); + } + } + } + + #[test] + fn prioritize_filters_and_blurs() { + let moderator = Moderator { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: None, + }; + 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 { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: Some(HashMap::from_iter([( + String::from("did:web:labeler.test"), + vec![interpreted_label_value_definition( + "porn", + LabelPreference::Warn, + "inform", + "none", + )], + )])), + }; + 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", + )]), + )); + assert_ui(&result, &[], DecisionContext::ProfileList); + assert_ui(&result, &[], DecisionContext::ProfileView); + assert_ui(&result, &[], DecisionContext::Avatar); + assert_ui(&result, &[], DecisionContext::Banner); + assert_ui(&result, &[], DecisionContext::DisplayName); + assert_ui( + &result, + &[ModerationTestResultFlag::Inform], + DecisionContext::ContentList, + ); + assert_ui( + &result, + &[ModerationTestResultFlag::Inform], + DecisionContext::ContentView, + ); + assert_ui(&result, &[], DecisionContext::ContentMedia); + } + + #[test] + fn does_not_override_imperative_labels() { + let moderator = Moderator { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: Some(HashMap::from_iter([( + String::from("did:web:labeler.test"), + vec![interpreted_label_value_definition( + "!hide", + LabelPreference::Warn, + "inform", + "none", + )], + )])), + }; + 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", + )]), + )); + assert_ui(&result, &[], DecisionContext::ProfileList); + assert_ui(&result, &[], DecisionContext::ProfileView); + assert_ui(&result, &[], DecisionContext::Avatar); + assert_ui(&result, &[], DecisionContext::Banner); + assert_ui(&result, &[], DecisionContext::DisplayName); + assert_ui( + &result, + &[ + ModerationTestResultFlag::Filter, + ModerationTestResultFlag::Blur, + ModerationTestResultFlag::NoOverride, + ], + DecisionContext::ContentList, + ); + assert_ui( + &result, + &[ + ModerationTestResultFlag::Blur, + ModerationTestResultFlag::NoOverride, + ], + DecisionContext::ContentView, + ); + assert_ui(&result, &[], DecisionContext::ContentMedia); + } +} diff --git a/bsky-sdk/src/moderation/decision.rs b/bsky-sdk/src/moderation/decision.rs new file mode 100644 index 0000000..e723094 --- /dev/null +++ b/bsky-sdk/src/moderation/decision.rs @@ -0,0 +1,284 @@ +use crate::moderation::BehaviorValue; + +use super::types::{ + ContentListBehavior, ContentMediaBehavior, ContentViewBehavior, + InterpretedLabelValueDefinition, LabelPreference, LabelValueDefinitionFlag, ModerationBehavior, + ModerationCause, ModerationCauseLabel, ModerationCauseSource, ProfileViewBehavior, +}; +use super::{labels::KnownLabelValue, ui::ModerationUi, Moderator}; +use atrium_api::{com::atproto::label::defs::Label, types::string::Did}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DecisionContext { + ProfileList, + ProfileView, + Avatar, + Banner, + DisplayName, + ContentList, + ContentView, + 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 LabelTarget { + Account, + Profile, + Content, +} + +#[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, + Priority5, + Priority7, + Priority8, +} + +#[derive(Debug)] +pub struct ModerationDecision { + did: Option, + is_me: bool, + causes: Vec, +} + +impl ModerationDecision { + 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() + | ModerationCause::BlockedBy() + | ModerationCause::BlockOther() => { + todo!(); + } + ModerationCause::Label(label) => { + if ((context == DecisionContext::ProfileList + && matches!(label.target, LabelTarget::Account)) + || (context == DecisionContext::ContentList + && matches!(label.target, LabelTarget::Account | LabelTarget::Content))) + && (label.setting == LabelPreference::Hide && !self.is_me) + { + ui.filters.push(cause.clone()) + } + if !label.downgraded.unwrap_or_default() { + 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() => { + todo!(); + } + ModerationCause::MuteWord() => { + todo!(); + } + ModerationCause::Hidden() => { + todo!(); + } + } + } + ui.filters.sort_by_cached_key(|c| c.priority()); + ui.blurs.sort_by_cached_key(|c| c.priority()); + ui + } + 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_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: None, + }))); + } + 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 + .as_ref() + .and_then(|label_defs| label_defs.get(label.src.as_ref())) + .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/labels.rs b/bsky-sdk/src/moderation/labels.rs new file mode 100644 index 0000000..1a3e8bd --- /dev/null +++ b/bsky-sdk/src/moderation/labels.rs @@ -0,0 +1,90 @@ +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 = (); + + 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(()), + } + } +} + +impl KnownLabelValue { + pub fn definition(&self) -> InterpretedLabelValueDefinition { + match self { + Self::ReservedHide => InterpretedLabelValueDefinition { + identifier: String::from("!hide"), + default_setting: LabelPreference::Hide, + 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::Porn => InterpretedLabelValueDefinition { + identifier: String::from("porn"), + default_setting: LabelPreference::Hide, + 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() + }, + }, + }, + _ => todo!(), + } + } +} diff --git a/bsky-sdk/src/moderation/types.rs b/bsky-sdk/src/moderation/types.rs new file mode 100644 index 0000000..ea1f1c4 --- /dev/null +++ b/bsky-sdk/src/moderation/types.rs @@ -0,0 +1,463 @@ +use super::decision::{DecisionContext, LabelTarget, Priority}; +use atrium_api::agent::bluesky::BSKY_LABELER_DID; +use atrium_api::app::bsky::actor::defs::{ProfileView, ProfileViewBasic, ProfileViewDetailed}; +use atrium_api::app::bsky::feed::defs::PostView; +use atrium_api::app::bsky::graph::defs::ListViewBasic; +use atrium_api::com::atproto::label::defs::Label; +use atrium_api::types::string::Did; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, str::FromStr}; + +// labels + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LabelValueDefinitionFlag { + NoOverride, + Adult, + Unauthed, + NoSelf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum LabelPreference { + Ignore, + Warn, + Hide, +} + +impl FromStr for LabelPreference { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "ignore" => Ok(Self::Ignore), + "warn" => Ok(Self::Warn), + "hide" => Ok(Self::Hide), + _ => Err(()), + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct InterpretedLabelValueDefinition { + pub identifier: String, + pub default_setting: LabelPreference, + pub flags: Vec, + pub behaviors: InterpretedLabelValueDefinitionBehaviors, + // TODO +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct InterpretedLabelValueDefinitionBehaviors { + pub account: ModerationBehavior, + pub profile: ModerationBehavior, + pub content: ModerationBehavior, +} + +impl InterpretedLabelValueDefinitionBehaviors { + pub 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 + +#[derive(Debug)] +pub enum SubjectProfile { + ProfileViewBasic(ProfileViewBasic), + ProfileView(ProfileView), + ProfileViewDetailed(ProfileViewDetailed), +} + +impl SubjectProfile { + pub fn did(&self) -> &Did { + match self { + Self::ProfileViewBasic(p) => &p.did, + Self::ProfileView(p) => &p.did, + Self::ProfileViewDetailed(p) => &p.did, + } + } + pub fn labels(&self) -> &Option> { + match self { + Self::ProfileViewBasic(p) => &p.labels, + Self::ProfileView(p) => &p.labels, + Self::ProfileViewDetailed(p) => &p.labels, + } + } +} + +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) + } +} + +pub type SubjectPost = PostView; + +// behaviors + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BehaviorValue { + Blur, + Alert, + Inform, +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct ModerationBehavior { + pub profile_list: Option, + pub profile_view: Option, + pub avatar: Option, + pub banner: Option, + pub display_name: Option, + pub content_list: Option, + pub content_view: Option, + pub content_media: Option, +} + +impl ModerationBehavior { + pub 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), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) 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 = (); + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + BehaviorValue::Alert => Ok(Self::Alert), + BehaviorValue::Inform => Ok(Self::Inform), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) 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 = (); + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + BehaviorValue::Alert => Ok(Self::Alert), + BehaviorValue::Inform => Ok(Self::Inform), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) 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 = (); + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + BehaviorValue::Alert => Ok(Self::Alert), + BehaviorValue::Inform => Err(()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BannerBehavior { + Blur, +} + +impl From for BehaviorValue { + fn from(b: BannerBehavior) -> Self { + match b { + BannerBehavior::Blur => Self::Blur, + } + } +} + +impl TryFrom for BannerBehavior { + type Error = (); + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + _ => Err(()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DisplayNameBehavior { + Blur, +} + +impl From for BehaviorValue { + fn from(b: DisplayNameBehavior) -> Self { + match b { + DisplayNameBehavior::Blur => Self::Blur, + } + } +} + +impl TryFrom for DisplayNameBehavior { + type Error = (); + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + _ => Err(()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) 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 = (); + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + BehaviorValue::Alert => Ok(Self::Alert), + BehaviorValue::Inform => Ok(Self::Inform), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) 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 = (); + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + BehaviorValue::Alert => Ok(Self::Alert), + BehaviorValue::Inform => Ok(Self::Inform), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ContentMediaBehavior { + Blur, +} + +impl From for BehaviorValue { + fn from(b: ContentMediaBehavior) -> Self { + match b { + ContentMediaBehavior::Blur => Self::Blur, + } + } +} + +impl TryFrom for ContentMediaBehavior { + type Error = (); + + fn try_from(b: BehaviorValue) -> Result { + match b { + BehaviorValue::Blur => Ok(Self::Blur), + _ => Err(()), + } + } +} + +#[derive(Debug, Clone)] +pub(crate) enum ModerationCause { + Blocking( + //TODO + ), + BlockedBy( + //TODO + ), + BlockOther( + //TODO + ), + Label(Box), + Muted( + //TODO + ), + MuteWord( + //TODO + ), + Hidden( + //TODO + ), +} + +impl ModerationCause { + pub fn downgrade(&mut self) { + match self { + Self::Label(label) => label.downgraded = Some(true), + _ => todo!(), + } + } + pub fn priority(&self) -> Priority { + match self { + Self::Label(label) => label.priority, + _ => todo!(), + } + } +} + +#[derive(Debug, Clone)] +pub(crate) enum ModerationCauseSource { + User, + List(ListViewBasic), + Labeler(Did), +} + +#[derive(Debug, Clone)] +pub(crate) 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 priority: Priority, + pub downgraded: Option, +} + +// moderation preferences + +#[derive(Debug, 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, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ModerationPrefs { + pub adult_content_enabled: bool, + pub labels: HashMap, + pub labelers: 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(), + } + } +} diff --git a/bsky-sdk/src/moderation/ui.rs b/bsky-sdk/src/moderation/ui.rs new file mode 100644 index 0000000..87db270 --- /dev/null +++ b/bsky-sdk/src/moderation/ui.rs @@ -0,0 +1,24 @@ +use super::types::ModerationCause; + +pub struct ModerationUi { + pub no_override: bool, + pub(crate) filters: Vec, + pub(crate) blurs: Vec, + pub(crate) alerts: Vec, + pub(crate) informs: Vec, +} + +impl ModerationUi { + pub fn filter(&self) -> bool { + !self.filters.is_empty() + } + pub fn blur(&self) -> bool { + !self.blurs.is_empty() + } + pub fn alert(&self) -> bool { + !self.alerts.is_empty() + } + pub fn inform(&self) -> bool { + !self.informs.is_empty() + } +} diff --git a/bsky-sdk/src/preference.rs b/bsky-sdk/src/preference.rs new file mode 100644 index 0000000..325776c --- /dev/null +++ b/bsky-sdk/src/preference.rs @@ -0,0 +1,8 @@ +use crate::moderation::ModerationPrefs; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Preferences { + pub moderation_prefs: ModerationPrefs, +} From 3c6f14d6649b959ab392c78e66f5e6ab0a82369c Mon Sep 17 00:00:00 2001 From: sugyan Date: Wed, 29 May 2024 23:47:09 +0900 Subject: [PATCH 02/29] chore: Tweak --- bsky-sdk/src/agent/config.rs | 2 +- bsky-sdk/src/moderation/decision.rs | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/bsky-sdk/src/agent/config.rs b/bsky-sdk/src/agent/config.rs index e17c6e1..92975bd 100644 --- a/bsky-sdk/src/agent/config.rs +++ b/bsky-sdk/src/agent/config.rs @@ -35,7 +35,7 @@ impl Default for Config { } #[async_trait] -pub trait Loader: Sized { +pub trait Loader { async fn load( &self, ) -> core::result::Result>; diff --git a/bsky-sdk/src/moderation/decision.rs b/bsky-sdk/src/moderation/decision.rs index e723094..5293292 100644 --- a/bsky-sdk/src/moderation/decision.rs +++ b/bsky-sdk/src/moderation/decision.rs @@ -1,12 +1,7 @@ -use crate::moderation::BehaviorValue; - -use super::types::{ - ContentListBehavior, ContentMediaBehavior, ContentViewBehavior, - InterpretedLabelValueDefinition, LabelPreference, LabelValueDefinitionFlag, ModerationBehavior, - ModerationCause, ModerationCauseLabel, ModerationCauseSource, ProfileViewBehavior, -}; +use super::types::*; use super::{labels::KnownLabelValue, ui::ModerationUi, Moderator}; -use atrium_api::{com::atproto::label::defs::Label, types::string::Did}; +use atrium_api::com::atproto::label::defs::Label; +use atrium_api::types::string::Did; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DecisionContext { From bf423ed66eed20a69ea08e8c38ba9275119116a4 Mon Sep 17 00:00:00 2001 From: sugyan Date: Thu, 30 May 2024 11:15:16 +0900 Subject: [PATCH 03/29] Add moderation tests --- bsky-sdk/src/moderation.rs | 348 ++++++++++++++++++++++++++++++++----- 1 file changed, 302 insertions(+), 46 deletions(-) diff --git a/bsky-sdk/src/moderation.rs b/bsky-sdk/src/moderation.rs index c05a7ec..fa03779 100644 --- a/bsky-sdk/src/moderation.rs +++ b/bsky-sdk/src/moderation.rs @@ -164,13 +164,13 @@ mod tests { } } - fn interpreted_label_value_definition( + fn interpret_label_value_definition( identifier: &str, default_setting: LabelPreference, severity: &str, blurs: &str, + adult_only: bool, ) -> InterpretedLabelValueDefinition { - let flags = vec![LabelValueDefinitionFlag::NoSelf]; let alert_or_inform = match severity { "alert" => BehaviorValue::Alert, "inform" => BehaviorValue::Inform, @@ -179,7 +179,33 @@ mod tests { let mut behaviors = InterpretedLabelValueDefinitionBehaviors::default(); match blurs { "content" => { - todo!() + // target=account, blurs=content + behaviors.account.profile_list = Some(alert_or_inform.try_into().unwrap()); + behaviors.account.profile_view = Some(alert_or_inform.try_into().unwrap()); + behaviors.account.content_list = Some(BehaviorValue::Blur.try_into().unwrap()); + behaviors.account.content_view = Some( + if adult_only { + BehaviorValue::Blur + } else { + alert_or_inform + } + .try_into() + .unwrap(), + ); + // target=profile, blurs=content + behaviors.profile.profile_list = Some(alert_or_inform.try_into().unwrap()); + behaviors.profile.profile_view = Some(alert_or_inform.try_into().unwrap()); + // target=content, blurs=content + behaviors.content.content_list = Some(BehaviorValue::Blur.try_into().unwrap()); + behaviors.content.content_view = Some( + if adult_only { + BehaviorValue::Blur + } else { + alert_or_inform + } + .try_into() + .unwrap(), + ); } "media" => { todo!() @@ -199,6 +225,10 @@ mod tests { } _ => unreachable!(), } + let mut flags = vec![LabelValueDefinitionFlag::NoSelf]; + if adult_only { + flags.push(LabelValueDefinitionFlag::Adult); + } InterpretedLabelValueDefinition { identifier: identifier.into(), default_setting, @@ -213,8 +243,6 @@ mod tests { context: DecisionContext, ) { let ui = decision.ui(context); - println!("{:?}", ui.inform()); - println!("{:?}", ui.blur()); if expected.is_empty() { assert!( !ui.inform(), @@ -424,11 +452,12 @@ mod tests { }, label_defs: Some(HashMap::from_iter([( String::from("did:web:labeler.test"), - vec![interpreted_label_value_definition( + vec![interpret_label_value_definition( "porn", LabelPreference::Warn, "inform", "none", + false, )], )])), }; @@ -441,22 +470,14 @@ mod tests { "porn", )]), )); - assert_ui(&result, &[], DecisionContext::ProfileList); - assert_ui(&result, &[], DecisionContext::ProfileView); - assert_ui(&result, &[], DecisionContext::Avatar); - assert_ui(&result, &[], DecisionContext::Banner); - assert_ui(&result, &[], DecisionContext::DisplayName); - assert_ui( - &result, - &[ModerationTestResultFlag::Inform], - DecisionContext::ContentList, - ); - assert_ui( - &result, - &[ModerationTestResultFlag::Inform], - DecisionContext::ContentView, - ); - assert_ui(&result, &[], DecisionContext::ContentMedia); + for context in DecisionContext::ALL { + let expected = match context { + DecisionContext::ContentList => vec![ModerationTestResultFlag::Inform], + DecisionContext::ContentView => vec![ModerationTestResultFlag::Inform], + _ => vec![], + }; + assert_ui(&result, &expected, context); + } } #[test] @@ -474,11 +495,12 @@ mod tests { }, label_defs: Some(HashMap::from_iter([( String::from("did:web:labeler.test"), - vec![interpreted_label_value_definition( + vec![interpret_label_value_definition( "!hide", LabelPreference::Warn, "inform", "none", + false, )], )])), }; @@ -491,28 +513,262 @@ mod tests { "!hide", )]), )); - assert_ui(&result, &[], DecisionContext::ProfileList); - assert_ui(&result, &[], DecisionContext::ProfileView); - assert_ui(&result, &[], DecisionContext::Avatar); - assert_ui(&result, &[], DecisionContext::Banner); - assert_ui(&result, &[], DecisionContext::DisplayName); - assert_ui( - &result, - &[ - ModerationTestResultFlag::Filter, - ModerationTestResultFlag::Blur, - ModerationTestResultFlag::NoOverride, - ], - DecisionContext::ContentList, - ); - assert_ui( - &result, - &[ - ModerationTestResultFlag::Blur, - ModerationTestResultFlag::NoOverride, - ], - DecisionContext::ContentView, - ); - assert_ui(&result, &[], DecisionContext::ContentMedia); + for context in DecisionContext::ALL { + let expected = match context { + DecisionContext::ContentList => vec![ + ModerationTestResultFlag::Filter, + ModerationTestResultFlag::Blur, + ModerationTestResultFlag::NoOverride, + ], + DecisionContext::ContentView => vec![ + ModerationTestResultFlag::Blur, + ModerationTestResultFlag::NoOverride, + ], + _ => vec![], + }; + assert_ui(&result, &expected, context); + } + } + + #[test] + fn ignore_invalid_label_value_names() { + let moderator = Moderator { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: Some(HashMap::from_iter([( + String::from("did:web:labeler.test"), + vec![ + interpret_label_value_definition( + "BadLabel", + LabelPreference::Warn, + "inform", + "content", + false, + ), + interpret_label_value_definition( + "bad/label", + LabelPreference::Warn, + "inform", + "content", + false, + ), + ], + )])), + }; + 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 { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: Some(HashMap::from_iter([( + String::from("did:web:labeler.test"), + vec![ + interpret_label_value_definition( + "default-hide", + LabelPreference::Hide, + "inform", + "content", + false, + ), + interpret_label_value_definition( + "default-warn", + LabelPreference::Warn, + "inform", + "content", + false, + ), + interpret_label_value_definition( + "default-ignore", + LabelPreference::Ignore, + "inform", + "content", + false, + ), + ], + )])), + }; + 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![ + ModerationTestResultFlag::Filter, + ModerationTestResultFlag::Blur, + ], + DecisionContext::ContentView => vec![ModerationTestResultFlag::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![ModerationTestResultFlag::Blur], + DecisionContext::ContentView => vec![ModerationTestResultFlag::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 { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: Some(HashMap::from_iter([( + String::from("did:web:labeler.test"), + vec![interpret_label_value_definition( + "adult", + LabelPreference::Hide, + "inform", + "content", + true, + )], + )])), + }; + 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![ + ModerationTestResultFlag::Filter, + ModerationTestResultFlag::Blur, + ModerationTestResultFlag::NoOverride, + ], + DecisionContext::ContentView => vec![ + ModerationTestResultFlag::Blur, + ModerationTestResultFlag::NoOverride, + ], + _ => vec![], + }; + assert_ui(&result, &expected, context); + } + } + + #[test] + fn adult_content_disabled_forces_hide() { + let moderator = Moderator { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: None, + }; + 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![ModerationTestResultFlag::Filter], + DecisionContext::ContentMedia => vec![ + ModerationTestResultFlag::Blur, + ModerationTestResultFlag::NoOverride, + ], + _ => vec![], + }; + assert_ui(&result, &expected, context); + } } } From ee016921bd249f80c184ec7107f7aa4e952bcc24 Mon Sep 17 00:00:00 2001 From: sugyan Date: Thu, 30 May 2024 14:35:56 +0900 Subject: [PATCH 04/29] Add tests, add tests --- bsky-sdk/src/moderation.rs | 688 +-------------------- bsky-sdk/src/moderation/labels.rs | 58 ++ bsky-sdk/src/moderation/tests.rs | 686 ++++++++++++++++++++ bsky-sdk/src/moderation/tests/behaviors.rs | 425 +++++++++++++ 4 files changed, 1170 insertions(+), 687 deletions(-) create mode 100644 bsky-sdk/src/moderation/tests.rs create mode 100644 bsky-sdk/src/moderation/tests/behaviors.rs diff --git a/bsky-sdk/src/moderation.rs b/bsky-sdk/src/moderation.rs index fa03779..c673066 100644 --- a/bsky-sdk/src/moderation.rs +++ b/bsky-sdk/src/moderation.rs @@ -85,690 +85,4 @@ impl Moderator { } #[cfg(test)] -mod tests { - use super::decision::DecisionContext; - use super::*; - use atrium_api::app::bsky::actor::defs::ProfileViewBasic; - use atrium_api::app::bsky::feed::defs::PostView; - use atrium_api::com::atproto::label::defs::Label; - use atrium_api::records::{KnownRecord, Record}; - use atrium_api::types::string::Datetime; - - const FAKE_CID: &str = "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq"; - - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - enum ModerationTestResultFlag { - Filter, - Blur, - Alert, - Inform, - NoOverride, - } - - 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 interpret_label_value_definition( - identifier: &str, - default_setting: LabelPreference, - severity: &str, - blurs: &str, - adult_only: bool, - ) -> InterpretedLabelValueDefinition { - let alert_or_inform = match severity { - "alert" => BehaviorValue::Alert, - "inform" => BehaviorValue::Inform, - _ => unreachable!(), - }; - let mut behaviors = InterpretedLabelValueDefinitionBehaviors::default(); - match blurs { - "content" => { - // target=account, blurs=content - behaviors.account.profile_list = Some(alert_or_inform.try_into().unwrap()); - behaviors.account.profile_view = Some(alert_or_inform.try_into().unwrap()); - behaviors.account.content_list = Some(BehaviorValue::Blur.try_into().unwrap()); - behaviors.account.content_view = Some( - if adult_only { - BehaviorValue::Blur - } else { - alert_or_inform - } - .try_into() - .unwrap(), - ); - // target=profile, blurs=content - behaviors.profile.profile_list = Some(alert_or_inform.try_into().unwrap()); - behaviors.profile.profile_view = Some(alert_or_inform.try_into().unwrap()); - // target=content, blurs=content - behaviors.content.content_list = Some(BehaviorValue::Blur.try_into().unwrap()); - behaviors.content.content_view = Some( - if adult_only { - BehaviorValue::Blur - } else { - alert_or_inform - } - .try_into() - .unwrap(), - ); - } - "media" => { - todo!() - } - "none" => { - // target=account, blurs=none - behaviors.account.profile_list = Some(alert_or_inform.try_into().unwrap()); - behaviors.account.profile_view = Some(alert_or_inform.try_into().unwrap()); - behaviors.account.content_list = Some(alert_or_inform.try_into().unwrap()); - behaviors.account.content_view = Some(alert_or_inform.try_into().unwrap()); - // target=profile, blurs=none - behaviors.profile.profile_list = Some(alert_or_inform.try_into().unwrap()); - behaviors.profile.profile_view = Some(alert_or_inform.try_into().unwrap()); - // target=content, blurs=none - behaviors.content.content_list = Some(alert_or_inform.try_into().unwrap()); - behaviors.content.content_view = Some(alert_or_inform.try_into().unwrap()); - } - _ => unreachable!(), - } - let mut flags = vec![LabelValueDefinitionFlag::NoSelf]; - if adult_only { - flags.push(LabelValueDefinitionFlag::Adult); - } - InterpretedLabelValueDefinition { - identifier: identifier.into(), - default_setting, - flags, - behaviors, - } - } - - fn assert_ui( - decision: &ModerationDecision, - expected: &[ModerationTestResultFlag], - 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(&ModerationTestResultFlag::Inform), - "inform should be {} for context {context:?}", - !ui.inform() - ); - assert_eq!( - ui.alert(), - expected.contains(&ModerationTestResultFlag::Alert), - "alert should be {} for context {context:?}", - !ui.alert() - ); - assert_eq!( - ui.blur(), - expected.contains(&ModerationTestResultFlag::Blur), - "blur should be {} for context {context:?}", - !ui.blur() - ); - assert_eq!( - ui.filter(), - expected.contains(&ModerationTestResultFlag::Filter), - "filter should be {} for context {context:?}", - !ui.filter() - ); - assert_eq!( - ui.no_override, - expected.contains(&ModerationTestResultFlag::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 { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { - adult_content_enabled: true, - labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Hide)]), - ..Default::default() - }, - label_defs: None, - }; - let result = moderator.moderate_profile(&profile); - assert_ui( - &result, - &[ModerationTestResultFlag::Blur], - DecisionContext::Avatar, - ) - } - // porn (ignore) - { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { - adult_content_enabled: true, - labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Ignore)]), - ..Default::default() - }, - label_defs: None, - }; - 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 { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { - adult_content_enabled: true, - labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Hide)]), - ..Default::default() - }, - label_defs: None, - }; - let result = moderator.moderate_profile(&profile); - for context in DecisionContext::ALL { - assert_ui(&result, &[], context); - } - } - // porn (label group disabled) - { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: 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, - }], - }, - label_defs: None, - }; - let result = moderator.moderate_profile(&profile); - for context in DecisionContext::ALL { - assert_ui(&result, &[], context); - } - } - } - - #[test] - fn prioritize_filters_and_blurs() { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: 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, - }], - }, - label_defs: None, - }; - 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 { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: 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, - }], - }, - label_defs: Some(HashMap::from_iter([( - String::from("did:web:labeler.test"), - vec![interpret_label_value_definition( - "porn", - LabelPreference::Warn, - "inform", - "none", - false, - )], - )])), - }; - 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![ModerationTestResultFlag::Inform], - DecisionContext::ContentView => vec![ModerationTestResultFlag::Inform], - _ => vec![], - }; - assert_ui(&result, &expected, context); - } - } - - #[test] - fn does_not_override_imperative_labels() { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: 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, - }], - }, - label_defs: Some(HashMap::from_iter([( - String::from("did:web:labeler.test"), - vec![interpret_label_value_definition( - "!hide", - LabelPreference::Warn, - "inform", - "none", - false, - )], - )])), - }; - 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![ - ModerationTestResultFlag::Filter, - ModerationTestResultFlag::Blur, - ModerationTestResultFlag::NoOverride, - ], - DecisionContext::ContentView => vec![ - ModerationTestResultFlag::Blur, - ModerationTestResultFlag::NoOverride, - ], - _ => vec![], - }; - assert_ui(&result, &expected, context); - } - } - - #[test] - fn ignore_invalid_label_value_names() { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: 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, - }], - }, - label_defs: Some(HashMap::from_iter([( - String::from("did:web:labeler.test"), - vec![ - interpret_label_value_definition( - "BadLabel", - LabelPreference::Warn, - "inform", - "content", - false, - ), - interpret_label_value_definition( - "bad/label", - LabelPreference::Warn, - "inform", - "content", - false, - ), - ], - )])), - }; - 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 { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: 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, - }], - }, - label_defs: Some(HashMap::from_iter([( - String::from("did:web:labeler.test"), - vec![ - interpret_label_value_definition( - "default-hide", - LabelPreference::Hide, - "inform", - "content", - false, - ), - interpret_label_value_definition( - "default-warn", - LabelPreference::Warn, - "inform", - "content", - false, - ), - interpret_label_value_definition( - "default-ignore", - LabelPreference::Ignore, - "inform", - "content", - false, - ), - ], - )])), - }; - 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![ - ModerationTestResultFlag::Filter, - ModerationTestResultFlag::Blur, - ], - DecisionContext::ContentView => vec![ModerationTestResultFlag::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![ModerationTestResultFlag::Blur], - DecisionContext::ContentView => vec![ModerationTestResultFlag::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 { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: 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, - }], - }, - label_defs: Some(HashMap::from_iter([( - String::from("did:web:labeler.test"), - vec![interpret_label_value_definition( - "adult", - LabelPreference::Hide, - "inform", - "content", - true, - )], - )])), - }; - 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![ - ModerationTestResultFlag::Filter, - ModerationTestResultFlag::Blur, - ModerationTestResultFlag::NoOverride, - ], - DecisionContext::ContentView => vec![ - ModerationTestResultFlag::Blur, - ModerationTestResultFlag::NoOverride, - ], - _ => vec![], - }; - assert_ui(&result, &expected, context); - } - } - - #[test] - fn adult_content_disabled_forces_hide() { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: 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, - }], - }, - label_defs: None, - }; - 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![ModerationTestResultFlag::Filter], - DecisionContext::ContentMedia => vec![ - ModerationTestResultFlag::Blur, - ModerationTestResultFlag::NoOverride, - ], - _ => vec![], - }; - assert_ui(&result, &expected, context); - } - } -} +mod tests; diff --git a/bsky-sdk/src/moderation/labels.rs b/bsky-sdk/src/moderation/labels.rs index 1a3e8bd..44f15df 100644 --- a/bsky-sdk/src/moderation/labels.rs +++ b/bsky-sdk/src/moderation/labels.rs @@ -63,6 +63,64 @@ impl KnownLabelValue { }, }, }, + Self::ReservedWarn => InterpretedLabelValueDefinition { + identifier: String::from("!warn"), + default_setting: LabelPreference::Warn, + 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 { + identifier: String::from("!no-unauthenticated"), + default_setting: LabelPreference::Hide, + 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 { identifier: String::from("porn"), default_setting: LabelPreference::Hide, diff --git a/bsky-sdk/src/moderation/tests.rs b/bsky-sdk/src/moderation/tests.rs new file mode 100644 index 0000000..4d213ea --- /dev/null +++ b/bsky-sdk/src/moderation/tests.rs @@ -0,0 +1,686 @@ +mod behaviors; + +use crate::moderation::decision::{DecisionContext, ModerationDecision}; +use crate::moderation::types::*; +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; +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 ModerationTestResultFlag { + Filter, + Blur, + Alert, + Inform, + NoOverride, +} + +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 interpret_label_value_definition( + identifier: &str, + default_setting: LabelPreference, + severity: &str, + blurs: &str, + adult_only: bool, +) -> InterpretedLabelValueDefinition { + let alert_or_inform = match severity { + "alert" => BehaviorValue::Alert, + "inform" => BehaviorValue::Inform, + _ => unreachable!(), + }; + let mut behaviors = InterpretedLabelValueDefinitionBehaviors::default(); + match blurs { + "content" => { + // target=account, blurs=content + behaviors.account.profile_list = Some(alert_or_inform.try_into().unwrap()); + behaviors.account.profile_view = Some(alert_or_inform.try_into().unwrap()); + behaviors.account.content_list = Some(BehaviorValue::Blur.try_into().unwrap()); + behaviors.account.content_view = Some( + if adult_only { + BehaviorValue::Blur + } else { + alert_or_inform + } + .try_into() + .unwrap(), + ); + // target=profile, blurs=content + behaviors.profile.profile_list = Some(alert_or_inform.try_into().unwrap()); + behaviors.profile.profile_view = Some(alert_or_inform.try_into().unwrap()); + // target=content, blurs=content + behaviors.content.content_list = Some(BehaviorValue::Blur.try_into().unwrap()); + behaviors.content.content_view = Some( + if adult_only { + BehaviorValue::Blur + } else { + alert_or_inform + } + .try_into() + .unwrap(), + ); + } + "media" => { + todo!() + } + "none" => { + // target=account, blurs=none + behaviors.account.profile_list = Some(alert_or_inform.try_into().unwrap()); + behaviors.account.profile_view = Some(alert_or_inform.try_into().unwrap()); + behaviors.account.content_list = Some(alert_or_inform.try_into().unwrap()); + behaviors.account.content_view = Some(alert_or_inform.try_into().unwrap()); + // target=profile, blurs=none + behaviors.profile.profile_list = Some(alert_or_inform.try_into().unwrap()); + behaviors.profile.profile_view = Some(alert_or_inform.try_into().unwrap()); + // target=content, blurs=none + behaviors.content.content_list = Some(alert_or_inform.try_into().unwrap()); + behaviors.content.content_view = Some(alert_or_inform.try_into().unwrap()); + } + _ => unreachable!(), + } + let mut flags = vec![LabelValueDefinitionFlag::NoSelf]; + if adult_only { + flags.push(LabelValueDefinitionFlag::Adult); + } + InterpretedLabelValueDefinition { + identifier: identifier.into(), + default_setting, + flags, + behaviors, + } +} + +fn assert_ui( + decision: &ModerationDecision, + expected: &[ModerationTestResultFlag], + 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(&ModerationTestResultFlag::Inform), + "inform should be {} for context {context:?}", + !ui.inform() + ); + assert_eq!( + ui.alert(), + expected.contains(&ModerationTestResultFlag::Alert), + "alert should be {} for context {context:?}", + !ui.alert() + ); + assert_eq!( + ui.blur(), + expected.contains(&ModerationTestResultFlag::Blur), + "blur should be {} for context {context:?}", + !ui.blur() + ); + assert_eq!( + ui.filter(), + expected.contains(&ModerationTestResultFlag::Filter), + "filter should be {} for context {context:?}", + !ui.filter() + ); + assert_eq!( + ui.no_override, + expected.contains(&ModerationTestResultFlag::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 { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Hide)]), + ..Default::default() + }, + label_defs: None, + }; + let result = moderator.moderate_profile(&profile); + assert_ui( + &result, + &[ModerationTestResultFlag::Blur], + DecisionContext::Avatar, + ) + } + // porn (ignore) + { + let moderator = Moderator { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Ignore)]), + ..Default::default() + }, + label_defs: None, + }; + 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 { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: ModerationPrefs { + adult_content_enabled: true, + labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Hide)]), + ..Default::default() + }, + label_defs: None, + }; + let result = moderator.moderate_profile(&profile); + for context in DecisionContext::ALL { + assert_ui(&result, &[], context); + } + } + // porn (label group disabled) + { + let moderator = Moderator { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: None, + }; + let result = moderator.moderate_profile(&profile); + for context in DecisionContext::ALL { + assert_ui(&result, &[], context); + } + } +} + +#[test] +fn prioritize_filters_and_blurs() { + let moderator = Moderator { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: None, + }; + 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 { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: Some(HashMap::from_iter([( + String::from("did:web:labeler.test"), + vec![interpret_label_value_definition( + "porn", + LabelPreference::Warn, + "inform", + "none", + false, + )], + )])), + }; + 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![ModerationTestResultFlag::Inform], + DecisionContext::ContentView => vec![ModerationTestResultFlag::Inform], + _ => vec![], + }; + assert_ui(&result, &expected, context); + } +} + +#[test] +fn does_not_override_imperative_labels() { + let moderator = Moderator { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: Some(HashMap::from_iter([( + String::from("did:web:labeler.test"), + vec![interpret_label_value_definition( + "!hide", + LabelPreference::Warn, + "inform", + "none", + false, + )], + )])), + }; + 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![ + ModerationTestResultFlag::Filter, + ModerationTestResultFlag::Blur, + ModerationTestResultFlag::NoOverride, + ], + DecisionContext::ContentView => vec![ + ModerationTestResultFlag::Blur, + ModerationTestResultFlag::NoOverride, + ], + _ => vec![], + }; + assert_ui(&result, &expected, context); + } +} + +#[test] +fn ignore_invalid_label_value_names() { + let moderator = Moderator { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: Some(HashMap::from_iter([( + String::from("did:web:labeler.test"), + vec![ + interpret_label_value_definition( + "BadLabel", + LabelPreference::Warn, + "inform", + "content", + false, + ), + interpret_label_value_definition( + "bad/label", + LabelPreference::Warn, + "inform", + "content", + false, + ), + ], + )])), + }; + 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 { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: Some(HashMap::from_iter([( + String::from("did:web:labeler.test"), + vec![ + interpret_label_value_definition( + "default-hide", + LabelPreference::Hide, + "inform", + "content", + false, + ), + interpret_label_value_definition( + "default-warn", + LabelPreference::Warn, + "inform", + "content", + false, + ), + interpret_label_value_definition( + "default-ignore", + LabelPreference::Ignore, + "inform", + "content", + false, + ), + ], + )])), + }; + 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![ + ModerationTestResultFlag::Filter, + ModerationTestResultFlag::Blur, + ], + DecisionContext::ContentView => vec![ModerationTestResultFlag::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![ModerationTestResultFlag::Blur], + DecisionContext::ContentView => vec![ModerationTestResultFlag::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 { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: Some(HashMap::from_iter([( + String::from("did:web:labeler.test"), + vec![interpret_label_value_definition( + "adult", + LabelPreference::Hide, + "inform", + "content", + true, + )], + )])), + }; + 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![ + ModerationTestResultFlag::Filter, + ModerationTestResultFlag::Blur, + ModerationTestResultFlag::NoOverride, + ], + DecisionContext::ContentView => vec![ + ModerationTestResultFlag::Blur, + ModerationTestResultFlag::NoOverride, + ], + _ => vec![], + }; + assert_ui(&result, &expected, context); + } +} + +#[test] +fn adult_content_disabled_forces_hide() { + let moderator = Moderator { + user_did: Some("did:web:alice.test".parse().expect("invalid did")), + prefs: 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, + }], + }, + label_defs: None, + }; + 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![ModerationTestResultFlag::Filter], + DecisionContext::ContentMedia => vec![ + ModerationTestResultFlag::Blur, + ModerationTestResultFlag::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 0000000..dfe1781 --- /dev/null +++ b/bsky-sdk/src/moderation/tests/behaviors.rs @@ -0,0 +1,425 @@ +use atrium_api::app::bsky::actor::defs::ProfileViewBasic; + +use super::{assert_ui, label, profile_view_basic}; +use super::{post_view, ModerationTestResultFlag}; +use crate::moderation::decision::DecisionContext; +use crate::moderation::types::*; +use crate::moderation::Moderator; +use std::collections::HashMap; +use std::ops::Sub; + +#[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 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 TestScenarioLabels { + post: Vec, + profile: Vec, + account: Vec, + quoted_post: Vec, + quoted_account: Vec, +} + +#[derive(Debug, Default)] +struct TestExpectedBehaviors { + profile_list: Vec, + profile_view: Vec, + avatar: Vec, + banner: Vec, + display_name: Vec, + content_list: Vec, + content_view: Vec, + content_media: Vec, +} + +#[derive(Debug)] +struct ModerationTestScenario { + cfg: TestConfig, + subject: TestSubject, + author: TestUser, + labels: TestScenarioLabels, + behaviors: TestExpectedBehaviors, +} + +impl ModerationTestScenario { + 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, + ); + } + assert_ui(&result, &self.behaviors.avatar, DecisionContext::Avatar); + assert_ui(&result, &self.behaviors.banner, DecisionContext::Banner); + assert_ui( + &result, + &self.behaviors.display_name, + DecisionContext::DisplayName, + ); + assert_ui( + &result, + &self.behaviors.content_list, + DecisionContext::ContentList, + ); + assert_ui( + &result, + &self.behaviors.content_view, + DecisionContext::ContentView, + ); + assert_ui( + &result, + &self.behaviors.content_media, + DecisionContext::ContentMedia, + ); + } + fn moderator(&self) -> Moderator { + Moderator { + user_did: match self.cfg { + TestConfig::LoggedOut => None, + _ => Some("did:web:self.test".parse().expect("invalid did")), + }, + prefs: 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, + }], + }, + label_defs: None, + } + } + 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, + )) + } + profile_view_basic( + &format!("{}.test", self.author.as_ref()), + None, + Some(labels), + ) + } + 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 post_moderation_behaviors() { + use ModerationTestResultFlag::*; + let scenarios = [ + ( + "Imperative label ('!hide') on account", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!hide') on post", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + post: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!hide') on author profile", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + display_name: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!hide') on author account", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + display_name: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!warn') on post", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + post: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + content_list: vec![Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!warn') on author profile", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + display_name: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!warn') on author account", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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", + ModerationTestScenario { + cfg: TestConfig::LoggedOut, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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() + }, + }, + ), + ]; + for (_, scenario) in scenarios { + scenario.run(); + } +} From 40c763c0efcc23fb3a3c4f9ded66445d1c7e4f79 Mon Sep 17 00:00:00 2001 From: sugyan Date: Thu, 30 May 2024 18:19:30 +0900 Subject: [PATCH 05/29] Implement blocking behavior --- bsky-sdk/src/moderation.rs | 13 +- bsky-sdk/src/moderation/decision.rs | 56 +- bsky-sdk/src/moderation/tests/behaviors.rs | 732 ++++++++++++++++++++- bsky-sdk/src/moderation/types.rs | 43 +- 4 files changed, 822 insertions(+), 22 deletions(-) diff --git a/bsky-sdk/src/moderation.rs b/bsky-sdk/src/moderation.rs index c673066..0e4d0f2 100644 --- a/bsky-sdk/src/moderation.rs +++ b/bsky-sdk/src/moderation.rs @@ -29,8 +29,17 @@ impl Moderator { let mut acc = ModerationDecision::new(); acc.set_did(subject.did().clone()); acc.set_is_me(self.user_did.as_ref() == Some(subject.did())); - // TODO: muted? - // TODO: blocked? + if let Some(viewer) = subject.viewer() { + // TODO: muted? + if let Some(blocking) = &viewer.blocking { + if let Some(list_view) = &viewer.blocking_by_list { + acc.add_blocking_by_list(list_view); + } else { + acc.add_blocking(blocking); + } + } + // TODO: 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" diff --git a/bsky-sdk/src/moderation/decision.rs b/bsky-sdk/src/moderation/decision.rs index 5293292..bec26ea 100644 --- a/bsky-sdk/src/moderation/decision.rs +++ b/bsky-sdk/src/moderation/decision.rs @@ -1,5 +1,6 @@ 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; @@ -46,6 +47,7 @@ pub(crate) enum ModerationBehaviorSeverity { pub(crate) enum Priority { Priority1, Priority2, + Priority3, Priority5, Priority7, Priority8, @@ -69,21 +71,47 @@ impl ModerationDecision { }; for cause in &self.causes { match cause { - ModerationCause::Blocking() + ModerationCause::Blocking(_) | ModerationCause::BlockedBy() | ModerationCause::BlockOther() => { - todo!(); + if self.is_me { + continue; + } + if matches!( + context, + DecisionContext::ProfileList | DecisionContext::ContentList + ) { + ui.filters.push(cause.clone()) + } + if !cause.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 ((context == DecisionContext::ProfileList - && matches!(label.target, LabelTarget::Account)) - || (context == DecisionContext::ContentList - && matches!(label.target, LabelTarget::Account | LabelTarget::Content))) - && (label.setting == LabelPreference::Hide && !self.is_me) + 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.unwrap_or_default() { + if !cause.downgraded() { match label.behavior.behavior_for(context) { Some(BehaviorValue::Blur) => { ui.blurs.push(cause.clone()); @@ -232,6 +260,18 @@ impl ModerationDecision { downgraded: None, }))); } + pub(crate) fn add_blocking_by_list(&mut self, list_view: &ListViewBasic) { + todo!() + } + pub(crate) fn add_blocking(&mut self, blocking: &str) { + self.causes.push(ModerationCause::Blocking(Box::new( + ModerationCauseBlocking { + source: ModerationCauseSource::User, + priority: Priority::Priority3, + downgraded: None, + }, + ))) + } pub(crate) fn downgrade(&mut self) { for cause in self.causes.iter_mut() { cause.downgrade() diff --git a/bsky-sdk/src/moderation/tests/behaviors.rs b/bsky-sdk/src/moderation/tests/behaviors.rs index dfe1781..efcd8fd 100644 --- a/bsky-sdk/src/moderation/tests/behaviors.rs +++ b/bsky-sdk/src/moderation/tests/behaviors.rs @@ -1,12 +1,25 @@ -use atrium_api::app::bsky::actor::defs::ProfileViewBasic; - -use super::{assert_ui, label, profile_view_basic}; +use super::{assert_ui, label, profile_view_basic, FAKE_CID}; use super::{post_view, ModerationTestResultFlag}; 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; -use std::ops::Sub; + +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 { @@ -49,6 +62,74 @@ enum TestUser { 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 { @@ -176,11 +257,13 @@ impl ModerationTestScenario { val, )) } - profile_view_basic( + 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(); @@ -418,6 +501,643 @@ fn post_moderation_behaviors() { }, }, ), + ( + "Imperative label ('!no-unauthenticated') on profile when logged out", + ModerationTestScenario { + cfg: TestConfig::LoggedOut, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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", + ModerationTestScenario { + cfg: TestConfig::LoggedOut, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + post: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + content_list: vec![Filter, Blur, NoOverride], + content_view: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Imperative label ('!no-unauthenticated') on author profile when logged out", + ModerationTestScenario { + cfg: TestConfig::LoggedOut, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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", + ModerationTestScenario { + cfg: TestConfig::LoggedOut, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors::default(), + }, + ), + ( + "Imperative label ('!no-unauthenticated') on profile when logged in", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors::default(), + }, + ), + ( + "Imperative label ('!no-unauthenticated') on post when logged in", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + post: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors::default(), + }, + ), + ( + "Imperative label ('!no-unauthenticated') on author profile when logged in", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors::default(), + }, + ), + ( + "Imperative label ('!no-unauthenticated') on author account when logged in", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("!no-unauthenticated")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors::default(), + }, + ), + ( + "Blur-media label ('porn') on account (hide)", + ModerationTestScenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + profile_list: vec![Filter], + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Filter], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on profile (hide)", + ModerationTestScenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on post (hide)", + ModerationTestScenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + post: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + content_list: vec![Filter], + content_media: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on author profile (hide)", + ModerationTestScenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on author account (hide)", + ModerationTestScenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + profile_list: vec![Filter], + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Filter], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on account (warn)", + ModerationTestScenario { + cfg: TestConfig::PornWarn, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on profile (warn)", + ModerationTestScenario { + cfg: TestConfig::PornWarn, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on post (warn)", + ModerationTestScenario { + cfg: TestConfig::PornWarn, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + post: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + content_media: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on author profile (warn)", + ModerationTestScenario { + cfg: TestConfig::PornWarn, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on author account (warn)", + ModerationTestScenario { + cfg: TestConfig::PornWarn, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Blur-media label ('porn') on account (ignore)", + ModerationTestScenario { + cfg: TestConfig::PornIgnore, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors::default(), + }, + ), + ( + "Blur-media label ('porn') on profile (ignore)", + ModerationTestScenario { + cfg: TestConfig::PornIgnore, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors::default(), + }, + ), + ( + "Blur-media label ('porn') on post (ignore)", + ModerationTestScenario { + cfg: TestConfig::PornIgnore, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + post: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors::default(), + }, + ), + ( + "Blur-media label ('porn') on author profile (ignore)", + ModerationTestScenario { + cfg: TestConfig::PornIgnore, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors::default(), + }, + ), + ( + "Blur-media label ('porn') on author account (ignore)", + ModerationTestScenario { + cfg: TestConfig::PornIgnore, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors::default(), + }, + ), + ( + "Adult-only label on account when adult content is disabled", + ModerationTestScenario { + cfg: TestConfig::AdultDisabled, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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", + ModerationTestScenario { + cfg: TestConfig::AdultDisabled, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Adult-only label on post when adult content is disabled", + ModerationTestScenario { + cfg: TestConfig::AdultDisabled, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + post: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + content_list: vec![Filter], + content_media: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Adult-only label on author profile when adult content is disabled", + ModerationTestScenario { + cfg: TestConfig::AdultDisabled, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + profile: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + ..Default::default() + }, + }, + ), + ( + "Adult-only label on author account when adult content is disabled", + ModerationTestScenario { + cfg: TestConfig::AdultDisabled, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur, NoOverride], + banner: vec![Blur, NoOverride], + content_list: vec![Filter], + ..Default::default() + }, + }, + ), + ( + "Self-profile: !hide on account", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::UserSelf, + labels: TestScenarioLabels { + account: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::UserSelf, + labels: TestScenarioLabels { + profile: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + display_name: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Self-post: Imperative label ('!hide') on post", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::UserSelf, + labels: TestScenarioLabels { + post: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + content_list: vec![Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Self-post: Imperative label ('!hide') on author profile", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::UserSelf, + labels: TestScenarioLabels { + profile: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + display_name: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Self-post: Imperative label ('!hide') on author account", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::UserSelf, + labels: TestScenarioLabels { + account: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::UserSelf, + labels: TestScenarioLabels { + post: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + content_list: vec![Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Self-post: Imperative label ('!warn') on author profile", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::UserSelf, + labels: TestScenarioLabels { + profile: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + display_name: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Self-post: Imperative label ('!warn') on author account", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::UserSelf, + labels: TestScenarioLabels { + account: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Blur], + content_view: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Mute/block: Blocking user", + ModerationTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Bob, + labels: TestScenarioLabels::default(), + behaviors: TestExpectedBehaviors { + 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() + }, + }, + ), ]; for (_, scenario) in scenarios { scenario.run(); diff --git a/bsky-sdk/src/moderation/types.rs b/bsky-sdk/src/moderation/types.rs index ea1f1c4..c8d3d88 100644 --- a/bsky-sdk/src/moderation/types.rs +++ b/bsky-sdk/src/moderation/types.rs @@ -1,6 +1,8 @@ use super::decision::{DecisionContext, LabelTarget, Priority}; use atrium_api::agent::bluesky::BSKY_LABELER_DID; -use atrium_api::app::bsky::actor::defs::{ProfileView, ProfileViewBasic, ProfileViewDetailed}; +use atrium_api::app::bsky::actor::defs::{ + 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; @@ -75,20 +77,27 @@ pub enum SubjectProfile { } impl SubjectProfile { - pub fn did(&self) -> &Did { + pub(crate) fn did(&self) -> &Did { match self { Self::ProfileViewBasic(p) => &p.did, Self::ProfileView(p) => &p.did, Self::ProfileViewDetailed(p) => &p.did, } } - pub fn labels(&self) -> &Option> { + 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 { @@ -133,6 +142,16 @@ pub(crate) struct ModerationBehavior { } impl ModerationBehavior { + pub 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 fn behavior_for(&self, context: DecisionContext) -> Option { match context { DecisionContext::ProfileList => self.profile_list.clone().map(Into::into), @@ -364,9 +383,7 @@ impl TryFrom for ContentMediaBehavior { #[derive(Debug, Clone)] pub(crate) enum ModerationCause { - Blocking( - //TODO - ), + Blocking(Box), BlockedBy( //TODO ), @@ -386,6 +403,13 @@ pub(crate) enum ModerationCause { } impl ModerationCause { + pub fn downgraded(&self) -> bool { + match self { + Self::Blocking(cause) => cause.downgraded.unwrap_or_default(), + Self::Label(cause) => cause.downgraded.unwrap_or_default(), + _ => todo!(), + } + } pub fn downgrade(&mut self) { match self { Self::Label(label) => label.downgraded = Some(true), @@ -407,6 +431,13 @@ pub(crate) enum ModerationCauseSource { Labeler(Did), } +#[derive(Debug, Clone)] +pub(crate) struct ModerationCauseBlocking { + pub source: ModerationCauseSource, + pub priority: Priority, + pub downgraded: Option, +} + #[derive(Debug, Clone)] pub(crate) struct ModerationCauseLabel { pub source: ModerationCauseSource, From 5d04fce2bce30493bdcf208e7e825e6e8ab5f1a0 Mon Sep 17 00:00:00 2001 From: sugyan Date: Thu, 30 May 2024 22:37:54 +0900 Subject: [PATCH 06/29] Implement muted behaviors --- bsky-sdk/src/moderation.rs | 16 +- bsky-sdk/src/moderation/decision.rs | 91 +++++++--- bsky-sdk/src/moderation/tests/behaviors.rs | 186 +++++++++++++++------ bsky-sdk/src/moderation/types.rs | 68 ++++---- 4 files changed, 249 insertions(+), 112 deletions(-) diff --git a/bsky-sdk/src/moderation.rs b/bsky-sdk/src/moderation.rs index 0e4d0f2..1bd8110 100644 --- a/bsky-sdk/src/moderation.rs +++ b/bsky-sdk/src/moderation.rs @@ -30,15 +30,23 @@ impl Moderator { acc.set_did(subject.did().clone()); acc.set_is_me(self.user_did.as_ref() == Some(subject.did())); if let Some(viewer) = subject.viewer() { - // TODO: muted? - if let Some(blocking) = &viewer.blocking { + 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(blocking); + acc.add_blocking(); } } - // TODO: blocked_by? + if viewer.blocked_by.unwrap_or_default() { + acc.add_blocked_by(); + } } if let Some(labels) = subject.labels() { for label in labels.iter().filter(|l| { diff --git a/bsky-sdk/src/moderation/decision.rs b/bsky-sdk/src/moderation/decision.rs index bec26ea..0cfd042 100644 --- a/bsky-sdk/src/moderation/decision.rs +++ b/bsky-sdk/src/moderation/decision.rs @@ -48,7 +48,9 @@ pub(crate) enum Priority { Priority1, Priority2, Priority3, + Priority4, Priority5, + Priority6, Priority7, Priority8, } @@ -71,9 +73,9 @@ impl ModerationDecision { }; for cause in &self.causes { match cause { - ModerationCause::Blocking(_) - | ModerationCause::BlockedBy() - | ModerationCause::BlockOther() => { + ModerationCause::Blocking(b) + | ModerationCause::BlockedBy(b) + | ModerationCause::BlockOther(b) => { if self.is_me { continue; } @@ -83,7 +85,7 @@ impl ModerationDecision { ) { ui.filters.push(cause.clone()) } - if !cause.downgraded() { + if !b.downgraded { match ModerationBehavior::BLOCK_BEHAVIOR.behavior_for(context) { Some(BehaviorValue::Blur) => { ui.no_override = true; @@ -111,7 +113,7 @@ impl ModerationDecision { { ui.filters.push(cause.clone()) } - if !cause.downgraded() { + if !label.downgraded { match label.behavior.behavior_for(context) { Some(BehaviorValue::Blur) => { ui.blurs.push(cause.clone()); @@ -129,13 +131,35 @@ impl ModerationDecision { } } } - ModerationCause::Muted() => { - todo!(); + 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() => { + ModerationCause::MuteWord(_) => { todo!(); } - ModerationCause::Hidden() => { + ModerationCause::Hidden(_) => { todo!(); } } @@ -171,6 +195,27 @@ impl ModerationDecision { 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(ModerationCauseOther { + source: ModerationCauseSource::User, + downgraded: false, + })) + } + pub(crate) fn add_blocking_by_list(&mut self, list_view: &ListViewBasic) { + self.causes + .push(ModerationCause::Blocking(ModerationCauseOther { + source: ModerationCauseSource::List(list_view.clone()), + downgraded: false, + })) + } + pub(crate) fn add_blocked_by(&mut self) { + self.causes + .push(ModerationCause::BlockedBy(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; @@ -244,7 +289,7 @@ impl ModerationDecision { && !moderator.prefs.adult_content_enabled); self.causes - .push(ModerationCause::Label(Box::new(ModerationCauseLabel { + .push(ModerationCause::Label(ModerationCauseLabel { source: if is_self || labeler.is_none() { ModerationCauseSource::User } else { @@ -257,20 +302,22 @@ impl ModerationDecision { behavior, no_override, priority, - downgraded: None, - }))); + downgraded: false, + })); } - pub(crate) fn add_blocking_by_list(&mut self, list_view: &ListViewBasic) { - todo!() - } - pub(crate) fn add_blocking(&mut self, blocking: &str) { - self.causes.push(ModerationCause::Blocking(Box::new( - ModerationCauseBlocking { + pub(crate) fn add_muted(&mut self) { + self.causes + .push(ModerationCause::Muted(ModerationCauseOther { source: ModerationCauseSource::User, - priority: Priority::Priority3, - downgraded: None, - }, - ))) + downgraded: false, + })) + } + pub(crate) fn add_muted_by_list(&mut self, list_view: &ListViewBasic) { + self.causes + .push(ModerationCause::Muted(ModerationCauseOther { + source: ModerationCauseSource::List(list_view.clone()), + downgraded: false, + })) } pub(crate) fn downgrade(&mut self) { for cause in self.causes.iter_mut() { diff --git a/bsky-sdk/src/moderation/tests/behaviors.rs b/bsky-sdk/src/moderation/tests/behaviors.rs index efcd8fd..89d662d 100644 --- a/bsky-sdk/src/moderation/tests/behaviors.rs +++ b/bsky-sdk/src/moderation/tests/behaviors.rs @@ -167,7 +167,7 @@ struct TestExpectedBehaviors { } #[derive(Debug)] -struct ModerationTestScenario { +struct BehaviorsTestScenario { cfg: TestConfig, subject: TestSubject, author: TestUser, @@ -175,7 +175,7 @@ struct ModerationTestScenario { behaviors: TestExpectedBehaviors, } -impl ModerationTestScenario { +impl BehaviorsTestScenario { fn run(&self) { let moderator = self.moderator(); let result = match self.subject { @@ -293,7 +293,7 @@ fn post_moderation_behaviors() { let scenarios = [ ( "Imperative label ('!hide') on account", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, @@ -315,7 +315,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!hide') on profile", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, @@ -333,7 +333,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!hide') on post", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, @@ -350,7 +350,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!hide') on author profile", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, @@ -368,7 +368,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!hide') on author account", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, @@ -388,7 +388,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!warn') on account", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, @@ -409,7 +409,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!warn') on profile", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, @@ -427,7 +427,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!warn') on post", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, @@ -444,7 +444,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!warn') on author profile", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, @@ -462,7 +462,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!warn') on author account", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, @@ -481,7 +481,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on account when logged out", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::LoggedOut, subject: TestSubject::Profile, author: TestUser::Alice, @@ -503,7 +503,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on profile when logged out", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::LoggedOut, subject: TestSubject::Profile, author: TestUser::Alice, @@ -525,7 +525,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on post when logged out", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::LoggedOut, subject: TestSubject::Post, author: TestUser::Alice, @@ -542,7 +542,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on author profile when logged out", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::LoggedOut, subject: TestSubject::Post, author: TestUser::Alice, @@ -562,7 +562,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on author account when logged out", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::LoggedOut, subject: TestSubject::Post, author: TestUser::Alice, @@ -582,7 +582,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on account when logged in", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, @@ -595,7 +595,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on profile when logged in", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, @@ -608,7 +608,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on post when logged in", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, @@ -621,7 +621,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on author profile when logged in", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, @@ -634,7 +634,7 @@ fn post_moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on author account when logged in", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, @@ -647,7 +647,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on account (hide)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornHide, subject: TestSubject::Profile, author: TestUser::Alice, @@ -666,7 +666,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on profile (hide)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornHide, subject: TestSubject::Profile, author: TestUser::Alice, @@ -683,7 +683,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on post (hide)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornHide, subject: TestSubject::Post, author: TestUser::Alice, @@ -700,7 +700,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on author profile (hide)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornHide, subject: TestSubject::Post, author: TestUser::Alice, @@ -717,7 +717,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on author account (hide)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornHide, subject: TestSubject::Post, author: TestUser::Alice, @@ -736,7 +736,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on account (warn)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornWarn, subject: TestSubject::Profile, author: TestUser::Alice, @@ -753,7 +753,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on profile (warn)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornWarn, subject: TestSubject::Profile, author: TestUser::Alice, @@ -770,7 +770,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on post (warn)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornWarn, subject: TestSubject::Post, author: TestUser::Alice, @@ -786,7 +786,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on author profile (warn)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornWarn, subject: TestSubject::Post, author: TestUser::Alice, @@ -803,7 +803,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on author account (warn)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornWarn, subject: TestSubject::Post, author: TestUser::Alice, @@ -820,7 +820,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on account (ignore)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornIgnore, subject: TestSubject::Profile, author: TestUser::Alice, @@ -833,7 +833,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on profile (ignore)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornIgnore, subject: TestSubject::Profile, author: TestUser::Alice, @@ -846,7 +846,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on post (ignore)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornIgnore, subject: TestSubject::Post, author: TestUser::Alice, @@ -859,7 +859,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on author profile (ignore)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornIgnore, subject: TestSubject::Post, author: TestUser::Alice, @@ -872,7 +872,7 @@ fn post_moderation_behaviors() { ), ( "Blur-media label ('porn') on author account (ignore)", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::PornIgnore, subject: TestSubject::Post, author: TestUser::Alice, @@ -885,7 +885,7 @@ fn post_moderation_behaviors() { ), ( "Adult-only label on account when adult content is disabled", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::AdultDisabled, subject: TestSubject::Profile, author: TestUser::Alice, @@ -904,7 +904,7 @@ fn post_moderation_behaviors() { ), ( "Adult-only label on profile when adult content is disabled", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::AdultDisabled, subject: TestSubject::Profile, author: TestUser::Alice, @@ -921,7 +921,7 @@ fn post_moderation_behaviors() { ), ( "Adult-only label on post when adult content is disabled", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::AdultDisabled, subject: TestSubject::Post, author: TestUser::Alice, @@ -938,7 +938,7 @@ fn post_moderation_behaviors() { ), ( "Adult-only label on author profile when adult content is disabled", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::AdultDisabled, subject: TestSubject::Post, author: TestUser::Alice, @@ -955,7 +955,7 @@ fn post_moderation_behaviors() { ), ( "Adult-only label on author account when adult content is disabled", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::AdultDisabled, subject: TestSubject::Post, author: TestUser::Alice, @@ -973,7 +973,7 @@ fn post_moderation_behaviors() { ), ( "Self-profile: !hide on account", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::UserSelf, @@ -995,7 +995,7 @@ fn post_moderation_behaviors() { ), ( "Self-profile: !hide on profile", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::UserSelf, @@ -1013,7 +1013,7 @@ fn post_moderation_behaviors() { ), ( "Self-post: Imperative label ('!hide') on post", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::UserSelf, @@ -1030,7 +1030,7 @@ fn post_moderation_behaviors() { ), ( "Self-post: Imperative label ('!hide') on author profile", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::UserSelf, @@ -1048,7 +1048,7 @@ fn post_moderation_behaviors() { ), ( "Self-post: Imperative label ('!hide') on author account", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::UserSelf, @@ -1068,7 +1068,7 @@ fn post_moderation_behaviors() { ), ( "Self-post: Imperative label ('!warn') on post", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::UserSelf, @@ -1085,7 +1085,7 @@ fn post_moderation_behaviors() { ), ( "Self-post: Imperative label ('!warn') on author profile", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::UserSelf, @@ -1103,7 +1103,7 @@ fn post_moderation_behaviors() { ), ( "Self-post: Imperative label ('!warn') on author account", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::UserSelf, @@ -1122,7 +1122,7 @@ fn post_moderation_behaviors() { ), ( "Mute/block: Blocking user", - ModerationTestScenario { + BehaviorsTestScenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Bob, @@ -1138,6 +1138,90 @@ fn post_moderation_behaviors() { }, }, ), + ( + "Post with blocked author", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Bob, + labels: TestScenarioLabels::default(), + behaviors: TestExpectedBehaviors { + 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", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Carla, + labels: TestScenarioLabels::default(), + behaviors: TestExpectedBehaviors { + 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", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Georgia, + labels: TestScenarioLabels::default(), + behaviors: TestExpectedBehaviors { + 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", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Carla, + labels: TestScenarioLabels::default(), + behaviors: TestExpectedBehaviors { + 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", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Dan, + labels: TestScenarioLabels::default(), + behaviors: TestExpectedBehaviors { + profile_list: vec![Filter, Inform], + profile_view: vec![Alert], + content_list: vec![Filter, Blur], + content_view: vec![Inform], + ..Default::default() + }, + }, + ), ]; for (_, scenario) in scenarios { scenario.run(); diff --git a/bsky-sdk/src/moderation/types.rs b/bsky-sdk/src/moderation/types.rs index c8d3d88..441303c 100644 --- a/bsky-sdk/src/moderation/types.rs +++ b/bsky-sdk/src/moderation/types.rs @@ -152,6 +152,16 @@ impl ModerationBehavior { content_view: Some(ContentViewBehavior::Blur), content_media: None, }; + pub 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 fn behavior_for(&self, context: DecisionContext) -> Option { match context { DecisionContext::ProfileList => self.profile_list.clone().map(Into::into), @@ -383,42 +393,31 @@ impl TryFrom for ContentMediaBehavior { #[derive(Debug, Clone)] pub(crate) enum ModerationCause { - Blocking(Box), - BlockedBy( - //TODO - ), - BlockOther( - //TODO - ), - Label(Box), - Muted( - //TODO - ), - MuteWord( - //TODO - ), - Hidden( - //TODO - ), + Blocking(ModerationCauseOther), + BlockedBy(ModerationCauseOther), + BlockOther(ModerationCauseOther), + Label(ModerationCauseLabel), + Muted(ModerationCauseOther), + MuteWord(ModerationCauseOther), + Hidden(ModerationCauseOther), } impl ModerationCause { - pub fn downgraded(&self) -> bool { + pub fn priority(&self) -> Priority { match self { - Self::Blocking(cause) => cause.downgraded.unwrap_or_default(), - Self::Label(cause) => cause.downgraded.unwrap_or_default(), + Self::Blocking(_) => Priority::Priority3, + Self::BlockedBy(_) => Priority::Priority4, + Self::Label(label) => label.priority, + Self::Muted(_) => Priority::Priority6, _ => todo!(), } } pub fn downgrade(&mut self) { match self { - Self::Label(label) => label.downgraded = Some(true), - _ => todo!(), - } - } - pub fn priority(&self) -> Priority { - match self { - Self::Label(label) => label.priority, + 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, _ => todo!(), } } @@ -431,13 +430,6 @@ pub(crate) enum ModerationCauseSource { Labeler(Did), } -#[derive(Debug, Clone)] -pub(crate) struct ModerationCauseBlocking { - pub source: ModerationCauseSource, - pub priority: Priority, - pub downgraded: Option, -} - #[derive(Debug, Clone)] pub(crate) struct ModerationCauseLabel { pub source: ModerationCauseSource, @@ -448,7 +440,13 @@ pub(crate) struct ModerationCauseLabel { pub behavior: ModerationBehavior, pub no_override: bool, pub priority: Priority, - pub downgraded: Option, + pub downgraded: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct ModerationCauseOther { + pub source: ModerationCauseSource, + pub downgraded: bool, } // moderation preferences From 0391484f0758d0ddb1cbd954fa93b32eb8ec88aa Mon Sep 17 00:00:00 2001 From: sugyan Date: Thu, 30 May 2024 23:06:05 +0900 Subject: [PATCH 07/29] Complete behaviors tests --- bsky-sdk/src/moderation/tests/behaviors.rs | 246 ++++++++++++++++++++- 1 file changed, 244 insertions(+), 2 deletions(-) diff --git a/bsky-sdk/src/moderation/tests/behaviors.rs b/bsky-sdk/src/moderation/tests/behaviors.rs index 89d662d..41022ad 100644 --- a/bsky-sdk/src/moderation/tests/behaviors.rs +++ b/bsky-sdk/src/moderation/tests/behaviors.rs @@ -150,8 +150,6 @@ struct TestScenarioLabels { post: Vec, profile: Vec, account: Vec, - quoted_post: Vec, - quoted_account: Vec, } #[derive(Debug, Default)] @@ -1222,6 +1220,250 @@ fn post_moderation_behaviors() { }, }, ), + ( + "Mute/block: Muted-by-list user", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Elise, + labels: TestScenarioLabels::default(), + behaviors: TestExpectedBehaviors { + 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", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Fern, + labels: TestScenarioLabels::default(), + behaviors: TestExpectedBehaviors { + 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", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Dan, + labels: TestScenarioLabels::default(), + behaviors: TestExpectedBehaviors { + content_list: vec![Filter, Blur], + content_view: vec![Inform], + ..Default::default() + }, + }, + ), + ( + "Post with muted-by-list author", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Elise, + labels: TestScenarioLabels::default(), + behaviors: TestExpectedBehaviors { + content_list: vec![Filter, Blur], + content_view: vec![Inform], + ..Default::default() + }, + }, + ), + ( + "Merging: '!hide' label on account of blocked user", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Bob, + labels: TestScenarioLabels { + account: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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)", + BehaviorsTestScenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("!hide"), String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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)", + BehaviorsTestScenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("!warn"), String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("!hide")], + profile: vec![String::from("!warn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Profile, + author: TestUser::Alice, + labels: TestScenarioLabels { + account: vec![String::from("!warn")], + profile: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Fern, + labels: TestScenarioLabels::default(), + behaviors: TestExpectedBehaviors { + 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", + BehaviorsTestScenario { + cfg: TestConfig::None, + subject: TestSubject::Post, + author: TestUser::Bob, + labels: TestScenarioLabels { + post: vec![String::from("!hide")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + 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)", + BehaviorsTestScenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + post: vec![String::from("!warn"), String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + content_list: vec![Filter, Blur], + content_view: vec![Blur], + content_media: vec![Blur], + ..Default::default() + }, + }, + ), + ( + "Merging: '!warn' and 'porn' labels on post (hide)", + BehaviorsTestScenario { + cfg: TestConfig::PornHide, + subject: TestSubject::Post, + author: TestUser::Alice, + labels: TestScenarioLabels { + post: vec![String::from("!warn"), String::from("porn")], + ..Default::default() + }, + behaviors: TestExpectedBehaviors { + content_list: vec![Filter, Blur], + content_view: vec![Blur], + content_media: vec![Blur], + ..Default::default() + }, + }, + ), ]; for (_, scenario) in scenarios { scenario.run(); From c71180ca1a917f3908e619b4cb1db92ed6637c40 Mon Sep 17 00:00:00 2001 From: sugyan Date: Sat, 1 Jun 2024 00:08:24 +0900 Subject: [PATCH 08/29] Add Hash to api types --- atrium-api/src/types/integer.rs | 6 +++--- atrium-api/src/types/string.rs | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/atrium-api/src/types/integer.rs b/atrium-api/src/types/integer.rs index 78fd85c..cdebe31 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 b275906..beabfef 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); From b9dd60576094943947f7117e9239b31af228cea8 Mon Sep 17 00:00:00 2001 From: sugyan Date: Sat, 1 Jun 2024 00:10:56 +0900 Subject: [PATCH 09/29] WIP: Add BskyAgent.moderator() --- bsky-sdk/src/agent.rs | 42 ++- bsky-sdk/src/error.rs | 2 + bsky-sdk/src/moderation.rs | 20 +- bsky-sdk/src/moderation/decision.rs | 38 +-- bsky-sdk/src/moderation/labels.rs | 28 +- bsky-sdk/src/moderation/tests.rs | 155 +++++---- bsky-sdk/src/moderation/tests/behaviors.rs | 13 +- bsky-sdk/src/moderation/types.rs | 375 +++++++++++++-------- bsky-sdk/src/moderation/util.rs | 55 +++ 9 files changed, 484 insertions(+), 244 deletions(-) create mode 100644 bsky-sdk/src/moderation/util.rs diff --git a/bsky-sdk/src/agent.rs b/bsky-sdk/src/agent.rs index c3b5946..f6da21b 100644 --- a/bsky-sdk/src/agent.rs +++ b/bsky-sdk/src/agent.rs @@ -2,7 +2,8 @@ pub mod config; use self::config::Config; use crate::error::Result; -use crate::moderation::ModerationPrefsLabeler; +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}; @@ -63,6 +64,12 @@ where 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( @@ -115,6 +122,39 @@ where .collect(), )); } + 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 diff --git a/bsky-sdk/src/error.rs b/bsky-sdk/src/error.rs index 450e6cc..49077e5 100644 --- a/bsky-sdk/src/error.rs +++ b/bsky-sdk/src/error.rs @@ -13,6 +13,8 @@ pub enum Error { ConfigSave(Box), #[error(transparent)] IpldSerde(#[from] ipld_core::serde::SerdeError), + #[error(transparent)] + Moderation(#[from] crate::moderation::Error), } #[derive(Error, Debug)] diff --git a/bsky-sdk/src/moderation.rs b/bsky-sdk/src/moderation.rs index 1bd8110..dfa1391 100644 --- a/bsky-sdk/src/moderation.rs +++ b/bsky-sdk/src/moderation.rs @@ -2,20 +2,34 @@ pub mod decision; mod labels; mod types; pub mod ui; +pub mod util; -use self::decision::{LabelTarget, ModerationDecision}; +use self::decision::ModerationDecision; pub use self::types::*; use atrium_api::types::{string::Did, Union}; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; -#[derive(Debug)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Moderator { user_did: Option, prefs: ModerationPrefs, - label_defs: Option>>, + label_defs: HashMap>, } impl Moderator { + pub fn new( + user_did: Option, + prefs: ModerationPrefs, + label_defs: HashMap>, + ) -> Self { + Self { + user_did, + prefs, + label_defs, + } + } pub fn moderate_profile(&self, profile: &SubjectProfile) -> ModerationDecision { ModerationDecision::merge(&[ self.account_decision(profile), diff --git a/bsky-sdk/src/moderation/decision.rs b/bsky-sdk/src/moderation/decision.rs index 0cfd042..d88018e 100644 --- a/bsky-sdk/src/moderation/decision.rs +++ b/bsky-sdk/src/moderation/decision.rs @@ -29,13 +29,6 @@ impl DecisionContext { ]; } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum LabelTarget { - Account, - Profile, - Content, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ModerationBehaviorSeverity { High, @@ -197,24 +190,24 @@ impl ModerationDecision { } pub(crate) fn add_blocking(&mut self) { self.causes - .push(ModerationCause::Blocking(ModerationCauseOther { + .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(ModerationCauseOther { - source: ModerationCauseSource::List(list_view.clone()), + .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(ModerationCauseOther { + .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 { @@ -289,7 +282,7 @@ impl ModerationDecision { && !moderator.prefs.adult_content_enabled); self.causes - .push(ModerationCause::Label(ModerationCauseLabel { + .push(ModerationCause::Label(Box::new(ModerationCauseLabel { source: if is_self || labeler.is_none() { ModerationCauseSource::User } else { @@ -303,21 +296,21 @@ impl ModerationDecision { no_override, priority, downgraded: false, - })); + }))); } pub(crate) fn add_muted(&mut self) { self.causes - .push(ModerationCause::Muted(ModerationCauseOther { + .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(ModerationCauseOther { - source: ModerationCauseSource::List(list_view.clone()), + .push(ModerationCause::Muted(Box::new(ModerationCauseOther { + source: ModerationCauseSource::List(Box::new(list_view.clone())), downgraded: false, - })) + }))); } pub(crate) fn downgrade(&mut self) { for cause in self.causes.iter_mut() { @@ -335,8 +328,7 @@ impl ModerationDecision { { if let Some(def) = moderator .label_defs - .as_ref() - .and_then(|label_defs| label_defs.get(label.src.as_ref())) + .get(&label.src) .and_then(|defs| defs.iter().find(|def| def.identifier == label.val)) { return Some(def.clone()); diff --git a/bsky-sdk/src/moderation/labels.rs b/bsky-sdk/src/moderation/labels.rs index 44f15df..1070d6d 100644 --- a/bsky-sdk/src/moderation/labels.rs +++ b/bsky-sdk/src/moderation/labels.rs @@ -33,8 +33,13 @@ impl KnownLabelValue { pub fn definition(&self) -> InterpretedLabelValueDefinition { match self { Self::ReservedHide => InterpretedLabelValueDefinition { - identifier: String::from("!hide"), + adult_only: false, + blurs: LabelValueDefinitionBlurs::Content, default_setting: LabelPreference::Hide, + identifier: String::from("!hide"), + locales: Vec::new(), + severity: LabelValueDefinitionSeverity::Alert, + defined_by: None, flags: vec![ LabelValueDefinitionFlag::NoOverride, LabelValueDefinitionFlag::NoSelf, @@ -64,8 +69,13 @@ impl KnownLabelValue { }, }, Self::ReservedWarn => InterpretedLabelValueDefinition { - identifier: String::from("!warn"), + adult_only: false, + blurs: LabelValueDefinitionBlurs::Content, default_setting: LabelPreference::Warn, + identifier: String::from("!warn"), + locales: Vec::new(), + severity: LabelValueDefinitionSeverity::None, + defined_by: None, flags: vec![LabelValueDefinitionFlag::NoSelf], behaviors: InterpretedLabelValueDefinitionBehaviors { account: ModerationBehavior { @@ -91,8 +101,13 @@ impl KnownLabelValue { }, }, Self::ReservedNoUnauthenticated => InterpretedLabelValueDefinition { - identifier: String::from("!no-unauthenticated"), + adult_only: false, + blurs: LabelValueDefinitionBlurs::Content, default_setting: LabelPreference::Hide, + identifier: String::from("!no-unauthenticated"), + locales: Vec::new(), + severity: LabelValueDefinitionSeverity::None, + defined_by: None, flags: vec![ LabelValueDefinitionFlag::NoOverride, LabelValueDefinitionFlag::Unauthed, @@ -122,8 +137,13 @@ impl KnownLabelValue { }, }, Self::Porn => InterpretedLabelValueDefinition { - identifier: String::from("porn"), + adult_only: false, + blurs: LabelValueDefinitionBlurs::Media, default_setting: LabelPreference::Hide, + identifier: String::from("porn"), + locales: Vec::new(), + severity: LabelValueDefinitionSeverity::None, + defined_by: None, flags: vec![LabelValueDefinitionFlag::Adult], behaviors: InterpretedLabelValueDefinitionBehaviors { account: ModerationBehavior { diff --git a/bsky-sdk/src/moderation/tests.rs b/bsky-sdk/src/moderation/tests.rs index 4d213ea..c1ca262 100644 --- a/bsky-sdk/src/moderation/tests.rs +++ b/bsky-sdk/src/moderation/tests.rs @@ -146,8 +146,23 @@ fn interpret_label_value_definition( flags.push(LabelValueDefinitionFlag::Adult); } InterpretedLabelValueDefinition { - identifier: identifier.into(), + adult_only: false, + blurs: match blurs { + "content" => LabelValueDefinitionBlurs::Content, + "media" => LabelValueDefinitionBlurs::Media, + "none" => LabelValueDefinitionBlurs::None, + _ => unreachable!(), + }, default_setting, + identifier: identifier.into(), + locales: Vec::new(), + severity: match severity { + "alert" => LabelValueDefinitionSeverity::Alert, + "inform" => LabelValueDefinitionSeverity::Inform, + "none" => LabelValueDefinitionSeverity::None, + _ => unreachable!(), + }, + defined_by: None, flags, behaviors, } @@ -224,15 +239,15 @@ fn self_label_global() { )); // porn (hide) { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { + 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() }, - label_defs: None, - }; + HashMap::new(), + ); let result = moderator.moderate_profile(&profile); assert_ui( &result, @@ -242,15 +257,15 @@ fn self_label_global() { } // porn (ignore) { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { + 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() }, - label_defs: None, - }; + HashMap::new(), + ); let result = moderator.moderate_profile(&profile); assert_ui(&result, &[], DecisionContext::Avatar) } @@ -269,15 +284,15 @@ fn unsubscribed_or_ignore_labels() { )); // porn (moderator disabled) { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { + 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() }, - label_defs: None, - }; + HashMap::new(), + ); let result = moderator.moderate_profile(&profile); for context in DecisionContext::ALL { assert_ui(&result, &[], context); @@ -285,9 +300,9 @@ fn unsubscribed_or_ignore_labels() { } // porn (label group disabled) { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { + 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 { @@ -295,9 +310,10 @@ fn unsubscribed_or_ignore_labels() { labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Ignore)]), is_default_labeler: false, }], + ..Default::default() }, - label_defs: None, - }; + HashMap::new(), + ); let result = moderator.moderate_profile(&profile); for context in DecisionContext::ALL { assert_ui(&result, &[], context); @@ -307,9 +323,9 @@ fn unsubscribed_or_ignore_labels() { #[test] fn prioritize_filters_and_blurs() { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { + 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 { @@ -317,9 +333,10 @@ fn prioritize_filters_and_blurs() { labels: HashMap::new(), is_default_labeler: false, }], + ..Default::default() }, - label_defs: None, - }; + HashMap::new(), + ); let result = moderator.moderate_post(&post_view( &profile_view_basic("bob.test", Some("Bob"), None), "Hello", @@ -352,9 +369,9 @@ fn prioritize_filters_and_blurs() { #[test] fn prioritize_custom_labels() { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { + 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 { @@ -362,9 +379,10 @@ fn prioritize_custom_labels() { labels: HashMap::from_iter([(String::from("porn"), LabelPreference::Warn)]), is_default_labeler: false, }], + ..Default::default() }, - label_defs: Some(HashMap::from_iter([( - String::from("did:web:labeler.test"), + HashMap::from_iter([( + "did:web:labeler.test".parse().expect("invalid did"), vec![interpret_label_value_definition( "porn", LabelPreference::Warn, @@ -372,8 +390,8 @@ fn prioritize_custom_labels() { "none", false, )], - )])), - }; + )]), + ); let result = moderator.moderate_post(&post_view( &profile_view_basic("bob.test", Some("Bob"), None), "Hello", @@ -395,9 +413,9 @@ fn prioritize_custom_labels() { #[test] fn does_not_override_imperative_labels() { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { adult_content_enabled: true, labels: HashMap::new(), labelers: vec![ModerationPrefsLabeler { @@ -405,9 +423,10 @@ fn does_not_override_imperative_labels() { labels: HashMap::new(), is_default_labeler: false, }], + ..Default::default() }, - label_defs: Some(HashMap::from_iter([( - String::from("did:web:labeler.test"), + HashMap::from_iter([( + "did:web:labeler.test".parse().expect("invalid did"), vec![interpret_label_value_definition( "!hide", LabelPreference::Warn, @@ -415,8 +434,8 @@ fn does_not_override_imperative_labels() { "none", false, )], - )])), - }; + )]), + ); let result = moderator.moderate_post(&post_view( &profile_view_basic("bob.test", Some("Bob"), None), "Hello", @@ -445,9 +464,9 @@ fn does_not_override_imperative_labels() { #[test] fn ignore_invalid_label_value_names() { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { adult_content_enabled: true, labels: HashMap::new(), labelers: vec![ModerationPrefsLabeler { @@ -458,9 +477,10 @@ fn ignore_invalid_label_value_names() { ]), is_default_labeler: false, }], + ..Default::default() }, - label_defs: Some(HashMap::from_iter([( - String::from("did:web:labeler.test"), + HashMap::from_iter([( + "did:web:labeler.test".parse().expect("invalid did"), vec![ interpret_label_value_definition( "BadLabel", @@ -477,8 +497,8 @@ fn ignore_invalid_label_value_names() { false, ), ], - )])), - }; + )]), + ); let result = moderator.moderate_post(&post_view( &profile_view_basic("bob.test", Some("Bob"), None), "Hello", @@ -502,9 +522,9 @@ fn ignore_invalid_label_value_names() { #[test] fn custom_labels_with_default_settings() { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { + let moderator = Moderator::new( + Some("did:web:alice.test".parse().expect("invalid did")), + ModerationPrefs { adult_content_enabled: true, labels: HashMap::new(), labelers: vec![ModerationPrefsLabeler { @@ -512,9 +532,10 @@ fn custom_labels_with_default_settings() { labels: HashMap::new(), is_default_labeler: false, }], + ..Default::default() }, - label_defs: Some(HashMap::from_iter([( - String::from("did:web:labeler.test"), + HashMap::from_iter([( + "did:web:labeler.test".parse().expect("invalid did"), vec![ interpret_label_value_definition( "default-hide", @@ -538,8 +559,8 @@ fn custom_labels_with_default_settings() { false, ), ], - )])), - }; + )]), + ); let author = profile_view_basic("bob.test", Some("Bob"), None); { let result = moderator.moderate_post(&post_view( @@ -600,9 +621,9 @@ fn custom_labels_with_default_settings() { #[test] fn custom_labels_require_adult_content_enabled() { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { + 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 { @@ -610,9 +631,10 @@ fn custom_labels_require_adult_content_enabled() { labels: HashMap::from_iter([(String::from("adult"), LabelPreference::Ignore)]), is_default_labeler: false, }], + ..Default::default() }, - label_defs: Some(HashMap::from_iter([( - String::from("did:web:labeler.test"), + HashMap::from_iter([( + "did:web:labeler.test".parse().expect("invalid did"), vec![interpret_label_value_definition( "adult", LabelPreference::Hide, @@ -620,8 +642,8 @@ fn custom_labels_require_adult_content_enabled() { "content", true, )], - )])), - }; + )]), + ); let result = moderator.moderate_post(&post_view( &profile_view_basic("bob.test", Some("Bob"), None), "Hello", @@ -650,9 +672,9 @@ fn custom_labels_require_adult_content_enabled() { #[test] fn adult_content_disabled_forces_hide() { - let moderator = Moderator { - user_did: Some("did:web:alice.test".parse().expect("invalid did")), - prefs: ModerationPrefs { + 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 { @@ -660,9 +682,10 @@ fn adult_content_disabled_forces_hide() { labels: HashMap::new(), is_default_labeler: false, }], + ..Default::default() }, - label_defs: None, - }; + HashMap::new(), + ); let result = moderator.moderate_post(&post_view( &profile_view_basic("bob.test", Some("Bob"), None), "Hello", diff --git a/bsky-sdk/src/moderation/tests/behaviors.rs b/bsky-sdk/src/moderation/tests/behaviors.rs index 41022ad..853fa3b 100644 --- a/bsky-sdk/src/moderation/tests/behaviors.rs +++ b/bsky-sdk/src/moderation/tests/behaviors.rs @@ -216,12 +216,12 @@ impl BehaviorsTestScenario { ); } fn moderator(&self) -> Moderator { - Moderator { - user_did: match self.cfg { + Moderator::new( + match self.cfg { TestConfig::LoggedOut => None, _ => Some("did:web:self.test".parse().expect("invalid did")), }, - prefs: ModerationPrefs { + ModerationPrefs { adult_content_enabled: matches!( self.cfg, TestConfig::PornHide | TestConfig::PornWarn | TestConfig::PornIgnore @@ -232,9 +232,10 @@ impl BehaviorsTestScenario { labels: HashMap::new(), is_default_labeler: false, }], + ..Default::default() }, - label_defs: None, - } + HashMap::new(), + ) } fn profile(&self) -> ProfileViewBasic { let mut labels = Vec::new(); @@ -286,7 +287,7 @@ impl BehaviorsTestScenario { } #[test] -fn post_moderation_behaviors() { +fn moderation_behaviors() { use ModerationTestResultFlag::*; let scenarios = [ ( diff --git a/bsky-sdk/src/moderation/types.rs b/bsky-sdk/src/moderation/types.rs index 441303c..36a34e9 100644 --- a/bsky-sdk/src/moderation/types.rs +++ b/bsky-sdk/src/moderation/types.rs @@ -1,125 +1,28 @@ -use super::decision::{DecisionContext, LabelTarget, Priority}; +use super::decision::{DecisionContext, Priority}; use atrium_api::agent::bluesky::BSKY_LABELER_DID; use atrium_api::app::bsky::actor::defs::{ - ProfileView, ProfileViewBasic, ProfileViewDetailed, ViewerState, + 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; +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}; +use thiserror::Error; -// labels - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum LabelValueDefinitionFlag { - NoOverride, - Adult, - Unauthed, - NoSelf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum LabelPreference { - Ignore, - Warn, - Hide, -} - -impl FromStr for LabelPreference { - type Err = (); +// errors - fn from_str(s: &str) -> Result { - match s { - "ignore" => Ok(Self::Ignore), - "warn" => Ok(Self::Warn), - "hide" => Ok(Self::Hide), - _ => Err(()), - } - } +#[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, } -#[derive(Debug, Clone)] -pub(crate) struct InterpretedLabelValueDefinition { - pub identifier: String, - pub default_setting: LabelPreference, - pub flags: Vec, - pub behaviors: InterpretedLabelValueDefinitionBehaviors, - // TODO -} - -#[derive(Debug, Default, Clone)] -pub(crate) struct InterpretedLabelValueDefinitionBehaviors { - pub account: ModerationBehavior, - pub profile: ModerationBehavior, - pub content: ModerationBehavior, -} - -impl InterpretedLabelValueDefinitionBehaviors { - pub 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 - -#[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) - } -} - -pub type SubjectPost = PostView; - // behaviors #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -129,20 +32,29 @@ pub(crate) enum BehaviorValue { Inform, } -#[derive(Debug, Default, Clone)] -pub(crate) struct ModerationBehavior { +#[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 const BLOCK_BEHAVIOR: Self = Self { + pub(crate) const BLOCK_BEHAVIOR: Self = Self { profile_list: Some(ProfileListBehavior::Blur), profile_view: Some(ProfileViewBehavior::Alert), avatar: Some(AvatarBehavior::Blur), @@ -152,7 +64,7 @@ impl ModerationBehavior { content_view: Some(ContentViewBehavior::Blur), content_media: None, }; - pub const MUTE_BEHAVIOR: Self = Self { + pub(crate) const MUTE_BEHAVIOR: Self = Self { profile_list: Some(ProfileListBehavior::Inform), profile_view: Some(ProfileViewBehavior::Alert), avatar: None, @@ -162,7 +74,7 @@ impl ModerationBehavior { content_view: Some(ContentViewBehavior::Inform), content_media: None, }; - pub fn behavior_for(&self, context: DecisionContext) -> Option { + 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), @@ -176,8 +88,9 @@ impl ModerationBehavior { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ProfileListBehavior { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProfileListBehavior { Blur, Alert, Inform, @@ -205,8 +118,9 @@ impl TryFrom for ProfileListBehavior { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ProfileViewBehavior { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProfileViewBehavior { Blur, Alert, Inform, @@ -234,8 +148,9 @@ impl TryFrom for ProfileViewBehavior { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum AvatarBehavior { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AvatarBehavior { Blur, Alert, } @@ -261,8 +176,9 @@ impl TryFrom for AvatarBehavior { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum BannerBehavior { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BannerBehavior { Blur, } @@ -285,8 +201,9 @@ impl TryFrom for BannerBehavior { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum DisplayNameBehavior { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DisplayNameBehavior { Blur, } @@ -309,8 +226,9 @@ impl TryFrom for DisplayNameBehavior { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ContentListBehavior { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ContentListBehavior { Blur, Alert, Inform, @@ -338,8 +256,9 @@ impl TryFrom for ContentListBehavior { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ContentViewBehavior { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ContentViewBehavior { Blur, Alert, Inform, @@ -367,8 +286,9 @@ impl TryFrom for ContentViewBehavior { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ContentMediaBehavior { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ContentMediaBehavior { Blur, } @@ -391,15 +311,183 @@ impl TryFrom for ContentMediaBehavior { } } +// labels + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LabelTarget { + Account, + Profile, + Content, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum LabelPreference { + Ignore, + Warn, + 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), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum LabelValueDefinitionFlag { + NoOverride, + Adult, + Unauthed, + NoSelf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LabelValueDefinitionBlurs { + Content, + Media, + 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), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LabelValueDefinitionSeverity { + Inform, + Alert, + 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), + } + } +} + +#[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 flags: Vec, + pub behaviors: InterpretedLabelValueDefinitionBehaviors, +} + +#[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 + +#[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) + } +} + +pub type SubjectPost = PostView; + #[derive(Debug, Clone)] pub(crate) enum ModerationCause { - Blocking(ModerationCauseOther), - BlockedBy(ModerationCauseOther), - BlockOther(ModerationCauseOther), - Label(ModerationCauseLabel), - Muted(ModerationCauseOther), - MuteWord(ModerationCauseOther), - Hidden(ModerationCauseOther), + Blocking(Box), + BlockedBy(Box), + BlockOther(Box), + Label(Box), + Muted(Box), + MuteWord(Box), + Hidden(Box), } impl ModerationCause { @@ -426,7 +514,7 @@ impl ModerationCause { #[derive(Debug, Clone)] pub(crate) enum ModerationCauseSource { User, - List(ListViewBasic), + List(Box), Labeler(Did), } @@ -451,7 +539,7 @@ pub(crate) struct ModerationCauseOther { // moderation preferences -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModerationPrefsLabeler { pub did: Did, pub labels: HashMap, @@ -469,11 +557,14 @@ impl Default for ModerationPrefsLabeler { } } -#[derive(Debug, Serialize, Deserialize)] +#[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 { @@ -487,6 +578,8 @@ impl Default for ModerationPrefs { (String::from("graphic-media"), LabelPreference::Warn), ]), labelers: Vec::default(), + muted_words: Vec::default(), + hidden_posts: Vec::default(), } } } diff --git a/bsky-sdk/src/moderation/util.rs b/bsky-sdk/src/moderation/util.rs new file mode 100644 index 0000000..7f31d71 --- /dev/null +++ b/bsky-sdk/src/moderation/util.rs @@ -0,0 +1,55 @@ +use super::types::{ + InterpretedLabelValueDefinition, InterpretedLabelValueDefinitionBehaviors, LabelPreference, +}; +use super::LabelValueDefinitionFlag; +use crate::Result; +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() +} + +pub fn interpret_label_value_definition( + def: &LabelValueDefinition, + defined_by: Option, +) -> Result { + let adult_only = def.adult_only.unwrap_or_default(); + let severity = def.severity.parse()?; + let default_setting = + if let Some(pref) = def.default_setting.as_ref().and_then(|s| s.parse().ok()) { + pref + } else { + LabelPreference::Warn + }; + let mut flags = vec![LabelValueDefinitionFlag::NoSelf]; + if adult_only { + flags.push(LabelValueDefinitionFlag::Adult); + } + let mut behaviors = InterpretedLabelValueDefinitionBehaviors::default(); + // TODO + Ok(InterpretedLabelValueDefinition { + adult_only, + blurs: def.blurs.parse()?, + default_setting, + identifier: def.identifier.clone(), + locales: def.locales.clone(), + severity, + defined_by, + flags, + behaviors, + }) +} From ad36782340e4d3ba86894ca1fdd6e8d233f9d346 Mon Sep 17 00:00:00 2001 From: sugyan Date: Sun, 2 Jun 2024 23:33:09 +0900 Subject: [PATCH 10/29] Add interpret_label_value_definition --- bsky-sdk/src/agent.rs | 3 + bsky-sdk/src/moderation/labels.rs | 4 + bsky-sdk/src/moderation/tests.rs | 227 ++++++++++++------------------ bsky-sdk/src/moderation/types.rs | 27 ++-- bsky-sdk/src/moderation/util.rs | 67 ++++++++- 5 files changed, 172 insertions(+), 156 deletions(-) diff --git a/bsky-sdk/src/agent.rs b/bsky-sdk/src/agent.rs index f6da21b..61df697 100644 --- a/bsky-sdk/src/agent.rs +++ b/bsky-sdk/src/agent.rs @@ -61,6 +61,9 @@ where .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); } diff --git a/bsky-sdk/src/moderation/labels.rs b/bsky-sdk/src/moderation/labels.rs index 1070d6d..c5bdd00 100644 --- a/bsky-sdk/src/moderation/labels.rs +++ b/bsky-sdk/src/moderation/labels.rs @@ -40,6 +40,7 @@ impl KnownLabelValue { locales: Vec::new(), severity: LabelValueDefinitionSeverity::Alert, defined_by: None, + configurable: false, flags: vec![ LabelValueDefinitionFlag::NoOverride, LabelValueDefinitionFlag::NoSelf, @@ -76,6 +77,7 @@ impl KnownLabelValue { locales: Vec::new(), severity: LabelValueDefinitionSeverity::None, defined_by: None, + configurable: false, flags: vec![LabelValueDefinitionFlag::NoSelf], behaviors: InterpretedLabelValueDefinitionBehaviors { account: ModerationBehavior { @@ -108,6 +110,7 @@ impl KnownLabelValue { locales: Vec::new(), severity: LabelValueDefinitionSeverity::None, defined_by: None, + configurable: false, flags: vec![ LabelValueDefinitionFlag::NoOverride, LabelValueDefinitionFlag::Unauthed, @@ -144,6 +147,7 @@ impl KnownLabelValue { locales: Vec::new(), severity: LabelValueDefinitionSeverity::None, defined_by: None, + configurable: true, flags: vec![LabelValueDefinitionFlag::Adult], behaviors: InterpretedLabelValueDefinitionBehaviors { account: ModerationBehavior { diff --git a/bsky-sdk/src/moderation/tests.rs b/bsky-sdk/src/moderation/tests.rs index c1ca262..3140db7 100644 --- a/bsky-sdk/src/moderation/tests.rs +++ b/bsky-sdk/src/moderation/tests.rs @@ -2,10 +2,11 @@ mod behaviors; 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; +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; @@ -80,94 +81,6 @@ fn label(src: &str, uri: &str, val: &str) -> Label { } } -fn interpret_label_value_definition( - identifier: &str, - default_setting: LabelPreference, - severity: &str, - blurs: &str, - adult_only: bool, -) -> InterpretedLabelValueDefinition { - let alert_or_inform = match severity { - "alert" => BehaviorValue::Alert, - "inform" => BehaviorValue::Inform, - _ => unreachable!(), - }; - let mut behaviors = InterpretedLabelValueDefinitionBehaviors::default(); - match blurs { - "content" => { - // target=account, blurs=content - behaviors.account.profile_list = Some(alert_or_inform.try_into().unwrap()); - behaviors.account.profile_view = Some(alert_or_inform.try_into().unwrap()); - behaviors.account.content_list = Some(BehaviorValue::Blur.try_into().unwrap()); - behaviors.account.content_view = Some( - if adult_only { - BehaviorValue::Blur - } else { - alert_or_inform - } - .try_into() - .unwrap(), - ); - // target=profile, blurs=content - behaviors.profile.profile_list = Some(alert_or_inform.try_into().unwrap()); - behaviors.profile.profile_view = Some(alert_or_inform.try_into().unwrap()); - // target=content, blurs=content - behaviors.content.content_list = Some(BehaviorValue::Blur.try_into().unwrap()); - behaviors.content.content_view = Some( - if adult_only { - BehaviorValue::Blur - } else { - alert_or_inform - } - .try_into() - .unwrap(), - ); - } - "media" => { - todo!() - } - "none" => { - // target=account, blurs=none - behaviors.account.profile_list = Some(alert_or_inform.try_into().unwrap()); - behaviors.account.profile_view = Some(alert_or_inform.try_into().unwrap()); - behaviors.account.content_list = Some(alert_or_inform.try_into().unwrap()); - behaviors.account.content_view = Some(alert_or_inform.try_into().unwrap()); - // target=profile, blurs=none - behaviors.profile.profile_list = Some(alert_or_inform.try_into().unwrap()); - behaviors.profile.profile_view = Some(alert_or_inform.try_into().unwrap()); - // target=content, blurs=none - behaviors.content.content_list = Some(alert_or_inform.try_into().unwrap()); - behaviors.content.content_view = Some(alert_or_inform.try_into().unwrap()); - } - _ => unreachable!(), - } - let mut flags = vec![LabelValueDefinitionFlag::NoSelf]; - if adult_only { - flags.push(LabelValueDefinitionFlag::Adult); - } - InterpretedLabelValueDefinition { - adult_only: false, - blurs: match blurs { - "content" => LabelValueDefinitionBlurs::Content, - "media" => LabelValueDefinitionBlurs::Media, - "none" => LabelValueDefinitionBlurs::None, - _ => unreachable!(), - }, - default_setting, - identifier: identifier.into(), - locales: Vec::new(), - severity: match severity { - "alert" => LabelValueDefinitionSeverity::Alert, - "inform" => LabelValueDefinitionSeverity::Inform, - "none" => LabelValueDefinitionSeverity::None, - _ => unreachable!(), - }, - defined_by: None, - flags, - behaviors, - } -} - fn assert_ui( decision: &ModerationDecision, expected: &[ModerationTestResultFlag], @@ -384,12 +297,17 @@ fn prioritize_custom_labels() { HashMap::from_iter([( "did:web:labeler.test".parse().expect("invalid did"), vec![interpret_label_value_definition( - "porn", - LabelPreference::Warn, - "inform", - "none", - false, - )], + &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( @@ -428,12 +346,17 @@ fn does_not_override_imperative_labels() { HashMap::from_iter([( "did:web:labeler.test".parse().expect("invalid did"), vec![interpret_label_value_definition( - "!hide", - LabelPreference::Warn, - "inform", - "none", - false, - )], + &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( @@ -483,19 +406,29 @@ fn ignore_invalid_label_value_names() { "did:web:labeler.test".parse().expect("invalid did"), vec![ interpret_label_value_definition( - "BadLabel", - LabelPreference::Warn, - "inform", - "content", - false, - ), + &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( - "bad/label", - LabelPreference::Warn, - "inform", - "content", - false, - ), + &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"), ], )]), ); @@ -538,26 +471,41 @@ fn custom_labels_with_default_settings() { "did:web:labeler.test".parse().expect("invalid did"), vec![ interpret_label_value_definition( - "default-hide", - LabelPreference::Hide, - "inform", - "content", - false, - ), + &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( - "default-warn", - LabelPreference::Warn, - "inform", - "content", - false, - ), + &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( - "default-ignore", - LabelPreference::Ignore, - "inform", - "content", - false, - ), + &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"), ], )]), ); @@ -636,12 +584,17 @@ fn custom_labels_require_adult_content_enabled() { HashMap::from_iter([( "did:web:labeler.test".parse().expect("invalid did"), vec![interpret_label_value_definition( - "adult", - LabelPreference::Hide, - "inform", - "content", - true, - )], + &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( diff --git a/bsky-sdk/src/moderation/types.rs b/bsky-sdk/src/moderation/types.rs index 36a34e9..1385420 100644 --- a/bsky-sdk/src/moderation/types.rs +++ b/bsky-sdk/src/moderation/types.rs @@ -21,6 +21,8 @@ pub enum Error { LabelValueDefinitionBlurs, #[error("invalid label value definition severity")] LabelValueDefinitionSeverity, + #[error("invalid behavior value")] + BehaviorValue, } // behaviors @@ -107,7 +109,7 @@ impl From for BehaviorValue { } impl TryFrom for ProfileListBehavior { - type Error = (); + type Error = Error; fn try_from(b: BehaviorValue) -> Result { match b { @@ -137,7 +139,7 @@ impl From for BehaviorValue { } impl TryFrom for ProfileViewBehavior { - type Error = (); + type Error = Error; fn try_from(b: BehaviorValue) -> Result { match b { @@ -165,13 +167,13 @@ impl From for BehaviorValue { } impl TryFrom for AvatarBehavior { - type Error = (); + type Error = Error; fn try_from(b: BehaviorValue) -> Result { match b { BehaviorValue::Blur => Ok(Self::Blur), BehaviorValue::Alert => Ok(Self::Alert), - BehaviorValue::Inform => Err(()), + BehaviorValue::Inform => Err(Error::BehaviorValue), } } } @@ -191,12 +193,12 @@ impl From for BehaviorValue { } impl TryFrom for BannerBehavior { - type Error = (); + type Error = Error; fn try_from(b: BehaviorValue) -> Result { match b { BehaviorValue::Blur => Ok(Self::Blur), - _ => Err(()), + _ => Err(Error::BehaviorValue), } } } @@ -216,12 +218,12 @@ impl From for BehaviorValue { } impl TryFrom for DisplayNameBehavior { - type Error = (); + type Error = Error; fn try_from(b: BehaviorValue) -> Result { match b { BehaviorValue::Blur => Ok(Self::Blur), - _ => Err(()), + _ => Err(Error::BehaviorValue), } } } @@ -245,7 +247,7 @@ impl From for BehaviorValue { } impl TryFrom for ContentListBehavior { - type Error = (); + type Error = Error; fn try_from(b: BehaviorValue) -> Result { match b { @@ -275,7 +277,7 @@ impl From for BehaviorValue { } impl TryFrom for ContentViewBehavior { - type Error = (); + type Error = Error; fn try_from(b: BehaviorValue) -> Result { match b { @@ -301,12 +303,12 @@ impl From for BehaviorValue { } impl TryFrom for ContentMediaBehavior { - type Error = (); + type Error = Error; fn try_from(b: BehaviorValue) -> Result { match b { BehaviorValue::Blur => Ok(Self::Blur), - _ => Err(()), + _ => Err(Error::BehaviorValue), } } } @@ -405,6 +407,7 @@ pub struct InterpretedLabelValueDefinition { // others #[serde(skip_serializing_if = "Option::is_none")] pub defined_by: Option, + pub configurable: bool, pub flags: Vec, pub behaviors: InterpretedLabelValueDefinitionBehaviors, } diff --git a/bsky-sdk/src/moderation/util.rs b/bsky-sdk/src/moderation/util.rs index 7f31d71..0c71064 100644 --- a/bsky-sdk/src/moderation/util.rs +++ b/bsky-sdk/src/moderation/util.rs @@ -1,7 +1,4 @@ -use super::types::{ - InterpretedLabelValueDefinition, InterpretedLabelValueDefinitionBehaviors, LabelPreference, -}; -use super::LabelValueDefinitionFlag; +use super::types::*; use crate::Result; use atrium_api::app::bsky::labeler::defs::LabelerViewDetailed; use atrium_api::com::atproto::label::defs::LabelValueDefinition; @@ -28,27 +25,83 @@ pub fn interpret_label_value_definition( defined_by: Option, ) -> Result { let adult_only = def.adult_only.unwrap_or_default(); - let severity = def.severity.parse()?; + 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(); - // TODO + 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: def.blurs.parse()?, + blurs, default_setting, identifier: def.identifier.clone(), locales: def.locales.clone(), severity, defined_by, + configurable: true, flags, behaviors, }) From 77d348b82db49b3e1c74bd434b3a4c5056b985af Mon Sep 17 00:00:00 2001 From: sugyan Date: Sun, 2 Jun 2024 23:36:04 +0900 Subject: [PATCH 11/29] Add workflows --- .github/workflows/bsky-sdk.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/bsky-sdk.yml diff --git a/.github/workflows/bsky-sdk.yml b/.github/workflows/bsky-sdk.yml new file mode 100644 index 0000000..0fa59d4 --- /dev/null +++ b/.github/workflows/bsky-sdk.yml @@ -0,0 +1,20 @@ +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 + - name: Run tests + run: cargo test -p bsky-sdk --verbose From 1129848d1737880246737be9790909f58ef078dc Mon Sep 17 00:00:00 2001 From: sugyan Date: Mon, 3 Jun 2024 23:42:58 +0900 Subject: [PATCH 12/29] Add tests::custom_labels --- bsky-sdk/src/moderation.rs | 2 + bsky-sdk/src/moderation/error.rs | 17 + bsky-sdk/src/moderation/labels.rs | 5 +- bsky-sdk/src/moderation/tests.rs | 28 ++ bsky-sdk/src/moderation/tests/behaviors.rs | 40 +-- .../src/moderation/tests/custom_labels.rs | 300 ++++++++++++++++++ bsky-sdk/src/moderation/types.rs | 46 ++- bsky-sdk/src/moderation/util.rs | 2 +- 8 files changed, 390 insertions(+), 50 deletions(-) create mode 100644 bsky-sdk/src/moderation/error.rs create mode 100644 bsky-sdk/src/moderation/tests/custom_labels.rs diff --git a/bsky-sdk/src/moderation.rs b/bsky-sdk/src/moderation.rs index dfa1391..d18d9fc 100644 --- a/bsky-sdk/src/moderation.rs +++ b/bsky-sdk/src/moderation.rs @@ -1,10 +1,12 @@ pub mod decision; +mod error; mod labels; 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, Union}; use serde::{Deserialize, Serialize}; diff --git a/bsky-sdk/src/moderation/error.rs b/bsky-sdk/src/moderation/error.rs new file mode 100644 index 0000000..fc2aae9 --- /dev/null +++ b/bsky-sdk/src/moderation/error.rs @@ -0,0 +1,17 @@ +use thiserror::Error; + +#[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, +} + +pub type Result = std::result::Result; diff --git a/bsky-sdk/src/moderation/labels.rs b/bsky-sdk/src/moderation/labels.rs index c5bdd00..9bd973d 100644 --- a/bsky-sdk/src/moderation/labels.rs +++ b/bsky-sdk/src/moderation/labels.rs @@ -1,3 +1,4 @@ +use super::error::Error; use super::types::*; use std::str::FromStr; @@ -13,7 +14,7 @@ pub(crate) enum KnownLabelValue { } impl FromStr for KnownLabelValue { - type Err = (); + type Err = Error; fn from_str(s: &str) -> Result { match s { @@ -24,7 +25,7 @@ impl FromStr for KnownLabelValue { "sexual" => Ok(Self::Sexual), "nudity" => Ok(Self::Nudity), "graphic-media" => Ok(Self::GraphicMedia), - _ => Err(()), + _ => Err(Error::KnownLabelValue), } } } diff --git a/bsky-sdk/src/moderation/tests.rs b/bsky-sdk/src/moderation/tests.rs index 3140db7..5b7486b 100644 --- a/bsky-sdk/src/moderation/tests.rs +++ b/bsky-sdk/src/moderation/tests.rs @@ -1,4 +1,5 @@ mod behaviors; +mod custom_labels; use crate::moderation::decision::{DecisionContext, ModerationDecision}; use crate::moderation::types::*; @@ -22,6 +23,33 @@ enum ModerationTestResultFlag { NoOverride, } +#[derive(Debug, Default)] +struct TestExpectedBehaviors { + profile_list: Vec, + profile_view: Vec, + avatar: Vec, + banner: Vec, + display_name: Vec, + content_list: Vec, + content_view: Vec, + content_media: Vec, +} + +impl TestExpectedBehaviors { + 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>, diff --git a/bsky-sdk/src/moderation/tests/behaviors.rs b/bsky-sdk/src/moderation/tests/behaviors.rs index 853fa3b..8bc78dc 100644 --- a/bsky-sdk/src/moderation/tests/behaviors.rs +++ b/bsky-sdk/src/moderation/tests/behaviors.rs @@ -1,5 +1,5 @@ -use super::{assert_ui, label, profile_view_basic, FAKE_CID}; -use super::{post_view, ModerationTestResultFlag}; +use super::{assert_ui, label, post_view, profile_view_basic}; +use super::{ModerationTestResultFlag, TestExpectedBehaviors, FAKE_CID}; use crate::moderation::decision::DecisionContext; use crate::moderation::types::*; use crate::moderation::Moderator; @@ -152,18 +152,6 @@ struct TestScenarioLabels { account: Vec, } -#[derive(Debug, Default)] -struct TestExpectedBehaviors { - profile_list: Vec, - profile_view: Vec, - avatar: Vec, - banner: Vec, - display_name: Vec, - content_list: Vec, - content_view: Vec, - content_media: Vec, -} - #[derive(Debug)] struct BehaviorsTestScenario { cfg: TestConfig, @@ -192,28 +180,16 @@ impl BehaviorsTestScenario { DecisionContext::ProfileView, ); } - assert_ui(&result, &self.behaviors.avatar, DecisionContext::Avatar); - assert_ui(&result, &self.behaviors.banner, DecisionContext::Banner); - assert_ui( - &result, - &self.behaviors.display_name, + for context in [ + DecisionContext::Avatar, + DecisionContext::Banner, DecisionContext::DisplayName, - ); - assert_ui( - &result, - &self.behaviors.content_list, DecisionContext::ContentList, - ); - assert_ui( - &result, - &self.behaviors.content_view, DecisionContext::ContentView, - ); - assert_ui( - &result, - &self.behaviors.content_media, DecisionContext::ContentMedia, - ); + ] { + assert_ui(&result, self.behaviors.expected_for(context), context); + } } fn moderator(&self) -> Moderator { Moderator::new( 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 0000000..b42bef3 --- /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::{ModerationTestResultFlag, TestExpectedBehaviors}; +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 CustomLabelTestScenario { + blurs: LabelValueDefinitionBlurs, + severity: LabelValueDefinitionSeverity, + account: TestExpectedBehaviors, + profile: TestExpectedBehaviors, + post: TestExpectedBehaviors, +} + +impl CustomLabelTestScenario { + 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 ModerationTestResultFlag::*; + let scenarios = [ + CustomLabelTestScenario { + blurs: LabelValueDefinitionBlurs::Content, + severity: LabelValueDefinitionSeverity::Alert, + account: TestExpectedBehaviors { + profile_list: vec![Filter, Alert], + profile_view: vec![Alert], + content_list: vec![Filter, Blur], + content_view: vec![Alert], + ..Default::default() + }, + profile: TestExpectedBehaviors { + profile_list: vec![Alert], + profile_view: vec![Alert], + ..Default::default() + }, + post: TestExpectedBehaviors { + content_list: vec![Filter, Blur], + content_view: vec![Alert], + ..Default::default() + }, + }, + CustomLabelTestScenario { + blurs: LabelValueDefinitionBlurs::Content, + severity: LabelValueDefinitionSeverity::Inform, + account: TestExpectedBehaviors { + profile_list: vec![Filter, Inform], + profile_view: vec![Inform], + content_list: vec![Filter, Blur], + content_view: vec![Inform], + ..Default::default() + }, + profile: TestExpectedBehaviors { + profile_list: vec![Inform], + profile_view: vec![Inform], + ..Default::default() + }, + post: TestExpectedBehaviors { + content_list: vec![Filter, Blur], + content_view: vec![Inform], + ..Default::default() + }, + }, + CustomLabelTestScenario { + blurs: LabelValueDefinitionBlurs::Content, + severity: LabelValueDefinitionSeverity::None, + account: TestExpectedBehaviors { + profile_list: vec![Filter], + content_list: vec![Filter, Blur], + ..Default::default() + }, + profile: TestExpectedBehaviors { + ..Default::default() + }, + post: TestExpectedBehaviors { + content_list: vec![Filter, Blur], + ..Default::default() + }, + }, + CustomLabelTestScenario { + blurs: LabelValueDefinitionBlurs::Media, + severity: LabelValueDefinitionSeverity::Alert, + account: TestExpectedBehaviors { + profile_list: vec![Filter, Alert], + profile_view: vec![Alert], + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Filter], + ..Default::default() + }, + profile: TestExpectedBehaviors { + profile_list: vec![Alert], + profile_view: vec![Alert], + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + post: TestExpectedBehaviors { + content_list: vec![Filter], + content_media: vec![Blur], + ..Default::default() + }, + }, + CustomLabelTestScenario { + blurs: LabelValueDefinitionBlurs::Media, + severity: LabelValueDefinitionSeverity::Inform, + account: TestExpectedBehaviors { + profile_list: vec![Filter, Inform], + profile_view: vec![Inform], + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Filter], + ..Default::default() + }, + profile: TestExpectedBehaviors { + profile_list: vec![Inform], + profile_view: vec![Inform], + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + post: TestExpectedBehaviors { + content_list: vec![Filter], + content_media: vec![Blur], + ..Default::default() + }, + }, + CustomLabelTestScenario { + blurs: LabelValueDefinitionBlurs::Media, + severity: LabelValueDefinitionSeverity::None, + account: TestExpectedBehaviors { + profile_list: vec![Filter], + avatar: vec![Blur], + banner: vec![Blur], + content_list: vec![Filter], + ..Default::default() + }, + profile: TestExpectedBehaviors { + avatar: vec![Blur], + banner: vec![Blur], + ..Default::default() + }, + post: TestExpectedBehaviors { + content_list: vec![Filter], + content_media: vec![Blur], + ..Default::default() + }, + }, + CustomLabelTestScenario { + blurs: LabelValueDefinitionBlurs::None, + severity: LabelValueDefinitionSeverity::Alert, + account: TestExpectedBehaviors { + profile_list: vec![Filter, Alert], + profile_view: vec![Alert], + content_list: vec![Filter, Alert], + content_view: vec![Alert], + ..Default::default() + }, + profile: TestExpectedBehaviors { + profile_list: vec![Alert], + profile_view: vec![Alert], + ..Default::default() + }, + post: TestExpectedBehaviors { + content_list: vec![Filter, Alert], + content_view: vec![Alert], + ..Default::default() + }, + }, + CustomLabelTestScenario { + blurs: LabelValueDefinitionBlurs::None, + severity: LabelValueDefinitionSeverity::Inform, + account: TestExpectedBehaviors { + profile_list: vec![Filter, Inform], + profile_view: vec![Inform], + content_list: vec![Filter, Inform], + content_view: vec![Inform], + ..Default::default() + }, + profile: TestExpectedBehaviors { + profile_list: vec![Inform], + profile_view: vec![Inform], + ..Default::default() + }, + post: TestExpectedBehaviors { + content_list: vec![Filter, Inform], + content_view: vec![Inform], + ..Default::default() + }, + }, + CustomLabelTestScenario { + blurs: LabelValueDefinitionBlurs::None, + severity: LabelValueDefinitionSeverity::None, + account: TestExpectedBehaviors { + profile_list: vec![Filter], + content_list: vec![Filter], + ..Default::default() + }, + profile: TestExpectedBehaviors { + ..Default::default() + }, + post: TestExpectedBehaviors { + 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 index 1385420..0ae76fe 100644 --- a/bsky-sdk/src/moderation/types.rs +++ b/bsky-sdk/src/moderation/types.rs @@ -1,4 +1,5 @@ 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, @@ -9,21 +10,6 @@ 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}; -use thiserror::Error; - -// errors - -#[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, -} // behaviors @@ -330,6 +316,16 @@ pub enum LabelPreference { 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; @@ -360,6 +356,16 @@ pub enum LabelValueDefinitionBlurs { 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; @@ -381,6 +387,16 @@ pub enum LabelValueDefinitionSeverity { 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; diff --git a/bsky-sdk/src/moderation/util.rs b/bsky-sdk/src/moderation/util.rs index 0c71064..004c709 100644 --- a/bsky-sdk/src/moderation/util.rs +++ b/bsky-sdk/src/moderation/util.rs @@ -1,5 +1,5 @@ +use super::error::Result; use super::types::*; -use crate::Result; use atrium_api::app::bsky::labeler::defs::LabelerViewDetailed; use atrium_api::com::atproto::label::defs::LabelValueDefinition; use atrium_api::types::string::Did; From 3d098862d0ed612c968e35c3b7855b5965562eca Mon Sep 17 00:00:00 2001 From: sugyan Date: Wed, 5 Jun 2024 16:42:38 +0900 Subject: [PATCH 13/29] Add moderation for mutewords --- bsky-sdk/src/moderation.rs | 87 +-------- bsky-sdk/src/moderation/decision.rs | 45 ++++- bsky-sdk/src/moderation/subjects.rs | 3 + bsky-sdk/src/moderation/subjects/account.rs | 38 ++++ bsky-sdk/src/moderation/subjects/post.rs | 196 ++++++++++++++++++++ bsky-sdk/src/moderation/subjects/profile.rs | 20 ++ bsky-sdk/src/moderation/tests.rs | 1 + bsky-sdk/src/moderation/tests/mutewords.rs | 48 +++++ bsky-sdk/src/moderation/types.rs | 12 ++ 9 files changed, 371 insertions(+), 79 deletions(-) create mode 100644 bsky-sdk/src/moderation/subjects.rs create mode 100644 bsky-sdk/src/moderation/subjects/account.rs create mode 100644 bsky-sdk/src/moderation/subjects/post.rs create mode 100644 bsky-sdk/src/moderation/subjects/profile.rs create mode 100644 bsky-sdk/src/moderation/tests/mutewords.rs diff --git a/bsky-sdk/src/moderation.rs b/bsky-sdk/src/moderation.rs index d18d9fc..5b36fe9 100644 --- a/bsky-sdk/src/moderation.rs +++ b/bsky-sdk/src/moderation.rs @@ -1,6 +1,7 @@ pub mod decision; mod error; mod labels; +mod subjects; mod types; pub mod ui; pub mod util; @@ -8,7 +9,7 @@ pub mod util; use self::decision::ModerationDecision; pub use self::error::{Error, Result}; pub use self::types::*; -use atrium_api::types::{string::Did, Union}; +use atrium_api::types::string::Did; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -33,87 +34,19 @@ impl Moderator { } } pub fn moderate_profile(&self, profile: &SubjectProfile) -> ModerationDecision { - ModerationDecision::merge(&[ - self.account_decision(profile), - self.profile_decision(profile), - ]) + ModerationDecision::merge(&[self.decide_account(profile), self.decide_profile(profile)]) } pub fn moderate_post(&self, post: &SubjectPost) -> ModerationDecision { - self.post_decision(post) + self.decide_post(post) } - fn account_decision(&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 + pub fn moderate_notification(&self) -> ModerationDecision { + todo!() } - fn profile_decision(&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 + pub fn moderate_feed_generator(&self) -> ModerationDecision { + todo!() } - fn post_decision(&self, subject: &SubjectPost) -> 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); - } - } - // TODO: hidden? - // TODO: muted words? - - let embed_acc = Option::::None; - if let Some(Union::Refs(embed)) = &subject.embed { - todo!() - } - - let mut decisions = vec![acc]; - if let Some(mut embed_acc) = embed_acc { - embed_acc.downgrade(); - decisions.push(embed_acc); - } - decisions.extend([ - self.account_decision(&subject.author.clone().into()), - self.profile_decision(&subject.author.clone().into()), - ]); - ModerationDecision::merge(&decisions) + pub fn moderate_user_list(&self) -> ModerationDecision { + todo!() } } diff --git a/bsky-sdk/src/moderation/decision.rs b/bsky-sdk/src/moderation/decision.rs index d88018e..4191837 100644 --- a/bsky-sdk/src/moderation/decision.rs +++ b/bsky-sdk/src/moderation/decision.rs @@ -149,8 +149,27 @@ impl ModerationDecision { } } } - ModerationCause::MuteWord(_) => { - todo!(); + 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(_) => { todo!(); @@ -161,6 +180,21 @@ impl ModerationDecision { ui.blurs.sort_by_cached_key(|c| c.priority()); ui } + pub fn blocked(&self) -> bool { + self.causes.iter().any(|c| { + matches!( + c, + ModerationCause::Blocking(_) + | ModerationCause::BlockedBy(_) + | ModerationCause::BlockOther(_) + ) + }) + } + pub fn muted(&self) -> bool { + self.causes + .iter() + .any(|c| matches!(c, ModerationCause::Muted(_))) + } pub(crate) fn new() -> Self { Self { did: None, @@ -312,6 +346,13 @@ impl ModerationDecision { 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 downgrade(&mut self) { for cause in self.causes.iter_mut() { cause.downgrade() diff --git a/bsky-sdk/src/moderation/subjects.rs b/bsky-sdk/src/moderation/subjects.rs new file mode 100644 index 0000000..f998b65 --- /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 0000000..726d2df --- /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 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 0000000..a0b01b0 --- /dev/null +++ b/bsky-sdk/src/moderation/subjects/post.rs @@ -0,0 +1,196 @@ +use super::super::decision::ModerationDecision; +use super::super::types::{LabelTarget, SubjectPost}; +use super::super::Moderator; +use atrium_api::app::bsky::actor::defs::MutedWord; +use atrium_api::app::bsky::richtext::facet::MainFeaturesItem; +use atrium_api::records::{KnownRecord, Record}; +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(); + +impl Moderator { + pub 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); + } + } + // TODO: hidden? + if !is_me && check_muted_words(subject, &self.prefs.muted_words) { + acc.add_muted_word(); + } + + let embed_acc = Option::::None; + if let Some(Union::Refs(embed)) = &subject.embed { + todo!() + } + + 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 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 +} + +/** + * 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 +]; + +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.len() == 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(&muted_word, " ") + .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/profile.rs b/bsky-sdk/src/moderation/subjects/profile.rs new file mode 100644 index 0000000..5e943b2 --- /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 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 index 5b7486b..75ab025 100644 --- a/bsky-sdk/src/moderation/tests.rs +++ b/bsky-sdk/src/moderation/tests.rs @@ -1,5 +1,6 @@ mod behaviors; mod custom_labels; +mod mutewords; use crate::moderation::decision::{DecisionContext, ModerationDecision}; use crate::moderation::types::*; diff --git a/bsky-sdk/src/moderation/tests/mutewords.rs b/bsky-sdk/src/moderation/tests/mutewords.rs new file mode 100644 index 0000000..efc021c --- /dev/null +++ b/bsky-sdk/src/moderation/tests/mutewords.rs @@ -0,0 +1,48 @@ +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; + +// TODO: RichText + +#[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" + ); +} diff --git a/bsky-sdk/src/moderation/types.rs b/bsky-sdk/src/moderation/types.rs index 0ae76fe..9dfe0cb 100644 --- a/bsky-sdk/src/moderation/types.rs +++ b/bsky-sdk/src/moderation/types.rs @@ -62,6 +62,16 @@ impl ModerationBehavior { 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::Inform), + content_view: Some(ContentViewBehavior::Inform), + content_media: None, + }; pub(crate) fn behavior_for(&self, context: DecisionContext) -> Option { match context { DecisionContext::ProfileList => self.profile_list.clone().map(Into::into), @@ -516,6 +526,7 @@ impl ModerationCause { Self::BlockedBy(_) => Priority::Priority4, Self::Label(label) => label.priority, Self::Muted(_) => Priority::Priority6, + Self::MuteWord(_) => Priority::Priority6, _ => todo!(), } } @@ -525,6 +536,7 @@ impl ModerationCause { 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, _ => todo!(), } } From cefa4a4ddca9566c11351f10b8be2747853bbf4e Mon Sep 17 00:00:00 2001 From: sugyan Date: Wed, 5 Jun 2024 22:26:44 +0900 Subject: [PATCH 14/29] Add moderation::tests::quoteposts --- bsky-sdk/src/moderation/decision.rs | 6 +- bsky-sdk/src/moderation/subjects/post.rs | 75 +++- bsky-sdk/src/moderation/tests.rs | 95 ++-- bsky-sdk/src/moderation/tests/behaviors.rs | 416 +++++++++--------- .../src/moderation/tests/custom_labels.rs | 86 ++-- bsky-sdk/src/moderation/tests/quoteposts.rs | 282 ++++++++++++ 6 files changed, 643 insertions(+), 317 deletions(-) create mode 100644 bsky-sdk/src/moderation/tests/quoteposts.rs diff --git a/bsky-sdk/src/moderation/decision.rs b/bsky-sdk/src/moderation/decision.rs index 4191837..fd84cc4 100644 --- a/bsky-sdk/src/moderation/decision.rs +++ b/bsky-sdk/src/moderation/decision.rs @@ -204,9 +204,9 @@ impl ModerationDecision { } 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)); + // 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, diff --git a/bsky-sdk/src/moderation/subjects/post.rs b/bsky-sdk/src/moderation/subjects/post.rs index a0b01b0..e03dcd0 100644 --- a/bsky-sdk/src/moderation/subjects/post.rs +++ b/bsky-sdk/src/moderation/subjects/post.rs @@ -2,6 +2,8 @@ use super::super::decision::ModerationDecision; 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::app::bsky::richtext::facet::MainFeaturesItem; use atrium_api::records::{KnownRecord, Record}; use atrium_api::types::Union; @@ -29,10 +31,35 @@ impl Moderator { acc.add_muted_word(); } - let embed_acc = Option::::None; - if let Some(Union::Refs(embed)) = &subject.embed { - todo!() - } + 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 { @@ -43,6 +70,46 @@ impl Moderator { 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_muted_words(subject: &SubjectPost, muted_words: &[MutedWord]) -> bool { diff --git a/bsky-sdk/src/moderation/tests.rs b/bsky-sdk/src/moderation/tests.rs index 75ab025..6b97044 100644 --- a/bsky-sdk/src/moderation/tests.rs +++ b/bsky-sdk/src/moderation/tests.rs @@ -1,6 +1,7 @@ mod behaviors; mod custom_labels; mod mutewords; +mod quoteposts; use crate::moderation::decision::{DecisionContext, ModerationDecision}; use crate::moderation::types::*; @@ -16,7 +17,7 @@ use std::collections::HashMap; const FAKE_CID: &str = "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ModerationTestResultFlag { +enum ResultFlag { Filter, Blur, Alert, @@ -25,19 +26,19 @@ enum ModerationTestResultFlag { } #[derive(Debug, Default)] -struct TestExpectedBehaviors { - profile_list: Vec, - profile_view: Vec, - avatar: Vec, - banner: Vec, - display_name: Vec, - content_list: Vec, - content_view: Vec, - content_media: Vec, +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 TestExpectedBehaviors { - fn expected_for(&self, context: DecisionContext) -> &Vec { +impl ExpectedBehaviors { + fn expected_for(&self, context: DecisionContext) -> &Vec { match context { DecisionContext::ProfileList => &self.profile_list, DecisionContext::ProfileView => &self.profile_view, @@ -110,11 +111,7 @@ fn label(src: &str, uri: &str, val: &str) -> Label { } } -fn assert_ui( - decision: &ModerationDecision, - expected: &[ModerationTestResultFlag], - context: DecisionContext, -) { +fn assert_ui(decision: &ModerationDecision, expected: &[ResultFlag], context: DecisionContext) { let ui = decision.ui(context); if expected.is_empty() { assert!( @@ -137,31 +134,31 @@ fn assert_ui( } else { assert_eq!( ui.inform(), - expected.contains(&ModerationTestResultFlag::Inform), + expected.contains(&ResultFlag::Inform), "inform should be {} for context {context:?}", !ui.inform() ); assert_eq!( ui.alert(), - expected.contains(&ModerationTestResultFlag::Alert), + expected.contains(&ResultFlag::Alert), "alert should be {} for context {context:?}", !ui.alert() ); assert_eq!( ui.blur(), - expected.contains(&ModerationTestResultFlag::Blur), + expected.contains(&ResultFlag::Blur), "blur should be {} for context {context:?}", !ui.blur() ); assert_eq!( ui.filter(), - expected.contains(&ModerationTestResultFlag::Filter), + expected.contains(&ResultFlag::Filter), "filter should be {} for context {context:?}", !ui.filter() ); assert_eq!( ui.no_override, - expected.contains(&ModerationTestResultFlag::NoOverride), + expected.contains(&ResultFlag::NoOverride), "no_override should be {} for context {context:?}", !ui.no_override ); @@ -191,11 +188,7 @@ fn self_label_global() { HashMap::new(), ); let result = moderator.moderate_profile(&profile); - assert_ui( - &result, - &[ModerationTestResultFlag::Blur], - DecisionContext::Avatar, - ) + assert_ui(&result, &[ResultFlag::Blur], DecisionContext::Avatar) } // porn (ignore) { @@ -350,8 +343,8 @@ fn prioritize_custom_labels() { )); for context in DecisionContext::ALL { let expected = match context { - DecisionContext::ContentList => vec![ModerationTestResultFlag::Inform], - DecisionContext::ContentView => vec![ModerationTestResultFlag::Inform], + DecisionContext::ContentList => vec![ResultFlag::Inform], + DecisionContext::ContentView => vec![ResultFlag::Inform], _ => vec![], }; assert_ui(&result, &expected, context); @@ -399,15 +392,10 @@ fn does_not_override_imperative_labels() { )); for context in DecisionContext::ALL { let expected = match context { - DecisionContext::ContentList => vec![ - ModerationTestResultFlag::Filter, - ModerationTestResultFlag::Blur, - ModerationTestResultFlag::NoOverride, - ], - DecisionContext::ContentView => vec![ - ModerationTestResultFlag::Blur, - ModerationTestResultFlag::NoOverride, - ], + DecisionContext::ContentList => { + vec![ResultFlag::Filter, ResultFlag::Blur, ResultFlag::NoOverride] + } + DecisionContext::ContentView => vec![ResultFlag::Blur, ResultFlag::NoOverride], _ => vec![], }; assert_ui(&result, &expected, context); @@ -551,11 +539,8 @@ fn custom_labels_with_default_settings() { )); for context in DecisionContext::ALL { let expected = match context { - DecisionContext::ContentList => vec![ - ModerationTestResultFlag::Filter, - ModerationTestResultFlag::Blur, - ], - DecisionContext::ContentView => vec![ModerationTestResultFlag::Inform], + DecisionContext::ContentList => vec![ResultFlag::Filter, ResultFlag::Blur], + DecisionContext::ContentView => vec![ResultFlag::Inform], _ => vec![], }; assert_ui(&result, &expected, context); @@ -573,8 +558,8 @@ fn custom_labels_with_default_settings() { )); for context in DecisionContext::ALL { let expected = match context { - DecisionContext::ContentList => vec![ModerationTestResultFlag::Blur], - DecisionContext::ContentView => vec![ModerationTestResultFlag::Inform], + DecisionContext::ContentList => vec![ResultFlag::Blur], + DecisionContext::ContentView => vec![ResultFlag::Inform], _ => vec![], }; assert_ui(&result, &expected, context); @@ -637,15 +622,10 @@ fn custom_labels_require_adult_content_enabled() { )); for context in DecisionContext::ALL { let expected = match context { - DecisionContext::ContentList => vec![ - ModerationTestResultFlag::Filter, - ModerationTestResultFlag::Blur, - ModerationTestResultFlag::NoOverride, - ], - DecisionContext::ContentView => vec![ - ModerationTestResultFlag::Blur, - ModerationTestResultFlag::NoOverride, - ], + DecisionContext::ContentList => { + vec![ResultFlag::Filter, ResultFlag::Blur, ResultFlag::NoOverride] + } + DecisionContext::ContentView => vec![ResultFlag::Blur, ResultFlag::NoOverride], _ => vec![], }; assert_ui(&result, &expected, context); @@ -679,11 +659,8 @@ fn adult_content_disabled_forces_hide() { )); for context in DecisionContext::ALL { let expected = match context { - DecisionContext::ContentList => vec![ModerationTestResultFlag::Filter], - DecisionContext::ContentMedia => vec![ - ModerationTestResultFlag::Blur, - ModerationTestResultFlag::NoOverride, - ], + 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 index 8bc78dc..ee8b8d2 100644 --- a/bsky-sdk/src/moderation/tests/behaviors.rs +++ b/bsky-sdk/src/moderation/tests/behaviors.rs @@ -1,5 +1,5 @@ use super::{assert_ui, label, post_view, profile_view_basic}; -use super::{ModerationTestResultFlag, TestExpectedBehaviors, FAKE_CID}; +use super::{ExpectedBehaviors, ResultFlag, FAKE_CID}; use crate::moderation::decision::DecisionContext; use crate::moderation::types::*; use crate::moderation::Moderator; @@ -146,22 +146,22 @@ impl AsRef for TestUser { } #[derive(Debug, Default)] -struct TestScenarioLabels { +struct TestLabels { post: Vec, profile: Vec, account: Vec, } #[derive(Debug)] -struct BehaviorsTestScenario { +struct Scenario { cfg: TestConfig, subject: TestSubject, author: TestUser, - labels: TestScenarioLabels, - behaviors: TestExpectedBehaviors, + labels: TestLabels, + behaviors: ExpectedBehaviors, } -impl BehaviorsTestScenario { +impl Scenario { fn run(&self) { let moderator = self.moderator(); let result = match self.subject { @@ -264,19 +264,19 @@ impl BehaviorsTestScenario { #[test] fn moderation_behaviors() { - use ModerationTestResultFlag::*; + use ResultFlag::*; let scenarios = [ ( "Imperative label ('!hide') on account", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!hide")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { profile_list: vec![Filter, Blur, NoOverride], profile_view: vec![Blur, NoOverride], avatar: vec![Blur, NoOverride], @@ -290,15 +290,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!hide') on profile", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("!hide")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur, NoOverride], banner: vec![Blur, NoOverride], display_name: vec![Blur, NoOverride], @@ -308,15 +308,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!hide') on post", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { post: vec![String::from("!hide")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { content_list: vec![Filter, Blur, NoOverride], content_view: vec![Blur, NoOverride], ..Default::default() @@ -325,15 +325,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!hide') on author profile", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("!hide")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur, NoOverride], banner: vec![Blur, NoOverride], display_name: vec![Blur, NoOverride], @@ -343,15 +343,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!hide') on author account", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!hide")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur, NoOverride], banner: vec![Blur, NoOverride], display_name: vec![Blur, NoOverride], @@ -363,15 +363,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!warn') on account", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!warn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { profile_list: vec![Blur], profile_view: vec![Blur], avatar: vec![Blur], @@ -384,15 +384,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!warn') on profile", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("!warn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], display_name: vec![Blur], @@ -402,15 +402,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!warn') on post", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { post: vec![String::from("!warn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { content_list: vec![Blur], content_view: vec![Blur], ..Default::default() @@ -419,15 +419,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!warn') on author profile", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("!warn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], display_name: vec![Blur], @@ -437,15 +437,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!warn') on author account", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!warn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], content_list: vec![Blur], @@ -456,15 +456,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on account when logged out", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::LoggedOut, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!no-unauthenticated")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { profile_list: vec![Filter, Blur, NoOverride], profile_view: vec![Blur, NoOverride], avatar: vec![Blur, NoOverride], @@ -478,15 +478,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on profile when logged out", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::LoggedOut, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("!no-unauthenticated")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { profile_list: vec![Filter, Blur, NoOverride], profile_view: vec![Blur, NoOverride], avatar: vec![Blur, NoOverride], @@ -500,15 +500,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on post when logged out", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::LoggedOut, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { post: vec![String::from("!no-unauthenticated")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { content_list: vec![Filter, Blur, NoOverride], content_view: vec![Blur, NoOverride], ..Default::default() @@ -517,15 +517,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on author profile when logged out", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::LoggedOut, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("!no-unauthenticated")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur, NoOverride], banner: vec![Blur, NoOverride], display_name: vec![Blur, NoOverride], @@ -537,15 +537,15 @@ fn moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on author account when logged out", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::LoggedOut, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!no-unauthenticated")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur, NoOverride], banner: vec![Blur, NoOverride], display_name: vec![Blur, NoOverride], @@ -557,80 +557,80 @@ fn moderation_behaviors() { ), ( "Imperative label ('!no-unauthenticated') on account when logged in", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!no-unauthenticated")], ..Default::default() }, - behaviors: TestExpectedBehaviors::default(), + behaviors: ExpectedBehaviors::default(), }, ), ( "Imperative label ('!no-unauthenticated') on profile when logged in", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("!no-unauthenticated")], ..Default::default() }, - behaviors: TestExpectedBehaviors::default(), + behaviors: ExpectedBehaviors::default(), }, ), ( "Imperative label ('!no-unauthenticated') on post when logged in", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { post: vec![String::from("!no-unauthenticated")], ..Default::default() }, - behaviors: TestExpectedBehaviors::default(), + behaviors: ExpectedBehaviors::default(), }, ), ( "Imperative label ('!no-unauthenticated') on author profile when logged in", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("!no-unauthenticated")], ..Default::default() }, - behaviors: TestExpectedBehaviors::default(), + behaviors: ExpectedBehaviors::default(), }, ), ( "Imperative label ('!no-unauthenticated') on author account when logged in", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!no-unauthenticated")], ..Default::default() }, - behaviors: TestExpectedBehaviors::default(), + behaviors: ExpectedBehaviors::default(), }, ), ( "Blur-media label ('porn') on account (hide)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornHide, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { profile_list: vec![Filter], avatar: vec![Blur], banner: vec![Blur], @@ -641,15 +641,15 @@ fn moderation_behaviors() { ), ( "Blur-media label ('porn') on profile (hide)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornHide, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], ..Default::default() @@ -658,15 +658,15 @@ fn moderation_behaviors() { ), ( "Blur-media label ('porn') on post (hide)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornHide, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { post: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { content_list: vec![Filter], content_media: vec![Blur], ..Default::default() @@ -675,15 +675,15 @@ fn moderation_behaviors() { ), ( "Blur-media label ('porn') on author profile (hide)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornHide, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], ..Default::default() @@ -692,15 +692,15 @@ fn moderation_behaviors() { ), ( "Blur-media label ('porn') on author account (hide)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornHide, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { profile_list: vec![Filter], avatar: vec![Blur], banner: vec![Blur], @@ -711,15 +711,15 @@ fn moderation_behaviors() { ), ( "Blur-media label ('porn') on account (warn)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornWarn, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], ..Default::default() @@ -728,15 +728,15 @@ fn moderation_behaviors() { ), ( "Blur-media label ('porn') on profile (warn)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornWarn, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], ..Default::default() @@ -745,15 +745,15 @@ fn moderation_behaviors() { ), ( "Blur-media label ('porn') on post (warn)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornWarn, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { post: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { content_media: vec![Blur], ..Default::default() }, @@ -761,15 +761,15 @@ fn moderation_behaviors() { ), ( "Blur-media label ('porn') on author profile (warn)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornWarn, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], ..Default::default() @@ -778,15 +778,15 @@ fn moderation_behaviors() { ), ( "Blur-media label ('porn') on author account (warn)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornWarn, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], ..Default::default() @@ -795,80 +795,80 @@ fn moderation_behaviors() { ), ( "Blur-media label ('porn') on account (ignore)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornIgnore, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors::default(), + behaviors: ExpectedBehaviors::default(), }, ), ( "Blur-media label ('porn') on profile (ignore)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornIgnore, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors::default(), + behaviors: ExpectedBehaviors::default(), }, ), ( "Blur-media label ('porn') on post (ignore)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornIgnore, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { post: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors::default(), + behaviors: ExpectedBehaviors::default(), }, ), ( "Blur-media label ('porn') on author profile (ignore)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornIgnore, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors::default(), + behaviors: ExpectedBehaviors::default(), }, ), ( "Blur-media label ('porn') on author account (ignore)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornIgnore, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors::default(), + behaviors: ExpectedBehaviors::default(), }, ), ( "Adult-only label on account when adult content is disabled", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::AdultDisabled, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { profile_list: vec![Filter], avatar: vec![Blur, NoOverride], banner: vec![Blur, NoOverride], @@ -879,15 +879,15 @@ fn moderation_behaviors() { ), ( "Adult-only label on profile when adult content is disabled", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::AdultDisabled, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur, NoOverride], banner: vec![Blur, NoOverride], ..Default::default() @@ -896,15 +896,15 @@ fn moderation_behaviors() { ), ( "Adult-only label on post when adult content is disabled", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::AdultDisabled, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { post: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { content_list: vec![Filter], content_media: vec![Blur, NoOverride], ..Default::default() @@ -913,15 +913,15 @@ fn moderation_behaviors() { ), ( "Adult-only label on author profile when adult content is disabled", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::AdultDisabled, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur, NoOverride], banner: vec![Blur, NoOverride], ..Default::default() @@ -930,15 +930,15 @@ fn moderation_behaviors() { ), ( "Adult-only label on author account when adult content is disabled", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::AdultDisabled, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur, NoOverride], banner: vec![Blur, NoOverride], content_list: vec![Filter], @@ -948,15 +948,15 @@ fn moderation_behaviors() { ), ( "Self-profile: !hide on account", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::UserSelf, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!hide")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { profile_list: vec![Blur], profile_view: vec![Blur], avatar: vec![Blur], @@ -970,15 +970,15 @@ fn moderation_behaviors() { ), ( "Self-profile: !hide on profile", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::UserSelf, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("!hide")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], display_name: vec![Blur], @@ -988,15 +988,15 @@ fn moderation_behaviors() { ), ( "Self-post: Imperative label ('!hide') on post", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::UserSelf, - labels: TestScenarioLabels { + labels: TestLabels { post: vec![String::from("!hide")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { content_list: vec![Blur], content_view: vec![Blur], ..Default::default() @@ -1005,15 +1005,15 @@ fn moderation_behaviors() { ), ( "Self-post: Imperative label ('!hide') on author profile", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::UserSelf, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("!hide")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], display_name: vec![Blur], @@ -1023,15 +1023,15 @@ fn moderation_behaviors() { ), ( "Self-post: Imperative label ('!hide') on author account", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::UserSelf, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!hide")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], display_name: vec![Blur], @@ -1043,15 +1043,15 @@ fn moderation_behaviors() { ), ( "Self-post: Imperative label ('!warn') on post", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::UserSelf, - labels: TestScenarioLabels { + labels: TestLabels { post: vec![String::from("!warn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { content_list: vec![Blur], content_view: vec![Blur], ..Default::default() @@ -1060,15 +1060,15 @@ fn moderation_behaviors() { ), ( "Self-post: Imperative label ('!warn') on author profile", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::UserSelf, - labels: TestScenarioLabels { + labels: TestLabels { profile: vec![String::from("!warn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], display_name: vec![Blur], @@ -1078,15 +1078,15 @@ fn moderation_behaviors() { ), ( "Self-post: Imperative label ('!warn') on author account", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::UserSelf, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!warn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], content_list: vec![Blur], @@ -1097,12 +1097,12 @@ fn moderation_behaviors() { ), ( "Mute/block: Blocking user", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Bob, - labels: TestScenarioLabels::default(), - behaviors: TestExpectedBehaviors { + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { profile_list: vec![Filter, Blur, NoOverride], profile_view: vec![Alert], avatar: vec![Blur, NoOverride], @@ -1115,12 +1115,12 @@ fn moderation_behaviors() { ), ( "Post with blocked author", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Bob, - labels: TestScenarioLabels::default(), - behaviors: TestExpectedBehaviors { + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { avatar: vec![Blur, NoOverride], banner: vec![Blur, NoOverride], content_list: vec![Filter, Blur, NoOverride], @@ -1131,12 +1131,12 @@ fn moderation_behaviors() { ), ( "Post with author blocking user", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Carla, - labels: TestScenarioLabels::default(), - behaviors: TestExpectedBehaviors { + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { avatar: vec![Blur, NoOverride], banner: vec![Blur, NoOverride], content_list: vec![Filter, Blur, NoOverride], @@ -1147,12 +1147,12 @@ fn moderation_behaviors() { ), ( "Mute/block: Blocking-by-list user", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Georgia, - labels: TestScenarioLabels::default(), - behaviors: TestExpectedBehaviors { + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { profile_list: vec![Filter, Blur, NoOverride], profile_view: vec![Alert], avatar: vec![Blur, NoOverride], @@ -1165,12 +1165,12 @@ fn moderation_behaviors() { ), ( "Mute/block: Blocked by user", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Carla, - labels: TestScenarioLabels::default(), - behaviors: TestExpectedBehaviors { + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { profile_list: vec![Filter, Blur, NoOverride], profile_view: vec![Alert], avatar: vec![Blur, NoOverride], @@ -1183,12 +1183,12 @@ fn moderation_behaviors() { ), ( "Mute/block: Muted user", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Dan, - labels: TestScenarioLabels::default(), - behaviors: TestExpectedBehaviors { + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { profile_list: vec![Filter, Inform], profile_view: vec![Alert], content_list: vec![Filter, Blur], @@ -1199,12 +1199,12 @@ fn moderation_behaviors() { ), ( "Mute/block: Muted-by-list user", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Elise, - labels: TestScenarioLabels::default(), - behaviors: TestExpectedBehaviors { + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { profile_list: vec![Filter, Inform], profile_view: vec![Alert], content_list: vec![Filter, Blur], @@ -1215,12 +1215,12 @@ fn moderation_behaviors() { ), ( "Merging: blocking & blocked-by user", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Fern, - labels: TestScenarioLabels::default(), - behaviors: TestExpectedBehaviors { + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { profile_list: vec![Filter, Blur, NoOverride], profile_view: vec![Alert], avatar: vec![Blur, NoOverride], @@ -1233,12 +1233,12 @@ fn moderation_behaviors() { ), ( "Post with muted author", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Dan, - labels: TestScenarioLabels::default(), - behaviors: TestExpectedBehaviors { + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { content_list: vec![Filter, Blur], content_view: vec![Inform], ..Default::default() @@ -1247,12 +1247,12 @@ fn moderation_behaviors() { ), ( "Post with muted-by-list author", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Elise, - labels: TestScenarioLabels::default(), - behaviors: TestExpectedBehaviors { + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { content_list: vec![Filter, Blur], content_view: vec![Inform], ..Default::default() @@ -1261,15 +1261,15 @@ fn moderation_behaviors() { ), ( "Merging: '!hide' label on account of blocked user", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Bob, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!hide")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { profile_list: vec![Filter, Blur, NoOverride], profile_view: vec![Blur, Alert, NoOverride], avatar: vec![Blur, NoOverride], @@ -1283,15 +1283,15 @@ fn moderation_behaviors() { ), ( "Merging: '!hide' and 'porn' labels on account (hide)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornHide, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!hide"), String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { profile_list: vec![Filter, Blur, NoOverride], profile_view: vec![Blur, NoOverride], avatar: vec![Blur, NoOverride], @@ -1305,15 +1305,15 @@ fn moderation_behaviors() { ), ( "Merging: '!warn' and 'porn' labels on account (hide)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornHide, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!warn"), String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { profile_list: vec![Filter, Blur], profile_view: vec![Blur], avatar: vec![Blur], @@ -1326,16 +1326,16 @@ fn moderation_behaviors() { ), ( "Merging: !hide on account, !warn on profile", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!hide")], profile: vec![String::from("!warn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { profile_list: vec![Filter, Blur, NoOverride], profile_view: vec![Blur, NoOverride], avatar: vec![Blur, NoOverride], @@ -1349,16 +1349,16 @@ fn moderation_behaviors() { ), ( "Merging: !warn on account, !hide on profile", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Profile, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { account: vec![String::from("!warn")], profile: vec![String::from("!hide")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { profile_list: vec![Blur], profile_view: vec![Blur], avatar: vec![Blur, NoOverride], @@ -1372,12 +1372,12 @@ fn moderation_behaviors() { ), ( "Merging: post with blocking & blocked-by author", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Fern, - labels: TestScenarioLabels::default(), - behaviors: TestExpectedBehaviors { + labels: TestLabels::default(), + behaviors: ExpectedBehaviors { avatar: vec![Blur, NoOverride], banner: vec![Blur, NoOverride], content_list: vec![Filter, Blur, NoOverride], @@ -1388,15 +1388,15 @@ fn moderation_behaviors() { ), ( "Merging: '!hide' label on post by blocked user", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::None, subject: TestSubject::Post, author: TestUser::Bob, - labels: TestScenarioLabels { + labels: TestLabels { post: vec![String::from("!hide")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { avatar: vec![Blur, NoOverride], banner: vec![Blur, NoOverride], content_list: vec![Filter, Blur, NoOverride], @@ -1407,15 +1407,15 @@ fn moderation_behaviors() { ), ( "Merging: '!hide' and 'porn' labels on post (hide)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornHide, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { post: vec![String::from("!warn"), String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { content_list: vec![Filter, Blur], content_view: vec![Blur], content_media: vec![Blur], @@ -1425,15 +1425,15 @@ fn moderation_behaviors() { ), ( "Merging: '!warn' and 'porn' labels on post (hide)", - BehaviorsTestScenario { + Scenario { cfg: TestConfig::PornHide, subject: TestSubject::Post, author: TestUser::Alice, - labels: TestScenarioLabels { + labels: TestLabels { post: vec![String::from("!warn"), String::from("porn")], ..Default::default() }, - behaviors: TestExpectedBehaviors { + behaviors: ExpectedBehaviors { content_list: vec![Filter, Blur], content_view: vec![Blur], content_media: vec![Blur], diff --git a/bsky-sdk/src/moderation/tests/custom_labels.rs b/bsky-sdk/src/moderation/tests/custom_labels.rs index b42bef3..eea49a3 100644 --- a/bsky-sdk/src/moderation/tests/custom_labels.rs +++ b/bsky-sdk/src/moderation/tests/custom_labels.rs @@ -1,5 +1,5 @@ use super::{assert_ui, label, post_view, profile_view_basic}; -use super::{ModerationTestResultFlag, TestExpectedBehaviors}; +use super::{ExpectedBehaviors, ResultFlag}; use crate::moderation::decision::DecisionContext; use crate::moderation::error::Result; use crate::moderation::types::*; @@ -9,15 +9,15 @@ use atrium_api::com::atproto::label::defs::LabelValueDefinition; use std::collections::HashMap; #[derive(Debug)] -struct CustomLabelTestScenario { +struct Scenario { blurs: LabelValueDefinitionBlurs, severity: LabelValueDefinitionSeverity, - account: TestExpectedBehaviors, - profile: TestExpectedBehaviors, - post: TestExpectedBehaviors, + account: ExpectedBehaviors, + profile: ExpectedBehaviors, + post: ExpectedBehaviors, } -impl CustomLabelTestScenario { +impl Scenario { fn run(&self) { let moderator = self.moderator().expect("failed to create moderator"); // account @@ -106,70 +106,70 @@ impl CustomLabelTestScenario { #[test] fn moderation_custom_labels() { - use ModerationTestResultFlag::*; + use ResultFlag::*; let scenarios = [ - CustomLabelTestScenario { + Scenario { blurs: LabelValueDefinitionBlurs::Content, severity: LabelValueDefinitionSeverity::Alert, - account: TestExpectedBehaviors { + account: ExpectedBehaviors { profile_list: vec![Filter, Alert], profile_view: vec![Alert], content_list: vec![Filter, Blur], content_view: vec![Alert], ..Default::default() }, - profile: TestExpectedBehaviors { + profile: ExpectedBehaviors { profile_list: vec![Alert], profile_view: vec![Alert], ..Default::default() }, - post: TestExpectedBehaviors { + post: ExpectedBehaviors { content_list: vec![Filter, Blur], content_view: vec![Alert], ..Default::default() }, }, - CustomLabelTestScenario { + Scenario { blurs: LabelValueDefinitionBlurs::Content, severity: LabelValueDefinitionSeverity::Inform, - account: TestExpectedBehaviors { + account: ExpectedBehaviors { profile_list: vec![Filter, Inform], profile_view: vec![Inform], content_list: vec![Filter, Blur], content_view: vec![Inform], ..Default::default() }, - profile: TestExpectedBehaviors { + profile: ExpectedBehaviors { profile_list: vec![Inform], profile_view: vec![Inform], ..Default::default() }, - post: TestExpectedBehaviors { + post: ExpectedBehaviors { content_list: vec![Filter, Blur], content_view: vec![Inform], ..Default::default() }, }, - CustomLabelTestScenario { + Scenario { blurs: LabelValueDefinitionBlurs::Content, severity: LabelValueDefinitionSeverity::None, - account: TestExpectedBehaviors { + account: ExpectedBehaviors { profile_list: vec![Filter], content_list: vec![Filter, Blur], ..Default::default() }, - profile: TestExpectedBehaviors { + profile: ExpectedBehaviors { ..Default::default() }, - post: TestExpectedBehaviors { + post: ExpectedBehaviors { content_list: vec![Filter, Blur], ..Default::default() }, }, - CustomLabelTestScenario { + Scenario { blurs: LabelValueDefinitionBlurs::Media, severity: LabelValueDefinitionSeverity::Alert, - account: TestExpectedBehaviors { + account: ExpectedBehaviors { profile_list: vec![Filter, Alert], profile_view: vec![Alert], avatar: vec![Blur], @@ -177,23 +177,23 @@ fn moderation_custom_labels() { content_list: vec![Filter], ..Default::default() }, - profile: TestExpectedBehaviors { + profile: ExpectedBehaviors { profile_list: vec![Alert], profile_view: vec![Alert], avatar: vec![Blur], banner: vec![Blur], ..Default::default() }, - post: TestExpectedBehaviors { + post: ExpectedBehaviors { content_list: vec![Filter], content_media: vec![Blur], ..Default::default() }, }, - CustomLabelTestScenario { + Scenario { blurs: LabelValueDefinitionBlurs::Media, severity: LabelValueDefinitionSeverity::Inform, - account: TestExpectedBehaviors { + account: ExpectedBehaviors { profile_list: vec![Filter, Inform], profile_view: vec![Inform], avatar: vec![Blur], @@ -201,94 +201,94 @@ fn moderation_custom_labels() { content_list: vec![Filter], ..Default::default() }, - profile: TestExpectedBehaviors { + profile: ExpectedBehaviors { profile_list: vec![Inform], profile_view: vec![Inform], avatar: vec![Blur], banner: vec![Blur], ..Default::default() }, - post: TestExpectedBehaviors { + post: ExpectedBehaviors { content_list: vec![Filter], content_media: vec![Blur], ..Default::default() }, }, - CustomLabelTestScenario { + Scenario { blurs: LabelValueDefinitionBlurs::Media, severity: LabelValueDefinitionSeverity::None, - account: TestExpectedBehaviors { + account: ExpectedBehaviors { profile_list: vec![Filter], avatar: vec![Blur], banner: vec![Blur], content_list: vec![Filter], ..Default::default() }, - profile: TestExpectedBehaviors { + profile: ExpectedBehaviors { avatar: vec![Blur], banner: vec![Blur], ..Default::default() }, - post: TestExpectedBehaviors { + post: ExpectedBehaviors { content_list: vec![Filter], content_media: vec![Blur], ..Default::default() }, }, - CustomLabelTestScenario { + Scenario { blurs: LabelValueDefinitionBlurs::None, severity: LabelValueDefinitionSeverity::Alert, - account: TestExpectedBehaviors { + account: ExpectedBehaviors { profile_list: vec![Filter, Alert], profile_view: vec![Alert], content_list: vec![Filter, Alert], content_view: vec![Alert], ..Default::default() }, - profile: TestExpectedBehaviors { + profile: ExpectedBehaviors { profile_list: vec![Alert], profile_view: vec![Alert], ..Default::default() }, - post: TestExpectedBehaviors { + post: ExpectedBehaviors { content_list: vec![Filter, Alert], content_view: vec![Alert], ..Default::default() }, }, - CustomLabelTestScenario { + Scenario { blurs: LabelValueDefinitionBlurs::None, severity: LabelValueDefinitionSeverity::Inform, - account: TestExpectedBehaviors { + account: ExpectedBehaviors { profile_list: vec![Filter, Inform], profile_view: vec![Inform], content_list: vec![Filter, Inform], content_view: vec![Inform], ..Default::default() }, - profile: TestExpectedBehaviors { + profile: ExpectedBehaviors { profile_list: vec![Inform], profile_view: vec![Inform], ..Default::default() }, - post: TestExpectedBehaviors { + post: ExpectedBehaviors { content_list: vec![Filter, Inform], content_view: vec![Inform], ..Default::default() }, }, - CustomLabelTestScenario { + Scenario { blurs: LabelValueDefinitionBlurs::None, severity: LabelValueDefinitionSeverity::None, - account: TestExpectedBehaviors { + account: ExpectedBehaviors { profile_list: vec![Filter], content_list: vec![Filter], ..Default::default() }, - profile: TestExpectedBehaviors { + profile: ExpectedBehaviors { ..Default::default() }, - post: TestExpectedBehaviors { + post: ExpectedBehaviors { content_list: vec![Filter], ..Default::default() }, diff --git a/bsky-sdk/src/moderation/tests/quoteposts.rs b/bsky-sdk/src/moderation/tests/quoteposts.rs new file mode 100644 index 0000000..0e5f49c --- /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(); + } +} From a1df2c30b1ed5484d103e595de3a9409b521a892 Mon Sep 17 00:00:00 2001 From: sugyan Date: Wed, 5 Jun 2024 22:56:48 +0900 Subject: [PATCH 15/29] Fix warnings --- bsky-sdk/src/moderation/decision.rs | 33 ++++++++++++++++++++---- bsky-sdk/src/moderation/subjects/post.rs | 30 ++++++++++++++++++++- bsky-sdk/src/moderation/types.rs | 28 +++++++++++++------- bsky-sdk/src/moderation/ui.rs | 8 +++--- 4 files changed, 80 insertions(+), 19 deletions(-) diff --git a/bsky-sdk/src/moderation/decision.rs b/bsky-sdk/src/moderation/decision.rs index fd84cc4..4442775 100644 --- a/bsky-sdk/src/moderation/decision.rs +++ b/bsky-sdk/src/moderation/decision.rs @@ -37,7 +37,7 @@ pub(crate) enum ModerationBehaviorSeverity { } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub(crate) enum Priority { +pub enum Priority { Priority1, Priority2, Priority3, @@ -68,7 +68,7 @@ impl ModerationDecision { match cause { ModerationCause::Blocking(b) | ModerationCause::BlockedBy(b) - | ModerationCause::BlockOther(b) => { + /* | ModerationCause::BlockOther(b) */ => { if self.is_me { continue; } @@ -171,8 +171,24 @@ impl ModerationDecision { } } } - ModerationCause::Hidden(_) => { - todo!(); + 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()); + } + _ => {} + } + } } } } @@ -186,7 +202,7 @@ impl ModerationDecision { c, ModerationCause::Blocking(_) | ModerationCause::BlockedBy(_) - | ModerationCause::BlockOther(_) + /* | ModerationCause::BlockOther(_) */ ) }) } @@ -353,6 +369,13 @@ impl ModerationDecision { 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() diff --git a/bsky-sdk/src/moderation/subjects/post.rs b/bsky-sdk/src/moderation/subjects/post.rs index e03dcd0..57a9326 100644 --- a/bsky-sdk/src/moderation/subjects/post.rs +++ b/bsky-sdk/src/moderation/subjects/post.rs @@ -26,7 +26,9 @@ impl Moderator { acc.add_label(LabelTarget::Content, label, self); } } - // TODO: hidden? + 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(); } @@ -112,6 +114,32 @@ impl Moderator { } } +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; diff --git a/bsky-sdk/src/moderation/types.rs b/bsky-sdk/src/moderation/types.rs index 9dfe0cb..1efa75c 100644 --- a/bsky-sdk/src/moderation/types.rs +++ b/bsky-sdk/src/moderation/types.rs @@ -68,8 +68,18 @@ impl ModerationBehavior { avatar: None, banner: None, display_name: None, - content_list: Some(ContentListBehavior::Inform), - content_view: Some(ContentViewBehavior::Inform), + 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 { @@ -509,10 +519,10 @@ impl From for SubjectProfile { pub type SubjectPost = PostView; #[derive(Debug, Clone)] -pub(crate) enum ModerationCause { +pub enum ModerationCause { Blocking(Box), BlockedBy(Box), - BlockOther(Box), + // BlockOther(Box), Label(Box), Muted(Box), MuteWord(Box), @@ -527,7 +537,7 @@ impl ModerationCause { Self::Label(label) => label.priority, Self::Muted(_) => Priority::Priority6, Self::MuteWord(_) => Priority::Priority6, - _ => todo!(), + Self::Hidden(_) => Priority::Priority6, } } pub fn downgrade(&mut self) { @@ -537,20 +547,20 @@ impl ModerationCause { Self::Label(label) => label.downgraded = true, Self::Muted(muted) => muted.downgraded = true, Self::MuteWord(mute_word) => mute_word.downgraded = true, - _ => todo!(), + Self::Hidden(hidden) => hidden.downgraded = true, } } } #[derive(Debug, Clone)] -pub(crate) enum ModerationCauseSource { +pub enum ModerationCauseSource { User, List(Box), Labeler(Did), } #[derive(Debug, Clone)] -pub(crate) struct ModerationCauseLabel { +pub struct ModerationCauseLabel { pub source: ModerationCauseSource, pub label: Label, pub label_def: InterpretedLabelValueDefinition, @@ -563,7 +573,7 @@ pub(crate) struct ModerationCauseLabel { } #[derive(Debug, Clone)] -pub(crate) struct ModerationCauseOther { +pub struct ModerationCauseOther { pub source: ModerationCauseSource, pub downgraded: bool, } diff --git a/bsky-sdk/src/moderation/ui.rs b/bsky-sdk/src/moderation/ui.rs index 87db270..5a88f3a 100644 --- a/bsky-sdk/src/moderation/ui.rs +++ b/bsky-sdk/src/moderation/ui.rs @@ -2,10 +2,10 @@ use super::types::ModerationCause; pub struct ModerationUi { pub no_override: bool, - pub(crate) filters: Vec, - pub(crate) blurs: Vec, - pub(crate) alerts: Vec, - pub(crate) informs: Vec, + pub filters: Vec, + pub blurs: Vec, + pub alerts: Vec, + pub informs: Vec, } impl ModerationUi { From 9dac37ddbeb7638f18a59a588d124752e0d71b2a Mon Sep 17 00:00:00 2001 From: sugyan Date: Wed, 5 Jun 2024 23:03:29 +0900 Subject: [PATCH 16/29] Add labels --- bsky-sdk/src/moderation/labels.rs | 82 ++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/bsky-sdk/src/moderation/labels.rs b/bsky-sdk/src/moderation/labels.rs index 9bd973d..66da991 100644 --- a/bsky-sdk/src/moderation/labels.rs +++ b/bsky-sdk/src/moderation/labels.rs @@ -167,7 +167,87 @@ impl KnownLabelValue { }, }, }, - _ => todo!(), + 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() + }, + }, + }, } } } From ba2a1905fb878c2ba68cac6c84a1133aafced88a Mon Sep 17 00:00:00 2001 From: sugyan Date: Thu, 6 Jun 2024 21:58:03 +0900 Subject: [PATCH 17/29] Add rich_text --- .github/workflows/bsky-sdk.yml | 8 +- Cargo.lock | 8 + bsky-sdk/Cargo.toml | 5 +- bsky-sdk/src/lib.rs | 2 + bsky-sdk/src/rich_text.rs | 99 +++++++ bsky-sdk/src/rich_text/tests.rs | 322 ++++++++++++++++++++++ bsky-sdk/src/rich_text/tests/detection.rs | 1 + 7 files changed, 442 insertions(+), 3 deletions(-) create mode 100644 bsky-sdk/src/rich_text.rs create mode 100644 bsky-sdk/src/rich_text/tests.rs create mode 100644 bsky-sdk/src/rich_text/tests/detection.rs diff --git a/.github/workflows/bsky-sdk.yml b/.github/workflows/bsky-sdk.yml index 0fa59d4..bbe8b6f 100644 --- a/.github/workflows/bsky-sdk.yml +++ b/.github/workflows/bsky-sdk.yml @@ -15,6 +15,10 @@ jobs: steps: - uses: actions/checkout@v4 - name: Build - run: cargo build -p bsky-sdk --verbose + run: | + cargo build -p bsky-sdk --verbose + cargo build -p bsky-sdk --verbose --no-default-features - name: Run tests - run: cargo test -p bsky-sdk --verbose + run: | + cargo test -p bsky-sdk --verbose + cargo test -p bsky-sdk --verbose --no-default-features diff --git a/Cargo.lock b/Cargo.lock index 3ecd39e..280f10c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,11 +249,13 @@ dependencies = [ "atrium-xrpc-client", "http 1.1.0", "ipld-core", + "regex", "serde", "serde_json", "thiserror", "tokio", "toml", + "unicode-segmentation", ] [[package]] @@ -2037,6 +2039,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" diff --git a/bsky-sdk/Cargo.toml b/bsky-sdk/Cargo.toml index 2a3e104..79a0b66 100644 --- a/bsky-sdk/Cargo.toml +++ b/bsky-sdk/Cargo.toml @@ -17,14 +17,17 @@ atrium-api.workspace = true atrium-xrpc-client.workspace = true http.workspace = true ipld-core.workspace = 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 = ["rich-text"] +rich-text = ["unicode-segmentation"] config-toml = ["toml"] diff --git a/bsky-sdk/src/lib.rs b/bsky-sdk/src/lib.rs index 93320ef..e552bad 100644 --- a/bsky-sdk/src/lib.rs +++ b/bsky-sdk/src/lib.rs @@ -2,6 +2,8 @@ pub mod agent; mod error; pub mod moderation; pub mod preference; +#[cfg(feature = "rich-text")] +pub mod rich_text; pub use agent::BskyAgent; pub use atrium_api as api; diff --git a/bsky-sdk/src/rich_text.rs b/bsky-sdk/src/rich_text.rs new file mode 100644 index 0000000..b9c5c9c --- /dev/null +++ b/bsky-sdk/src/rich_text.rs @@ -0,0 +1,99 @@ +use atrium_api::app::bsky::richtext::facet::ByteSlice; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Debug, Clone)] +pub struct RichText { + text: String, + facets: Option>, +} + +impl RichText { + const BYTE_SLICE_ZERO: ByteSlice = ByteSlice { + byte_start: 0, + byte_end: 0, + }; + pub fn new( + text: impl AsRef, + facets: Option>, + ) -> Self { + RichText { + text: text.as_ref().into(), + facets, + } + } + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + pub fn len(&self) -> usize { + self.text.len() + } + pub fn grapheme_len(&self) -> usize { + self.text.as_str().graphemes(true).count() + } + 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 + } + } + } + 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); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/bsky-sdk/src/rich_text/tests.rs b/bsky-sdk/src/rich_text/tests.rs new file mode 100644 index 0000000..de0f56e --- /dev/null +++ b/bsky-sdk/src/rich_text/tests.rs @@ -0,0 +1,322 @@ +mod detection; + +use crate::rich_text::RichText; +use atrium_api::app::bsky::richtext::facet::{ByteSlice, Main}; +use atrium_api::types::{Union, UnknownData}; +use ipld_core::ipld::Ipld; + +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_start, + byte_end, + }, + } +} + +#[test] +fn calculate_bytelength_and_grapheme_length() { + { + let rt = RichText::new("Hello!", None); + assert_eq!(rt.len(), 6); + assert_eq!(rt.grapheme_len(), 6); + } + { + let rt = RichText::new("👨‍👩‍👧‍👧", None); + assert_eq!(rt.len(), 25); + assert_eq!(rt.grapheme_len(), 1); + } + { + let rt = RichText::new("👨‍👩‍👧‍👧🔥 good!✅", None); + assert_eq!(rt.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👨‍👩‍👧‍👧" + ); + } +} 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 0000000..8b13789 --- /dev/null +++ b/bsky-sdk/src/rich_text/tests/detection.rs @@ -0,0 +1 @@ + From e9a7c55f86a8c53a8a159384ec10fd1c62f102be Mon Sep 17 00:00:00 2001 From: sugyan Date: Thu, 6 Jun 2024 23:52:49 +0900 Subject: [PATCH 18/29] Add RichText::segment, add tests --- bsky-sdk/src/rich_text.rs | 83 +++++++++++++++- bsky-sdk/src/rich_text/tests.rs | 166 +++++++++++++++++++++++++++++++- 2 files changed, 245 insertions(+), 4 deletions(-) diff --git a/bsky-sdk/src/rich_text.rs b/bsky-sdk/src/rich_text.rs index b9c5c9c..2a1cb13 100644 --- a/bsky-sdk/src/rich_text.rs +++ b/bsky-sdk/src/rich_text.rs @@ -1,6 +1,50 @@ -use atrium_api::app::bsky::richtext::facet::ByteSlice; +use atrium_api::app::bsky::richtext::facet::{ByteSlice, Link, MainFeaturesItem, Mention, Tag}; +use atrium_api::types::Union; +use std::cmp::Ordering; use unicode_segmentation::UnicodeSegmentation; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RichTextSegment { + text: String, + facet: Option, +} + +impl RichTextSegment { + pub fn new( + text: impl AsRef, + facets: Option, + ) -> Self { + Self { + text: text.as_ref().into(), + facet: facets, + } + } + 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, + }) + }) + } + 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, + }) + }) + } + 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, + }) + }) + } +} + #[derive(Debug, Clone)] pub struct RichText { text: String, @@ -30,6 +74,43 @@ impl RichText { pub fn grapheme_len(&self) -> usize { self.text.as_str().graphemes(true).count() } + 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 + } 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() { diff --git a/bsky-sdk/src/rich_text/tests.rs b/bsky-sdk/src/rich_text/tests.rs index de0f56e..406d276 100644 --- a/bsky-sdk/src/rich_text/tests.rs +++ b/bsky-sdk/src/rich_text/tests.rs @@ -1,7 +1,7 @@ mod detection; -use crate::rich_text::RichText; -use atrium_api::app::bsky::richtext::facet::{ByteSlice, Main}; +use crate::rich_text::{RichText, RichTextSegment}; +use atrium_api::app::bsky::richtext::facet::{ByteSlice, Link, Main, MainFeaturesItem, Mention}; use atrium_api::types::{Union, UnknownData}; use ipld_core::ipld::Ipld; @@ -12,8 +12,8 @@ fn facet(byte_start: usize, byte_end: usize) -> Main { data: Ipld::Null, })], index: ByteSlice { - byte_start, byte_end, + byte_start, }, } } @@ -320,3 +320,163 @@ fn delete_with_fat_unicode() { ); } } + +#[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), + ] + ); + } +} From 03f4c8ff4ac520989731604ada283f065e9b934c Mon Sep 17 00:00:00 2001 From: sugyan Date: Sat, 8 Jun 2024 23:55:54 +0900 Subject: [PATCH 19/29] WIP: Add RichText::detect_facets --- bsky-sdk/src/rich_text.rs | 52 ++++++++ bsky-sdk/src/rich_text/detection.rs | 49 +++++++ bsky-sdk/src/rich_text/tests/detection.rs | 151 ++++++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 bsky-sdk/src/rich_text/detection.rs diff --git a/bsky-sdk/src/rich_text.rs b/bsky-sdk/src/rich_text.rs index 2a1cb13..74ef2e8 100644 --- a/bsky-sdk/src/rich_text.rs +++ b/bsky-sdk/src/rich_text.rs @@ -1,5 +1,12 @@ +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; @@ -174,6 +181,51 @@ impl RichText { facets.retain(|facet| facet.index.byte_start < facet.index.byte_end); } } + pub async fn detect_facets(&mut self, client: impl XrpcClient + Send + Sync) -> Result<()> { + let agent = BskyAgentBuilder::default() + .client(client) + .config(Config { + endpoint: "https://public.api.bsky.app".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)] diff --git a/bsky-sdk/src/rich_text/detection.rs b/bsky-sdk/src/rich_text/detection.rs new file mode 100644 index 0000000..1f697b0 --- /dev/null +++ b/bsky-sdk/src/rich_text/detection.rs @@ -0,0 +1,49 @@ +use atrium_api::app::bsky::richtext::facet::{ByteSlice, Link, Tag}; +use regex::Regex; +use std::sync::OnceLock; + +static RE_MENTION: 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, + }, + }); + } + } + facets +} diff --git a/bsky-sdk/src/rich_text/tests/detection.rs b/bsky-sdk/src/rich_text/tests/detection.rs index 8b13789..c409758 100644 --- a/bsky-sdk/src/rich_text/tests/detection.rs +++ b/bsky-sdk/src/rich_text/tests/detection.rs @@ -1 +1,152 @@ +use crate::error::Result; +use crate::rich_text::{RichText, RichTextSegment}; +use async_trait::async_trait; +use atrium_api::app::bsky::richtext::facet::MainFeaturesItem; +use atrium_api::types::Union; +use atrium_api::xrpc::types::Header; +use atrium_api::xrpc::{HttpClient, XrpcClient}; +use http::{Request, Response}; +struct MockClient; + +#[async_trait] +impl HttpClient for MockClient { + async fn send_http( + &self, + request: Request>, + ) -> core::result::Result>, 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() + } +} + +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), + Union::Refs(MainFeaturesItem::Tag(tag)) => Some(&tag.tag), + _ => None, + }) + }), + ) +} + +#[tokio::test] +async fn detect_facets() -> 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), + ], + ), + ]; + for (input, expected) in test_cases { + let mut rt = RichText::new(input, None); + rt.detect_facets(MockClient).await?; + assert_eq!( + rt.segments() + .iter() + .map(segment_to_output) + .collect::>(), + expected + ); + } + Ok(()) +} From 35ebaeb299985afa1dc1722966e3928ea2959df9 Mon Sep 17 00:00:00 2001 From: sugyan Date: Sun, 9 Jun 2024 23:48:44 +0900 Subject: [PATCH 20/29] Add link detection, add tests --- Cargo.lock | 16 +++ bsky-sdk/Cargo.toml | 3 +- bsky-sdk/src/rich_text.rs | 8 +- bsky-sdk/src/rich_text/detection.rs | 41 ++++++ bsky-sdk/src/rich_text/tests/detection.rs | 163 +++++++++++++++++++++- 5 files changed, 225 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 280f10c..4187ae7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,6 +249,7 @@ dependencies = [ "atrium-xrpc-client", "http 1.1.0", "ipld-core", + "psl", "regex", "serde", "serde_json", @@ -1357,6 +1358,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" diff --git a/bsky-sdk/Cargo.toml b/bsky-sdk/Cargo.toml index 79a0b66..fa3f708 100644 --- a/bsky-sdk/Cargo.toml +++ b/bsky-sdk/Cargo.toml @@ -17,6 +17,7 @@ atrium-api.workspace = true atrium-xrpc-client.workspace = true http.workspace = true ipld-core.workspace = true +psl = { version = "2.1.42", optional = true } regex.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true @@ -29,5 +30,5 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [features] default = ["rich-text"] -rich-text = ["unicode-segmentation"] +rich-text = ["psl", "unicode-segmentation"] config-toml = ["toml"] diff --git a/bsky-sdk/src/rich_text.rs b/bsky-sdk/src/rich_text.rs index 74ef2e8..ce8bc75 100644 --- a/bsky-sdk/src/rich_text.rs +++ b/bsky-sdk/src/rich_text.rs @@ -10,6 +10,8 @@ use detection::{detect_facets, FacetFeaturesItem}; use std::cmp::Ordering; use unicode_segmentation::UnicodeSegmentation; +const PUBLIC_API_ENDPOINT: &str = "https://public.api.bsky.app"; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RichTextSegment { text: String, @@ -181,11 +183,11 @@ impl RichText { facets.retain(|facet| facet.index.byte_start < facet.index.byte_end); } } - pub async fn detect_facets(&mut self, client: impl XrpcClient + Send + Sync) -> Result<()> { + pub async fn detect_facets(mut self, client: impl XrpcClient + Send + Sync) -> Result { let agent = BskyAgentBuilder::default() .client(client) .config(Config { - endpoint: "https://public.api.bsky.app".into(), + endpoint: PUBLIC_API_ENDPOINT.into(), ..Default::default() }) .build() @@ -224,7 +226,7 @@ impl RichText { } Some(facets) }; - Ok(()) + Ok(self) } } diff --git a/bsky-sdk/src/rich_text/detection.rs b/bsky-sdk/src/rich_text/detection.rs index 1f697b0..51d8a4c 100644 --- a/bsky-sdk/src/rich_text/detection.rs +++ b/bsky-sdk/src/rich_text/detection.rs @@ -1,8 +1,11 @@ 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(); #[derive(Debug, Clone, PartialEq, Eq)] pub struct FacetWithoutResolution { @@ -45,5 +48,43 @@ pub fn detect_facets(text: &str) -> Vec { }); } } + // 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.pop(); + index.byte_end -= 1; + } + facets.push(FacetWithoutResolution { + features: vec![FacetFeaturesItem::Link(Box::new(Link { uri }))], + index, + }); + } + } facets } diff --git a/bsky-sdk/src/rich_text/tests/detection.rs b/bsky-sdk/src/rich_text/tests/detection.rs index c409758..d3df662 100644 --- a/bsky-sdk/src/rich_text/tests/detection.rs +++ b/bsky-sdk/src/rich_text/tests/detection.rs @@ -136,10 +136,169 @@ async fn detect_facets() -> Result<()> { (" 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)]), ]; for (input, expected) in test_cases { - let mut rt = RichText::new(input, None); - rt.detect_facets(MockClient).await?; + let rt = RichText::new(input, None).detect_facets(MockClient).await?; assert_eq!( rt.segments() .iter() From f5b928a2ecc7f7d4837d38c1283b30d56ccf9ed3 Mon Sep 17 00:00:00 2001 From: sugyan Date: Mon, 10 Jun 2024 14:57:58 +0900 Subject: [PATCH 21/29] Implement tag detection --- bsky-sdk/src/rich_text.rs | 15 +- bsky-sdk/src/rich_text/detection.rs | 41 ++++- bsky-sdk/src/rich_text/tests/detection.rs | 208 ++++++++++++++++++++-- 3 files changed, 244 insertions(+), 20 deletions(-) diff --git a/bsky-sdk/src/rich_text.rs b/bsky-sdk/src/rich_text.rs index ce8bc75..1d4a121 100644 --- a/bsky-sdk/src/rich_text.rs +++ b/bsky-sdk/src/rich_text.rs @@ -74,6 +74,17 @@ impl RichText { facets, } } + 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) + } pub fn is_empty(&self) -> bool { self.text.is_empty() } @@ -183,7 +194,7 @@ impl RichText { facets.retain(|facet| facet.index.byte_start < facet.index.byte_end); } } - pub async fn detect_facets(mut self, client: impl XrpcClient + Send + Sync) -> Result { + pub async fn detect_facets(&mut self, client: impl XrpcClient + Send + Sync) -> Result<()> { let agent = BskyAgentBuilder::default() .client(client) .config(Config { @@ -226,7 +237,7 @@ impl RichText { } Some(facets) }; - Ok(self) + Ok(()) } } diff --git a/bsky-sdk/src/rich_text/detection.rs b/bsky-sdk/src/rich_text/detection.rs index 51d8a4c..acdd932 100644 --- a/bsky-sdk/src/rich_text/detection.rs +++ b/bsky-sdk/src/rich_text/detection.rs @@ -6,6 +6,8 @@ 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 { @@ -73,9 +75,10 @@ pub fn detect_facets(text: &str) -> Vec { byte_start: m.start(), }; // strip ending puncuation - if RE_ENDING_PUNCTUATION + if (RE_ENDING_PUNCTUATION .get_or_init(|| Regex::new(r"[.,;:!?]$").expect("invalid regex")) - .is_match(&uri) + .is_match(&uri)) + || (uri.ends_with(')') && !uri.contains('(')) { uri.pop(); index.byte_end -= 1; @@ -86,5 +89,39 @@ pub fn detect_facets(text: &str) -> Vec { }); } } + // 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) { + println!("{:?}", capture); + 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/detection.rs b/bsky-sdk/src/rich_text/tests/detection.rs index d3df662..4d18359 100644 --- a/bsky-sdk/src/rich_text/tests/detection.rs +++ b/bsky-sdk/src/rich_text/tests/detection.rs @@ -42,22 +42,8 @@ impl XrpcClient for MockClient { } } -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), - Union::Refs(MainFeaturesItem::Tag(tag)) => Some(&tag.tag), - _ => None, - }) - }), - ) -} - #[tokio::test] -async fn detect_facets() -> Result<()> { +async fn detect_facets_mentions_and_links() -> Result<()> { let test_cases = [ ("no mention", vec![("no mention", None)]), ( @@ -296,9 +282,82 @@ async fn detect_facets() -> Result<()> { ("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 = RichText::new(input, None).detect_facets(MockClient).await?; + let rt = RichText::new_with_detect_facets(input, MockClient).await?; assert_eq!( rt.segments() .iter() @@ -309,3 +368,120 @@ async fn detect_facets() -> Result<()> { } 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 = RichText::new_with_detect_facets(input, MockClient).await?; + assert_eq!( + rt.segments() + .iter() + .filter_map(segment_to_output) + .collect::>(), + expected + ); + } + Ok(()) +} From e6e151c17e29e22be703191d189991d21a337902 Mon Sep 17 00:00:00 2001 From: sugyan Date: Tue, 11 Jun 2024 16:53:24 +0900 Subject: [PATCH 22/29] Add tests for has_muted_word --- bsky-sdk/src/lib.rs | 45 ++ bsky-sdk/src/moderation.rs | 1 + bsky-sdk/src/moderation/mutewords.rs | 139 +++++ bsky-sdk/src/moderation/subjects/post.rs | 139 +---- bsky-sdk/src/moderation/tests/mutewords.rs | 603 ++++++++++++++++++++- bsky-sdk/src/rich_text.rs | 10 +- bsky-sdk/src/rich_text/detection.rs | 1 - bsky-sdk/src/rich_text/tests.rs | 6 +- bsky-sdk/src/rich_text/tests/detection.rs | 40 +- 9 files changed, 794 insertions(+), 190 deletions(-) create mode 100644 bsky-sdk/src/moderation/mutewords.rs diff --git a/bsky-sdk/src/lib.rs b/bsky-sdk/src/lib.rs index e552bad..0961803 100644 --- a/bsky-sdk/src/lib.rs +++ b/bsky-sdk/src/lib.rs @@ -8,3 +8,48 @@ 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::types::Header; + use atrium_api::xrpc::{HttpClient, XrpcClient}; + use http::{Request, Response}; + + 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 index 5b36fe9..6938a40 100644 --- a/bsky-sdk/src/moderation.rs +++ b/bsky-sdk/src/moderation.rs @@ -1,6 +1,7 @@ pub mod decision; mod error; mod labels; +pub mod mutewords; mod subjects; mod types; pub mod ui; diff --git a/bsky-sdk/src/moderation/mutewords.rs b/bsky-sdk/src/moderation/mutewords.rs new file mode 100644 index 0000000..351c41b --- /dev/null +++ b/bsky-sdk/src/moderation/mutewords.rs @@ -0,0 +1,139 @@ +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 +]; + +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/post.rs b/bsky-sdk/src/moderation/subjects/post.rs index 57a9326..f65ecc8 100644 --- a/bsky-sdk/src/moderation/subjects/post.rs +++ b/bsky-sdk/src/moderation/subjects/post.rs @@ -1,19 +1,12 @@ 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::app::bsky::richtext::facet::MainFeaturesItem; use atrium_api::records::{KnownRecord, Record}; 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(); impl Moderator { pub fn decide_post(&self, subject: &SubjectPost) -> ModerationDecision { @@ -159,133 +152,3 @@ fn check_muted_words(subject: &SubjectPost, muted_words: &[MutedWord]) -> bool { false } - -/** - * 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 -]; - -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.len() == 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(&muted_word, " ") - .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/tests/mutewords.rs b/bsky-sdk/src/moderation/tests/mutewords.rs index efc021c..6b4da71 100644 --- a/bsky-sdk/src/moderation/tests/mutewords.rs +++ b/bsky-sdk/src/moderation/tests/mutewords.rs @@ -1,10 +1,611 @@ use super::{post_view, profile_view_basic}; +use crate::error::Result; use crate::moderation::decision::DecisionContext; +use crate::moderation::mutewords::has_muted_word; use crate::moderation::{ModerationPrefs, Moderator}; +#[cfg(feature = "rich-text")] +use crate::rich_text::RichText; +use crate::tests::MockClient; use atrium_api::app::bsky::actor::defs::MutedWord; use std::collections::HashMap; -// TODO: RichText +#[cfg(feature = "rich-text")] +#[tokio::test] +async fn has_muted_word_from_rich_text() -> Result<()> { + // match: outline tag + { + let rt = RichText::new_with_detect_facets("This is a post #inlineTag", MockClient).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 = RichText::new_with_detect_facets("This is a post #inlineTag", MockClient).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 = RichText::new_with_detect_facets("This is a post #inlineTag", MockClient).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 = RichText::new_with_detect_facets("This is a post", MockClient).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 = RichText::new_with_detect_facets("改善希望です", MockClient).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 = RichText::new_with_detect_facets("Idk why ☠︎ but maybe", MockClient).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 = RichText::new_with_detect_facets("hey", MockClient).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 = RichText::new_with_detect_facets("javascript", MockClient).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 = + RichText::new_with_detect_facets("This is a post about javascript", MockClient).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 = RichText::new_with_detect_facets("Use your brain, Eric", MockClient).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 = RichText::new_with_detect_facets("Use your\n\tbrain, Eric", MockClient).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 = RichText::new_with_detect_facets("So happy :)", MockClient).await?; + assert!(has_muted_word( + &[MutedWord { + targets: vec![String::from("content")], + value: String::from(":)"), + }], + &rt.text, + &rt.facets, + &Some(vec![]), + &None + )); + } + // yay! + { + let rt = RichText::new_with_detect_facets("We're federating, yay!", MockClient).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 = RichText::new_with_detect_facets("We're federating, y!ppee!!", MockClient).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 = + RichText::new_with_detect_facets("Yay, Bluesky's mutewords work", MockClient).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 = RichText::new_with_detect_facets("Why so S@assy?", MockClient).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 = RichText::new_with_detect_facets("New York Times", MockClient).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 = RichText::new_with_detect_facets("Idk maybe a bot !command", MockClient).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 = RichText::new_with_detect_facets("Idk maybe a bot command", MockClient).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 = RichText::new_with_detect_facets("I'm e/acc pilled", MockClient).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 = RichText::new_with_detect_facets("I'm super-bad", MockClient).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 = + RichText::new_with_detect_facets("Weird post with idk_what_this_would_be", MockClient) + .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 = RichText::new_with_detect_facets("Post with context(iykyk)", MockClient).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 = RichText::new_with_detect_facets("Post with 🦋", MockClient).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 = RichText::new_with_detect_facets( + "I like turtles, or how I learned to stop worrying and love the internet.", + MockClient, + ) + .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 = RichText::new_with_detect_facets("私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか", MockClient).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() { diff --git a/bsky-sdk/src/rich_text.rs b/bsky-sdk/src/rich_text.rs index 1d4a121..d489c2e 100644 --- a/bsky-sdk/src/rich_text.rs +++ b/bsky-sdk/src/rich_text.rs @@ -56,8 +56,8 @@ impl RichTextSegment { #[derive(Debug, Clone)] pub struct RichText { - text: String, - facets: Option>, + pub text: String, + pub facets: Option>, } impl RichText { @@ -85,12 +85,6 @@ impl RichText { rt.detect_facets(client).await?; Ok(rt) } - pub fn is_empty(&self) -> bool { - self.text.is_empty() - } - pub fn len(&self) -> usize { - self.text.len() - } pub fn grapheme_len(&self) -> usize { self.text.as_str().graphemes(true).count() } diff --git a/bsky-sdk/src/rich_text/detection.rs b/bsky-sdk/src/rich_text/detection.rs index acdd932..479d760 100644 --- a/bsky-sdk/src/rich_text/detection.rs +++ b/bsky-sdk/src/rich_text/detection.rs @@ -98,7 +98,6 @@ pub fn detect_facets(text: &str) -> Vec { .expect("invalid regex") }); for capture in re.captures_iter(text) { - println!("{:?}", capture); if let Some(tag) = capture.get(2) { // strip ending punctuation and any spaces let tag = RE_TRAILING_PUNCTUATION diff --git a/bsky-sdk/src/rich_text/tests.rs b/bsky-sdk/src/rich_text/tests.rs index 406d276..2635040 100644 --- a/bsky-sdk/src/rich_text/tests.rs +++ b/bsky-sdk/src/rich_text/tests.rs @@ -22,17 +22,17 @@ fn facet(byte_start: usize, byte_end: usize) -> Main { fn calculate_bytelength_and_grapheme_length() { { let rt = RichText::new("Hello!", None); - assert_eq!(rt.len(), 6); + assert_eq!(rt.text.len(), 6); assert_eq!(rt.grapheme_len(), 6); } { let rt = RichText::new("👨‍👩‍👧‍👧", None); - assert_eq!(rt.len(), 25); + assert_eq!(rt.text.len(), 25); assert_eq!(rt.grapheme_len(), 1); } { let rt = RichText::new("👨‍👩‍👧‍👧🔥 good!✅", None); - assert_eq!(rt.len(), 38); + assert_eq!(rt.text.len(), 38); assert_eq!(rt.grapheme_len(), 9); } } diff --git a/bsky-sdk/src/rich_text/tests/detection.rs b/bsky-sdk/src/rich_text/tests/detection.rs index 4d18359..81578c4 100644 --- a/bsky-sdk/src/rich_text/tests/detection.rs +++ b/bsky-sdk/src/rich_text/tests/detection.rs @@ -1,46 +1,8 @@ use crate::error::Result; use crate::rich_text::{RichText, RichTextSegment}; -use async_trait::async_trait; +use crate::tests::MockClient; use atrium_api::app::bsky::richtext::facet::MainFeaturesItem; use atrium_api::types::Union; -use atrium_api::xrpc::types::Header; -use atrium_api::xrpc::{HttpClient, XrpcClient}; -use http::{Request, Response}; - -struct MockClient; - -#[async_trait] -impl HttpClient for MockClient { - async fn send_http( - &self, - request: Request>, - ) -> core::result::Result>, 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() - } -} #[tokio::test] async fn detect_facets_mentions_and_links() -> Result<()> { From dfb8df80626e515578e4f8f6930ae7a37c8b43ac Mon Sep 17 00:00:00 2001 From: sugyan Date: Tue, 11 Jun 2024 22:02:02 +0900 Subject: [PATCH 23/29] Update tests --- bsky-sdk/src/moderation/tests/mutewords.rs | 46 ++++++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/bsky-sdk/src/moderation/tests/mutewords.rs b/bsky-sdk/src/moderation/tests/mutewords.rs index 6b4da71..322980a 100644 --- a/bsky-sdk/src/moderation/tests/mutewords.rs +++ b/bsky-sdk/src/moderation/tests/mutewords.rs @@ -1,17 +1,17 @@ use super::{post_view, profile_view_basic}; -use crate::error::Result; use crate::moderation::decision::DecisionContext; -use crate::moderation::mutewords::has_muted_word; use crate::moderation::{ModerationPrefs, Moderator}; #[cfg(feature = "rich-text")] use crate::rich_text::RichText; -use crate::tests::MockClient; 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() -> Result<()> { +async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { + use crate::moderation::mutewords::has_muted_word; + use crate::tests::MockClient; + // match: outline tag { let rt = RichText::new_with_detect_facets("This is a post #inlineTag", MockClient).await?; @@ -647,3 +647,41 @@ fn does_not_mute_own_post() { "post should not be filtered" ); } + +#[cfg(feature = "rich-text")] +#[tokio::test] +async fn does_not_mute_own_tags() -> crate::error::Result<()> { + use crate::tests::MockClient; + 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 = RichText::new_with_detect_facets("Mute #words!", MockClient).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(()) +} From fa44518d20e407fdc342e56a89773e9d475456ef Mon Sep 17 00:00:00 2001 From: sugyan Date: Tue, 11 Jun 2024 23:13:00 +0900 Subject: [PATCH 24/29] Add default-client feature --- .github/workflows/bsky-sdk.yml | 6 + Cargo.lock | 2 - atrium-xrpc-client/Cargo.toml | 1 - atrium-xrpc-client/src/isahc.rs | 2 +- atrium-xrpc-client/src/reqwest.rs | 2 +- atrium-xrpc-client/src/tests.rs | 2 +- atrium-xrpc/src/lib.rs | 1 + bsky-sdk/Cargo.toml | 6 +- bsky-sdk/src/agent.rs | 107 +++------------- bsky-sdk/src/agent/builder.rs | 203 ++++++++++++++++++++++++++++++ bsky-sdk/src/error.rs | 15 +-- bsky-sdk/src/lib.rs | 2 +- bsky-sdk/src/rich_text.rs | 3 +- 13 files changed, 246 insertions(+), 106 deletions(-) create mode 100644 bsky-sdk/src/agent/builder.rs diff --git a/.github/workflows/bsky-sdk.yml b/.github/workflows/bsky-sdk.yml index bbe8b6f..55b5af6 100644 --- a/.github/workflows/bsky-sdk.yml +++ b/.github/workflows/bsky-sdk.yml @@ -18,7 +18,13 @@ jobs: 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 --verbose cargo test -p bsky-sdk --verbose --no-default-features + cargo test -p bsky-sdk --verbose --no-default-features --features default-client + cargo test -p bsky-sdk --verbose --no-default-features --features rich-text + cargo test -p bsky-sdk --verbose --all-features diff --git a/Cargo.lock b/Cargo.lock index 4187ae7..b277e10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,7 +185,6 @@ dependencies = [ "async-trait", "atrium-xrpc", "futures", - "http 1.1.0", "isahc", "mockito", "reqwest", @@ -247,7 +246,6 @@ dependencies = [ "async-trait", "atrium-api", "atrium-xrpc-client", - "http 1.1.0", "ipld-core", "psl", "regex", diff --git a/atrium-xrpc-client/Cargo.toml b/atrium-xrpc-client/Cargo.toml index cb4e522..ac2cbca 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 0f6492b..06f5917 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 9419567..83039e9 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 5c9a979..e0cda2d 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 bbc169c..c7fb33c 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.toml b/bsky-sdk/Cargo.toml index fa3f708..f82fa04 100644 --- a/bsky-sdk/Cargo.toml +++ b/bsky-sdk/Cargo.toml @@ -14,8 +14,7 @@ keywords = ["atproto", "bluesky", "atrium", "sdk"] anyhow.workspace = true async-trait.workspace = true atrium-api.workspace = true -atrium-xrpc-client.workspace = true -http.workspace = true +atrium-xrpc-client = { workspace = true, optional = true } ipld-core.workspace = true psl = { version = "2.1.42", optional = true } regex.workspace = true @@ -29,6 +28,7 @@ unicode-segmentation = { version = "1.11.0", optional = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [features] -default = ["rich-text"] +default = ["default-client", "rich-text"] +default-client = ["atrium-xrpc-client"] rich-text = ["psl", "unicode-segmentation"] config-toml = ["toml"] diff --git a/bsky-sdk/src/agent.rs b/bsky-sdk/src/agent.rs index 61df697..8a60c33 100644 --- a/bsky-sdk/src/agent.rs +++ b/bsky-sdk/src/agent.rs @@ -1,5 +1,7 @@ +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; @@ -10,29 +12,41 @@ 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; -pub struct BskyAgent +#[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(feature = "default-client")] impl BskyAgent { - pub fn builder() -> BskyAgentBuilder { + pub fn builder() -> BskyAgentBuilder { BskyAgentBuilder::default() } } -impl BskyAgent +impl BskyAgent where - S: SessionStore + Send + Sync, T: XrpcClient + Send + Sync, + S: SessionStore + Send + Sync, { pub async fn to_config(&self) -> Config { Config { @@ -160,10 +174,10 @@ where } } -impl Deref for BskyAgent +impl Deref for BskyAgent where - S: SessionStore + Send + Sync, T: XrpcClient + Send + Sync, + S: SessionStore + Send + Sync, { type Target = AtpAgent; @@ -171,84 +185,3 @@ where &self.inner } } - -pub struct BskyAgentBuilder -where - S: SessionStore + Send + Sync, - T: XrpcClient + Send + Sync, -{ - config: Config, - store: S, - client: T, -} - -impl BskyAgentBuilder -where - S: SessionStore + Send + Sync, - T: XrpcClient + Send + Sync, -{ - pub fn config(mut self, config: Config) -> Self { - self.config = config; - self - } - pub fn store(self, store: S0) -> BskyAgentBuilder - where - S0: SessionStore + Send + Sync, - { - BskyAgentBuilder { - config: self.config, - store, - client: self.client, - } - } - 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 }) - } -} - -impl Default for BskyAgentBuilder { - fn default() -> Self { - Self { - config: Config::default(), - client: ReqwestClient::new(Config::default().endpoint), - store: MemorySessionStore::default(), - } - } -} diff --git a/bsky-sdk/src/agent/builder.rs b/bsky-sdk/src/agent/builder.rs new file mode 100644 index 0000000..e6c4d26 --- /dev/null +++ b/bsky-sdk/src/agent/builder.rs @@ -0,0 +1,203 @@ +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; + +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, + S: SessionStore + Send + Sync, +{ + pub fn config(mut self, config: Config) -> Self { + self.config = config; + self + } + pub fn store(self, store: S0) -> BskyAgentBuilder + where + S0: SessionStore + Send + Sync, + { + BskyAgentBuilder { + config: self.config, + store, + client: self.client, + } + } + 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 }) + } +} + +impl BskyAgentBuilder +where + T: XrpcClient + Send + Sync, +{ + pub fn new(client: T) -> Self { + Self { + config: Config::default(), + store: MemorySessionStore::default(), + client, + } + } +} + +#[cfg(feature = "default-client")] +impl Default for BskyAgentBuilder { + 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/error.rs b/bsky-sdk/src/error.rs index 49077e5..3be0743 100644 --- a/bsky-sdk/src/error.rs +++ b/bsky-sdk/src/error.rs @@ -1,5 +1,6 @@ -use atrium_api::xrpc; -use http::StatusCode; +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. @@ -36,14 +37,14 @@ impl std::fmt::Display for GenericXrpcError { } } -impl From> for Error { - fn from(err: xrpc::Error) -> Self { - if let xrpc::Error::XrpcResponse(e) = err { +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 { - xrpc::error::XrpcErrorKind::Custom(_) => String::from("custom error"), - xrpc::error::XrpcErrorKind::Undefined(res) => res.to_string(), + XrpcErrorKind::Custom(_) => String::from("custom error"), + XrpcErrorKind::Undefined(res) => res.to_string(), }), })) } else { diff --git a/bsky-sdk/src/lib.rs b/bsky-sdk/src/lib.rs index 0961803..58a3063 100644 --- a/bsky-sdk/src/lib.rs +++ b/bsky-sdk/src/lib.rs @@ -12,9 +12,9 @@ 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}; - use http::{Request, Response}; pub struct MockClient; diff --git a/bsky-sdk/src/rich_text.rs b/bsky-sdk/src/rich_text.rs index d489c2e..cd98ab3 100644 --- a/bsky-sdk/src/rich_text.rs +++ b/bsky-sdk/src/rich_text.rs @@ -189,8 +189,7 @@ impl RichText { } } pub async fn detect_facets(&mut self, client: impl XrpcClient + Send + Sync) -> Result<()> { - let agent = BskyAgentBuilder::default() - .client(client) + let agent = BskyAgentBuilder::new(client) .config(Config { endpoint: PUBLIC_API_ENDPOINT.into(), ..Default::default() From 9fd838d497544302d9838ee17fba67adc941dbf3 Mon Sep 17 00:00:00 2001 From: sugyan Date: Wed, 12 Jun 2024 22:44:42 +0900 Subject: [PATCH 25/29] Update interface, and tests --- bsky-sdk/src/error.rs | 2 +- bsky-sdk/src/moderation/tests/mutewords.rs | 65 ++++++++++------------ bsky-sdk/src/rich_text.rs | 14 ++++- bsky-sdk/src/rich_text/tests.rs | 15 +++++ bsky-sdk/src/rich_text/tests/detection.rs | 8 +-- 5 files changed, 62 insertions(+), 42 deletions(-) diff --git a/bsky-sdk/src/error.rs b/bsky-sdk/src/error.rs index 3be0743..9ae9d2f 100644 --- a/bsky-sdk/src/error.rs +++ b/bsky-sdk/src/error.rs @@ -53,5 +53,5 @@ impl From> for Error { } } -/// Type alias to use this crate's [`Error`] type in a [`Result`]. +/// Type alias to use this crate's [`Error`](crate::Error) type in a [`Result`](core::result::Result). pub type Result = core::result::Result; diff --git a/bsky-sdk/src/moderation/tests/mutewords.rs b/bsky-sdk/src/moderation/tests/mutewords.rs index 322980a..fda662c 100644 --- a/bsky-sdk/src/moderation/tests/mutewords.rs +++ b/bsky-sdk/src/moderation/tests/mutewords.rs @@ -1,8 +1,6 @@ use super::{post_view, profile_view_basic}; use crate::moderation::decision::DecisionContext; use crate::moderation::{ModerationPrefs, Moderator}; -#[cfg(feature = "rich-text")] -use crate::rich_text::RichText; use atrium_api::app::bsky::actor::defs::MutedWord; use std::collections::HashMap; @@ -10,11 +8,11 @@ use std::collections::HashMap; #[tokio::test] async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { use crate::moderation::mutewords::has_muted_word; - use crate::tests::MockClient; + use crate::rich_text::tests::rich_text_with_detect_facets; // match: outline tag { - let rt = RichText::new_with_detect_facets("This is a post #inlineTag", MockClient).await?; + let rt = rich_text_with_detect_facets("This is a post #inlineTag").await?; assert!(has_muted_word( &[MutedWord { targets: vec![String::from("tag")], @@ -28,7 +26,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // match: inline tag { - let rt = RichText::new_with_detect_facets("This is a post #inlineTag", MockClient).await?; + let rt = rich_text_with_detect_facets("This is a post #inlineTag").await?; assert!(has_muted_word( &[MutedWord { targets: vec![String::from("tag")], @@ -42,7 +40,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // match: content target matches inline tag { - let rt = RichText::new_with_detect_facets("This is a post #inlineTag", MockClient).await?; + let rt = rich_text_with_detect_facets("This is a post #inlineTag").await?; assert!(has_muted_word( &[MutedWord { targets: vec![String::from("content")], @@ -56,7 +54,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // no match: only tag targets { - let rt = RichText::new_with_detect_facets("This is a post", MockClient).await?; + let rt = rich_text_with_detect_facets("This is a post").await?; assert!(!has_muted_word( &[MutedWord { targets: vec![String::from("tag")], @@ -70,7 +68,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // match: single character 希 { - let rt = RichText::new_with_detect_facets("改善希望です", MockClient).await?; + let rt = rich_text_with_detect_facets("改善希望です").await?; assert!(has_muted_word( &[MutedWord { targets: vec![String::from("content")], @@ -84,7 +82,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // match: single char with length > 1 ☠︎ { - let rt = RichText::new_with_detect_facets("Idk why ☠︎ but maybe", MockClient).await?; + let rt = rich_text_with_detect_facets("Idk why ☠︎ but maybe").await?; assert!(has_muted_word( &[MutedWord { targets: vec![String::from("content")], @@ -98,7 +96,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // no match: long muted word, short post { - let rt = RichText::new_with_detect_facets("hey", MockClient).await?; + let rt = rich_text_with_detect_facets("hey").await?; assert!(!has_muted_word( &[MutedWord { targets: vec![String::from("content")], @@ -112,7 +110,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // match: exact text { - let rt = RichText::new_with_detect_facets("javascript", MockClient).await?; + let rt = rich_text_with_detect_facets("javascript").await?; assert!(has_muted_word( &[MutedWord { targets: vec![String::from("content")], @@ -126,8 +124,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // match: word within post { - let rt = - RichText::new_with_detect_facets("This is a post about javascript", MockClient).await?; + let rt = rich_text_with_detect_facets("This is a post about javascript").await?; assert!(has_muted_word( &[MutedWord { targets: vec![String::from("content")], @@ -141,7 +138,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // no match: partial word { - let rt = RichText::new_with_detect_facets("Use your brain, Eric", MockClient).await?; + let rt = rich_text_with_detect_facets("Use your brain, Eric").await?; assert!(!has_muted_word( &[MutedWord { targets: vec![String::from("content")], @@ -155,7 +152,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // match: multiline { - let rt = RichText::new_with_detect_facets("Use your\n\tbrain, Eric", MockClient).await?; + let rt = rich_text_with_detect_facets("Use your\n\tbrain, Eric").await?; assert!(has_muted_word( &[MutedWord { targets: vec![String::from("content")], @@ -169,7 +166,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // match: :) { - let rt = RichText::new_with_detect_facets("So happy :)", MockClient).await?; + let rt = rich_text_with_detect_facets("So happy :)").await?; assert!(has_muted_word( &[MutedWord { targets: vec![String::from("content")], @@ -183,7 +180,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // yay! { - let rt = RichText::new_with_detect_facets("We're federating, yay!", MockClient).await?; + let rt = rich_text_with_detect_facets("We're federating, yay!").await?; // match: yay! assert!(has_muted_word( &[MutedWord { @@ -209,7 +206,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // y!ppee!! { - let rt = RichText::new_with_detect_facets("We're federating, y!ppee!!", MockClient).await?; + let rt = rich_text_with_detect_facets("We're federating, y!ppee!!").await?; // match: y!ppee assert!(has_muted_word( &[MutedWord { @@ -235,8 +232,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // apostrophes: Bluesky's { - let rt = - RichText::new_with_detect_facets("Yay, Bluesky's mutewords work", MockClient).await?; + let rt = rich_text_with_detect_facets("Yay, Bluesky's mutewords work").await?; // match: Bluesky's assert!(has_muted_word( &[MutedWord { @@ -284,7 +280,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // Why so S@assy? { - let rt = RichText::new_with_detect_facets("Why so S@assy?", MockClient).await?; + let rt = rich_text_with_detect_facets("Why so S@assy?").await?; // match: S@assy assert!(has_muted_word( &[MutedWord { @@ -310,7 +306,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // New York Times { - let rt = RichText::new_with_detect_facets("New York Times", MockClient).await?; + let rt = rich_text_with_detect_facets("New York Times").await?; // match: new york times assert!(has_muted_word( &[MutedWord { @@ -325,7 +321,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // !command { - let rt = RichText::new_with_detect_facets("Idk maybe a bot !command", MockClient).await?; + let rt = rich_text_with_detect_facets("Idk maybe a bot !command").await?; // match: !command assert!(has_muted_word( &[MutedWord { @@ -349,7 +345,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { &None )); // no match: !command - let rt = RichText::new_with_detect_facets("Idk maybe a bot command", MockClient).await?; + let rt = rich_text_with_detect_facets("Idk maybe a bot command").await?; assert!(!has_muted_word( &[MutedWord { targets: vec![String::from("content")], @@ -363,7 +359,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // e/acc { - let rt = RichText::new_with_detect_facets("I'm e/acc pilled", MockClient).await?; + let rt = rich_text_with_detect_facets("I'm e/acc pilled").await?; // match: e/acc assert!(has_muted_word( &[MutedWord { @@ -389,7 +385,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // super-bad { - let rt = RichText::new_with_detect_facets("I'm super-bad", MockClient).await?; + let rt = rich_text_with_detect_facets("I'm super-bad").await?; // match: super-bad assert!(has_muted_word( &[MutedWord { @@ -448,9 +444,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // idk_what_this_would_be { - let rt = - RichText::new_with_detect_facets("Weird post with idk_what_this_would_be", MockClient) - .await?; + 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 { @@ -498,7 +492,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // parentheses { - let rt = RichText::new_with_detect_facets("Post with context(iykyk)", MockClient).await?; + let rt = rich_text_with_detect_facets("Post with context(iykyk)").await?; // match: context(iykyk) assert!(has_muted_word( &[MutedWord { @@ -546,7 +540,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // 🦋 { - let rt = RichText::new_with_detect_facets("Post with 🦋", MockClient).await?; + let rt = rich_text_with_detect_facets("Post with 🦋").await?; // match: 🦋 assert!(has_muted_word( &[MutedWord { @@ -561,9 +555,8 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // phrases { - let rt = RichText::new_with_detect_facets( + let rt = rich_text_with_detect_facets( "I like turtles, or how I learned to stop worrying and love the internet.", - MockClient, ) .await?; // match: stop worrying @@ -591,7 +584,7 @@ async fn has_muted_word_from_rich_text() -> crate::error::Result<()> { } // languages without spaces { - let rt = RichText::new_with_detect_facets("私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか", MockClient).await?; + let rt = rich_text_with_detect_facets("私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか").await?; // match: インターネット assert!(has_muted_word( &[MutedWord { @@ -651,7 +644,7 @@ fn does_not_mute_own_post() { #[cfg(feature = "rich-text")] #[tokio::test] async fn does_not_mute_own_tags() -> crate::error::Result<()> { - use crate::tests::MockClient; + use crate::rich_text::tests::rich_text_with_detect_facets; use atrium_api::records::{KnownRecord, Record}; let prefs = ModerationPrefs { @@ -664,7 +657,7 @@ async fn does_not_mute_own_tags() -> crate::error::Result<()> { }], hidden_posts: Vec::new(), }; - let rt = RichText::new_with_detect_facets("Mute #words!", MockClient).await?; + 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, diff --git a/bsky-sdk/src/rich_text.rs b/bsky-sdk/src/rich_text.rs index cd98ab3..7b27682 100644 --- a/bsky-sdk/src/rich_text.rs +++ b/bsky-sdk/src/rich_text.rs @@ -74,6 +74,18 @@ impl RichText { facets, } } + #[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) + } + #[cfg(not(feature = "default-client"))] pub async fn new_with_detect_facets( text: impl AsRef, client: impl XrpcClient + Send + Sync, @@ -235,4 +247,4 @@ impl RichText { } #[cfg(test)] -mod tests; +pub(crate) mod tests; diff --git a/bsky-sdk/src/rich_text/tests.rs b/bsky-sdk/src/rich_text/tests.rs index 2635040..eb410d2 100644 --- a/bsky-sdk/src/rich_text/tests.rs +++ b/bsky-sdk/src/rich_text/tests.rs @@ -1,10 +1,25 @@ 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 { diff --git a/bsky-sdk/src/rich_text/tests/detection.rs b/bsky-sdk/src/rich_text/tests/detection.rs index 81578c4..826f8a8 100644 --- a/bsky-sdk/src/rich_text/tests/detection.rs +++ b/bsky-sdk/src/rich_text/tests/detection.rs @@ -1,6 +1,6 @@ +use super::rich_text_with_detect_facets; use crate::error::Result; -use crate::rich_text::{RichText, RichTextSegment}; -use crate::tests::MockClient; +use crate::rich_text::RichTextSegment; use atrium_api::app::bsky::richtext::facet::MainFeaturesItem; use atrium_api::types::Union; @@ -319,7 +319,7 @@ async fn detect_facets_mentions_and_links() -> Result<()> { ) } for (input, expected) in test_cases { - let rt = RichText::new_with_detect_facets(input, MockClient).await?; + let rt = rich_text_with_detect_facets(input).await?; assert_eq!( rt.segments() .iter() @@ -436,7 +436,7 @@ async fn detect_facets_tags() -> Result<()> { } for (input, expected) in test_cases { - let rt = RichText::new_with_detect_facets(input, MockClient).await?; + let rt = rich_text_with_detect_facets(input).await?; assert_eq!( rt.segments() .iter() From 1768ceb26064f0278bb744dffca7c2d5d4fd22ab Mon Sep 17 00:00:00 2001 From: sugyan Date: Wed, 12 Jun 2024 23:10:55 +0900 Subject: [PATCH 26/29] Add docs --- .github/workflows/bsky-sdk.yml | 10 +-- bsky-sdk/Cargo.toml | 4 + bsky-sdk/README.md | 152 +++++++++++++++++++++++++++++++++ bsky-sdk/src/agent.rs | 1 + bsky-sdk/src/error.rs | 2 +- bsky-sdk/src/lib.rs | 3 + bsky-sdk/src/moderation.rs | 1 + bsky-sdk/src/preference.rs | 1 + bsky-sdk/src/rich_text.rs | 5 +- 9 files changed, 171 insertions(+), 8 deletions(-) diff --git a/.github/workflows/bsky-sdk.yml b/.github/workflows/bsky-sdk.yml index 55b5af6..09c2a5a 100644 --- a/.github/workflows/bsky-sdk.yml +++ b/.github/workflows/bsky-sdk.yml @@ -23,8 +23,8 @@ jobs: cargo build -p bsky-sdk --verbose --all-features - name: Run tests run: | - cargo test -p bsky-sdk --verbose - cargo test -p bsky-sdk --verbose --no-default-features - cargo test -p bsky-sdk --verbose --no-default-features --features default-client - cargo test -p bsky-sdk --verbose --no-default-features --features rich-text - cargo test -p bsky-sdk --verbose --all-features + 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/bsky-sdk/Cargo.toml b/bsky-sdk/Cargo.toml index f82fa04..524d4ac 100644 --- a/bsky-sdk/Cargo.toml +++ b/bsky-sdk/Cargo.toml @@ -32,3 +32,7 @@ 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 index e69de29..5742f67 100644 --- a/bsky-sdk/README.md +++ b/bsky-sdk/README.md @@ -0,0 +1,152 @@ +# bsky-sdk + +[ATrium](https://github.com/sugyan/atrium)-based SDK for Bluesky. + +- ✔️ 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 index 8a60c33..b26570a 100644 --- a/bsky-sdk/src/agent.rs +++ b/bsky-sdk/src/agent.rs @@ -1,3 +1,4 @@ +//! Implementation of [`BskyAgent`] and their builder. mod builder; pub mod config; diff --git a/bsky-sdk/src/error.rs b/bsky-sdk/src/error.rs index 9ae9d2f..6f61aa8 100644 --- a/bsky-sdk/src/error.rs +++ b/bsky-sdk/src/error.rs @@ -53,5 +53,5 @@ impl From> for Error { } } -/// Type alias to use this crate's [`Error`](crate::Error) type in a [`Result`](core::result::Result). +/// 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 index 58a3063..2b2b1e8 100644 --- a/bsky-sdk/src/lib.rs +++ b/bsky-sdk/src/lib.rs @@ -1,7 +1,10 @@ +#![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; diff --git a/bsky-sdk/src/moderation.rs b/bsky-sdk/src/moderation.rs index 6938a40..83a610c 100644 --- a/bsky-sdk/src/moderation.rs +++ b/bsky-sdk/src/moderation.rs @@ -1,3 +1,4 @@ +//! Moderation module for working with Bluesky's moderation. pub mod decision; mod error; mod labels; diff --git a/bsky-sdk/src/preference.rs b/bsky-sdk/src/preference.rs index 325776c..8ea6090 100644 --- a/bsky-sdk/src/preference.rs +++ b/bsky-sdk/src/preference.rs @@ -1,3 +1,4 @@ +//! Preferences for Bluesky application. use crate::moderation::ModerationPrefs; use serde::{Deserialize, Serialize}; diff --git a/bsky-sdk/src/rich_text.rs b/bsky-sdk/src/rich_text.rs index 7b27682..f969d96 100644 --- a/bsky-sdk/src/rich_text.rs +++ b/bsky-sdk/src/rich_text.rs @@ -1,3 +1,4 @@ +//! Rich text module for working with text that contains facets. mod detection; use crate::agent::config::Config; @@ -14,8 +15,8 @@ const PUBLIC_API_ENDPOINT: &str = "https://public.api.bsky.app"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RichTextSegment { - text: String, - facet: Option, + pub text: String, + pub facet: Option, } impl RichTextSegment { From 53082bb31941234556e65d119f574fa26f66c68d Mon Sep 17 00:00:00 2001 From: sugyan Date: Thu, 13 Jun 2024 14:03:00 +0900 Subject: [PATCH 27/29] Add docs for agent --- bsky-sdk/src/agent.rs | 32 ++++++++++++++++++++++++- bsky-sdk/src/agent/builder.rs | 39 ++++++++++++++++++++----------- bsky-sdk/src/agent/config.rs | 17 +++++++++++++- bsky-sdk/src/agent/config/file.rs | 6 +++++ 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/bsky-sdk/src/agent.rs b/bsky-sdk/src/agent.rs index b26570a..cc4d761 100644 --- a/bsky-sdk/src/agent.rs +++ b/bsky-sdk/src/agent.rs @@ -1,4 +1,4 @@ -//! Implementation of [`BskyAgent`] and their builder. +//! Implementation of [`BskyAgent`] and their builders. mod builder; pub mod config; @@ -19,6 +19,22 @@ 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 @@ -37,8 +53,10 @@ where 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() } @@ -49,6 +67,7 @@ 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, @@ -57,6 +76,11 @@ where 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 { @@ -129,6 +153,11 @@ where } 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 @@ -140,6 +169,7 @@ where .collect(), )); } + /// Make a [`Moderator`] instance with the provided [`Preferences`]. pub async fn moderator(&self, preferences: &Preferences) -> Result { let labelers = self .api diff --git a/bsky-sdk/src/agent/builder.rs b/bsky-sdk/src/agent/builder.rs index e6c4d26..566f8c0 100644 --- a/bsky-sdk/src/agent/builder.rs +++ b/bsky-sdk/src/agent/builder.rs @@ -7,6 +7,7 @@ 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, @@ -17,15 +18,33 @@ where 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, @@ -36,6 +55,9 @@ where 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, @@ -78,21 +100,12 @@ where } } -impl BskyAgentBuilder -where - T: XrpcClient + Send + Sync, -{ - pub fn new(client: T) -> Self { - Self { - config: Config::default(), - store: MemorySessionStore::default(), - client, - } - } -} - +#[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)) } diff --git a/bsky-sdk/src/agent/config.rs b/bsky-sdk/src/agent/config.rs index 92975bd..2b64ad4 100644 --- a/bsky-sdk/src/agent/config.rs +++ b/bsky-sdk/src/agent/config.rs @@ -1,4 +1,5 @@ -pub mod file; +//! Configuration for the [`BskyAgent`](super::BskyAgent). +mod file; use crate::error::{Error, Result}; use async_trait::async_trait; @@ -6,24 +7,34 @@ 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"), @@ -34,15 +45,19 @@ impl Default for Config { } } +/// 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, diff --git a/bsky-sdk/src/agent/config/file.rs b/bsky-sdk/src/agent/config/file.rs index 902b6f3..681b8fb 100644 --- a/bsky-sdk/src/agent/config/file.rs +++ b/bsky-sdk/src/agent/config/file.rs @@ -3,11 +3,17 @@ 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(), From fd32edaeb40e9624a49bc2df5cfe64d8dbe31c5e Mon Sep 17 00:00:00 2001 From: sugyan Date: Thu, 13 Jun 2024 23:28:18 +0900 Subject: [PATCH 28/29] Add docs --- bsky-sdk/src/moderation.rs | 7 ++++ bsky-sdk/src/moderation/decision.rs | 35 ++++++++++++++++-- bsky-sdk/src/moderation/error.rs | 2 ++ bsky-sdk/src/moderation/mutewords.rs | 2 ++ bsky-sdk/src/moderation/subjects/account.rs | 2 +- bsky-sdk/src/moderation/subjects/post.rs | 2 +- bsky-sdk/src/moderation/subjects/profile.rs | 2 +- bsky-sdk/src/moderation/types.rs | 40 ++++++++++++++++----- bsky-sdk/src/moderation/ui.rs | 11 ++++++ bsky-sdk/src/moderation/util.rs | 2 ++ bsky-sdk/src/preference.rs | 1 + bsky-sdk/src/rich_text.rs | 15 ++++++++ 12 files changed, 107 insertions(+), 14 deletions(-) diff --git a/bsky-sdk/src/moderation.rs b/bsky-sdk/src/moderation.rs index 83a610c..69088a0 100644 --- a/bsky-sdk/src/moderation.rs +++ b/bsky-sdk/src/moderation.rs @@ -15,6 +15,7 @@ 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 { @@ -24,6 +25,7 @@ pub struct Moderator { } impl Moderator { + /// Create a new moderator. pub fn new( user_did: Option, prefs: ModerationPrefs, @@ -35,18 +37,23 @@ impl Moderator { 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!() } diff --git a/bsky-sdk/src/moderation/decision.rs b/bsky-sdk/src/moderation/decision.rs index 4442775..7579983 100644 --- a/bsky-sdk/src/moderation/decision.rs +++ b/bsky-sdk/src/moderation/decision.rs @@ -1,18 +1,28 @@ +//! 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, } @@ -37,7 +47,7 @@ pub(crate) enum ModerationBehaviorSeverity { } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum Priority { +pub(crate) enum Priority { Priority1, Priority2, Priority3, @@ -48,6 +58,22 @@ pub enum Priority { 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, @@ -56,6 +82,7 @@ pub struct ModerationDecision { } impl ModerationDecision { + /// Calculate the [`ModerationUi`] for a given context. pub fn ui(&self, context: DecisionContext) -> ModerationUi { let mut ui = ModerationUi { no_override: false, @@ -196,7 +223,8 @@ impl ModerationDecision { ui.blurs.sort_by_cached_key(|c| c.priority()); ui } - pub fn blocked(&self) -> bool { + /// Check if the decision is blocking or blocked by other user. + pub fn is_blocked(&self) -> bool { self.causes.iter().any(|c| { matches!( c, @@ -206,7 +234,8 @@ impl ModerationDecision { ) }) } - pub fn muted(&self) -> bool { + /// Check if the decision is by muted. + pub fn is_muted(&self) -> bool { self.causes .iter() .any(|c| matches!(c, ModerationCause::Muted(_))) diff --git a/bsky-sdk/src/moderation/error.rs b/bsky-sdk/src/moderation/error.rs index fc2aae9..a175dee 100644 --- a/bsky-sdk/src/moderation/error.rs +++ b/bsky-sdk/src/moderation/error.rs @@ -1,5 +1,6 @@ use thiserror::Error; +/// Error type for this module. #[derive(Error, Debug)] pub enum Error { #[error("invalid label preference")] @@ -14,4 +15,5 @@ pub enum Error { 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/mutewords.rs b/bsky-sdk/src/moderation/mutewords.rs index 351c41b..6ae63c2 100644 --- a/bsky-sdk/src/moderation/mutewords.rs +++ b/bsky-sdk/src/moderation/mutewords.rs @@ -1,3 +1,4 @@ +//! Muteword checking logic. use atrium_api::app::bsky::{actor::defs::MutedWord, richtext::facet::MainFeaturesItem}; use atrium_api::types::Union; use regex::Regex; @@ -22,6 +23,7 @@ const LANGUAGE_EXCEPTIONS: [&str; 5] = [ "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, diff --git a/bsky-sdk/src/moderation/subjects/account.rs b/bsky-sdk/src/moderation/subjects/account.rs index 726d2df..128f3e0 100644 --- a/bsky-sdk/src/moderation/subjects/account.rs +++ b/bsky-sdk/src/moderation/subjects/account.rs @@ -3,7 +3,7 @@ use super::super::types::{LabelTarget, SubjectProfile}; use super::super::Moderator; impl Moderator { - pub fn decide_account(&self, subject: &SubjectProfile) -> ModerationDecision { + 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())); diff --git a/bsky-sdk/src/moderation/subjects/post.rs b/bsky-sdk/src/moderation/subjects/post.rs index f65ecc8..f2b681a 100644 --- a/bsky-sdk/src/moderation/subjects/post.rs +++ b/bsky-sdk/src/moderation/subjects/post.rs @@ -9,7 +9,7 @@ use atrium_api::records::{KnownRecord, Record}; use atrium_api::types::Union; impl Moderator { - pub fn decide_post(&self, subject: &SubjectPost) -> ModerationDecision { + 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()); diff --git a/bsky-sdk/src/moderation/subjects/profile.rs b/bsky-sdk/src/moderation/subjects/profile.rs index 5e943b2..9bad728 100644 --- a/bsky-sdk/src/moderation/subjects/profile.rs +++ b/bsky-sdk/src/moderation/subjects/profile.rs @@ -3,7 +3,7 @@ use super::super::types::{LabelTarget, SubjectProfile}; use super::super::Moderator; impl Moderator { - pub fn decide_profile(&self, subject: &SubjectProfile) -> ModerationDecision { + 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())); diff --git a/bsky-sdk/src/moderation/types.rs b/bsky-sdk/src/moderation/types.rs index 1efa75c..3b07613 100644 --- a/bsky-sdk/src/moderation/types.rs +++ b/bsky-sdk/src/moderation/types.rs @@ -20,6 +20,7 @@ pub(crate) enum BehaviorValue { Inform, } +/// Moderation behaviors for different contexts. #[derive(Debug, Default, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ModerationBehavior { @@ -96,6 +97,7 @@ impl ModerationBehavior { } } +/// Moderation behaviors for the profile list. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProfileListBehavior { @@ -126,6 +128,7 @@ impl TryFrom for ProfileListBehavior { } } +/// Moderation behaviors for the profile view. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProfileViewBehavior { @@ -156,6 +159,7 @@ impl TryFrom for ProfileViewBehavior { } } +/// Moderation behaviors for the user's avatar. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AvatarBehavior { @@ -184,6 +188,7 @@ impl TryFrom for AvatarBehavior { } } +/// Moderation behaviors for the user's banner. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum BannerBehavior { @@ -209,6 +214,7 @@ impl TryFrom for BannerBehavior { } } +/// Moderation behaviors for the user's display name. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum DisplayNameBehavior { @@ -234,6 +240,7 @@ impl TryFrom for DisplayNameBehavior { } } +/// Moderation behaviors for the content list. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ContentListBehavior { @@ -264,6 +271,7 @@ impl TryFrom for ContentListBehavior { } } +/// Moderation behaviors for the content view. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ContentViewBehavior { @@ -294,6 +302,7 @@ impl TryFrom for ContentViewBehavior { } } +/// Moderation behaviors for the content media. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ContentMediaBehavior { @@ -321,6 +330,7 @@ impl TryFrom for ContentMediaBehavior { // labels +/// The target of a label. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LabelTarget { Account, @@ -328,6 +338,7 @@ pub enum LabelTarget { Content, } +/// The preference for a label. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum LabelPreference { @@ -359,6 +370,7 @@ impl FromStr for LabelPreference { } } +/// A flag for a label value definition. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum LabelValueDefinitionFlag { @@ -368,6 +380,7 @@ pub enum LabelValueDefinitionFlag { NoSelf, } +/// The blurs for a label value definition. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LabelValueDefinitionBlurs { @@ -399,6 +412,7 @@ impl FromStr for LabelValueDefinitionBlurs { } } +/// The severity for a label value definition. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LabelValueDefinitionSeverity { @@ -430,6 +444,7 @@ impl FromStr for LabelValueDefinitionSeverity { } } +/// A label value definition. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InterpretedLabelValueDefinition { @@ -448,6 +463,7 @@ pub struct InterpretedLabelValueDefinition { pub behaviors: InterpretedLabelValueDefinitionBehaviors, } +/// The behaviors for a label value definition. #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct InterpretedLabelValueDefinitionBehaviors { pub account: ModerationBehavior, @@ -467,6 +483,7 @@ impl InterpretedLabelValueDefinitionBehaviors { // subjects +/// A subject profile. #[derive(Debug)] pub enum SubjectProfile { ProfileViewBasic(ProfileViewBasic), @@ -516,8 +533,10 @@ impl From for SubjectProfile { } } +/// A subject post. pub type SubjectPost = PostView; +/// A cause for moderation decisions. #[derive(Debug, Clone)] pub enum ModerationCause { Blocking(Box), @@ -530,14 +549,14 @@ pub enum ModerationCause { } impl ModerationCause { - pub fn priority(&self) -> Priority { + pub fn priority(&self) -> u8 { match self { - Self::Blocking(_) => Priority::Priority3, - Self::BlockedBy(_) => Priority::Priority4, - Self::Label(label) => label.priority, - Self::Muted(_) => Priority::Priority6, - Self::MuteWord(_) => Priority::Priority6, - Self::Hidden(_) => Priority::Priority6, + 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) { @@ -552,6 +571,7 @@ impl ModerationCause { } } +/// The source of a moderation cause. #[derive(Debug, Clone)] pub enum ModerationCauseSource { User, @@ -559,6 +579,7 @@ pub enum ModerationCauseSource { Labeler(Did), } +/// A label moderation cause. #[derive(Debug, Clone)] pub struct ModerationCauseLabel { pub source: ModerationCauseSource, @@ -568,10 +589,11 @@ pub struct ModerationCauseLabel { pub setting: LabelPreference, pub behavior: ModerationBehavior, pub no_override: bool, - pub priority: Priority, + pub(crate) priority: Priority, pub downgraded: bool, } +/// An other moderation cause. #[derive(Debug, Clone)] pub struct ModerationCauseOther { pub source: ModerationCauseSource, @@ -580,6 +602,7 @@ pub struct ModerationCauseOther { // moderation preferences +/// The labeler preferences for moderation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModerationPrefsLabeler { pub did: Did, @@ -598,6 +621,7 @@ impl Default for ModerationPrefsLabeler { } } +/// The moderation preferences. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ModerationPrefs { diff --git a/bsky-sdk/src/moderation/ui.rs b/bsky-sdk/src/moderation/ui.rs index 5a88f3a..2f555c4 100644 --- a/bsky-sdk/src/moderation/ui.rs +++ b/bsky-sdk/src/moderation/ui.rs @@ -1,23 +1,34 @@ +//! 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 index 004c709..2f44d61 100644 --- a/bsky-sdk/src/moderation/util.rs +++ b/bsky-sdk/src/moderation/util.rs @@ -1,3 +1,4 @@ +//! Utility functions for label value definitions. use super::error::Result; use super::types::*; use atrium_api::app::bsky::labeler::defs::LabelerViewDetailed; @@ -20,6 +21,7 @@ pub(crate) fn interpret_label_value_definitions( .collect() } +/// Create an [`InterpretedLabelValueDefinition`] from a [`LabelValueDefinition`]. pub fn interpret_label_value_definition( def: &LabelValueDefinition, defined_by: Option, diff --git a/bsky-sdk/src/preference.rs b/bsky-sdk/src/preference.rs index 8ea6090..251bdae 100644 --- a/bsky-sdk/src/preference.rs +++ b/bsky-sdk/src/preference.rs @@ -2,6 +2,7 @@ use crate::moderation::ModerationPrefs; use serde::{Deserialize, Serialize}; +/// Preferences for Bluesky application. #[derive(Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Preferences { diff --git a/bsky-sdk/src/rich_text.rs b/bsky-sdk/src/rich_text.rs index f969d96..0ee1558 100644 --- a/bsky-sdk/src/rich_text.rs +++ b/bsky-sdk/src/rich_text.rs @@ -13,6 +13,7 @@ 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, @@ -20,6 +21,7 @@ pub struct RichTextSegment { } impl RichTextSegment { + /// Create a new rich text segment. pub fn new( text: impl AsRef, facets: Option, @@ -29,6 +31,7 @@ impl RichTextSegment { 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 { @@ -37,6 +40,7 @@ impl RichTextSegment { }) }) } + /// 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 { @@ -45,6 +49,7 @@ impl RichTextSegment { }) }) } + /// 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 { @@ -55,6 +60,7 @@ impl RichTextSegment { } } +/// A rich text structure that contains text and facets. #[derive(Debug, Clone)] pub struct RichText { pub text: String, @@ -66,6 +72,7 @@ impl RichText { 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>, @@ -75,6 +82,8 @@ impl RichText { 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; @@ -86,6 +95,7 @@ impl RichText { 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, @@ -98,9 +108,11 @@ impl RichText { 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)] @@ -138,6 +150,7 @@ impl RichText { } 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() { @@ -157,6 +170,7 @@ impl RichText { } } } + /// 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() { @@ -201,6 +215,7 @@ impl RichText { 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 { From fe82d61eaea3c754f6b3a748fd3aa7c9b7ebac65 Mon Sep 17 00:00:00 2001 From: sugyan Date: Thu, 13 Jun 2024 23:34:33 +0900 Subject: [PATCH 29/29] Ready for publish --- bsky-sdk/Cargo.toml | 1 + bsky-sdk/README.md | 7 +++++-- release-plz.toml | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/bsky-sdk/Cargo.toml b/bsky-sdk/Cargo.toml index 524d4ac..848b974 100644 --- a/bsky-sdk/Cargo.toml +++ b/bsky-sdk/Cargo.toml @@ -5,6 +5,7 @@ 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 diff --git a/bsky-sdk/README.md b/bsky-sdk/README.md index 5742f67..b18b1f6 100644 --- a/bsky-sdk/README.md +++ b/bsky-sdk/README.md @@ -1,6 +1,9 @@ -# bsky-sdk +# Bsky SDK: [ATrium](https://github.com/sugyan/atrium)-based SDK for Bluesky. -[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)). diff --git a/release-plz.toml b/release-plz.toml index 802048e..bc89019 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" },