diff options
| author | Martin Fischer <martin@push-f.com> | 2026-10-25 09:08:01 +0100 |
|---|---|---|
| committer | Martin Fischer <martin@push-f.com> | 2026-10-25 19:10:47 +0100 |
| commit | 41212a7a15198e53d205252a02069a7dd6ddadeb (patch) | |
| tree | 38a24eebf956b2453675ac1cefec925faf09de02 | |
initial commit
| -rw-r--r-- | .envrc | 1 | ||||
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | Cargo.lock | 2148 | ||||
| -rw-r--r-- | Cargo.toml | 19 | ||||
| -rw-r--r-- | README.md | 16 | ||||
| -rw-r--r-- | default.nix | 18 | ||||
| -rw-r--r-- | service.nix | 122 | ||||
| -rw-r--r-- | shell.nix | 12 | ||||
| -rw-r--r-- | src/bin/delete-recent.rs | 128 | ||||
| -rw-r--r-- | src/bots/fdroid/index.rs | 97 | ||||
| -rw-r--r-- | src/bots/fdroid/mod.rs | 572 | ||||
| -rw-r--r-- | src/bots/mod.rs | 1 | ||||
| -rw-r--r-- | src/main.rs | 224 | ||||
| -rw-r--r-- | src/report.rs | 57 |
14 files changed, 3418 insertions, 0 deletions
@@ -0,0 +1 @@ +use nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4973f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/result +*.json diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0132ddc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2148 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-compression" +version = "0.4.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atrium-api" +version = "0.25.8" +source = "git+https://github.com/atrium-rs/atrium?branch=update%2Flexicon-5c3b7c9c8#a2e623abe6b8d29d440872fb2e6ab5bda7ae464b" +dependencies = [ + "atrium-common", + "atrium-xrpc", + "chrono", + "http", + "ipld-core", + "langtag", + "regex", + "serde", + "serde_bytes", + "serde_json", + "thiserror", + "tokio", + "trait-variant", +] + +[[package]] +name = "atrium-common" +version = "0.1.4" +source = "git+https://github.com/atrium-rs/atrium?branch=update%2Flexicon-5c3b7c9c8#a2e623abe6b8d29d440872fb2e6ab5bda7ae464b" +dependencies = [ + "dashmap", + "lru", + "moka", + "thiserror", + "tokio", + "trait-variant", + "web-time", +] + +[[package]] +name = "atrium-xrpc" +version = "0.12.4" +source = "git+https://github.com/atrium-rs/atrium?branch=update%2Flexicon-5c3b7c9c8#a2e623abe6b8d29d440872fb2e6ab5bda7ae464b" +dependencies = [ + "http", + "serde", + "serde_html_form", + "serde_json", + "thiserror", + "trait-variant", +] + +[[package]] +name = "atrium-xrpc-client" +version = "0.5.15" +source = "git+https://github.com/atrium-rs/atrium?branch=update%2Flexicon-5c3b7c9c8#a2e623abe6b8d29d440872fb2e6ab5bda7ae464b" +dependencies = [ + "atrium-xrpc", + "reqwest", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base45" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bsky-news" +version = "0.0.0" +dependencies = [ + "atrium-api", + "bsky-sdk", + "chrono", + "clap", + "imagesize", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "unicode-segmentation", +] + +[[package]] +name = "bsky-sdk" +version = "0.1.24" +source = "git+https://github.com/atrium-rs/atrium?branch=update%2Flexicon-5c3b7c9c8#a2e623abe6b8d29d440872fb2e6ab5bda7ae464b" +dependencies = [ + "anyhow", + "atrium-api", + "atrium-xrpc-client", + "chrono", + "psl", + "regex", + "serde", + "serde_json", + "thiserror", + "trait-variant", + "unicode-segmentation", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cid" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a304f95f84d169a6f31c4d0a30d784643aaa0bbc9c1e449a2c23e963ec4971" +dependencies = [ + "multibase", + "multihash", + "serde", + "serde_bytes", + "unsigned-varint", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compression-codecs" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-encoding-macro" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" +dependencies = [ + "data-encoding", + "syn 3.0.9", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "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 = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipld-core" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090f624976d72f0b0bb71b86d58dc16c15e069193067cb3a3a09d655246cbbda" +dependencies = [ + "cid", + "serde", + "serde_bytes", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "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.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "match-lookup" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "549e39695cc0b640f3cb378053832db3d2133422d49e8dcae5c866a2aaf1f730" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multibase" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e0e4a371cbf1dfd666b658ba137763edb23c45beb43cfe369b5593cd6b437b6" +dependencies = [ + "base-x", + "base256emoji", + "base45", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "serde", + "unsigned-varint", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "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.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl" +version = "2.1.245" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46cce9bc841c0eeb395275a775d11e3cc91b904e6ab370a9a42369e111b3038e" +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.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_html_form" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[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 = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "trait-variant" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3a890a6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "bsky-news" +edition = "2024" +default-run = "bsky-news" + +[dependencies] +# using branch because https://github.com/atrium-rs/atrium/pull/346 hasn't landed yet +atrium-api = { git = "https://github.com/atrium-rs/atrium", branch = "update/lexicon-5c3b7c9c8" } +bsky-sdk = { git = "https://github.com/atrium-rs/atrium", branch = "update/lexicon-5c3b7c9c8" } +chrono = "0.4" +clap = { version = "4.5", features = ["derive"] } +imagesize = { version = "0.15", default-features = false, features = ["jpeg", "png"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["json"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tracing = "0.1" +tracing-subscriber = "0.3" +unicode-segmentation = "1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a46a97 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# bsky-news bots + +* [@f-droid.news.push-f.com] reports: + * new apps in the official [F-Droid] repo as soon as they become available + + +[@f-droid.news.push-f.com]: https://bsky.app/profile/f-droid.news.push-f.com +[F-Droid]: https://f-droid.org/ + +## Development + +Delete all posts created by the configured account during the last hour: + +``` +cargo run --bin delete-recent +``` diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..3466ecc --- /dev/null +++ b/default.nix @@ -0,0 +1,18 @@ +{ pkgs ? import <nixpkgs> { } }: + +pkgs.rustPlatform.buildRustPackage { + pname = "bsky-news"; + version = "0.0.0"; + + src = pkgs.lib.cleanSource ./.; + + nativeBuildInputs = [ pkgs.pkg-config ]; + buildInputs = [ pkgs.openssl ]; + + cargoLock = { + lockFile = ./Cargo.lock; + outputHashes = { + "atrium-api-0.25.8" = "sha256-mxeM5QnY/rJ8tR7bepBXAA2hG6Xzz7d+hQUwDAu1blE="; + }; + }; +} diff --git a/service.nix b/service.nix new file mode 100644 index 0000000..37d567e --- /dev/null +++ b/service.nix @@ -0,0 +1,122 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + inherit (lib) mkEnableOption mkIf mkMerge mkOption types; + + cfg = config.services.bsky-news; + + mkService = botName: bot: operation: { + description = "Bluesky ${botName} ${operation} job"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + environment.BSKY_CREDENTIALS_FILE = "%d/bsky"; + + serviceConfig = { + Type = "oneshot"; + ExecStart = lib.escapeShellArgs [ + (lib.getExe' cfg.package "bsky-news") + botName + operation + ]; + + LoadCredential = "bsky:${bot.credentialsFile}"; + DynamicUser = true; + StateDirectory = "bsky-news-${botName}"; + WorkingDirectory = "%S/bsky-news-${botName}"; + UMask = "0077"; + + NoNewPrivileges = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + ProtectClock = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + RestrictSUIDSGID = true; + }; + }; + + mkTimer = unitName: schedule: randomizedDelaySec: { + description = "Run ${unitName} periodically"; + wantedBy = [ "timers.target" ]; + + timerConfig = { + OnCalendar = schedule; + Persistent = true; + RandomizedDelaySec = randomizedDelaySec; + Unit = "${unitName}.service"; + }; + }; + + servicesForBot = botName: bot: + let + breakingUnit = "bsky-news-${botName}-breaking"; + in + { + ${breakingUnit} = mkService botName bot "breaking"; + }; + + timersForBot = botName: bot: + let + breakingUnit = "bsky-news-${botName}-breaking"; + in + { + ${breakingUnit} = mkTimer breakingUnit bot.breakingNews.schedule bot.breakingNews.randomizedDelaySec; + }; +in +{ + options.services.bsky-news = { + enable = mkEnableOption "periodic Bluesky news bots"; + + package = mkOption { + type = types.package; + default = pkgs.callPackage ./default.nix { }; + defaultText = lib.literalExpression "pkgs.callPackage ./default.nix { }"; + description = "The bsky-news package to run."; + }; + + bots = mkOption { + default = { }; + description = "Bluesky bot accounts and their schedules."; + type = types.attrsOf (types.submodule ({ ... }: { + options = { + credentialsFile = mkOption { + type = types.str; + description = '' + Path to a credentials file, normally an agenix secret. It is + exposed to the process through systemd's credential directory; + BSKY_CREDENTIALS_FILE contains the resulting path. + ''; + }; + + breakingNews = { + schedule = mkOption { + type = types.str; + default = "*:0/10"; + description = "systemd OnCalendar expression for breaking news."; + }; + + randomizedDelaySec = mkOption { + type = types.str; + default = "30s"; + description = "Maximum random delay applied to breaking-news runs."; + }; + }; + + }; + })); + }; + }; + + config = mkIf cfg.enable { + systemd.services = mkMerge (lib.mapAttrsToList servicesForBot cfg.bots); + systemd.timers = mkMerge (lib.mapAttrsToList timersForBot cfg.bots); + }; +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..e778eff --- /dev/null +++ b/shell.nix @@ -0,0 +1,12 @@ +{ pkgs ? import <nixpkgs> {} }: + +pkgs.mkShell { + packages = with pkgs; [ + cargo + rustc + rustfmt + clippy + openssl + pkg-config + ]; +} diff --git a/src/bin/delete-recent.rs b/src/bin/delete-recent.rs new file mode 100644 index 0000000..5486555 --- /dev/null +++ b/src/bin/delete-recent.rs @@ -0,0 +1,128 @@ +use std::{env, fs}; + +use atrium_api::{ + app::bsky::feed::post::RecordData, + com::atproto::repo::list_records::{OutputData, ParametersData}, + types::{ + TryFromUnknown, + string::{AtIdentifier, Datetime}, + }, +}; +use bsky_sdk::BskyAgent; +use chrono::{Duration, Utc}; +use clap::Parser; +use serde::Deserialize; + +#[derive(Debug, Parser)] +#[command(about = "Delete posts created by the account during the last hour")] +struct Args { + /// Print matching posts without deleting them. + #[arg(long)] + dry: bool, +} + +#[derive(Deserialize)] +struct Credentials { + identifier: String, + password: String, +} + +#[tokio::main] +async fn main() -> Result<(), String> { + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + let credentials = load_credentials()?; + let repo: AtIdentifier = credentials + .identifier + .parse() + .map_err(|error| format!("invalid Bluesky identifier: {error}"))?; + let cutoff = Datetime::new((Utc::now() - Duration::hours(1)).fixed_offset()); + + let agent = BskyAgent::builder() + .build() + .await + .map_err(|error| format!("failed to create Bluesky client: {error}"))?; + agent + .login(credentials.identifier, credentials.password) + .await + .map_err(|error| format!("failed to log in to Bluesky: {error}"))?; + + let mut cursor = None; + let mut posts = Vec::new(); + let mut reached_cutoff = false; + + while !reached_cutoff { + let output = agent + .api + .com + .atproto + .repo + .list_records( + ParametersData { + collection: "app.bsky.feed.post".parse().unwrap(), + cursor, + limit: Some(100.try_into().unwrap()), + repo: repo.clone(), + // listRecords is newest-first by default. + reverse: None, + } + .into(), + ) + .await + .map_err(|error| format!("failed to list posts: {error}"))?; + + let OutputData { + records, + cursor: next_cursor, + } = output.data; + for record in records { + let record = record.data; + let post = RecordData::try_from_unknown(record.value) + .map_err(|error| format!("failed to parse post {}: {error}", record.uri))?; + if post.created_at < cutoff { + reached_cutoff = true; + break; + } + posts.push((record.uri, post.text)); + } + + cursor = next_cursor; + if cursor.is_none() { + break; + } + } + + if posts.is_empty() { + println!("No posts from the last hour."); + return Ok(()); + } + + for (uri, text) in &posts { + if args.dry { + println!("Would delete {uri}: {text}"); + } else { + tracing::info!(%uri, "deleting post"); + agent + .delete_record(uri) + .await + .map_err(|error| format!("failed to delete {uri}: {error}"))?; + println!("Deleted {uri}: {text}"); + } + } + + if args.dry { + println!("{} post(s) would be deleted.", posts.len()); + } else { + println!("Deleted {} post(s).", posts.len()); + } + + Ok(()) +} + +fn load_credentials() -> Result<Credentials, String> { + let path = env::var("BSKY_CREDENTIALS_FILE") + .map_err(|_| "BSKY_CREDENTIALS_FILE is not set".to_owned())?; + let data = fs::read(&path).map_err(|error| format!("failed to read {path}: {error}"))?; + serde_json::from_slice(&data).map_err(|error| format!("failed to parse {path}: {error}")) +} diff --git a/src/bots/fdroid/index.rs b/src/bots/fdroid/index.rs new file mode 100644 index 0000000..61cc44d --- /dev/null +++ b/src/bots/fdroid/index.rs @@ -0,0 +1,97 @@ +//! Types for the F-Droid repository index. + +use std::collections::HashMap; + +use serde::{Deserialize, Deserializer}; + +pub(super) const PHONE_DEVICE_TYPE: &str = "phone"; +pub(super) const EN_LOCALE: &str = "en-US"; + +#[derive(Debug, Deserialize)] +pub(super) struct Index { + pub(super) repo: Repo, + pub(super) packages: HashMap<String, Package>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Repo { + pub(super) timestamp: u64, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Package { + pub(super) metadata: Metadata, + /// Releases keyed by the APK's SHA-256 digest. + pub(super) versions: HashMap<String, Version>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Version { + /// Anti-feature explanations keyed first by anti-feature name and then by locale. + #[serde(default, rename = "antiFeatures")] + pub(super) anti_features: HashMap<AntiFeature, HashMap<String, String>>, +} + +/// An [anti-feature](https://f-droid.org/docs/Anti-Features/). +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(super) enum AntiFeature { + Ads, + DisabledAlgorithm, + KnownVuln, + NoSourceSince, + NonFreeAdd, + NonFreeAssets, + NonFreeDep, + NonFreeNet, + TetheredNet, + Tracking, + Other(String), +} + +impl<'de> Deserialize<'de> for AntiFeature { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let name = String::deserialize(deserializer)?; + Ok(match name.as_str() { + "Ads" => Self::Ads, + "DisabledAlgorithm" => Self::DisabledAlgorithm, + "KnownVuln" => Self::KnownVuln, + "NoSourceSince" => Self::NoSourceSince, + "NonFreeAdd" => Self::NonFreeAdd, + "NonFreeAssets" => Self::NonFreeAssets, + "NonFreeDep" => Self::NonFreeDep, + "NonFreeNet" => Self::NonFreeNet, + "TetheredNet" => Self::TetheredNet, + "Tracking" => Self::Tracking, + _ => { + tracing::warn!("observed unknown F-Droid anti-feature {name}"); + Self::Other(name) + } + }) + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct Metadata { + pub(super) added: u64, + /// App names keyed by locale. Every package has an `en-US` name. + #[serde(default)] + pub(super) name: HashMap<String, String>, + /// Short app descriptions keyed by locale. + #[serde(default)] + pub(super) summary: HashMap<String, String>, + /// Screenshots keyed first by device type and then by locale. + #[serde(default)] + pub(super) screenshots: HashMap<String, HashMap<String, Vec<Screenshot>>>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Screenshot { + /// Path to the screenshot relative to the repository address. + pub(super) name: String, + /// Screenshot file size in bytes, retained for enforcing upload limits. + #[allow(dead_code)] + pub(super) size: u64, +} diff --git a/src/bots/fdroid/mod.rs b/src/bots/fdroid/mod.rs new file mode 100644 index 0000000..c0e7198 --- /dev/null +++ b/src/bots/fdroid/mod.rs @@ -0,0 +1,572 @@ +//! F-Droid + +mod index; + +use atrium_api::app::bsky::richtext::facet::{ByteSliceData, LinkData, MainData, MainFeaturesItem}; +use atrium_api::types::Union; +use reqwest::{ + StatusCode, + header::{ETAG, IF_NONE_MATCH}, +}; +use serde::{Deserialize, Serialize}; +use unicode_segmentation::UnicodeSegmentation; + +use self::index::{AntiFeature, EN_LOCALE, Index, PHONE_DEVICE_TYPE, Package, Screenshot}; +use crate::{ + MAX_IMAGE_SIZE, MAX_POST_GRAPHEMES, MAX_POST_IMAGES, http_client, + report::{Post, PostImage, Report}, +}; + +const REPOSITORY_URL: &str = "https://f-droid.org/repo"; +const PACKAGE_URL_PREFIX: &str = "https://f-droid.org/packages/"; +const ANTI_FEATURES_EXCLUDING_APPS: &[AntiFeature] = + &[AntiFeature::KnownVuln, AntiFeature::NoSourceSince]; + +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct BreakingState { + pub last_repo_timestamp: Option<u64>, + pub index_etag: Option<String>, +} + +pub struct Breaking; + +impl Report for Breaking { + type State = BreakingState; + + const STATE_FILE: &'static str = "fdroid-breaking.json"; + + async fn generate(state: Self::State) -> Result<(Vec<Post>, Self::State), String> { + let mut request = http_client().get(format!("{REPOSITORY_URL}/index-v2.json")); + if let Some(etag) = state.index_etag.as_deref() { + request = request.header(IF_NONE_MATCH, etag); + } + + let response = request + .send() + .await + .map_err(|error| format!("failed to fetch F-Droid index: {error}"))?; + if response.status() == StatusCode::NOT_MODIFIED { + return Ok((Vec::new(), state)); + } + + let response = response + .error_for_status() + .map_err(|error| format!("failed to fetch F-Droid index: {error}"))?; + let index_etag = response + .headers() + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let index = response + .json::<Index>() + .await + .map_err(|error| format!("failed to parse F-Droid index: {error}"))?; + + let Some(timestamp) = state.last_repo_timestamp else { + // We don't know the timestamp of the last index version. + tracing::info!( + timestamp = index.repo.timestamp, + "recording initial repository timestamp; will announce new apps with the next index version" + ); + let new_state = BreakingState { + last_repo_timestamp: Some(index.repo.timestamp), + index_etag, + }; + return Ok((Vec::new(), new_state)); + }; + + if index.repo.timestamp < timestamp { + let error = format!( + "F-Droid repository timestamp moved backwards from {timestamp} to {}", + index.repo.timestamp + ); + tracing::error!("{error}"); + return Err(error); + } + + let mut apps: Vec<_> = index + .packages + .iter() + .filter_map(|(id, package)| { + (package.metadata.added > timestamp) + .then(|| App::from_package(id.as_str(), package)) + }) + .collect(); + + tracing::info!( + timestamp = index.repo.timestamp, + new_apps = apps.len(), + total_apps = index.packages.len(), + "building posts" + ); + + apps.sort_unstable_by(|left, right| { + left.name + .to_lowercase() + .cmp(&right.name.to_lowercase()) + .then_with(|| left.id.cmp(right.id)) + }); + + Ok(( + download_images(build_announcement_posts(&apps)).await?, + BreakingState { + last_repo_timestamp: Some(index.repo.timestamp), + index_etag, + }, + )) + } +} + +struct App<'a> { + id: &'a str, + name: &'a str, + summary: &'a str, + screenshot: Option<&'a Screenshot>, + anti_features: Vec<AntiFeature>, +} + +impl<'a> App<'a> { + fn from_package(id: &'a str, package: &'a Package) -> Self { + Self { + id, + name: package + .metadata + .name + .get(EN_LOCALE) + .map_or(id, String::as_str), + summary: package + .metadata + .summary + .get(EN_LOCALE) + .map_or("", String::as_str), + screenshot: package + .metadata + .screenshots + .get(PHONE_DEVICE_TYPE) + .and_then(|screenshots| screenshots.get(EN_LOCALE)) + .and_then(|screenshots| { + screenshots + .iter() + // We could resize images but realistically they should fit. + .find(|screenshot| screenshot.size < MAX_IMAGE_SIZE) + }), + anti_features: package + .versions + .values() + .flat_map(|version| version.anti_features.keys()) + // "Tethered network service" means an app depends on a network service, + // which isn't easy to self-host. There's a lot of such network services out there + // be it OpenStreetMap or a public transit API ... not much apps can do about that. + // So we just disregard this anti-feature in this bot. + .filter(|&anti_feature| anti_feature != &AntiFeature::TetheredNet) + .cloned() + .collect(), + } + } +} + +fn build_announcement_posts(apps: &[App<'_>]) -> Vec<PendingPost> { + let apps: Vec<_> = apps + .iter() + .filter(|app| { + let Some(anti_feature) = ANTI_FEATURES_EXCLUDING_APPS + .iter() + .find(|anti_feature| app.anti_features.contains(anti_feature)) + else { + return true; + }; + + tracing::info!( + app_id = app.id, + app_name = app.name, + "omitting F-Droid app due to anti-feature {anti_feature:?}" + ); + false + }) + .collect(); + + if apps.is_empty() { + return Vec::new(); + } + + let mut builders = vec![PostBuilder::new(format!( + "{} new F-Droid packages are available:", + apps.len() + ))]; + + let (without_anti_features, with_anti_features): (Vec<_>, Vec<_>) = apps + .into_iter() + .partition(|app| app.anti_features.is_empty()); + let (with_screenshots, without_screenshots): (Vec<_>, Vec<_>) = without_anti_features + .into_iter() + .partition(|app| app.screenshot.is_some()); + + for app in with_screenshots { + let name = truncate(app.name, MAX_POST_GRAPHEMES); + let screenshot = app.screenshot.unwrap(); + let image = PendingImage { + url: format!("{REPOSITORY_URL}{}", screenshot.name), + // Using the summary for the alt text isn't great + // but the Bluesky lexicon provides no alternative. + alt: format!("{}: {}", app.name, app.summary), + }; + + let post = builders.last().unwrap(); + let separator = if post.text.is_empty() { + "" + } else if post.images.is_empty() { + "\n" + } else { + ", " + }; + if !post.can_fit(separator, &name) || post.images.len() == MAX_POST_IMAGES { + builders.push(PostBuilder::new(String::new())); + } + + let post = builders.last_mut().unwrap(); + let separator = if post.text.is_empty() { + "" + } else if post.images.is_empty() { + "\n" + } else { + ", " + }; + post.push_app(separator, &name, &name, app.id, Some(image)); + } + + for app in without_screenshots { + push_bullet_app(&mut builders, app); + } + + if let Some((first, rest)) = with_anti_features.split_first() { + const ANTI_FEATURES_HEADING: &str = + "New apps with an anti-feature (other than tethered network):"; + let line_limit = + MAX_POST_GRAPHEMES - grapheme_count(ANTI_FEATURES_HEADING) - grapheme_count("\n"); + let (name, line) = app_line(first, line_limit); + + // Keep anti-feature apps in their own section, starting at a post boundary. + builders.push(PostBuilder::new(ANTI_FEATURES_HEADING.to_owned())); + builders + .last_mut() + .unwrap() + .push_app("\n", &line, &name, first.id, None); + + for app in rest { + push_bullet_app(&mut builders, app); + } + } + + builders.into_iter().map(PostBuilder::finish).collect() +} + +fn push_bullet_app(builders: &mut Vec<PostBuilder>, app: &App<'_>) { + let (name, line) = app_line(app, MAX_POST_GRAPHEMES); + let post = builders.last().unwrap(); + let separator = if post.text.is_empty() { "" } else { "\n" }; + if !post.can_fit(separator, &line) { + builders.push(PostBuilder::new(String::new())); + } + + let post = builders.last_mut().unwrap(); + let separator = if post.text.is_empty() { "" } else { "\n" }; + post.push_app(separator, &line, &name, app.id, None); +} + +/// Build a bullet that fits within `limit`, preserving as much of the summary as possible. +fn app_line(app: &App<'_>, limit: usize) -> (String, String) { + let prefix = "* "; + let anti_features = anti_feature_annotation(&app.anti_features); + let suffix = ": "; + let fixed = grapheme_count(prefix) + + grapheme_count(app.name) + + grapheme_count(&anti_features) + + grapheme_count(suffix); + + if fixed <= limit { + let summary = truncate(app.summary, limit - fixed); + ( + app.name.to_owned(), + format!("{prefix}{}{anti_features}{suffix}{summary}", app.name), + ) + } else { + let reserved = grapheme_count(prefix) + grapheme_count(&anti_features); + let name = truncate(app.name, limit.saturating_sub(reserved)); + let line = format!("{prefix}{name}{anti_features}"); + (name, line) + } +} + +fn anti_feature_annotation(anti_features: &[AntiFeature]) -> String { + if anti_features.is_empty() { + return String::new(); + } + + let mut icons: Vec<_> = anti_features + .iter() + .map(|anti_feature| match anti_feature { + AntiFeature::Tracking => (0, "๐๏ธ"), + AntiFeature::Ads => (1, "๐ชง"), + AntiFeature::NonFreeNet => (2, "โ๏ธ"), + AntiFeature::NonFreeAssets => (3, "๐ผ๏ธ"), + AntiFeature::NonFreeAdd => (4, "๐งฉ"), + AntiFeature::NonFreeDep => (5, "๐ฆ"), + AntiFeature::DisabledAlgorithm => (6, "๐"), // shouldn't happen for new apps + AntiFeature::Other(_) => (7, "โ"), + AntiFeature::KnownVuln | AntiFeature::NoSourceSince | AntiFeature::TetheredNet => { + unreachable!() + } + }) + .collect(); + icons.sort_unstable_by_key(|(order, _)| *order); + icons.dedup_by_key(|(order, _)| *order); + + format!( + " ({})", + icons + .into_iter() + .map(|(_, icon)| icon) + .collect::<Vec<_>>() + .join("") + ) +} + +fn truncate(text: &str, limit: usize) -> String { + let length = grapheme_count(text); + if length <= limit { + return text.to_owned(); + } + if limit == 0 { + return String::new(); + } + + let mut shortened: String = text.graphemes(true).take(limit - 1).collect(); + shortened.push('โฆ'); + shortened +} + +fn grapheme_count(text: &str) -> usize { + text.graphemes(true).count() +} + +struct PendingPost { + text: String, + facets: Vec<atrium_api::app::bsky::richtext::facet::Main>, + images: Vec<PendingImage>, +} + +struct PendingImage { + url: String, + alt: String, +} + +struct PostBuilder { + text: String, + graphemes: usize, + facets: Vec<atrium_api::app::bsky::richtext::facet::Main>, + images: Vec<PendingImage>, +} + +impl PostBuilder { + fn new(text: String) -> Self { + let graphemes = grapheme_count(&text); + Self { + text, + graphemes, + facets: Vec::new(), + images: Vec::new(), + } + } + + fn can_fit(&self, separator: &str, text: &str) -> bool { + self.graphemes + grapheme_count(separator) + grapheme_count(text) <= MAX_POST_GRAPHEMES + } + + fn push_app( + &mut self, + separator: &str, + line: &str, + name: &str, + id: &str, + image: Option<PendingImage>, + ) { + self.text.push_str(separator); + let byte_start = self.text.len() + line.find(name).unwrap_or(0); + self.text.push_str(line); + let byte_end = byte_start + name.len(); + self.graphemes += grapheme_count(separator) + grapheme_count(line); + + self.facets.push( + MainData { + features: vec![Union::Refs(MainFeaturesItem::Link(Box::new( + LinkData { + uri: format!("{PACKAGE_URL_PREFIX}{id}/"), + } + .into(), + )))], + index: ByteSliceData { + byte_start, + byte_end, + } + .into(), + } + .into(), + ); + if let Some(image) = image { + self.images.push(image); + } + } + + fn finish(self) -> PendingPost { + PendingPost { + text: self.text, + facets: self.facets, + images: self.images, + } + } +} + +async fn download_images(pending_posts: Vec<PendingPost>) -> Result<Vec<Post>, String> { + let client = http_client(); + let mut posts = Vec::with_capacity(pending_posts.len()); + + for (post_index, post) in pending_posts.into_iter().enumerate() { + let mut images = Vec::with_capacity(post.images.len()); + for image in post.images { + let data = client + .get(&image.url) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(|error| { + format!( + "failed to download image {} for post {}: {error}", + image.url, + post_index + 1 + ) + })? + .bytes() + .await + .map_err(|error| { + format!( + "failed to download image {} for post {}: {error}", + image.url, + post_index + 1 + ) + })?; + images.push(PostImage { + data: data.to_vec(), + alt: image.alt, + }); + } + posts.push(Post { + text: post.text, + facets: post.facets, + images, + }); + } + + Ok(posts) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_announcement_posts() { + assert!(build_announcement_posts(&[]).is_empty()); + + let screenshot = Screenshot { + name: "/screenshots/camera.png".to_owned(), + size: 123, + }; + let apps = [ + App { + id: "org.example.camera", + name: "Camera ๐ท", + summary: "Take photos", + screenshot: Some(&screenshot), + anti_features: Vec::new(), + }, + App { + id: "org.example.notes", + name: "Notes", + summary: "Take notes", + screenshot: None, + anti_features: Vec::new(), + }, + App { + id: "org.example.tracker", + name: "Tracker", + summary: "Tracks users", + screenshot: None, + anti_features: vec![ + AntiFeature::Other("UnknownFeature".to_owned()), + AntiFeature::Tracking, + AntiFeature::NonFreeNet, + AntiFeature::NonFreeDep, + AntiFeature::NonFreeAssets, + AntiFeature::NonFreeAdd, + AntiFeature::DisabledAlgorithm, + AntiFeature::Ads, + ], + }, + App { + id: "org.example.vulnerable", + name: "Vulnerable", + summary: "Has a known vulnerability", + screenshot: None, + anti_features: vec![AntiFeature::KnownVuln], + }, + App { + id: "org.example.abandoned", + name: "Abandoned", + summary: "No longer has source available", + screenshot: None, + anti_features: vec![AntiFeature::NoSourceSince], + }, + ]; + + let posts = build_announcement_posts(&apps); + + assert_eq!(posts.len(), 2); + assert_eq!( + posts[0].text, + "3 new F-Droid packages are available:\n\ + Camera ๐ท\n\ + * Notes: Take notes" + ); + assert_eq!( + posts[1].text, + "New apps with an anti-feature (other than tethered network):\n\ + * Tracker (๐๏ธ๐ชงโ๏ธ๐ผ๏ธ๐งฉ๐ฆ๐โ): Tracks users" + ); + assert_eq!(posts[0].images.len(), 1); + assert_eq!( + posts[0].images[0].url, + "https://f-droid.org/repo/screenshots/camera.png" + ); + assert_eq!(posts[0].images[0].alt, "Camera ๐ท: Take photos"); + assert!(posts[1].images.is_empty()); + + let expected_links = [ + (&posts[0], "Camera ๐ท", "org.example.camera"), + (&posts[0], "Notes", "org.example.notes"), + (&posts[1], "Tracker", "org.example.tracker"), + ]; + for (post, name, id) in expected_links { + let facet = post + .facets + .iter() + .find(|facet| &post.text[facet.index.byte_start..facet.index.byte_end] == name) + .unwrap(); + match facet.features.as_slice() { + [Union::Refs(MainFeaturesItem::Link(link))] => { + assert_eq!(link.uri, format!("{PACKAGE_URL_PREFIX}{id}/")); + } + features => panic!("expected one link feature, got {features:?}"), + } + } + } +} diff --git a/src/bots/mod.rs b/src/bots/mod.rs new file mode 100644 index 0000000..63414d3 --- /dev/null +++ b/src/bots/mod.rs @@ -0,0 +1 @@ +pub mod fdroid; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0a5b264 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,224 @@ +mod bots; +mod report; + +use std::{env, fs, num::NonZeroU64}; + +use atrium_api::app::bsky::embed::defs::AspectRatioData; +use atrium_api::app::bsky::embed::gallery::{ImageData, MainData as GalleryData, MainItemsItem}; +use atrium_api::app::bsky::feed::post::{RecordData, RecordEmbedRefs, ReplyRefData}; +use atrium_api::com::atproto::repo::strong_ref::{Main as StrongRef, MainData as StrongRefData}; +use atrium_api::types::{Union, string::Datetime}; +use bsky_sdk::BskyAgent; +use clap::{Parser, ValueEnum}; +use report::{Post, Report}; +use serde::Deserialize; + +pub(crate) const MAX_POST_GRAPHEMES: usize = 300; +pub(crate) const MAX_POST_IMAGES: usize = 10; +pub(crate) const MAX_IMAGE_SIZE: u64 = 2_000_000; + +#[derive(Debug, Parser)] +struct Args { + bot: Bot, + report_kind: ReportKind, + + #[arg(long)] + dry: bool, +} + +#[derive(Clone, Debug, ValueEnum)] +enum Bot { + #[value(name = "fdroid")] + FDroid, +} + +#[derive(Clone, Debug, ValueEnum)] +enum ReportKind { + Breaking, +} + +#[tokio::main] +async fn main() -> Result<(), String> { + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + let credentials = (!args.dry).then(load_credentials).transpose()?; + + let (posts, update_state) = match (args.bot, args.report_kind) { + (Bot::FDroid, ReportKind::Breaking) => bots::fdroid::Breaking::report().await?, + }; + + if args.dry { + let post_count = posts.len(); + if posts.is_empty() { + println!("No posts to publish."); + } + for (index, post) in posts.into_iter().enumerate() { + if index > 0 { + println!(); + } + println!("Post {}/{}:\n{}", index + 1, post_count, post.text); + for image in post.images { + println!("Image: {}", image.alt); + } + } + return Ok(()); + } + + if !posts.is_empty() { + publish(posts, credentials.unwrap()).await?; + } + + // Do not advance the state unless every post was published successfully. + update_state() +} + +#[derive(Deserialize)] +struct Credentials { + identifier: String, + password: String, +} + +fn load_credentials() -> Result<Credentials, String> { + let path = env::var("BSKY_CREDENTIALS_FILE").map_err(|_| { + "BSKY_CREDENTIALS_FILE is not set; use --dry to run without publishing".to_owned() + })?; + let data = fs::read(&path).map_err(|error| format!("failed to read {path}: {error}"))?; + serde_json::from_slice(&data).map_err(|error| format!("failed to parse {path}: {error}")) +} + +async fn publish(posts: Vec<Post>, credentials: Credentials) -> Result<(), String> { + let agent = BskyAgent::builder() + .build() + .await + .map_err(|error| format!("failed to create Bluesky client: {error}"))?; + agent + .login(credentials.identifier, credentials.password) + .await + .map_err(|error| format!("failed to log in to Bluesky: {error}"))?; + + let post_count = posts.len(); + let image_count: usize = posts.iter().map(|post| post.images.len()).sum(); + let mut uploaded_image_count = 0; + let mut records = Vec::with_capacity(post_count); + + for (index, post) in posts.into_iter().enumerate() { + let embed = if post.images.is_empty() { + None + } else { + let mut images = Vec::with_capacity(post.images.len()); + for (image_index, image) in post.images.into_iter().enumerate() { + let dimensions = imagesize::blob_size(&image.data).map_err(|error| { + format!( + "failed to determine dimensions of image {} for post {}: {error}", + image_index + 1, + index + 1 + ) + })?; + let aspect_ratio = AspectRatioData { + width: NonZeroU64::new(dimensions.width as u64).ok_or_else(|| { + format!( + "image {} for post {} has zero width", + image_index + 1, + index + 1 + ) + })?, + height: NonZeroU64::new(dimensions.height as u64).ok_or_else(|| { + format!( + "image {} for post {} has zero height", + image_index + 1, + index + 1 + ) + })?, + } + .into(); + uploaded_image_count += 1; + tracing::info!( + image = uploaded_image_count, + images = image_count, + "uploading image" + ); + let blob = agent + .api + .com + .atproto + .repo + .upload_blob(image.data) + .await + .map_err(|error| { + format!( + "failed to upload image {} for post {}: {error}", + image_index + 1, + index + 1 + ) + })?; + images.push(Union::Refs(MainItemsItem::Image(Box::new( + ImageData { + alt: image.alt, + aspect_ratio, + image: blob.data.blob, + } + .into(), + )))); + } + Some(Union::Refs(RecordEmbedRefs::AppBskyEmbedGalleryMain( + Box::new(GalleryData { items: images }.into()), + ))) + }; + + records.push(RecordData { + created_at: Datetime::now(), + text: post.text, + facets: Some(post.facets), + embed, + entities: None, + labels: None, + langs: None, + reply: None, + tags: None, + }); + } + + let mut root: Option<StrongRef> = None; + let mut parent: Option<StrongRef> = None; + for (index, mut record) in records.into_iter().enumerate() { + if let Some(parent) = parent.as_ref() { + record.reply = Some( + ReplyRefData { + parent: parent.clone(), + root: root.as_ref().unwrap().clone(), + } + .into(), + ); + } + + tracing::info!(post = index + 1, posts = post_count, "creating post"); + let output = agent + .create_record(record) + .await + .map_err(|error| format!("failed to publish post {}: {error}", index + 1))?; + tracing::info!( + post = index + 1, + posts = post_count, + uri = %output.uri, + cid = ?output.cid, + "created post" + ); + let reference: StrongRef = StrongRefData { + uri: output.uri.clone(), + cid: output.cid.clone(), + } + .into(); + root.get_or_insert_with(|| reference.clone()); + parent = Some(reference); + } + + Ok(()) +} + +pub(crate) fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .user_agent(env!("CARGO_PKG_NAME")) + .build() + .unwrap() +} diff --git a/src/report.rs b/src/report.rs new file mode 100644 index 0000000..d776469 --- /dev/null +++ b/src/report.rs @@ -0,0 +1,57 @@ +use std::{fs, io, path::Path}; + +use atrium_api::app::bsky::richtext::facet::Main as Facet; +use serde::{Serialize, de::DeserializeOwned}; + +pub(crate) struct Post { + pub text: String, + pub facets: Vec<Facet>, + /// Images to upload before creating the post record. + pub images: Vec<PostImage>, +} + +pub(crate) struct PostImage { + pub data: Vec<u8>, + pub alt: String, +} + +pub(crate) trait Report: Sized { + type State: Default + DeserializeOwned + Serialize; + + const STATE_FILE: &'static str; + + async fn generate(state: Self::State) -> Result<(Vec<Post>, Self::State), String>; + + async fn report() -> Result<(Vec<Post>, impl FnOnce() -> Result<(), String>), String> { + let state_path = Path::new(Self::STATE_FILE); + let state = load_state(state_path)?; + let (posts, new_state) = Self::generate(state).await?; + Ok((posts, move || save_state(state_path, &new_state))) + } +} + +fn load_state<T>(path: &Path) -> Result<T, String> +where + T: Default + DeserializeOwned, +{ + match fs::read(path) { + Ok(data) => serde_json::from_slice(&data) + .map_err(|error| format!("failed to parse {}: {error}", path.display())), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(T::default()), + Err(error) => Err(format!("failed to read {}: {error}", path.display())), + } +} + +fn save_state<T>(path: &Path, state: &T) -> Result<(), String> +where + T: Serialize, +{ + let data = serde_json::to_vec_pretty(state) + .map_err(|error| format!("failed to serialize {}: {error}", path.display()))?; + let temporary_path = path.with_extension("json.tmp"); + + fs::write(&temporary_path, data) + .map_err(|error| format!("failed to write {}: {error}", temporary_path.display()))?; + fs::rename(&temporary_path, path) + .map_err(|error| format!("failed to replace {}: {error}", path.display())) +} |
