├── nostrss-core ├── src │ ├── grpc │ │ ├── relay_request.rs │ │ ├── mod.rs │ │ ├── profile_request.rs │ │ ├── feed_request.rs │ │ └── grpc_service.rs │ ├── scheduler │ │ ├── mod.rs │ │ └── scheduler.rs │ ├── template │ │ ├── mod.rs │ │ └── template.rs │ ├── profiles │ │ ├── mod.rs │ │ ├── profiles.rs │ │ └── config.rs │ ├── rss │ │ ├── mod.rs │ │ ├── rss.rs │ │ ├── parser.rs │ │ └── config.rs │ ├── socket │ │ ├── mod.rs │ │ ├── writer.rs │ │ ├── relay.rs │ │ ├── feed.rs │ │ ├── profile.rs │ │ └── handler.rs │ ├── fixtures │ │ ├── default.template │ │ ├── relays.yaml │ │ ├── rss.yaml │ │ ├── rss.json │ │ ├── relays.json │ │ ├── profiles.yaml │ │ └── profiles.json │ ├── nostr │ │ ├── mod.rs │ │ ├── relay.rs │ │ ├── service.rs │ │ ├── nostr.rs │ │ └── config.rs │ ├── app │ │ ├── mod.rs │ │ └── app.rs │ └── main.rs ├── Cargo.toml └── README.md ├── nostrss-cli ├── src │ ├── input │ │ ├── mod.rs │ │ ├── formatter.rs │ │ └── input.rs │ ├── commands │ │ ├── relay.rs │ │ ├── mod.rs │ │ ├── feed.rs │ │ └── profile.rs │ ├── handler.rs │ └── main.rs ├── Cargo.toml └── README.md ├── .gitignore ├── nostrss-grpc ├── src │ ├── lib.rs │ └── build.rs ├── Cargo.toml └── protos │ └── nostrss.proto ├── Cargo.toml ├── .github ├── ISSUE_TEMPLATE │ ├── feed_request.md │ └── feature_request.md └── workflows │ ├── build.yml │ ├── rust-clippy.yml │ └── release.yml ├── .env.test ├── Cross.toml ├── Dockerfile ├── .env.dist ├── LICENSE └── README.md /nostrss-core/src/grpc/relay_request.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /nostrss-core/src/scheduler/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod scheduler; 2 | -------------------------------------------------------------------------------- /nostrss-core/src/template/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod template; 2 | -------------------------------------------------------------------------------- /nostrss-cli/src/input/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod formatter; 2 | pub mod input; 3 | -------------------------------------------------------------------------------- /nostrss-core/src/profiles/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod profiles; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .env 3 | .vscode/ 4 | config/ 5 | *.test.json 6 | *.test.yaml -------------------------------------------------------------------------------- /nostrss-grpc/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod grpc { 2 | include!("nostrss.rs"); 3 | } 4 | -------------------------------------------------------------------------------- /nostrss-core/src/rss/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod parser; 3 | pub mod rss; 4 | -------------------------------------------------------------------------------- /nostrss-core/src/socket/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod feed; 2 | pub mod handler; 3 | pub mod profile; 4 | pub mod relay; 5 | -------------------------------------------------------------------------------- /nostrss-core/src/fixtures/default.template: -------------------------------------------------------------------------------- 1 | Default nostrss template file 2 | Feed: {name} 3 | Url: {url} 4 | Tags: {tags} -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["nostrss-core","nostrss-cli","nostrss-grpc"] 3 | resolver = "2" 4 | 5 | [profile.release] 6 | strip = true # Automatically strip symbols from the binary. 7 | opt-level = "z" # Optimize for size. 8 | lto = true 9 | panic = "abort" -------------------------------------------------------------------------------- /nostrss-grpc/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nostrss_grpc" 3 | version = "1.1.0" 4 | edition = "2021" 5 | build = "src/build.rs" 6 | 7 | [dependencies] 8 | prost = "0.13.1" 9 | tokio = { version = "1.43.1", features = ["macros", "rt-multi-thread"] } 10 | tonic = "0.12.3" 11 | protoc = "2.28.0" 12 | serde = "1.0.204" 13 | [build-dependencies] 14 | tonic-build = "0.12.3" -------------------------------------------------------------------------------- /nostrss-core/src/fixtures/relays.yaml: -------------------------------------------------------------------------------- 1 | - "name": "nostrical" 2 | "target": "wss://nostrical.com" 3 | "active": true 4 | "proxy": null 5 | - "name": "nostr-info" 6 | "target": "wss://relay.nostr.info" 7 | "active": true 8 | "proxy": null 9 | - "name": "wellorder" 10 | "target": "wss://nostr-pub.wellorder.net" 11 | "active": true 12 | "proxy": null -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feed_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feeds Request 3 | about: Request a feed to be added to nostrss.re instance 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | | Feed name | Feed URL | Logo URL | Banner URL | 11 | |-|-|-|-| 12 | | _Stacker.news_ | _https://www.stacker.news/rss_ | _wwww.mydomain.com/mylogo.jpg_ | _wwww.mydomain.com/mybanner.jpg_ | 13 | | | | | | 14 | 15 | **Additional notes and/or recommendations regarding the requested feed** 16 | -------------------------------------------------------------------------------- /nostrss-core/src/nostr/mod.rs: -------------------------------------------------------------------------------- 1 | use nostr_sdk::Keys; 2 | 3 | use self::relay::Relay; 4 | 5 | // pub mod config; 6 | pub mod relay; 7 | pub mod service; 8 | 9 | // Helper trait. 10 | pub trait NostrProfile { 11 | fn get_keys(&self) -> Keys; 12 | fn get_display_name(self) -> Option; 13 | fn get_description(self) -> Option; 14 | fn get_picture(self) -> Option; 15 | fn get_banner(self) -> Option; 16 | fn get_nip05(self) -> Option; 17 | fn get_lud16(self) -> Option; 18 | fn get_relays(&self) -> Vec; 19 | } 20 | -------------------------------------------------------------------------------- /nostrss-grpc/src/build.rs: -------------------------------------------------------------------------------- 1 | // mod nostrss; 2 | 3 | use std::env; 4 | use std::path::PathBuf; 5 | fn main() -> Result<(), Box> { 6 | let proto_file = "protos/nostrss.proto"; 7 | let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); 8 | 9 | tonic_build::configure() 10 | // .protoc_arg("--experimental_allow_proto3_optional") // for older systems 11 | .build_client(true) 12 | .build_server(true) 13 | .file_descriptor_set_path(out_dir.join("store_descriptor.bin")) 14 | .out_dir("./src") 15 | .compile(&[proto_file], &["proto"])?; 16 | 17 | Ok(()) 18 | } 19 | -------------------------------------------------------------------------------- /nostrss-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nostrss-cli" 3 | version = "1.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | clap = { version = "4.5.9", features = ["derive"] } 8 | serde = "1.0.204" 9 | tokio = { version = "1.43.1", features = ["full"] } 10 | dotenv = "0.15.0" 11 | log = "0.4.22" 12 | tonic = "0.12.3" 13 | tabled = "0.17.0" 14 | url = "2.5.2" 15 | cron = "0.14.0" 16 | secp256k1 = "0.30.0" 17 | bech32 = "0.11.0" 18 | 19 | [dependencies.nostrss_grpc] 20 | path = "../nostrss-grpc" 21 | 22 | [profile.release] 23 | strip = true # Automatically strip symbols from the binary. 24 | opt-level = "z" # Optimize for size. 25 | lto = true 26 | panic = "abort" 27 | -------------------------------------------------------------------------------- /nostrss-core/src/fixtures/rss.yaml: -------------------------------------------------------------------------------- 1 | 2 | - id: "wikipedia" 3 | name: "New pages on French wikipedia" 4 | url: "https://fr.wikipedia.org/w/index.php?title=Sp%C3%A9cial:Nouvelles_pages&feed=atom" 5 | schedule: "1/15 * * * * *" 6 | tags: ["wikipedia","changes"] 7 | pow_level: 15 8 | profiles: null 9 | template: "./src/fixtures/default.template" 10 | - id: "stackernews" 11 | name: "Stacker news feed" 12 | url: "https://stacker.news/rss" 13 | schedule: "1/30 * * * * *" 14 | template: null 15 | - id: "bitcoin-reddit" 16 | name: "r/bitcoin reddit feed" 17 | url: "https://www.reddit.com/r/bitcoin/.rss" 18 | schedule: "1/10 * * * * *" 19 | profiles: ["reddit"] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # Logging level. Values can be : error, warn, info, debug, trace. 2 | RUST_LOG=error 3 | 4 | # Nostr private key 5 | NOSTR_PK=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef 6 | 7 | # Nostr profile metadata 8 | 9 | NOSTR_DISPLAY_NAME="satoshi-nakamoto" 10 | NOSTR_NAME="Satoshi Nakamoto" 11 | NOSTR_NIP05="satoshi@nakamoto.btc" 12 | NOSTR_BANNER="https://example.com/banner.png" 13 | NOSTR_DESCRIPTION="Craig Wright is not satoshi" 14 | NOSTR_PICTURE="https://example.com/picture.jpg" 15 | 16 | DEFAULT_TEMPLATE="test nostrss template\nFeed: {name}\nUrl: {url}\nTags: {tags}" 17 | 18 | # Default pow to use for publishing notes 19 | DEFAULT_POW_LEVEL=0 20 | 21 | #Default cache size for a feed 22 | DEFAULT_CACHE_SIZE=20 23 | -------------------------------------------------------------------------------- /nostrss-core/src/fixtures/rss.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "stackernews", 4 | "name": "Stacker news", 5 | "url": "https://stacker.news/rss", 6 | "schedule": "1/2 * * * * *", 7 | "cache_size": 5 8 | }, 9 | { 10 | "id": "bitcoin-reddit", 11 | "name": "reddit r/bitcoin", 12 | "url": "https://www.reddit.com/r/bitcoin/.rss", 13 | "schedule": "1/5 * * * * *", 14 | "template": "./src/fixtures/default.template" 15 | }, 16 | { 17 | "id": "nostr-reddit", 18 | "name": "reddit r/nostr", 19 | "url": "https://www.reddit.com/r/nostr/.rss", 20 | "schedule": "1/10 * * * * *", 21 | "profiles": [ 22 | "reddit" 23 | ], 24 | "template": null 25 | } 26 | ] -------------------------------------------------------------------------------- /nostrss-cli/src/input/formatter.rs: -------------------------------------------------------------------------------- 1 | pub struct InputFormatter {} 2 | 3 | impl InputFormatter { 4 | pub fn input_to_vec(value: String) -> Vec { 5 | value.split(',').map(|e| e.trim().to_string()).collect() 6 | } 7 | 8 | pub fn string_nullifier(value: String) -> Option { 9 | match !value.is_empty() { 10 | true => Some(value.trim().to_string()), 11 | false => None, 12 | } 13 | } 14 | } 15 | 16 | #[cfg(test)] 17 | mod tests { 18 | use super::InputFormatter; 19 | 20 | #[test] 21 | fn input_to_vec_test() { 22 | let value = "a,b,c".to_string(); 23 | 24 | let result = InputFormatter::input_to_vec(value); 25 | assert_eq!(result.len(), 3); 26 | assert_eq!(result[0], "a".to_string()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /nostrss-core/src/fixtures/relays.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "noslol", 4 | "target": "wss://nos.lol", 5 | "active": true, 6 | "proxy": null 7 | }, 8 | { 9 | "name": "damus", 10 | "target": "wss://relay.damus.io", 11 | "active": true, 12 | "proxy": null 13 | }, 14 | { 15 | "name": "nostr-info", 16 | "target": "wss://relay.nostr.info", 17 | "active": true, 18 | "proxy": null 19 | }, 20 | { 21 | "name": "nostrical", 22 | "target": "wss://nostrical.com", 23 | "active": true, 24 | "proxy": "127.0.0.1:9050" 25 | }, 26 | { 27 | "name": "disabled-relay", 28 | "target": "wss://some-disabled-relay.com", 29 | "active": false, 30 | "proxy": null 31 | } 32 | ] -------------------------------------------------------------------------------- /nostrss-core/src/fixtures/profiles.yaml: -------------------------------------------------------------------------------- 1 | - id: "reddit" 2 | private_key: "56789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234" 3 | about: "Reddit rss feed" 4 | name: "nostrss-reddit" 5 | display_name: "nostrss-reddit" 6 | description: "A nostrss bot instance that provides reddit rss stream" 7 | picture: null 8 | banner: null 9 | nip05: null 10 | lud16: null 11 | - id: "stackernews" 12 | private_key: "6789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345" 13 | about: "Stacker news rss feed" 14 | name: "nostrss-stackernews" 15 | display_name: "nostrss-stackernews" 16 | description: "A nostrss bot instance that provides reddit stacker news stream" 17 | picture: null 18 | banner: null 19 | nip05: null 20 | lud16: null -------------------------------------------------------------------------------- /nostrss-core/src/nostr/relay.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::env; 3 | use std::net::SocketAddr; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 6 | pub struct Relay { 7 | pub name: String, 8 | pub target: String, 9 | pub active: bool, 10 | pub proxy: Option, 11 | #[serde(default = "Relay::default_pow_level")] 12 | pub pow_level: u8, 13 | } 14 | 15 | // into() implementation. Will return the URL string 16 | // representation of the relay. 17 | impl Into for Relay { 18 | fn into(self) -> String { 19 | self.target 20 | } 21 | } 22 | 23 | impl Relay { 24 | fn default_pow_level() -> u8 { 25 | env::var("DEFAULT_POW_LEVEL") 26 | .unwrap_or("0".to_string()) 27 | .parse::() 28 | .unwrap_or(0) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /nostrss-core/src/socket/writer.rs: -------------------------------------------------------------------------------- 1 | use std::os::unix; 2 | use tokio::{ 3 | io::AsyncReadExt, 4 | net::{UnixListener, UnixStream}, 5 | }; 6 | 7 | pub struct SocketWriter {} 8 | 9 | impl SocketWriter { 10 | pub async fn send(data: String) { 11 | let socket_path = "nostrss-socket"; 12 | let mut unix_writer = UnixStream::connect(socket_path).await; 13 | 14 | let unix_listener = match UnixListener::bind(socket_path) { 15 | Ok(listener) => listener, 16 | Err(e) => { 17 | log::error!("{}", e); 18 | panic!("Socket listener could not be ignited"); 19 | } 20 | }; 21 | 22 | match unix_writer { 23 | Ok(writer) => { 24 | _ = writer.try_write(b"Toto"); 25 | } 26 | Err(e) => {} 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | pre-build = ["apt update && apt install -y protobuf-compiler"] 3 | 4 | [target.aarch64-unknown-linux-gnu] 5 | image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:main" 6 | [target.armv7-unknown-linux-gnueabihf] 7 | image = "ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:main" 8 | [target.i686-unknown-linux-gnu] 9 | image = "ghcr.io/cross-rs/i686-unknown-linux-gnu:main" 10 | [target.i686-unknown-linux-musl] 11 | image = "ghcr.io/cross-rs/i686-unknown-linux-musl:main" 12 | [target.arm-unknown-linux-gnueabi] 13 | image = "ghcr.io/cross-rs/arm-unknown-linux-gnueabi:main" 14 | [target.x86_64-unknown-linux-gnu] 15 | image = "ghcr.io/cross-rs/x86_64-unknown-linux-gnu:main" 16 | [target.x86_64-unknown-linux-musl] 17 | image = "ghcr.io/cross-rs/x86_64-unknown-linux-musl:main" 18 | [target.x86_64-unknown-netbsd] 19 | image = "ghcr.io/cross-rs/x86_64-unknown-netbsd:main" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM lukemathwalker/cargo-chef:latest-rust-1.66.0 AS chef 2 | WORKDIR /app 3 | 4 | FROM chef AS planner 5 | COPY . /www/app/ 6 | WORKDIR /www/app/ 7 | RUN cargo chef prepare --recipe-path recipe.json 8 | 9 | FROM chef AS builder 10 | COPY --from=planner /www/app/recipe.json /www/app/recipe.json 11 | WORKDIR /www/app/ 12 | # Build dependencies - this is the caching Docker layer! 13 | RUN RUST_BACKTRACE=full cargo chef cook --release --recipe-path recipe.json 14 | # Build application 15 | COPY . /www/app/ 16 | RUN cargo build --release --bin nostrss 17 | 18 | # We do not need the Rust toolchain to run the binary! 19 | FROM debian:buster-slim AS runtime 20 | RUN apt update && apt install -y libpq-dev curl bash 21 | WORKDIR /www/app/ 22 | COPY --from=builder /www/app/target/release/nostrss /www/app/target/release/nostrss 23 | ENTRYPOINT ["/www/app/target/release/nostrss"] 24 | -------------------------------------------------------------------------------- /nostrss-core/src/fixtures/profiles.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "reddit", 4 | "private_key": "56789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01234", 5 | "about": "Reddit rss feed", 6 | "name": "nostrss-reddit", 7 | "display_name": "nostrss-reddit", 8 | "description": "A nostrss bot instance that provides reddit rss stream", 9 | "picture": null, 10 | "banner": null, 11 | "nip05": null, 12 | "lud16": null 13 | }, 14 | { 15 | "id": "stackernews", 16 | "private_key": "6789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345", 17 | "about": "Stacker news rss feed", 18 | "name": "nostrss-stackernews", 19 | "display_name": "nostrss-stackernews", 20 | "description": "A nostrss bot instance that provides reddit stacker news stream", 21 | "picture": null, 22 | "banner": null, 23 | "nip05": null, 24 | "lud16": null 25 | } 26 | ] 27 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | push: 5 | branches: [ '*' ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: ⚒ Install rust toolchain 20 | uses: actions-rs/toolchain@v1 21 | with: 22 | toolchain: 1.83 23 | override: true 24 | components: rustc 25 | - name: Install protobuf dependency 26 | run: sudo apt-get -y install protobuf-compiler 27 | - name: ⚡ Cache 28 | uses: actions/cache@v3 29 | with: 30 | path: | 31 | ~/.cargo/registry 32 | ~/.cargo/git 33 | target 34 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 35 | - name: 🏗 Build 36 | run: cargo build --verbose 37 | - name: 🎯 Run tests 38 | env: 39 | RUST_TEST_THREADS: 1 40 | run: cargo test --verbose -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | # Logging level. Values can be : error, warn, info, debug, trace. 2 | RUST_LOG=error 3 | 4 | # Nostr default private key 5 | NOSTR_PK=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef 6 | 7 | # Nostr default profile metadata 8 | NOSTR_DISPLAY_NAME="satoshi-nakamoto" 9 | NOSTR_NAME="Satoshi Nakamoto" 10 | NOSTR_NIP05="satoshi@nakamoto.btc" 11 | NOSTR_BANNER="https://example.com/banner.png" 12 | NOSTR_DESCRIPTION="Craig Wright is not satoshi" 13 | NOSTR_PICTURE="https://example.com/picture.jpg" 14 | 15 | # Default nostr message template 16 | DEFAULT_TEMPLATE="default nostrss template\nFeed: {name}\nUrl:{url}" 17 | 18 | # Default pow to use for publishing notes 19 | DEFAULT_POW_LEVEL=0 20 | 21 | #Default cache size for a feed. If not provided, feed without 22 | # explicit cache size will have no cache limit. 23 | DEFAULT_CACHE_SIZE=120 24 | 25 | # The grpc service address to use 26 | GRPC_ADDRESS="[::1]:33333" 27 | 28 | # The protocol to be used for GRPC. Possible values : http, https 29 | GRPC_PROTOCOL= "http" -------------------------------------------------------------------------------- /nostrss-core/src/socket/relay.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use tokio::sync::Mutex; 4 | 5 | use crate::app::app::App; 6 | 7 | pub struct RelayCommandHandler {} 8 | 9 | impl RelayCommandHandler { 10 | pub async fn handle(app: Arc>, action: String) -> String { 11 | match action.as_str() { 12 | "ADD" => Self::add(app).await, 13 | "DEL" => Self::delete(app).await, 14 | "LS" => Self::list(app).await, 15 | _ => "Unknown action".to_string(), 16 | }; 17 | 18 | "Relay handler".to_string() 19 | } 20 | 21 | async fn add(app: Arc>) -> String { 22 | let _lock = app.lock().await; 23 | "Relay added".to_string() 24 | } 25 | 26 | async fn delete(app: Arc>) -> String { 27 | let _lock = app.lock().await; 28 | "Relay deleted".to_string() 29 | } 30 | 31 | async fn list(app: Arc>) -> String { 32 | let _lock = app.lock().await; 33 | "Relays list".to_string() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /nostrss-cli/src/commands/relay.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use super::CommandsHandler; 4 | use clap::{Parser, ValueEnum}; 5 | use nostrss_grpc::grpc::nostrss_grpc_client::NostrssGrpcClient; 6 | use tonic::async_trait; 7 | use tonic::transport::Channel; 8 | 9 | #[derive(Clone, PartialEq, Parser, Debug, ValueEnum)] 10 | pub enum RelayActions { 11 | Add, 12 | Delete, 13 | List, 14 | } 15 | 16 | pub struct RelayCommandsHandler { 17 | pub client: NostrssGrpcClient, 18 | } 19 | 20 | #[async_trait] 21 | impl CommandsHandler for RelayCommandsHandler {} 22 | 23 | impl RelayCommandsHandler { 24 | pub async fn handle(&mut self, action: RelayActions) { 25 | match action { 26 | RelayActions::Add => self.add(), 27 | RelayActions::Delete => self.delete(), 28 | RelayActions::List => self.list(), 29 | } 30 | } 31 | fn list(&self) { 32 | println!("List relays"); 33 | } 34 | 35 | fn add(&self) { 36 | println!("add relay"); 37 | } 38 | 39 | fn delete(&mut self) { 40 | println!("add relay"); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2024 Asone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /nostrss-cli/src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, stdin, Write}; 2 | 3 | use tabled::{Table, Tabled}; 4 | use tonic::async_trait; 5 | 6 | /// Common trait for sub-handlers. 7 | #[async_trait] 8 | pub trait CommandsHandler { 9 | // A default helper to get input data from user. 10 | fn get_input(&self, label: &str, validator: Option bool>) -> String { 11 | let mut data = String::new(); 12 | print!("{}", label); 13 | _ = io::stdout().flush(); 14 | _ = stdin().read_line(&mut data); 15 | 16 | match validator { 17 | Some(validator) => match validator(data.clone().trim().to_string()) { 18 | true => data.trim().to_string(), 19 | false => { 20 | println!("Invalid value provided."); 21 | self.get_input(label, Some(validator)) 22 | } 23 | }, 24 | None => data.trim().to_string(), 25 | } 26 | } 27 | 28 | fn print(&self, data: Vec) { 29 | let table = Table::new(data).to_string(); 30 | println!("{}", table); 31 | } 32 | } 33 | 34 | pub mod feed; 35 | pub mod profile; 36 | pub mod relay; 37 | -------------------------------------------------------------------------------- /nostrss-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nostrss" 3 | version = "1.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | serde = "1.0.217" 8 | serde_json = "1.0.135" 9 | tokio = { version = "1.43.1", features = ["full"] } 10 | nostr-sdk = "0.30.0" 11 | tungstenite = { version = "0.26.1", features = ["rustls-tls-webpki-roots"]} 12 | clap = { version = "4.5.24", features = ["derive"] } 13 | tokio-cron-scheduler = "0.13.0" 14 | reqwest = "0.12.12" 15 | serde_yaml = "0.9.34-deprecated" 16 | dotenv = "0.15.0" 17 | quick-xml = "0.37.2" 18 | md5 = "0.7.0" 19 | log = "0.4.22" 20 | atom_syndication = "0.12.6" 21 | feed-rs = "2.3.1" 22 | bytes = "1.9.0" 23 | async-trait = "0.1.85" 24 | env_logger = "0.11.6" 25 | uuid = "1.11.0" 26 | openssl = { version = "0.10.72", features = ["vendored"] } 27 | new_string_template = "1.5.3" 28 | async-std = { version = "1.13.0", features = ["attributes", "tokio1"] } 29 | regex = "1.11.1" 30 | tonic = "0.12.3" 31 | url = "2.5.4" 32 | 33 | [dev-dependencies] 34 | tempfile = "3.15.0" 35 | mockall = "0.13.1" 36 | mime = "0.3.17" 37 | mediatype = "0.19.18" 38 | [dependencies.nostrss_grpc] 39 | path = "../nostrss-grpc" 40 | 41 | [profile.release] 42 | strip = true # Automatically strip symbols from the binary. 43 | opt-level = "z" # Optimize for size. 44 | lto = true 45 | panic = "abort" 46 | 47 | [build-dependencies] 48 | tonic-build = "0.12.3" 49 | -------------------------------------------------------------------------------- /nostrss-cli/README.md: -------------------------------------------------------------------------------- 1 | # Nostrss-cli 2 | 3 | Nostrss-cli is a CLI program that provides helpers to manage live instance of Nostrss. 4 | 5 | Default behavior of the CLI will only update the running instance configuration without modifying the loaded configuration files. 6 | 7 | If you want to persist the configuration modifications you can use the `--save` flag as first argument to update the configuration files. 8 | 9 | e.g: 10 | > nostrss-cli --save profile delete reddit 11 | 12 | The `--save` flag can be used to update profiles and feeds config files and works when instructing 13 | `add` or `delete` command. 14 | 15 | Note that, when using the flag, the config file will be overwritten with the full configuration of the instance. For example, if you add a profile without the flag and then another one with the `--save` flag, both new profiles will be written in the configuration file. 16 | 17 | ## State commands 18 | 19 | | Command | Description | 20 | |-|-| 21 | | nostrss-cli state | Ensures the core can be reached |  22 | 23 | 24 | ## Profiles 25 | 26 | | Command | Description | 27 | |-|-| 28 | | nostrss-cli profile list | Lists the profiles | 29 | | nostrss-cli profile add | Add a new profile | 30 | | nostrss-cli profile delete | Remove a profile. Beware, you can not delete default profile for stability issues | 31 | | nostrss-cli profile info | Get info of a specific profile | 32 | 33 | ### Feeds 34 | 35 | | Command | Description | 36 | |-|-| 37 | | nostrss-cli feed list | Lists the feeds | 38 | | nostrss-cli feed add | Add a new feed | 39 | | nostrss-cli feed delete | Remove a feed | 40 | | nostrss-cli feed info | Get info of a specific feed | 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nostrss 2 | Nostrss is a program that provides a lightweight and flexible bridge beetween RSS feeds and [Nostr protocol](https://nostr.com/). 3 | 4 | It also provides a CLI to manage your live instance. See more in the below sections. 5 | ## Download 6 | 7 | You can download the application through the [releases page](https://github.com/Asone/nostrss/releases). 8 | 9 | Note that there is no official release for official Mac OS nor Windows due to some specific configs required. Yet you can 10 | download the sources and compile it on your own if necessary. 11 | 12 | - See the [core readme](./nostrss-core/README.md) to configure and run the core service. 13 | - Seee the [cli readme](./nostrss-cli/README.md) to run the CLI program. 14 | 15 | ## Licence 16 | 17 | MIT Licence. 18 | 19 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 20 | 21 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 22 | 23 | The Software is provided “as is”, without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software or the use or other dealings in the Software. 24 | -------------------------------------------------------------------------------- /nostrss-core/src/rss/rss.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use std::collections::HashMap; 4 | use std::sync::Arc; 5 | 6 | use super::config::Feed; 7 | use super::config::RssConfig; 8 | use tokio_cron_scheduler::Job; 9 | use tokio_cron_scheduler::JobScheduler; 10 | use uuid::Uuid; 11 | 12 | #[derive(Clone)] 13 | pub struct RssInstance { 14 | pub config: RssConfig, 15 | pub scheduler: Arc, 16 | pub feeds_jobs: HashMap, 17 | pub feeds: Vec, 18 | pub maps: HashMap>, 19 | } 20 | 21 | impl RssInstance { 22 | pub async fn new(config: RssConfig) -> Self { 23 | let scheduler = match JobScheduler::new().await { 24 | Ok(result) => Arc::new(result), 25 | Err(_) => { 26 | // We shall improve the job creation error in a better way than just a panic 27 | panic!("Job creation error. Panicking !"); 28 | } 29 | }; 30 | let feeds = config.feeds.clone(); 31 | let feeds_jobs = HashMap::new(); 32 | Self { 33 | config, 34 | scheduler, 35 | feeds_jobs, 36 | feeds, 37 | maps: HashMap::new(), 38 | } 39 | } 40 | 41 | // Add a job to the scheduler. 42 | // Might be useless as the scheduler is publicly accessible. 43 | pub async fn add_job(self, job: Job) { 44 | let scheduler = self.scheduler; 45 | 46 | _ = scheduler.add(job).await; 47 | } 48 | 49 | // Remove a job to the scheduler. 50 | // Might be useless as the scheduler is publicly accessible. 51 | pub async fn remove_job(self, uuid: uuid::Uuid) { 52 | let scheduler = self.scheduler; 53 | 54 | _ = scheduler.remove(&uuid).await; 55 | } 56 | 57 | pub fn get_feeds(&self) -> Vec { 58 | self.feeds.clone() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/rust-clippy.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # rust-clippy is a tool that runs a bunch of lints to catch common 6 | # mistakes in your Rust code and help improve your Rust code. 7 | # More details at https://github.com/rust-lang/rust-clippy 8 | # and https://rust-lang.github.io/rust-clippy/ 9 | 10 | name: rust-clippy analyze 11 | 12 | on: 13 | push: 14 | branches: [ "main" ] 15 | pull_request: 16 | # The branches below must be a subset of the branches above 17 | branches: [ "main" ] 18 | schedule: 19 | - cron: '31 21 * * 1' 20 | 21 | jobs: 22 | rust-clippy-analyze: 23 | name: Run rust-clippy analyzing 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | security-events: write 28 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v2 32 | 33 | - name: Install Rust toolchain 34 | uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1 35 | with: 36 | profile: minimal 37 | toolchain: stable 38 | components: clippy 39 | override: true 40 | 41 | - name: Install required cargo 42 | run: cargo install clippy-sarif sarif-fmt 43 | 44 | - name: Run rust-clippy 45 | run: 46 | cargo clippy 47 | --all-features 48 | --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt 49 | continue-on-error: true 50 | 51 | - name: Upload analysis results to GitHub 52 | uses: github/codeql-action/upload-sarif@v1 53 | with: 54 | sarif_file: rust-clippy-results.sarif 55 | wait-for-processing: true 56 | -------------------------------------------------------------------------------- /nostrss-cli/src/handler.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | commands::{ 3 | feed::FeedCommandsHandler, profile::ProfileCommandsHandler, relay::RelayCommandsHandler, 4 | }, 5 | CliOptions, Subcommands, 6 | }; 7 | 8 | use nostrss_grpc::grpc::{nostrss_grpc_client::NostrssGrpcClient, StateRequest}; 9 | use tonic::transport::Channel; 10 | 11 | /// Global handler for the CLI commands. 12 | /// It provides a dispatcher that will send the command 13 | /// details to sub-handlers 14 | pub struct CliHandler { 15 | pub client: NostrssGrpcClient, 16 | } 17 | 18 | impl CliHandler { 19 | pub async fn dispatcher(&mut self, command: Subcommands, opts: CliOptions) { 20 | match command { 21 | Subcommands::State => { 22 | let request = tonic::Request::new(StateRequest {}); 23 | let response = self.client.state(request).await; 24 | match response { 25 | Ok(r) => { 26 | println!("{}", r.into_inner().state); 27 | } 28 | Err(e) => { 29 | println!("error: {}", e); 30 | } 31 | } 32 | } 33 | Subcommands::Feed { action } => { 34 | let mut feed_handler = FeedCommandsHandler { 35 | client: self.client.clone(), 36 | }; 37 | feed_handler.handle(action, opts).await; 38 | } 39 | Subcommands::Relay { action } => { 40 | let mut relay_handler = RelayCommandsHandler { 41 | client: self.client.clone(), 42 | }; 43 | println!("Relay commands"); 44 | relay_handler.handle(action).await; 45 | } 46 | Subcommands::Profile { action } => { 47 | let mut profile_handler = ProfileCommandsHandler { 48 | client: self.client.clone(), 49 | }; 50 | profile_handler.handle(action, opts).await; 51 | } 52 | }; 53 | } 54 | } 55 | 56 | #[cfg(test)] 57 | mod tests {} 58 | -------------------------------------------------------------------------------- /nostrss-core/src/app/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | 3 | #[cfg(test)] 4 | pub mod test_utils { 5 | use std::{collections::HashMap, sync::Arc}; 6 | 7 | use dotenv::from_filename; 8 | use tokio::sync::Mutex; 9 | 10 | use crate::{ 11 | app::app::{App, AppConfig}, 12 | nostr::service::NostrService, 13 | profiles::{config::Profile, profiles::ProfileHandler}, 14 | rss::{config::RssConfig, rss::RssInstance}, 15 | scheduler::scheduler::schedule, 16 | }; 17 | 18 | pub async fn mock_app() -> App { 19 | from_filename(".env.test").ok(); 20 | let rss_path = Some("./src/fixtures/rss.yaml".to_string()); 21 | let rss_config = RssConfig::new(rss_path); 22 | 23 | let rss = RssInstance::new(rss_config).await; 24 | 25 | let default_profile = Profile { 26 | ..Default::default() 27 | }; 28 | 29 | let test_profile = Profile { 30 | id: "test".to_string(), 31 | ..Default::default() 32 | }; 33 | 34 | let mut profiles = HashMap::new(); 35 | 36 | profiles.insert(default_profile.id.clone(), default_profile); 37 | profiles.insert(test_profile.id.clone(), test_profile); 38 | 39 | let nostr_service = NostrService { 40 | profiles, 41 | ..Default::default() 42 | }; 43 | let scheduler = tokio_cron_scheduler::JobScheduler::new().await.unwrap(); 44 | let mut app = App { 45 | rss, 46 | scheduler: Arc::new(scheduler), 47 | feeds_jobs: HashMap::new(), 48 | feeds_map: HashMap::new(), 49 | nostr_service, 50 | config: AppConfig { 51 | ..Default::default() 52 | }, 53 | profile_handler: ProfileHandler(HashMap::new()), 54 | }; 55 | 56 | for feed in app.rss.feeds.clone() { 57 | let job = schedule( 58 | feed.clone().schedule.as_str(), 59 | feed.clone(), 60 | Arc::new(Mutex::new(app.feeds_map.clone())), 61 | app.nostr_service.get_client().await, 62 | app.get_profiles().await, 63 | app.get_config().await, 64 | ) 65 | .await; 66 | 67 | _ = &app.rss.feeds_jobs.insert(feed.id, job.guid()); 68 | } 69 | 70 | app 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /nostrss-core/src/grpc/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod feed_request; 2 | pub mod grpc_service; 3 | pub mod profile_request; 4 | 5 | #[cfg(test)] 6 | mod grpctest_utils { 7 | use std::{collections::HashMap, sync::Arc}; 8 | 9 | use dotenv::from_filename; 10 | use tokio::sync::Mutex; 11 | 12 | use crate::{ 13 | app::app::{App, AppConfig}, 14 | nostr::service::NostrService, 15 | profiles::{config::Profile, profiles::ProfileHandler}, 16 | rss::{config::RssConfig, rss::RssInstance}, 17 | scheduler::scheduler::schedule, 18 | }; 19 | 20 | pub async fn mock_app() -> App { 21 | from_filename(".env.test").ok(); 22 | let rss_path = Some("./src/fixtures/rss.yaml".to_string()); 23 | let rss_config = RssConfig::new(rss_path); 24 | 25 | let rss = RssInstance::new(rss_config).await; 26 | 27 | let default_profile = Profile { 28 | ..Default::default() 29 | }; 30 | 31 | let test_profile = Profile { 32 | id: "test".to_string(), 33 | ..Default::default() 34 | }; 35 | 36 | let mut profiles = HashMap::new(); 37 | 38 | profiles.insert(default_profile.id.clone(), default_profile); 39 | profiles.insert(test_profile.id.clone(), test_profile); 40 | 41 | let nostr_service = NostrService { 42 | profiles, 43 | ..Default::default() 44 | }; 45 | let scheduler = tokio_cron_scheduler::JobScheduler::new().await.unwrap(); 46 | let mut app = App { 47 | rss, 48 | scheduler: Arc::new(scheduler), 49 | feeds_jobs: HashMap::new(), 50 | feeds_map: HashMap::new(), 51 | nostr_service, 52 | config: AppConfig { 53 | ..Default::default() 54 | }, 55 | profile_handler: ProfileHandler(HashMap::new()), 56 | }; 57 | 58 | for feed in app.rss.feeds.clone() { 59 | let job = schedule( 60 | feed.clone().schedule.as_str(), 61 | feed.clone(), 62 | Arc::new(Mutex::new(app.feeds_map.clone())), 63 | app.nostr_service.get_client().await, 64 | app.get_profiles().await, 65 | app.get_config().await, 66 | ) 67 | .await; 68 | 69 | _ = &app.rss.feeds_jobs.insert(feed.id, job.guid()); 70 | } 71 | 72 | app 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /nostrss-core/src/socket/feed.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use tokio::sync::Mutex; 4 | 5 | use crate::app::app::App; 6 | 7 | pub struct FeedCommandHandler {} 8 | 9 | impl FeedCommandHandler { 10 | pub async fn handle(app: Arc>, action: String) -> String { 11 | let res = match action.as_str() { 12 | "ADD" => Self::add(app).await, 13 | "DEL" => Self::delete(app).await, 14 | "LS" => Self::list(app).await, 15 | _ => "Unknown action".to_string(), 16 | }; 17 | 18 | res 19 | } 20 | 21 | async fn add(app: Arc>) -> String { 22 | let _lock = app.lock().await; 23 | "Feed added".to_string() 24 | } 25 | 26 | async fn delete(app: Arc>) -> String { 27 | let _lock = app.lock().await; 28 | "Feed deleted".to_string() 29 | } 30 | 31 | async fn list(app: Arc>) -> String { 32 | let app_lock = match app.try_lock() { 33 | Ok(a) => a, 34 | Err(e) => { 35 | panic!("{}", e); 36 | } 37 | }; 38 | let mut res = "Feeds list:".to_string(); 39 | for feed in app_lock.rss.get_feeds() { 40 | res = format!("{}\n* {} : {}", res, feed.id, feed.url); 41 | } 42 | res 43 | } 44 | } 45 | 46 | #[cfg(test)] 47 | mod tests { 48 | 49 | use std::collections::HashMap; 50 | 51 | use dotenv::from_filename; 52 | 53 | use super::*; 54 | use crate::rss::{ 55 | config::{Feed, RssConfig}, 56 | rss::RssInstance, 57 | }; 58 | use tokio_cron_scheduler::JobScheduler; 59 | 60 | #[tokio::test] 61 | async fn test_socket_feed_list() { 62 | from_filename(".env.test").ok(); 63 | let rss_config = RssConfig { 64 | feeds: [Feed { 65 | ..Default::default() 66 | }] 67 | .to_vec(), 68 | }; 69 | 70 | let rss = RssInstance::new(rss_config).await; 71 | let scheduler = JobScheduler::new().await.unwrap(); 72 | 73 | let app = App { 74 | rss, 75 | scheduler: Arc::new(scheduler), 76 | clients: HashMap::new(), 77 | profiles: HashMap::new(), 78 | feeds_jobs: HashMap::new(), 79 | feeds_map: HashMap::new(), 80 | }; 81 | 82 | let result = FeedCommandHandler::list(Arc::new(Mutex::new(app))).await; 83 | let expected = "Feeds list:\n* default : https://www.nostr.info/"; 84 | assert_eq!(result, expected); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /nostrss-core/src/rss/parser.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use feed_rs::model::{Entry, Feed as RemoteFeed}; 4 | use log::info; 5 | use std::error::Error; 6 | use std::fmt; 7 | 8 | /// RSS parsing processor 9 | pub struct RssParser {} 10 | 11 | impl RssParser { 12 | // Reads a remote RSS feed. 13 | pub async fn read(url: String) -> Result { 14 | info!("requesting {:?}", url); 15 | 16 | // fetch 17 | let request_response = match reqwest::get(url).await { 18 | Ok(value) => value, 19 | Err(_) => { 20 | return Err(RssParserError::new("Error while fetching Rss Feed")); 21 | } 22 | }; 23 | 24 | // read 25 | let content = match request_response.text().await { 26 | Ok(result) => result, 27 | Err(_) => { 28 | return Err(RssParserError::new("Error while reading Rss feed response")); 29 | } 30 | }; 31 | 32 | // parse 33 | let feed = match feed_rs::parser::parse(content.as_bytes()) { 34 | Ok(feed) => feed, 35 | Err(e) => { 36 | let error = format!("Error while parsing Rss feed stream : {}", e); 37 | return Err(RssParserError::new(&error)); 38 | } 39 | }; 40 | 41 | Ok(feed) 42 | } 43 | 44 | // Retrieves the first item from a remote feed 45 | pub async fn get_first_item(url: String) -> Result { 46 | let feed = match Self::read(url).await { 47 | Ok(feed) => feed, 48 | Err(e) => { 49 | return Err(e); 50 | } 51 | }; 52 | 53 | Ok(feed.entries[0].clone()) 54 | } 55 | 56 | // Retrieves all items from a remote feed 57 | pub async fn get_items(url: String) -> Result, RssParserError> { 58 | let feed = match Self::read(url).await { 59 | Ok(feed) => feed, 60 | Err(e) => { 61 | return Err(e); 62 | } 63 | }; 64 | 65 | Ok(feed.entries) 66 | } 67 | 68 | pub fn new() -> Self { 69 | Self {} 70 | } 71 | } 72 | 73 | #[derive(Debug)] 74 | pub struct RssParserError { 75 | pub message: String, 76 | } 77 | 78 | impl RssParserError { 79 | pub fn new(message: &str) -> RssParserError { 80 | RssParserError { 81 | message: message.to_string(), 82 | } 83 | } 84 | } 85 | 86 | impl fmt::Display for RssParserError { 87 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 88 | write!(f, "MyError: {}", self.message) 89 | } 90 | } 91 | 92 | impl Error for RssParserError {} 93 | -------------------------------------------------------------------------------- /nostrss-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | mod commands; 2 | /// Nostrss-cli provides a bridge beetween rss feeds and [Nostr protocol](https://nostr.com/). 3 | /// 4 | /// To use it, you will have to provide some configuration, like relays and feeds to load, which are described into 5 | /// the [README.md](https://github.com/Asone/nostrss/blob/main/README.md) file.. 6 | //: The application is based on async cronjobs. 7 | mod handler; 8 | mod input; 9 | use std::{env, process::exit}; 10 | 11 | use commands::{feed::FeedActions, profile::ProfileActions, relay::RelayActions}; 12 | use dotenv::dotenv; 13 | use handler::CliHandler; 14 | 15 | use clap::{command, Parser}; 16 | 17 | use nostrss_grpc::grpc::nostrss_grpc_client::NostrssGrpcClient; 18 | 19 | #[derive(Parser, Debug)] 20 | #[command(author, version, about, long_about = None)] 21 | #[command(propagate_version = true)] 22 | struct Cli { 23 | #[command(subcommand)] 24 | subcommand: Subcommands, 25 | #[arg(long, short)] 26 | /// Save the modifications to config files 27 | pub save: bool, 28 | } 29 | 30 | #[derive(Debug, Default)] 31 | pub struct CliOptions { 32 | save: bool, 33 | } 34 | 35 | #[derive(Debug, PartialEq, Parser)] 36 | pub enum Subcommands { 37 | #[clap( 38 | name = "relay", 39 | about = "Provides commands for relay management", 40 | long_about = r#" 41 | Available actions for relays: 42 | * add : Add a relay 43 | * delete : Removes a relay 44 | * list : List the active relays 45 | * info : Get info on a relay 46 | "# 47 | )] 48 | Relay { 49 | #[clap(name = "action")] 50 | action: RelayActions, 51 | }, 52 | 53 | #[clap(name = "feed", about = "Provides commands for feed mcanagement")] 54 | Feed { action: FeedActions }, 55 | /// Provides commands for Profile management 56 | Profile { action: ProfileActions }, 57 | /// Checks health of core 58 | State, 59 | } 60 | 61 | #[tokio::main] 62 | async fn main() { 63 | dotenv().ok(); 64 | 65 | let grpc_address = env::var("GRPC_ADDRESS").unwrap_or("[::1]:33333".to_string()); 66 | 67 | let grpc_full_address = format!("{}{}", "http://", grpc_address); 68 | // Creates the gRPC client 69 | let client = match NostrssGrpcClient::connect(grpc_full_address).await { 70 | Ok(c) => c, 71 | Err(e) => { 72 | log::error!("Could not connect to core service. Are you sure it is up ?"); 73 | panic!("{}", e); 74 | } 75 | }; 76 | 77 | // Get CLI arguments and parameters 78 | let cli = Cli::parse(); 79 | 80 | let opts = CliOptions { 81 | save: cli.save, 82 | ..Default::default() 83 | }; 84 | 85 | let mut handler = CliHandler { client }; 86 | handler.dispatcher(cli.subcommand, opts).await; 87 | 88 | // If we reach this point we close the program gracefully 89 | exit(1); 90 | } 91 | -------------------------------------------------------------------------------- /nostrss-core/src/socket/profile.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use nostr_sdk::{ 4 | prelude::{FromSkStr, ToBech32}, 5 | Keys, 6 | }; 7 | use tokio::sync::Mutex; 8 | 9 | use crate::app::app::App; 10 | 11 | pub struct ProfileCommandHandler {} 12 | 13 | impl ProfileCommandHandler { 14 | pub async fn handle(app: Arc>, action: String) -> String { 15 | let res = match action.as_str() { 16 | "ADD" => Self::add(app).await, 17 | "DEL" => Self::delete(app).await, 18 | "LS" => Self::list(app).await, 19 | _ => "Unknown action".to_string(), 20 | }; 21 | 22 | res 23 | } 24 | 25 | async fn add(app: Arc>) -> String { 26 | let _lock = app.lock().await; 27 | "Profile added".to_string() 28 | } 29 | 30 | async fn delete(app: Arc>) -> String { 31 | let _lock = app.lock().await; 32 | "Profile deleted".to_string() 33 | } 34 | 35 | async fn list(app: Arc>) -> String { 36 | let app_lock = match app.try_lock() { 37 | Ok(a) => a, 38 | Err(e) => { 39 | panic!("{}", e); 40 | } 41 | }; 42 | let mut res = "Profiles list:".to_string(); 43 | for (key, value) in app_lock.profiles.iter() { 44 | let keys = match Keys::from_sk_str(&value.private_key) { 45 | Ok(keys) => keys, 46 | Err(_) => { 47 | continue; 48 | } 49 | }; 50 | res = format!( 51 | "{}\n* {} : {}", 52 | res, 53 | key, 54 | keys.public_key().to_bech32().unwrap() 55 | ); 56 | } 57 | res 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use std::{collections::HashMap, sync::Arc}; 64 | 65 | use dotenv::from_filename; 66 | use tokio_cron_scheduler::JobScheduler; 67 | 68 | use super::*; 69 | use crate::{ 70 | app::app::App, 71 | profiles::config::Profile, 72 | rss::{config::RssConfig, rss::RssInstance}, 73 | }; 74 | 75 | #[test] 76 | fn test_socket_profile_add() {} 77 | 78 | #[test] 79 | fn test_socket_profile_del() {} 80 | 81 | #[tokio::test] 82 | async fn test_socket_profile_list() { 83 | from_filename(".env.test").ok(); 84 | let rss_config = RssConfig { feeds: Vec::new() }; 85 | 86 | let rss = RssInstance::new(rss_config).await; 87 | let scheduler = JobScheduler::new().await.unwrap(); 88 | 89 | let mut profiles = HashMap::new(); 90 | 91 | profiles.insert( 92 | "test".to_string(), 93 | Profile { 94 | ..Default::default() 95 | }, 96 | ); 97 | 98 | let app = App { 99 | rss, 100 | scheduler: Arc::new(scheduler), 101 | clients: HashMap::new(), 102 | profiles, 103 | feeds_jobs: HashMap::new(), 104 | feeds_map: HashMap::new(), 105 | }; 106 | 107 | let result = ProfileCommandHandler::list(Arc::new(Mutex::new(app))).await; 108 | let expected = "Profiles list:\n* test : npub1ger2u5z8x945yvxsppkg4nkxslcqk8xe68wxxnmvkdv2cz563lls9fwehy"; 109 | assert_eq!(result, expected); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /nostrss-core/src/socket/handler.rs: -------------------------------------------------------------------------------- 1 | use std::{io::Error, sync::Arc}; 2 | 3 | use log::{error, info}; 4 | use regex::Regex; 5 | use tokio::{ 6 | io::{AsyncReadExt, AsyncWriteExt}, 7 | net::{UnixListener, UnixStream}, 8 | sync::Mutex, 9 | }; 10 | 11 | use crate::{ 12 | app::app::App, 13 | socket::{ 14 | feed::FeedCommandHandler, profile::ProfileCommandHandler, relay::RelayCommandHandler, 15 | }, 16 | }; 17 | 18 | /// This struct provides the socket processing management. 19 | pub struct SocketHandler { 20 | pub stream: UnixListener, 21 | } 22 | 23 | impl SocketHandler { 24 | // Creates a new instance of struct 25 | pub fn new(socket_path: &str) -> Self { 26 | if std::fs::metadata(socket_path).is_ok() { 27 | info!("A socket is already present. Deleting..."); 28 | _ = std::fs::remove_file(socket_path); 29 | } 30 | 31 | let stream = match UnixListener::bind(socket_path) { 32 | Ok(stream) => stream, 33 | Err(e) => { 34 | error!("{:?}", e); 35 | panic!("Couldn't start stream") 36 | } 37 | }; 38 | Self { stream } 39 | } 40 | 41 | pub async fn listen(&self, app: Arc>) -> Result { 42 | let s = self.stream.accept().await; 43 | 44 | let mut op_code = String::new(); 45 | 46 | match s { 47 | Ok((mut stream, _addr)) => { 48 | _ = stream.read_to_string(&mut op_code).await; 49 | 50 | self.dispatch(stream, app, op_code).await; 51 | Ok("".to_string()) 52 | } 53 | Err(e) => { 54 | error!("Connection failed"); 55 | 56 | Err(e) 57 | } 58 | } 59 | } 60 | 61 | // Dispatches commands through user workflow 62 | pub async fn dispatch(&self, mut stream: UnixStream, app: Arc>, op_code: String) { 63 | let cat_regex = Regex::new(r"^([A-Z])_([A-Z]{2,4})"); 64 | 65 | if cat_regex.is_err() { 66 | error!("Could not parse command"); 67 | () 68 | } 69 | 70 | let category = cat_regex.unwrap().captures(op_code.as_str()); 71 | println!("{:?}", category); 72 | 73 | if category.is_none() { 74 | "Unknown command".to_string(); 75 | }; 76 | 77 | let category = category.unwrap(); 78 | 79 | // action 80 | let action = match &category.get(2) { 81 | Some(a) => a.as_str(), 82 | None => "", 83 | }; 84 | 85 | // category 86 | let category = match &category.get(1) { 87 | Some(c) => c.as_str(), 88 | None => "", 89 | }; 90 | 91 | let response = match category { 92 | "R" => RelayCommandHandler::handle(app, action.to_string()).await, 93 | "P" => ProfileCommandHandler::handle(app, action.to_string()).await, 94 | "F" => FeedCommandHandler::handle(app, action.to_string()).await, 95 | _ => "Unknown command".to_string(), 96 | }; 97 | _ = stream.write(response.as_bytes()).await; 98 | } 99 | } 100 | 101 | #[cfg(test)] 102 | mod tests { 103 | 104 | #[test] 105 | fn test_dispatch_method() {} 106 | 107 | #[test] 108 | fn test_new_method() {} 109 | 110 | #[test] 111 | fn test_listen_method() {} 112 | } 113 | -------------------------------------------------------------------------------- /nostrss-grpc/protos/nostrss.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto2"; 2 | 3 | package nostrss; 4 | 5 | service NostrssGRPC { 6 | rpc State (StateRequest) returns (StateResponse); 7 | 8 | rpc ProfilesList (ProfilesListRequest) returns (ProfilesListResponse); 9 | rpc ProfileInfo (ProfileInfoRequest) returns (ProfileInfoResponse); 10 | rpc DeleteProfile (DeleteProfileRequest) returns (DeleteProfileResponse); 11 | rpc AddProfile (AddProfileRequest) returns (AddProfileResponse); 12 | 13 | rpc FeedsList (FeedsListRequest) returns (FeedsListResponse); 14 | rpc FeedInfo (FeedInfoRequest) returns (FeedInfoResponse); 15 | rpc DeleteFeed (DeleteFeedRequest) returns (DeleteFeedResponse); 16 | rpc AddFeed (AddFeedRequest) returns (AddFeedResponse); 17 | 18 | rpc StartJob (StartJobRequest) returns (StartJobResponse); 19 | rpc StopJob (StopJobRequest) returns (StopJobResponse); 20 | 21 | } 22 | 23 | message StartJobRequest { 24 | required string feed_id = 1; 25 | } 26 | message StartJobResponse {} 27 | 28 | message StopJobRequest { 29 | required string feed_id = 1; 30 | } 31 | message StopJobResponse {} 32 | 33 | message StartStreamRequest {} 34 | message StartStreamResponse {} 35 | 36 | message StateRequest {} 37 | message StateResponse { 38 | required string state = 1; 39 | } 40 | 41 | message FeedItem { 42 | required string id = 1; 43 | required string name = 2; 44 | required string url = 3; 45 | required string schedule = 4; 46 | repeated string profiles = 5; 47 | repeated string tags = 6; 48 | optional string template = 7; 49 | optional uint64 cache_size = 8; 50 | required uint64 pow_level = 9; 51 | } 52 | 53 | 54 | // === Feeds === 55 | message FeedsListRequest{} 56 | message FeedsListResponse { 57 | repeated FeedItem feeds = 1; 58 | } 59 | 60 | message AddFeedRequest { 61 | required FeedItem feed = 1; 62 | optional bool save = 2; 63 | } 64 | 65 | message AddFeedResponse { 66 | 67 | } 68 | 69 | message DeleteFeedRequest { 70 | required string id = 1; 71 | optional bool save = 2; 72 | } 73 | 74 | message DeleteFeedResponse { 75 | 76 | } 77 | 78 | message FeedInfoRequest { 79 | required string id = 1; 80 | } 81 | 82 | message FeedInfoResponse { 83 | required FeedItem feed = 1; 84 | } 85 | 86 | // === Profiles === 87 | 88 | message ProfilesListRequest {} 89 | message ProfilesListResponse { 90 | repeated ProfileItem profiles = 1; 91 | } 92 | 93 | message ProfileItem { 94 | required string id = 1; 95 | required string public_key = 2; 96 | optional string name = 3; 97 | repeated string relays = 4; 98 | optional string display_name = 5; 99 | optional string description = 6; 100 | optional string picture = 7; 101 | optional string banner = 8; 102 | optional string nip05 = 9; 103 | optional string lud16 = 10; 104 | optional int32 pow_level = 11; 105 | repeated string recommended_relays = 12; 106 | } 107 | 108 | message NewProfileItem { 109 | required string id = 1; 110 | required string private_key = 2; 111 | optional string name = 3; 112 | repeated string relays = 4; 113 | optional string display_name = 5; 114 | optional string description = 6; 115 | optional string picture = 7; 116 | optional string banner = 8; 117 | optional string nip05 = 9; 118 | optional string lud16 = 10; 119 | optional int32 pow_level = 11; 120 | repeated string recommended_relays = 12; 121 | } 122 | 123 | message AddProfileRequest { 124 | required NewProfileItem profile = 1; 125 | optional bool save = 2; 126 | } 127 | 128 | message AddProfileResponse { 129 | 130 | } 131 | 132 | message DeleteProfileRequest { 133 | required string id = 1; 134 | optional bool save = 2; 135 | } 136 | 137 | message DeleteProfileResponse { 138 | } 139 | 140 | message ProfileInfoRequest { 141 | required string id = 1; 142 | } 143 | 144 | message ProfileInfoResponse { 145 | required ProfileItem profile = 1; 146 | } 147 | -------------------------------------------------------------------------------- /nostrss-core/src/nostr/service.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, fmt, sync::Arc}; 2 | 3 | use log::debug; 4 | use nostr_sdk::{Client, EventBuilder, EventId, Keys, Metadata, Result}; 5 | use reqwest::Url; 6 | use tokio::sync::Mutex; 7 | 8 | use crate::{ 9 | nostr::NostrProfile, 10 | profiles::{config::Profile, profiles::ProfileHandler}, 11 | }; 12 | 13 | use super::relay::Relay; 14 | 15 | pub enum NostrServiceError { 16 | BroadcastError, 17 | ProfileNotFoundError, 18 | } 19 | 20 | impl fmt::Debug for NostrServiceError { 21 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 22 | match self { 23 | NostrServiceError::BroadcastError => write!(f, "Broadcast error"), 24 | NostrServiceError::ProfileNotFoundError => write!(f, "Profile not found"), 25 | } 26 | } 27 | } 28 | 29 | #[derive(Debug, Clone)] 30 | pub struct NostrService { 31 | pub client: Client, 32 | pub default_relays: HashMap, 33 | pub profiles: HashMap, 34 | } 35 | 36 | impl Default for NostrService { 37 | fn default() -> Self { 38 | Self { 39 | client: Client::new(&Keys::generate()), 40 | default_relays: HashMap::new(), 41 | profiles: HashMap::new(), 42 | } 43 | } 44 | } 45 | 46 | impl NostrService { 47 | pub async fn new(client: Client, relays: String, profiles: Option) -> Self { 48 | let profile_handler = ProfileHandler::new(&profiles, &relays); 49 | 50 | let profiles = profile_handler.get_profiles(); 51 | let default_relays = profile_handler.new_get_default_relays(); 52 | Self { 53 | client, 54 | default_relays, 55 | profiles, 56 | } 57 | } 58 | 59 | pub async fn update_profile(&self, profile_id: String) -> Result { 60 | let profile = match self.profiles.get(&profile_id) { 61 | Some(result) => result, 62 | None => return Err(NostrServiceError::ProfileNotFoundError), 63 | }; 64 | 65 | let mut metadata = Metadata::new(); 66 | 67 | if profile.clone().get_display_name().is_some() { 68 | // metadata.name(self.config.display_name.clone().unwrap()); 69 | metadata = metadata.display_name(profile.clone().get_display_name().unwrap()); 70 | metadata = metadata.name(profile.clone().get_name().unwrap()); 71 | }; 72 | 73 | if profile.clone().get_description().is_some() { 74 | metadata = metadata.about(profile.clone().get_description().unwrap()); 75 | }; 76 | 77 | if profile.clone().get_picture().is_some() { 78 | let parsed_url = nostr_sdk::Url::parse(profile.clone().get_picture().unwrap().as_str()); 79 | 80 | if parsed_url.is_ok() { 81 | metadata = metadata.picture(parsed_url.unwrap()); 82 | } 83 | }; 84 | 85 | if profile.clone().get_banner().is_some() { 86 | let parsed_url = nostr_sdk::Url::parse(profile.clone().get_banner().unwrap().as_str()); 87 | 88 | if parsed_url.is_ok() { 89 | metadata = metadata.banner(parsed_url.unwrap()); 90 | } 91 | }; 92 | 93 | if profile.clone().get_nip05().is_some() { 94 | metadata = metadata.nip05(profile.clone().get_nip05().unwrap()); 95 | }; 96 | 97 | if profile.clone().get_lud16().is_some() { 98 | metadata = metadata.lud16(profile.clone().get_lud16().unwrap()); 99 | }; 100 | 101 | debug!("{:?}", metadata); 102 | 103 | let event = EventBuilder::metadata(&metadata) 104 | .to_event(&profile.get_keys()) 105 | .unwrap(); 106 | 107 | // Broadcast metadata (NIP-01) to relays 108 | let result = self.client.clone().send_event(event).await; 109 | 110 | if result.is_err() { 111 | return Err(NostrServiceError::BroadcastError); 112 | } 113 | 114 | Ok(result.unwrap()) 115 | } 116 | 117 | pub async fn get_client(&self) -> Arc> { 118 | Arc::new(Mutex::new(self.client.clone())) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v*.*.*' 9 | 10 | jobs: 11 | style: 12 | name: Check Style 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v1 17 | 18 | - name: Install rust 19 | uses: actions-rs/toolchain@v1 20 | with: 21 | toolchain: stable 22 | components: rustfmt 23 | profile: minimal 24 | override: true 25 | 26 | - name: cargo fmt -- --check 27 | uses: actions-rs/cargo@v1 28 | with: 29 | command: fmt 30 | args: --all -- --check 31 | test: 32 | name: Test 33 | needs: [style] 34 | runs-on: ubuntu-latest 35 | 36 | strategy: 37 | matrix: 38 | # build: [stable, beta, nightly] 39 | build: [stable, beta] 40 | include: 41 | - build: beta 42 | rust: beta 43 | # - build: nightly 44 | # rust: nightly 45 | # benches: true 46 | 47 | steps: 48 | - name: Checkout 49 | uses: actions/checkout@v1 50 | 51 | - name: Install rust 52 | uses: actions-rs/toolchain@v1 53 | with: 54 | toolchain: ${{ matrix.rust || 'stable' }} 55 | profile: minimal 56 | override: true 57 | - name: Install ssl, musl & protobuf 58 | run: | 59 | sudo apt-get update 60 | sudo apt-get install -y pkg-config libssl-dev musl-tools protobuf-compiler 61 | - name: Build debug 62 | uses: actions-rs/cargo@v1 63 | with: 64 | command: build 65 | args: ${{ matrix.features }} 66 | - name: Test 67 | uses: actions-rs/cargo@v1 68 | env: 69 | RUST_TEST_THREADS: 1 70 | with: 71 | command: test 72 | args: ${{ matrix.features }} 73 | - name: Test all benches 74 | if: matrix.benches 75 | uses: actions-rs/cargo@v1 76 | env: 77 | RUST_TEST_THREADS: 1 78 | with: 79 | command: test 80 | args: --benches ${{ matrix.features }} 81 | deploy: 82 | name: deploy 83 | needs: [test] 84 | if: startsWith(github.ref, 'refs/tags/') 85 | runs-on: ubuntu-latest 86 | strategy: 87 | matrix: 88 | target: 89 | - aarch64-unknown-linux-gnu 90 | - armv7-unknown-linux-gnueabihf 91 | - i686-unknown-linux-gnu 92 | - i686-unknown-linux-musl 93 | - arm-unknown-linux-gnueabi 94 | - x86_64-unknown-linux-gnu 95 | - x86_64-unknown-linux-musl 96 | - x86_64-unknown-netbsd 97 | # - aarch64-apple-darwin # CI fails as it appears that we have to use gcc provided by apple. 98 | # - x86_64-pc-windows-gnu # Currently release fails as compiled will have a .exe extension 99 | steps: 100 | - name: Checkout 101 | uses: actions/checkout@v1 102 | - name: Install ssl, musl & protobuf 103 | run: | 104 | sudo apt-get update 105 | sudo apt-get install -y pkg-config libssl-dev musl-tools protobuf-compiler 106 | - name: Install rust 107 | uses: actions-rs/toolchain@v1 108 | with: 109 | toolchain: stable 110 | profile: minimal 111 | override: true 112 | target: ${{ matrix.target }} 113 | - name: Build target 114 | uses: actions-rs/cargo@v1 115 | with: 116 | use-cross: true 117 | command: build 118 | args: --release --target ${{ matrix.target }} 119 | - name: Package 120 | shell: bash 121 | run: | 122 | #strip target/${{ matrix.target }}/release/nostrss 123 | cd target/${{ matrix.target }}/release 124 | tar czvf ../../../nostrss-${{ matrix.target }}.tar.gz nostrss nostrss-cli 125 | cd - 126 | - name: Publish 127 | uses: softprops/action-gh-release@v1 128 | # TODO: if any of the build step fails, the release should be deleted. 129 | with: 130 | files: 'nostrss*' 131 | env: 132 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 133 | -------------------------------------------------------------------------------- /nostrss-cli/src/input/input.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use bech32::Hrp; 4 | use url::Url; 5 | 6 | pub struct InputValidators {} 7 | 8 | impl InputValidators { 9 | pub fn required_input_validator(value: String) -> bool { 10 | if value.is_empty() { 11 | return false; 12 | } 13 | 14 | true 15 | } 16 | 17 | pub fn url_validator(value: String) -> bool { 18 | Url::parse(&value).is_ok() 19 | } 20 | 21 | pub fn cron_pattern_validator(value: String) -> bool { 22 | cron::Schedule::from_str(&value).is_ok() 23 | } 24 | 25 | pub fn key_validator(value: String) -> bool { 26 | let decoded = bech32::decode(value.trim()); 27 | 28 | match decoded { 29 | Ok(result) => { 30 | if result.0 != Hrp::parse("nsec").unwrap() { 31 | return false; 32 | } 33 | } 34 | Err(_) => { 35 | let key_bytes = value.trim().as_bytes(); 36 | 37 | // Validate key length 38 | if key_bytes.len() != 64 { 39 | return false; 40 | } 41 | 42 | // Validate key contains only hexadecimal characters 43 | for &byte in key_bytes.iter() { 44 | if !byte.is_ascii_hexdigit() { 45 | return false; 46 | } 47 | } 48 | } 49 | }; 50 | 51 | true 52 | } 53 | 54 | pub fn default_guard_validator(value: String) -> bool { 55 | if value == "default".to_string() { 56 | return false; 57 | } 58 | 59 | true 60 | } 61 | } 62 | 63 | #[cfg(test)] 64 | mod tests { 65 | use crate::input::input::InputValidators; 66 | 67 | #[test] 68 | fn required_input_validator_test() { 69 | let value = "abc".to_string(); 70 | let result = InputValidators::required_input_validator(value); 71 | 72 | assert_eq!(result, true); 73 | 74 | let value = "".to_string(); 75 | let result = InputValidators::required_input_validator(value); 76 | 77 | assert_eq!(result, false); 78 | } 79 | 80 | #[test] 81 | fn url_validator_test() { 82 | let value = "https://www.domain.org".to_string(); 83 | let result = InputValidators::url_validator(value); 84 | 85 | assert_eq!(result, true); 86 | 87 | let value = "invalid_url".to_string(); 88 | let result = InputValidators::url_validator(value); 89 | 90 | assert_eq!(result, false); 91 | } 92 | 93 | #[test] 94 | fn cron_pattern_validator_test() { 95 | let value = "1/10 * * * * *".to_string(); 96 | let result = InputValidators::cron_pattern_validator(value); 97 | 98 | assert_eq!(result, true); 99 | 100 | let value = "1/10 * * *".to_string(); 101 | let result = InputValidators::cron_pattern_validator(value); 102 | 103 | assert_eq!(result, false); 104 | 105 | let value = "1/10 * * * * * * * *".to_string(); 106 | let result = InputValidators::cron_pattern_validator(value); 107 | 108 | assert_eq!(result, false); 109 | } 110 | 111 | #[test] 112 | fn key_validator_test() { 113 | let value = "6789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345".to_string(); 114 | 115 | let result = InputValidators::key_validator(value); 116 | 117 | assert_eq!(result, true); 118 | 119 | let value = "6789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(); 120 | 121 | let result = InputValidators::key_validator(value); 122 | 123 | assert_eq!(result, false); 124 | 125 | let value = "6789abcdef0123456789abcdef0123456789abcdef0123456789abkdef012345".to_string(); 126 | 127 | let result = InputValidators::key_validator(value); 128 | 129 | assert_eq!(result, false); 130 | 131 | let value = "nsec14uuscmj9ac0f3lqfq33cuq6mu8q7sscvpyyhsjn5r8q9w5pdafgq0qrj8a".to_string(); 132 | 133 | let result = InputValidators::key_validator(value); 134 | 135 | assert_eq!(result, true); 136 | 137 | let value = "nsec14uuscmj9ac0f3lqfq33cuq6mu8q7sscvpyyhsjn5r8q9w5pdafgq0qrj8d".to_string(); 138 | 139 | let result = InputValidators::key_validator(value); 140 | 141 | assert_eq!(result, false); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /nostrss-core/src/main.rs: -------------------------------------------------------------------------------- 1 | // mod commands; 2 | mod app; 3 | mod grpc; 4 | mod nostr; 5 | mod profiles; 6 | mod rss; 7 | mod scheduler; 8 | mod template; 9 | use crate::app::app::{App, AppConfig}; 10 | use crate::scheduler::scheduler::schedule; 11 | use clap::Parser; 12 | use dotenv::dotenv; 13 | use grpc::grpc_service::NostrssServerService; 14 | use log::info; 15 | use nostr_sdk::Result; 16 | use nostrss_grpc::grpc::nostrss_grpc_server::NostrssGrpcServer; 17 | use std::env; 18 | use std::sync::Arc; 19 | use std::thread::sleep; 20 | use std::time::Duration; 21 | use tonic::transport::Server; 22 | 23 | use tokio::sync::Mutex; 24 | 25 | /// Nostrss provides a bridge beetween rss feeds and [Nostr protocol](https://nostr.com/). 26 | /// 27 | /// To use it, you will have to provide some configuration, like relays and feeds to load, which are described into 28 | /// the [README.md](https://github.com/Asone/nostrss/blob/main/README.md) file.. 29 | //: The application is based on async cronjobs. 30 | #[tokio::main] 31 | async fn main() -> Result<()> { 32 | // Load .env configuration 33 | dotenv().ok(); 34 | 35 | // init env logger 36 | env_logger::init(); 37 | 38 | // Create app instance 39 | let app = App::new(AppConfig::parse()).await; 40 | 41 | // Extract initial feeds list 42 | let feeds = app.rss.feeds.clone(); 43 | 44 | // Arc the main app 45 | let global_app_arc = Arc::new(Mutex::new(app)); 46 | 47 | // Update profile for each profile 48 | let _ = { 49 | let global_app_lock = global_app_arc.lock().await; 50 | 51 | let profiles_arc = global_app_lock.get_profiles().await; 52 | 53 | let profiles_lock = profiles_arc.lock().await; 54 | let update_flag = global_app_lock.config.update.unwrap_or(true); 55 | 56 | match update_flag { 57 | true => { 58 | for profile in profiles_lock.clone() { 59 | match global_app_lock 60 | .nostr_service 61 | .update_profile(profile.0.clone()) 62 | .await 63 | { 64 | Ok(result) => { 65 | log::info!( 66 | "Profile {} updated with event id {}", 67 | profile.0.clone(), 68 | result 69 | ) 70 | } 71 | Err(e) => { 72 | log::error!("Error updating profile {} : {:#?}", profile.0.clone(), e) 73 | } 74 | } 75 | } 76 | } 77 | false => {} 78 | } 79 | }; 80 | 81 | /* 82 | Build job for each feed. 83 | */ 84 | for feed in feeds { 85 | // Lock the app mutex 86 | let mut app_lock = global_app_arc.lock().await; 87 | 88 | // Local instance of feed 89 | let f = feed.clone(); 90 | 91 | let client_arc = app_lock.nostr_service.get_client().await; 92 | 93 | // Arc the map of feeds for use in the scheduled jobs 94 | let maps = Arc::new(Mutex::new(app_lock.feeds_map.clone())); 95 | let app_config_arc = app_lock.get_config().await; 96 | // Extract cronjob rule 97 | let scheduler_rule = f.schedule.as_str(); 98 | let profiles = app_lock.get_profiles().await; 99 | // Call job builder 100 | let job = schedule( 101 | scheduler_rule, 102 | feed, 103 | maps, 104 | client_arc, 105 | profiles, 106 | app_config_arc, 107 | ) 108 | .await; 109 | info!("Job id for feed {:?}: {:?}", f.name, job.guid()); 110 | 111 | // Load job reference in jobs map 112 | _ = &app_lock.rss.feeds_jobs.insert(f.id, job.guid()); 113 | 114 | // Load job in scheduler 115 | _ = &app_lock.rss.scheduler.add(job).await; 116 | } 117 | 118 | // Start jobs. 119 | // We scope the instructions in a block to avoidd 120 | // locking the app arc on the whole instance as we 121 | // need to be able to lock it again later. 122 | { 123 | let app_lock = global_app_arc.lock().await; 124 | _ = &app_lock.rss.scheduler.start().await; 125 | }; 126 | 127 | // GRPC server 128 | { 129 | let local_app = Arc::clone(&global_app_arc); 130 | 131 | let grpc_address = env::var("GRPC_ADDRESS").unwrap_or("[::1]:33333".to_string()); 132 | let address = grpc_address.parse().unwrap(); 133 | 134 | let nostrss_grpc = NostrssServerService { app: local_app }; 135 | 136 | match Server::builder() 137 | .add_service(NostrssGrpcServer::new(nostrss_grpc)) 138 | .serve(address) 139 | .await 140 | { 141 | Ok(r) => println!("{:?}", r), 142 | Err(e) => panic!("{:?}", e), 143 | }; 144 | }; 145 | 146 | // Loop to maintain program running 147 | loop { 148 | // Sleep to avoid useless high CPU usage 149 | sleep(Duration::from_millis(100)); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /nostrss-core/README.md: -------------------------------------------------------------------------------- 1 | # nostrss (Core) 2 | 3 | ## Run 4 | 5 | To run the program you will have to provide two arguments : 6 | 7 | > nostrss --relays --feeds --profiles --update 8 | 9 | Both provided files can be either `yaml` or `json` files. 10 | You will find examples of the files structure in the [fixtures](./src/fixtures/) folder. 11 | 12 | ## Configuration objects 13 | ### RSS feeds 14 | 15 | | Key | Type | Required | Description 16 | |-----------|---------------|----------|------------------------------------------------------------| 17 | | id | String | Yes | The identifier of the feed | 18 | | name | String | Yes | The name of the feed to be displayed in the nostr message | 19 | | url | String | Yes | The URL of the feed | 20 | | schedule | Cron pattern | Yes | The Cronjob rule | 21 | | profile | Array of strings | No | The profiles to be used for this rss feed | 22 | | tags | Array of strings | No | A list of tags to be used for messages | 23 | | template | String | No | An optional path to a template to use for feed publishing. | 24 | | cache_size | Integer | No | The snapshot size made in job. If no value is provided and no default value is set through env, cache will have no limit. | 25 | 26 | ##### Examples : 27 | - [json file example](./src/fixtures/rss.json) 28 | - [yaml file example](./src/fixtures/rss.yaml) 29 | ### Relays 30 | 31 | | Key | Type | Required | Description 32 | |-----------|---------------|----------|------------------------------------------------------------| 33 | | name | String | Yes | The relay name | 34 | | target | String | Yes | The url to the relay, must be a websocket service | 35 | | active | Boolean | Yes | Not used yet, will be used to skip using relays | 36 | | proxy | Cron pattern | No | An optional proxy to connect through | 37 | 38 | ##### Examples : 39 | - [json file example](./src/fixtures/relays.json) 40 | - [yaml file example](./src/fixtures/relays.yaml) 41 | 42 | ### Profiles 43 | #### Default 44 | 45 | You must configure your default Nostr identity through the environment variables. 46 | 47 | You can use a .env file for that. Refer to the [.env.dist](./.env.dist) as example. 48 | 49 | If no private key is provided, a random one will be generated. 50 | 51 | If you have no private key already, you can go on [astral.ninja](https://astral.ninja/) to generate one. 52 | 53 | #### Profile Values 54 | 55 | | Key | Type | Required | Description | 56 | |---------------|---------------|----------|------------------------------------------------------------| 57 | | id | String | Yes | | 58 | | private_key | String | Yes | | 59 | | about | String | No | 60 | | name | String | No | The handle name | 61 | | display_name | String | No | The name to be displayed | 62 | | description | String | No | | 63 | | picture | String | No | A valid URL to an image for picture | 64 | | banner | String | No | A valid URL to an image for banner | 65 | | nip05 | String| No | Identity certificatioon 66 | | lud16 | String |No | LN Wallet | 67 | | pow_level | String |No | The pow difficulty to use for publishing under the current profile | 68 | | recommended_relays | Array of relays ids |No | The relays that should be recommended to clients for the published notes | 69 | 70 | ##### Examples : 71 | - [json file example](./src/fixtures/profiles.json) 72 | - [yaml file example](./src/fixtures/profiles.yaml) 73 | 74 | ### Templating 75 | 76 | Nostrss allows you to customize the message sent for each feed. Custom templates are optional. 77 | See [RSS Feeds](#rss-feeds) section to see how to provide custom templates for feeds. 78 | 79 | If no custom template path is provided, Nostrss will automatically fallback on the default template provided in [.env.dist](./.env.dist) config file. 80 | 81 | If no default template is either provided, the feed threaded-job will panic, but the application will keep running. 82 | This avoids a global panic and keeps all sane jobs running. 83 | 84 | If provided path for custom template is non-existant, the job will raise an error and publishing will be skipped. 85 | 86 | Below are the variables you can use for templating : 87 | 88 | | Variable | Description | 89 | | ------------ |---------------------------------- | 90 | | name | The `feed` given name | 91 | | title | The `entry` title | 92 | | content | The `entry` content. Usually a description of the item | 93 | | url | The URL to the `entry` | 94 | | tags | The tags of the `feed` | 95 | 96 | An example template is provided in the [fixtures](./src/fixtures/default.template) 97 | 98 | ## RSS broadcasting 99 | 100 | > [!WARNING] 101 | > Starting you're using a prior version of nostrss v1.1.0, use [cron crate rules](https://crates.io/crates/cron) pattern instead. For any upper version you can rely on the information below. 102 | 103 | Cronjob rules are defined in the [feeds config file](./src/fixtures/rss.json) following the [croner-rust crate rules](https://docs.rs/croner/latest/croner/#pattern). 104 | 105 | For each tick the remote feed will be matched with a local fingerprint, for which, any unmatching entry against of the feed will be broadcasted to relays. 106 | 107 | ### Dry run mode 108 | 109 | You can run the program in a `dry-run` mode, so the program will run the whole processes as usual but will avoid broadcasting the final result onto the network. 110 | 111 | When activating the `dry-run` mode, the programm will log the json that would have been broadcasted into the `STDOUT`. 112 | 113 | To run the `dry-run` mode use the `--dry-run` flag when instanciating `nostrss` : 114 | 115 | > nostrss --relays --feeds --profiles --update --dry-run -------------------------------------------------------------------------------- /nostrss-core/src/grpc/profile_request.rs: -------------------------------------------------------------------------------- 1 | use nostrss_grpc::grpc::{ 2 | self, AddProfileRequest, AddProfileResponse, DeleteProfileRequest, DeleteProfileResponse, 3 | NewProfileItem, ProfileInfoRequest, ProfileInfoResponse, ProfileItem, ProfilesListRequest, 4 | ProfilesListResponse, 5 | }; 6 | use tokio::sync::MutexGuard; 7 | use tonic::{Code, Request, Response, Status}; 8 | 9 | use crate::{app::app::App, profiles::config::Profile}; 10 | 11 | impl From for Profile { 12 | fn from(value: NewProfileItem) -> Self { 13 | let pow_level = match value.pow_level { 14 | Some(value) => value as u8, 15 | None => 0, 16 | }; 17 | 18 | Self { 19 | id: value.id, 20 | private_key: value.private_key, 21 | relays: Vec::new(), 22 | about: value.description.clone(), 23 | name: value.name, 24 | display_name: value.display_name, 25 | description: value.description, 26 | picture: value.picture, 27 | banner: value.banner, 28 | nip05: value.nip05, 29 | lud16: value.lud16, 30 | pow_level, 31 | recommended_relays: Some(value.recommended_relays), 32 | } 33 | } 34 | } 35 | 36 | pub struct ProfileRequestHandler {} 37 | 38 | impl ProfileRequestHandler { 39 | // Interface to retrieve the list of profiles on instance 40 | pub async fn profiles_list( 41 | app: MutexGuard<'_, App>, 42 | _: Request, 43 | ) -> Result, Status> { 44 | let mut profiles = Vec::new(); 45 | 46 | for profile in app.nostr_service.profiles.clone() { 47 | profiles.push(ProfileItem::from(profile.1)); 48 | } 49 | 50 | Ok(Response::new(grpc::ProfilesListResponse { profiles })) 51 | } 52 | 53 | // Interface to retrieve the detailed configuration of a single profile on instance 54 | pub async fn profile_info( 55 | app: MutexGuard<'_, App>, 56 | request: Request, 57 | ) -> Result, Status> { 58 | let id = &request.into_inner().id; 59 | match app.nostr_service.profiles.get(id.trim()) { 60 | Some(_) => Ok(Response::new(ProfileInfoResponse { 61 | profile: ProfileItem::from(app.nostr_service.profiles[id.trim()].clone()), 62 | })), 63 | None => Err(Status::new(Code::NotFound, "Profile not found")), 64 | } 65 | } 66 | 67 | pub async fn add_profile( 68 | mut app: MutexGuard<'_, App>, 69 | request: Request, 70 | ) -> Result, Status> { 71 | let new_profile_item = request.into_inner().profile; 72 | 73 | let profile = Profile::from(new_profile_item); 74 | 75 | app.nostr_service 76 | .profiles 77 | .insert(profile.id.clone(), profile); 78 | 79 | Ok(Response::new(grpc::AddProfileResponse {})) 80 | } 81 | 82 | // Interface to delete a profile on instance 83 | pub async fn delete_profile( 84 | mut app: MutexGuard<'_, App>, 85 | request: Request, 86 | ) -> Result, Status> { 87 | let delete_profile_inner = request.into_inner(); 88 | let save = delete_profile_inner.save(); 89 | let profile_id = &delete_profile_inner.id; 90 | let profile = app.nostr_service.profiles.remove(profile_id.trim()); 91 | 92 | if profile.is_none() { 93 | return Err(Status::new(Code::NotFound, "No profile with that id found")); 94 | } 95 | 96 | if profile_id == "default" { 97 | return Err(Status::new( 98 | Code::PermissionDenied, 99 | "Default profile can not be deleted", 100 | )); 101 | } 102 | 103 | if save == true { 104 | _ = &app.update_profile_config().await; 105 | } 106 | 107 | Ok(Response::new(grpc::DeleteProfileResponse {})) 108 | } 109 | } 110 | 111 | #[cfg(test)] 112 | mod tests { 113 | 114 | use super::*; 115 | use std::sync::Arc; 116 | 117 | use crate::grpc::grpctest_utils::mock_app; 118 | use nostrss_grpc::grpc::{AddProfileRequest, NewProfileItem}; 119 | use tokio::sync::Mutex; 120 | use tonic::Request; 121 | 122 | #[tokio::test] 123 | async fn add_profile_test() { 124 | let app = Arc::new(Mutex::new(mock_app().await)); 125 | 126 | let add_profile_request = AddProfileRequest { 127 | profile: NewProfileItem { 128 | id: "added".to_string(), 129 | private_key: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" 130 | .to_string(), 131 | ..Default::default() 132 | }, 133 | save: Some(false), 134 | }; 135 | 136 | let request = Request::new(add_profile_request); 137 | 138 | let profile_add_request_result = { 139 | let app_lock = app.lock().await; 140 | ProfileRequestHandler::add_profile(app_lock, request).await 141 | }; 142 | 143 | assert_eq!(profile_add_request_result.is_ok(), true); 144 | 145 | let profiles_list_request = ProfilesListRequest {}; 146 | let request = Request::new(profiles_list_request); 147 | 148 | let profiles_list_request_result = { 149 | let app_lock = app.lock().await; 150 | ProfileRequestHandler::profiles_list(app_lock, request).await 151 | }; 152 | 153 | let response = profiles_list_request_result.unwrap().into_inner(); 154 | 155 | assert_eq!(response.profiles.len(), 3); 156 | } 157 | 158 | #[tokio::test] 159 | async fn list_profiles_test() { 160 | let app = Arc::new(Mutex::new(mock_app().await)); 161 | 162 | let profiles_list_request = ProfilesListRequest {}; 163 | let request = Request::new(profiles_list_request); 164 | 165 | let profiles_list_request_result = 166 | ProfileRequestHandler::profiles_list(app.lock().await, request).await; 167 | 168 | assert_eq!(profiles_list_request_result.is_ok(), true); 169 | 170 | let response = profiles_list_request_result.unwrap().into_inner(); 171 | 172 | assert_eq!(response.profiles.len(), 2); 173 | } 174 | 175 | #[tokio::test] 176 | async fn delete_profile_test() { 177 | let app = Arc::new(Mutex::new(mock_app().await)); 178 | 179 | let delete_profile_request = DeleteProfileRequest { 180 | id: "test".to_string(), 181 | save: Some(false), 182 | }; 183 | 184 | let request = Request::new(delete_profile_request); 185 | 186 | let delete_profile_request_result = 187 | ProfileRequestHandler::delete_profile(app.lock().await, request).await; 188 | 189 | assert_eq!(delete_profile_request_result.is_ok(), true); 190 | } 191 | 192 | #[tokio::test] 193 | async fn profile_info_test() { 194 | let app = Arc::new(Mutex::new(mock_app().await)); 195 | 196 | let profiles_list_request = ProfilesListRequest {}; 197 | let request = Request::new(profiles_list_request); 198 | 199 | let profiles_list_request_result = 200 | ProfileRequestHandler::profiles_list(app.lock().await, request).await; 201 | 202 | assert_eq!(profiles_list_request_result.is_ok(), true); 203 | 204 | let response = profiles_list_request_result.unwrap().into_inner(); 205 | 206 | assert_eq!(response.profiles.len(), 2); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /nostrss-cli/src/commands/feed.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use clap::{Parser, ValueEnum}; 4 | use nostrss_grpc::grpc::{ 5 | nostrss_grpc_client::NostrssGrpcClient, AddFeedRequest, DeleteFeedRequest, FeedInfoRequest, 6 | FeedItem, FeedsListRequest, 7 | }; 8 | use tabled::Tabled; 9 | use tonic::{async_trait, transport::Channel}; 10 | 11 | use crate::{ 12 | input::{formatter::InputFormatter, input::InputValidators}, 13 | CliOptions, 14 | }; 15 | 16 | use super::CommandsHandler; 17 | 18 | #[derive(Clone, PartialEq, Parser, Debug, ValueEnum)] 19 | pub enum FeedActions { 20 | Add, 21 | Delete, 22 | List, 23 | Info, 24 | } 25 | 26 | pub struct FeedCommandsHandler { 27 | pub client: NostrssGrpcClient, 28 | } 29 | 30 | #[derive(Tabled)] 31 | pub struct FeedDetailsTemplate { 32 | pub key: String, 33 | pub value: String, 34 | } 35 | 36 | pub struct FullFeedTemplate { 37 | pub id: String, 38 | pub name: String, 39 | pub url: String, 40 | pub schedule: String, 41 | pub profiles: String, 42 | pub tags: String, 43 | pub template: String, 44 | pub cache_size: Option, 45 | pub pow_level: String, 46 | } 47 | 48 | impl From for FullFeedTemplate { 49 | fn from(value: FeedItem) -> Self { 50 | let profiles = value.profiles.join(","); 51 | let tags = value.tags.join(","); 52 | let cache_size = match value.cache_size { 53 | Some(r) => Some(r.to_string()), 54 | None => None, 55 | }; 56 | let pow_level = value.pow_level.to_string(); 57 | Self { 58 | id: value.id, 59 | name: value.name, 60 | url: value.url, 61 | schedule: value.schedule, 62 | profiles, 63 | tags, 64 | template: value.template.unwrap_or("".to_string()), 65 | cache_size, 66 | pow_level, 67 | } 68 | } 69 | } 70 | 71 | impl FullFeedTemplate { 72 | fn properties_to_vec(&self) -> Vec { 73 | let cache_binding = self.cache_size.clone().unwrap_or_else(|| "".to_string()); 74 | let properties: Vec<(String, &String)> = [ 75 | ("id".to_string(), &self.id), 76 | ("name".to_string(), &self.name), 77 | ("url".to_string(), &self.url), 78 | ("schedule".to_string(), &self.schedule), 79 | ("profiles".to_string(), &self.profiles), 80 | ("tags".to_string(), &self.tags), 81 | ("template".to_string(), &self.template), 82 | ("cache_size".to_string(), &cache_binding), 83 | ("pow_level".to_string(), &self.pow_level), 84 | ] 85 | .to_vec(); 86 | 87 | properties 88 | .into_iter() 89 | .map(|p| FeedDetailsTemplate { 90 | key: p.0, 91 | value: p.1.to_string(), 92 | }) 93 | .collect() 94 | } 95 | } 96 | 97 | #[derive(Tabled)] 98 | struct FeedsTemplate { 99 | name: String, 100 | url: String, 101 | schedule: String, 102 | } 103 | 104 | impl FeedsTemplate { 105 | fn new(name: &str, url: &str, schedule: &str) -> Self { 106 | Self { 107 | name: name.to_string(), 108 | url: url.to_string(), 109 | schedule: schedule.to_string(), 110 | } 111 | } 112 | } 113 | 114 | #[async_trait] 115 | impl CommandsHandler for FeedCommandsHandler {} 116 | 117 | impl FeedCommandsHandler { 118 | pub async fn handle(&mut self, action: FeedActions, opts: CliOptions) { 119 | match action { 120 | FeedActions::Add => self.add(opts).await, 121 | FeedActions::Delete => self.delete(opts).await, 122 | FeedActions::List => self.list().await, 123 | FeedActions::Info => self.info().await, 124 | } 125 | } 126 | 127 | async fn list(&mut self) { 128 | let request = tonic::Request::new(FeedsListRequest {}); 129 | let response = self.client.feeds_list(request).await; 130 | match response { 131 | Ok(response) => { 132 | let raws: Vec = response 133 | .into_inner() 134 | .feeds 135 | .into_iter() 136 | .map(|f| FeedsTemplate::new(&f.id, &f.url, &f.schedule)) 137 | .collect(); 138 | 139 | self.print(raws); 140 | } 141 | 142 | Err(e) => { 143 | println!("Error {}: {}", e.code(), e.message()); 144 | } 145 | } 146 | } 147 | 148 | async fn add(&mut self, opts: CliOptions) { 149 | println!("=== Add a new feed ==="); 150 | let id = self.get_input("Id: ", Some(InputValidators::required_input_validator)); 151 | let name = self.get_input("Name: ", Some(InputValidators::required_input_validator)); 152 | let url = self.get_input("Url: ", Some(InputValidators::url_validator)); 153 | let schedule = self.get_input( 154 | "scheduler pattern: ", 155 | Some(InputValidators::cron_pattern_validator), 156 | ); 157 | let profiles: Vec = InputFormatter::input_to_vec( 158 | self.get_input("profiles ids (separated with coma): ", None), 159 | ); 160 | let tags: Vec = 161 | InputFormatter::input_to_vec(self.get_input("Tags (separated with coma):", None)); 162 | let template = self.get_input("Template path: ", None); 163 | let cache_size_input = self.get_input("Cache size: ", None); 164 | let cache_size = if cache_size_input.is_empty() { 165 | None 166 | } else { 167 | Some(cache_size_input.parse().unwrap_or(1000)) 168 | }; 169 | let pow_level = self.get_input("Pow Level: ", None).parse().unwrap_or(0); 170 | 171 | let request = tonic::Request::new(AddFeedRequest { 172 | feed: FeedItem { 173 | id, 174 | name, 175 | url, 176 | schedule, 177 | profiles, 178 | template: Some(template), 179 | tags, 180 | cache_size, 181 | pow_level, 182 | }, 183 | save: Some(opts.save), 184 | }); 185 | 186 | let response = self.client.add_feed(request).await; 187 | 188 | match response { 189 | Ok(_) => { 190 | println!("Feed successfuly added"); 191 | } 192 | Err(e) => { 193 | println!("Error: {}: {}", e.code(), e.message()); 194 | } 195 | } 196 | } 197 | 198 | async fn delete(&mut self, opts: CliOptions) { 199 | let id = self.get_input("Id: ", None); 200 | 201 | let request = tonic::Request::new(DeleteFeedRequest { 202 | id, 203 | save: Some(opts.save), 204 | }); 205 | 206 | let response = self.client.delete_feed(request).await; 207 | 208 | match response { 209 | Ok(_) => { 210 | println!("Feed successfully deleted"); 211 | } 212 | Err(e) => { 213 | println!("Error {}: {}", e.code(), e.message()); 214 | } 215 | } 216 | } 217 | 218 | async fn info(&mut self) { 219 | let id = self.get_input("Id: ", None); 220 | 221 | let request = tonic::Request::new(FeedInfoRequest { 222 | id: id.trim().to_string(), 223 | }); 224 | let response = self.client.feed_info(request).await; 225 | 226 | match response { 227 | Ok(response) => { 228 | let feed = response.into_inner().feed; 229 | 230 | let feed = FullFeedTemplate::from(feed); 231 | 232 | self.print(feed.properties_to_vec()); 233 | } 234 | Err(e) => { 235 | println!("Error {}: {}", e.code(), e.message()); 236 | } 237 | } 238 | } 239 | } 240 | 241 | #[cfg(tests)] 242 | mod tests { 243 | 244 | #[test] 245 | fn cli_feed_info_command_test() {} 246 | } 247 | -------------------------------------------------------------------------------- /nostrss-core/src/grpc/feed_request.rs: -------------------------------------------------------------------------------- 1 | use nostrss_grpc::grpc::{ 2 | self, AddFeedRequest, AddFeedResponse, DeleteFeedRequest, DeleteFeedResponse, FeedInfoRequest, 3 | FeedInfoResponse, FeedItem, FeedsListRequest, FeedsListResponse, 4 | }; 5 | use std::sync::Arc; 6 | use tokio::sync::{Mutex, MutexGuard}; 7 | use tonic::{Code, Request, Response, Status}; 8 | 9 | use crate::{app::app::App, rss::config::Feed, scheduler::scheduler::schedule}; 10 | 11 | pub struct FeedRequestHandler {} 12 | 13 | impl FeedRequestHandler { 14 | pub async fn feeds_list( 15 | app: MutexGuard<'_, App>, 16 | _: Request, 17 | ) -> Result, Status> { 18 | let mut feeds = Vec::new(); 19 | 20 | for feed in app.rss.feeds.clone() { 21 | let f = FeedItem::from(feed); 22 | feeds.push(f); 23 | } 24 | 25 | Ok(Response::new(grpc::FeedsListResponse { feeds })) 26 | } 27 | 28 | pub async fn feed_info( 29 | app: MutexGuard<'_, App>, 30 | request: Request, 31 | ) -> Result, Status> { 32 | let id = &request.into_inner().id; 33 | match app.rss.feeds.clone().into_iter().find(|f| &f.id == id) { 34 | Some(feed) => Ok(Response::new(FeedInfoResponse { 35 | feed: FeedItem::from(feed), 36 | })), 37 | None => Err(Status::new(Code::NotFound, "Feed not found")), 38 | } 39 | } 40 | 41 | pub async fn add_feed( 42 | mut app: MutexGuard<'_, App>, 43 | request: Request, 44 | ) -> Result, Status> { 45 | let data = request.into_inner(); 46 | let save = data.save(); 47 | let feed = Feed::from(data.feed); 48 | let map = Arc::new(Mutex::new(app.feeds_map.clone())); 49 | let profiles = app.get_profiles().await; 50 | let client = app.nostr_service.get_client().await; 51 | let config = app.get_config().await; 52 | app.rss.feeds.push(feed.clone()); 53 | 54 | let job = schedule( 55 | feed.schedule.clone().as_str(), 56 | feed.clone(), 57 | map, 58 | client, 59 | profiles, 60 | config, 61 | ) 62 | .await; 63 | 64 | _ = app.rss.feeds_jobs.insert(feed.id.clone(), job.guid()); 65 | _ = app.rss.scheduler.add(job).await; 66 | 67 | if save == true { 68 | _ = &app.update_feeds_config(&app.rss.feeds).await; 69 | } 70 | 71 | Ok(Response::new(AddFeedResponse {})) 72 | } 73 | 74 | // Interface to delete a feed on instance 75 | pub async fn delete_feed( 76 | mut app: MutexGuard<'_, App>, 77 | request: Request, 78 | ) -> Result, Status> { 79 | let data = request.into_inner(); 80 | 81 | let save = data.save(); 82 | let feed_id = data.id; 83 | 84 | let idx = match app.rss.feeds.iter().position(|f| &f.id == &feed_id) { 85 | Some(idx) => idx, 86 | None => { 87 | return Err(Status::new( 88 | Code::NotFound, 89 | "No feed found with provided id", 90 | )); 91 | } 92 | }; 93 | 94 | let app_clone = app.rss.clone(); 95 | let job_uuid = app_clone.feeds_jobs.get(feed_id.trim()); 96 | 97 | if job_uuid.is_none() { 98 | return Err(Status::new( 99 | Code::NotFound, 100 | "Job associated to feed not found", 101 | )); 102 | } 103 | 104 | _ = &app.rss.feeds.remove(idx); 105 | _ = &app.scheduler.remove(job_uuid.unwrap()).await; 106 | 107 | if save == true { 108 | _ = &app.update_feeds_config(&app.rss.feeds).await; 109 | } 110 | 111 | Ok(Response::new(grpc::DeleteFeedResponse {})) 112 | } 113 | } 114 | 115 | #[cfg(test)] 116 | mod tests { 117 | 118 | use super::*; 119 | use std::sync::Arc; 120 | 121 | use crate::grpc::grpctest_utils::mock_app; 122 | use nostrss_grpc::grpc::AddFeedRequest; 123 | use tokio::sync::Mutex; 124 | use tonic::Request; 125 | 126 | #[tokio::test] 127 | async fn feeds_list_test() { 128 | let app = Arc::new(Mutex::new(mock_app().await)); 129 | 130 | let feeds_list_request = FeedsListRequest {}; 131 | let request = Request::new(feeds_list_request); 132 | 133 | let feeds_list_request_result = 134 | FeedRequestHandler::feeds_list(app.lock().await, request).await; 135 | 136 | assert_eq!(feeds_list_request_result.is_ok(), true); 137 | 138 | let response = feeds_list_request_result.unwrap().into_inner(); 139 | 140 | assert_eq!(response.feeds.len(), 3); 141 | } 142 | 143 | #[tokio::test] 144 | async fn add_feed_test() { 145 | let app = Arc::new(Mutex::new(mock_app().await)); 146 | 147 | let add_feed_request = AddFeedRequest { 148 | feed: FeedItem { 149 | id: "test".to_string(), 150 | name: "my test feed".to_string(), 151 | url: "http://myrss.rs".to_string(), 152 | schedule: "1/10 * * * * *".to_string(), 153 | profiles: Vec::new(), 154 | tags: Vec::new(), 155 | template: None, 156 | cache_size: Some(50), 157 | pow_level: 50, 158 | }, 159 | save: Some(false), 160 | }; 161 | 162 | let request = Request::new(add_feed_request); 163 | 164 | let add_feed_result = { 165 | let app_lock = app.lock().await; 166 | FeedRequestHandler::add_feed(app_lock, request).await 167 | }; 168 | 169 | assert_eq!(add_feed_result.is_ok(), true); 170 | 171 | let feeds_list_request = FeedsListRequest {}; 172 | let request = Request::new(feeds_list_request); 173 | 174 | let feeds_list_request_result = { 175 | let app_lock = app.lock().await; 176 | FeedRequestHandler::feeds_list(app_lock, request).await 177 | }; 178 | 179 | assert_eq!(feeds_list_request_result.is_ok(), true); 180 | 181 | let response = feeds_list_request_result.unwrap().into_inner(); 182 | 183 | assert_eq!(response.feeds.len(), 4); 184 | } 185 | 186 | #[tokio::test] 187 | async fn delete_feed_test() { 188 | let app = Arc::new(Mutex::new(mock_app().await)); 189 | 190 | let delete_feed_request = DeleteFeedRequest { 191 | id: "stackernews".to_string(), 192 | save: Some(false), 193 | }; 194 | 195 | let request = Request::new(delete_feed_request); 196 | 197 | let delete_feed_request_result = { 198 | let app_lock = app.lock().await; 199 | FeedRequestHandler::delete_feed(app_lock, request).await 200 | }; 201 | 202 | assert_eq!(delete_feed_request_result.is_ok(), true); 203 | 204 | let feeds_list_request = FeedsListRequest {}; 205 | let request = Request::new(feeds_list_request); 206 | 207 | let feeds_list_request_result = { 208 | let app_lock = app.lock().await; 209 | FeedRequestHandler::feeds_list(app_lock, request).await 210 | }; 211 | let response = feeds_list_request_result.unwrap().into_inner(); 212 | 213 | assert_eq!(response.feeds.len(), 2); 214 | } 215 | 216 | #[tokio::test] 217 | async fn feed_info_test() { 218 | let app = Arc::new(Mutex::new(mock_app().await)); 219 | 220 | let feed_info_request = FeedInfoRequest { 221 | id: "stackernews".to_string(), 222 | }; 223 | let request = Request::new(feed_info_request); 224 | 225 | let feed_info_request_result = 226 | FeedRequestHandler::feed_info(app.lock().await, request).await; 227 | 228 | assert_eq!(feed_info_request_result.is_ok(), true); 229 | 230 | let response = feed_info_request_result.unwrap().into_inner(); 231 | 232 | assert_eq!(response.feed.id, "stackernews"); 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /nostrss-core/src/template/template.rs: -------------------------------------------------------------------------------- 1 | use crate::rss::config::Feed; 2 | use feed_rs::model::Entry; 3 | use log::error; 4 | use new_string_template::{error::TemplateError, template::Template}; 5 | use std::env; 6 | use std::{collections::HashMap, fs}; 7 | 8 | #[derive(Debug)] 9 | pub enum TemplateParserError { 10 | LoadError, 11 | } 12 | 13 | /// Provides template rendering to the application 14 | pub struct TemplateProcessor {} 15 | 16 | impl TemplateProcessor { 17 | // Tries to load a template through an option path 18 | // If template is not provided, the method fallsback 19 | // to environment template. 20 | fn load_template(path: Option) -> Result { 21 | match path { 22 | Some(path) => { 23 | let file = fs::read_to_string(path); 24 | match file { 25 | Ok(file_content) => Ok(file_content), 26 | Err(e) => { 27 | error!("{}", e); 28 | Err(TemplateParserError::LoadError) 29 | } 30 | } 31 | } 32 | None => Ok(Self::get_default_env_template()), 33 | } 34 | } 35 | 36 | // Parses template from environment 37 | fn get_default_env_template() -> String { 38 | match env::var("DEFAULT_TEMPLATE") { 39 | Ok(val) => val, 40 | Err(e) => { 41 | error!("{}", e); 42 | panic!(); 43 | } 44 | } 45 | } 46 | 47 | // Parses template with data 48 | pub fn parse(data: Feed, entry: Entry) -> Result { 49 | let template = Self::load_template(data.clone().template).unwrap(); 50 | let mut map = Self::parse_entry_to_hashmap(entry); 51 | 52 | map.insert("name", data.name.clone()); 53 | 54 | let mut tags_string = "".to_string(); 55 | 56 | for tag in data.tags.unwrap_or(Vec::new()) { 57 | tags_string = format!("{} #{}", tags_string, tag); 58 | } 59 | 60 | map.insert("tags", tags_string.trim().to_string()); 61 | 62 | let templ = Template::new(template); 63 | 64 | templ.render(&map) 65 | } 66 | 67 | // created a HashMap from the entry data 68 | // The HashMap is currently consumed by the template engine 69 | fn parse_entry_to_hashmap(data: Entry) -> HashMap<&'static str, String> { 70 | let mut map = HashMap::new(); 71 | 72 | let title = match data.title { 73 | Some(title) => title.content, 74 | None => "".to_string(), 75 | }; 76 | 77 | map.insert("title", title); 78 | map.insert("url", data.links[0].clone().href); 79 | 80 | let summary = match data.summary { 81 | Some(summary) => summary.content, 82 | None => "".to_string(), 83 | }; 84 | 85 | map.insert("summary", summary); 86 | 87 | map 88 | } 89 | } 90 | 91 | #[cfg(test)] 92 | mod tests { 93 | extern crate mime; 94 | 95 | use super::*; 96 | use dotenv::from_filename; 97 | use feed_rs::model::{Content, Link, Text}; 98 | use mediatype::MediaTypeBuf; 99 | 100 | #[test] 101 | fn test_default_template_fallback() { 102 | from_filename(".env.test").ok(); 103 | 104 | let entry = Entry { 105 | content: Some(Content { 106 | body: Some("Test".to_string()), 107 | ..Default::default() 108 | }), 109 | links: [Link { 110 | href: "https://www.nostr.info".to_string(), 111 | rel: None, 112 | media_type: None, 113 | href_lang: None, 114 | title: None, 115 | length: None, 116 | }] 117 | .to_vec(), 118 | ..Default::default() 119 | }; 120 | 121 | let feed = Feed { 122 | tags: Some(Vec::new()), 123 | ..Default::default() 124 | }; 125 | 126 | let result = TemplateProcessor::parse(feed, entry); 127 | 128 | assert_eq!(result.is_ok(), true); 129 | 130 | let expected = 131 | "test nostrss template\nFeed: Generic feed\nUrl: https://www.nostr.info\nTags: " 132 | .to_string(); 133 | let result = result.unwrap(); 134 | 135 | assert_eq!(result, expected); 136 | } 137 | 138 | #[test] 139 | fn test_custom_template() { 140 | from_filename(".env.test").ok(); 141 | let text_plain: mediatype::MediaTypeBuf = "text/plain; charset=UTF-8".parse().unwrap(); 142 | let entry = Entry { 143 | title: Some(Text { 144 | content_type: text_plain, 145 | src: None, 146 | content: "Test content".to_string(), 147 | }), 148 | content: Some(Content { 149 | body: Some("Test body".to_string()), 150 | ..Default::default() 151 | }), 152 | links: [Link { 153 | href: "https://www.nostr.info".to_string(), 154 | rel: None, 155 | media_type: None, 156 | href_lang: None, 157 | title: None, 158 | length: None, 159 | }] 160 | .to_vec(), 161 | ..Default::default() 162 | }; 163 | 164 | let tags = ["Test".to_string(), "nostrss".to_string()].to_vec(); 165 | let feed = Feed { 166 | tags: Some(tags), 167 | template: Some("./src/fixtures/default.template".to_string()), 168 | ..Default::default() 169 | }; 170 | 171 | let result = TemplateProcessor::parse(feed, entry); 172 | 173 | assert_eq!(result.is_ok(), true); 174 | 175 | let result = result.unwrap(); 176 | let expected = "Default nostrss template file\nFeed: Generic feed\nUrl: https://www.nostr.info\nTags: #Test #nostrss".to_string(); 177 | 178 | assert_eq!(result, expected); 179 | } 180 | 181 | #[test] 182 | fn test_entry_to_hashmap() { 183 | let text_plain: MediaTypeBuf = "text/plain; charset=UTF-8".parse().unwrap(); 184 | from_filename(".env.test").ok(); 185 | let entry = Entry { 186 | title: Some(Text { 187 | content_type: text_plain, 188 | src: None, 189 | content: "Test content".to_string(), 190 | }), 191 | content: Some(Content { 192 | body: Some("Test body".to_string()), 193 | ..Default::default() 194 | }), 195 | links: [Link { 196 | href: "https://www.nostr.info".to_string(), 197 | rel: None, 198 | media_type: None, 199 | href_lang: None, 200 | title: None, 201 | length: None, 202 | }] 203 | .to_vec(), 204 | ..Default::default() 205 | }; 206 | 207 | let hashmap = TemplateProcessor::parse_entry_to_hashmap(entry); 208 | 209 | assert_eq!(hashmap["title"], "Test content"); 210 | assert_eq!(hashmap["url"], "https://www.nostr.info"); 211 | } 212 | 213 | #[test] 214 | fn test_template_loading() { 215 | let path = "./src/fixtures/default.template".to_string(); 216 | let result = TemplateProcessor::load_template(Some(path)); 217 | assert_eq!(result.is_ok(), true); 218 | 219 | let result = result.unwrap(); 220 | let expected = 221 | "Default nostrss template file\nFeed: {name}\nUrl: {url}\nTags: {tags}".to_string(); 222 | assert_eq!(result, expected); 223 | 224 | let bad_path = "./src/fixture/nonexistant.template".to_string(); 225 | let result = TemplateProcessor::load_template(Some(bad_path)); 226 | assert_eq!(result.is_err(), true); 227 | 228 | from_filename(".env.test").ok(); 229 | let result = TemplateProcessor::load_template(None); 230 | assert_eq!(result.is_ok(), true); 231 | 232 | let result = result.unwrap(); 233 | let expected = "test nostrss template\nFeed: {name}\nUrl: {url}\nTags: {tags}".to_string(); 234 | assert_eq!(result, expected); 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /nostrss-core/src/profiles/profiles.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use std::{collections::HashMap, fs::File, path::Path, str::FromStr}; 4 | 5 | use log::error; 6 | use reqwest::Url; 7 | 8 | use crate::nostr::relay::Relay; 9 | 10 | use super::config::Profile; 11 | 12 | #[derive(Debug, PartialEq, Clone)] 13 | pub struct ProfileHandler(pub HashMap); 14 | 15 | impl ProfileHandler { 16 | pub fn new(path: &Option, default_relays: &str) -> Self { 17 | // Init profile instances index 18 | let mut profiles = Self(HashMap::new()); 19 | 20 | // Register default profile 21 | let mut default_profile = Profile::default(); 22 | default_profile = default_profile.set_relays_from_file(default_relays); 23 | 24 | profiles 25 | .0 26 | .insert(default_profile.clone().id, default_profile); 27 | 28 | if let Some(path) = path { 29 | profiles = profiles.load_profiles(path); 30 | }; 31 | 32 | profiles 33 | } 34 | 35 | pub fn save_profiles(self, path: &str, profiles: Vec<&Profile>) -> bool { 36 | let path = Path::new(path); 37 | 38 | if path.is_file() { 39 | match path.extension() { 40 | Some(ext) => match ext.to_str() { 41 | Some("yml") => { 42 | return self.save_yaml_profiles(path, profiles); 43 | } 44 | Some("yaml") => { 45 | return self.save_yaml_profiles(path, profiles); 46 | } 47 | Some("json") => { 48 | return self.save_json_profiles(path, profiles); 49 | } 50 | _ => { 51 | return false; 52 | } 53 | }, 54 | None => { 55 | return false; 56 | } 57 | } 58 | } 59 | 60 | false 61 | } 62 | 63 | pub fn save_json_profiles(self, path: &Path, profiles: Vec<&Profile>) -> bool { 64 | // let serialized = serde_json::to_string(&profiles).unwrap(); 65 | 66 | // let result = serde_json::to_writer_pretty(path, &profiles); 67 | 68 | let file = File::create(path).unwrap(); 69 | let writer = std::io::BufWriter::new(file); 70 | let result = serde_json::to_writer_pretty(writer, &profiles); 71 | 72 | match result { 73 | Ok(_) => true, 74 | Err(e) => { 75 | error!("{}", e); 76 | false 77 | } 78 | } 79 | } 80 | 81 | pub fn save_yaml_profiles(self, path: &Path, profiles: Vec<&Profile>) -> bool { 82 | // let serialized = serde_json::to_string(&profiles).unwrap(); 83 | 84 | // let result = serde_json::to_writer_pretty(path, &profiles); 85 | 86 | let file = File::create(path).unwrap(); 87 | let writer = std::io::BufWriter::new(file); 88 | let result = serde_yaml::to_writer(writer, &profiles); 89 | 90 | match result { 91 | Ok(_) => true, 92 | Err(e) => { 93 | error!("{}", e); 94 | false 95 | } 96 | } 97 | } 98 | 99 | pub fn load_profiles(self, path: &str) -> Self { 100 | let path = Path::new(path); 101 | 102 | if path.is_file() { 103 | match path.extension() { 104 | Some(ext) => match ext.to_str() { 105 | Some("yml") => { 106 | return self.load_yaml_profiles(path); 107 | } 108 | Some("yaml") => { 109 | return self.load_yaml_profiles(path); 110 | } 111 | Some("json") => { 112 | return self.load_json_profiles(path); 113 | } 114 | _ => { 115 | return self; 116 | } 117 | }, 118 | None => { 119 | return self; 120 | } 121 | } 122 | } 123 | 124 | self 125 | } 126 | 127 | pub fn load_json_profiles(mut self, path: &Path) -> Self { 128 | let file = match std::fs::File::open(path) { 129 | Ok(file) => file, 130 | Err(_) => { 131 | error!("Profiles file not found"); 132 | return self; 133 | } 134 | }; 135 | 136 | let profiles: Vec = match serde_json::from_reader(file) { 137 | Ok(profiles) => profiles, 138 | Err(e) => { 139 | error!("Invalid Profiles file"); 140 | error!("{}", e); 141 | return self; 142 | } 143 | }; 144 | 145 | self.0.extend(Self::profiles_vec_to_hashmap(profiles)); 146 | self 147 | } 148 | 149 | fn load_yaml_profiles(mut self, path: &Path) -> Self { 150 | let file = match std::fs::File::open(path) { 151 | Ok(file) => file, 152 | Err(_) => { 153 | error!("Profiles file not found"); 154 | return self; 155 | } 156 | }; 157 | let profiles: Vec = match serde_yaml::from_reader(file) { 158 | Ok(profiles) => profiles, 159 | Err(_) => { 160 | error!("Invalid Profiles file"); 161 | return self; 162 | } 163 | }; 164 | 165 | self.0.extend(Self::profiles_vec_to_hashmap(profiles)); 166 | self 167 | } 168 | 169 | fn profiles_vec_to_hashmap(profiles: Vec) -> HashMap { 170 | let mut profiles_hashmap = HashMap::new(); 171 | 172 | for profile in profiles { 173 | profiles_hashmap.insert(profile.id.clone(), profile); 174 | } 175 | 176 | profiles_hashmap 177 | } 178 | 179 | pub fn get_profiles(&self) -> HashMap { 180 | self.0.clone() 181 | } 182 | 183 | pub fn get_default(self) -> Profile { 184 | self.0["default"].clone() 185 | } 186 | 187 | pub fn get(&self, id: &String) -> Option { 188 | let result = self.0[id].clone(); 189 | if &result.id != id { 190 | return None; 191 | } 192 | Some(result) 193 | } 194 | 195 | pub fn get_default_relays(self) -> Vec { 196 | let default_profile = self.0["default"].clone(); 197 | default_profile.relays 198 | } 199 | 200 | pub fn new_get_default_relays(&self) -> HashMap { 201 | self.0["default"] 202 | .clone() 203 | .relays 204 | .into_iter() 205 | .map(|r| (Url::from_str(r.target.as_str()).unwrap(), r)) 206 | .collect() 207 | } 208 | } 209 | 210 | #[cfg(test)] 211 | mod tests { 212 | 213 | use dotenv::from_filename; 214 | 215 | use super::*; 216 | 217 | #[tokio::test] 218 | async fn test_default_profile_handler() { 219 | from_filename(".env.test").ok(); 220 | 221 | let relays_path = "src/fixtures/relays.json".to_string(); 222 | 223 | let profile_handler = ProfileHandler::new(&None, &relays_path); 224 | 225 | assert_eq!(profile_handler.0.keys().len(), 1); 226 | } 227 | #[tokio::test] 228 | async fn test_profile_handler_with_yaml_file() { 229 | from_filename(".env.test").ok(); 230 | 231 | let relays_path = "src/fixtures/relays.json".to_string(); 232 | let profiles_path = "src/fixtures/profiles.yaml".to_string(); 233 | 234 | let profile_handler = ProfileHandler::new(&Some(profiles_path), &relays_path); 235 | 236 | let profiles_size = profile_handler.0.keys().len(); 237 | assert_eq!(profiles_size, 3); 238 | } 239 | 240 | #[tokio::test] 241 | async fn test_profile_handler_with_json_file() { 242 | from_filename(".env.test").ok(); 243 | 244 | let relays_path = "src/fixtures/relays.json".to_string(); 245 | let profiles_path = "src/fixtures/profiles.json".to_string(); 246 | 247 | let profile_handler = ProfileHandler::new(&Some(profiles_path), &relays_path); 248 | 249 | let profiles_size = profile_handler.0.keys().len(); 250 | assert_eq!(profiles_size, 3); 251 | } 252 | 253 | #[tokio::test] 254 | async fn test_get() { 255 | from_filename(".env.test").ok(); 256 | 257 | let relays_path = "src/fixtures/relays.json".to_string(); 258 | 259 | let profile_handler = ProfileHandler::new(&None, &relays_path); 260 | let profile = profile_handler.get(&"default".to_string()); 261 | 262 | assert_eq!(&profile.is_some(), &true); 263 | } 264 | 265 | #[tokio::test] 266 | async fn test_profiles_vec_to_hashmap() { 267 | from_filename(".env.test").ok(); 268 | 269 | let mut profiles = Vec::new(); 270 | let profile = Profile { 271 | ..Default::default() 272 | }; 273 | let _ = &profiles.push(profile.clone()); 274 | 275 | let hashmap = ProfileHandler::profiles_vec_to_hashmap(profiles); 276 | 277 | assert_eq!(&hashmap.keys().len(), &1); 278 | assert_eq!(hashmap["default"], profile) 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /nostrss-core/src/app/app.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::Arc}; 2 | 3 | use crate::{ 4 | nostr::service::NostrService, 5 | profiles::{config::Profile, profiles::ProfileHandler}, 6 | rss::{ 7 | config::{Feed, RssConfig}, 8 | rss::RssInstance, 9 | }, 10 | }; 11 | use clap::Parser; 12 | use log::info; 13 | use nostr_sdk::{prelude::RelayOptions, prelude::ToBech32, Client, Keys}; 14 | 15 | use tokio::sync::Mutex; 16 | use tokio_cron_scheduler::JobScheduler; 17 | use uuid::Uuid; 18 | 19 | #[derive(Parser, Debug, Clone, Default)] 20 | #[command(author, version, about, long_about = None)] 21 | pub struct AppConfig { 22 | /// path to the relays list to load on init 23 | #[arg(long)] 24 | pub relays: String, 25 | 26 | /// path to the feeds list to load on init 27 | #[arg(long)] 28 | pub feeds: Option, 29 | 30 | /// path to the profiles list to load 31 | #[arg(long)] 32 | pub profiles: Option, 33 | 34 | /// The private key to populate keys 35 | #[arg(long)] 36 | pub private_key: Option, 37 | 38 | /// Run the progam without broadcasting onto the network 39 | #[arg(long, action)] 40 | pub dry_run: bool, 41 | 42 | #[arg(long)] 43 | pub update: Option, 44 | } 45 | 46 | pub struct App { 47 | pub rss: RssInstance, 48 | pub scheduler: Arc, 49 | // pub clients: HashMap, 50 | pub feeds_jobs: HashMap, 51 | pub feeds_map: HashMap>, 52 | pub nostr_service: NostrService, 53 | pub config: AppConfig, 54 | pub profile_handler: ProfileHandler, 55 | } 56 | 57 | impl App { 58 | pub async fn new(config: AppConfig) -> Self { 59 | let profile_handler = ProfileHandler::new(&config.profiles, &config.relays); 60 | 61 | let scheduler = match JobScheduler::new().await { 62 | Ok(result) => Arc::new(result), 63 | Err(_) => { 64 | // We shall improve the scheduler creation error in a better way than just a panic 65 | panic!("Scheduler init failure. Panicking !"); 66 | } 67 | }; 68 | 69 | // RSS feed handler 70 | let rss = RssInstance::new(RssConfig::new(config.clone().feeds)).await; 71 | 72 | let profiles = profile_handler.clone().get_profiles(); 73 | 74 | let default_relays = profile_handler.clone().get_default_relays(); 75 | 76 | for profile_entry in profiles { 77 | let profile_id = profile_entry.0.clone(); 78 | let mut profile = profile_entry.1.clone(); 79 | 80 | if profile.relays.is_empty() { 81 | profile.relays = default_relays.clone(); 82 | } 83 | 84 | let keys = Keys::parse(profile.private_key.as_str()).unwrap(); 85 | let profile_keys = &keys.public_key(); 86 | 87 | info!( 88 | "public key for profile {}: {:?}", 89 | &profile_id.clone(), 90 | &profile_keys 91 | ); 92 | info!( 93 | "bech32 public key : {:?}", 94 | &profile_keys.to_bech32().unwrap() 95 | ); 96 | } 97 | 98 | let client = Client::new(&Keys::generate()); 99 | 100 | for relay in default_relays.into_iter() { 101 | let mut opts = RelayOptions::new(); 102 | 103 | opts = opts.proxy(relay.proxy); 104 | 105 | _ = &client.add_relay_with_opts(relay.target, opts).await; 106 | } 107 | 108 | _ = &client.connect().await; 109 | 110 | let nostr_service = 111 | NostrService::new(client, config.relays.clone(), config.profiles.clone()).await; 112 | 113 | Self { 114 | rss, 115 | scheduler, 116 | feeds_jobs: HashMap::new(), 117 | feeds_map: HashMap::new(), 118 | nostr_service, 119 | config, 120 | profile_handler: ProfileHandler(HashMap::new()), 121 | } 122 | } 123 | 124 | pub async fn get_profiles(&self) -> Arc>> { 125 | Arc::new(Mutex::new(self.nostr_service.profiles.clone())) 126 | } 127 | 128 | pub async fn get_config(&self) -> Arc> { 129 | Arc::new(Mutex::new(self.config.clone())) 130 | } 131 | 132 | pub async fn update_profile_config(&self) -> bool { 133 | let profiles_arc = self.get_profiles().await; 134 | let profiles_lock = profiles_arc.lock().await; 135 | let profiles = profiles_lock 136 | .iter() 137 | .filter_map(|(_, profile)| { 138 | if profile.id.as_str() == "default" { 139 | None 140 | } else { 141 | Some(profile) 142 | } 143 | }) 144 | .collect(); 145 | 146 | if self.config.profiles.is_none() { 147 | return false; 148 | } 149 | 150 | let result = self 151 | .profile_handler 152 | .clone() 153 | .save_profiles(self.config.profiles.clone().unwrap().as_str(), profiles); 154 | 155 | result 156 | } 157 | 158 | pub async fn update_feeds_config(&self, feeds: &Vec) -> bool { 159 | if self.config.feeds.is_none() { 160 | return false; 161 | } 162 | let rss = self 163 | .rss 164 | .config 165 | .clone() 166 | .save_feeds(&self.config.feeds.clone().unwrap(), feeds); 167 | rss 168 | } 169 | } 170 | 171 | #[cfg(test)] 172 | mod tests { 173 | use super::*; 174 | use crate::{app::test_utils, profiles::config::Profile}; 175 | use std::env; 176 | use std::fs; 177 | 178 | fn prepare_test_files() { 179 | let current_dir = env::current_dir().unwrap(); 180 | let workdir = current_dir.to_str().unwrap(); 181 | 182 | let test_dir_path = format!("{}/{}", workdir.clone(), "src/fixtures/tests"); 183 | 184 | let _ = fs::create_dir_all(test_dir_path.clone()).unwrap(); 185 | 186 | let profiles_json_path = format!("{}/{}", workdir.clone(), "src/fixtures/profiles.json"); 187 | let profiles_json_test_path = format!("{}/{}.test.json", test_dir_path.clone(), "profiles"); 188 | fs::copy(profiles_json_path, profiles_json_test_path).unwrap(); 189 | 190 | let profiles_yaml_path = format!("{}/{}", workdir.clone(), "src/fixtures/profiles.yaml"); 191 | let profiles_yaml_test_path = format!("{}/{}.test.yaml", test_dir_path.clone(), "profiles"); 192 | fs::copy(profiles_yaml_path, profiles_yaml_test_path).unwrap(); 193 | 194 | let rss_json_path = format!("{}/{}", workdir.clone(), "src/fixtures/rss.json"); 195 | let rss_json_test_path = format!("{}/{}.test.json", test_dir_path.clone(), "rss"); 196 | fs::copy(rss_json_path, rss_json_test_path).unwrap(); 197 | 198 | let rss_yaml_path = format!("{}/{}", workdir.clone(), "src/fixtures/rss.yaml"); 199 | let rss_yaml_test_path = format!("{}/{}.test.yaml", test_dir_path.clone(), "rss"); 200 | fs::copy(rss_yaml_path, rss_yaml_test_path).unwrap(); 201 | } 202 | 203 | fn clean_test_files() { 204 | fs::remove_dir_all("src/fixtures/tests").unwrap(); 205 | } 206 | 207 | #[tokio::test] 208 | async fn update_profile_config_test() { 209 | prepare_test_files(); 210 | let mut app = test_utils::mock_app().await; 211 | 212 | let mut profiles = HashMap::new(); 213 | 214 | let profile_1 = Profile { 215 | id: "test1".to_string(), 216 | ..Default::default() 217 | }; 218 | 219 | let _ = &profiles.insert("test1".to_string(), profile_1); 220 | 221 | let profile_2 = Profile { 222 | id: "test2".to_string(), 223 | ..Default::default() 224 | }; 225 | 226 | _ = &profiles.insert("test2".to_string(), profile_2); 227 | 228 | let profile_3 = Profile { 229 | id: "test3".to_string(), 230 | ..Default::default() 231 | }; 232 | 233 | _ = &profiles.insert("test3".to_string(), profile_3); 234 | 235 | app.nostr_service.profiles = profiles; 236 | 237 | // Point app configuration to profiles json test file 238 | app.config.profiles = Some("src/fixtures/tests/profiles.test.json".to_string()); 239 | 240 | let r = app.update_profile_config().await; 241 | assert_eq!(true, r); 242 | 243 | // Point app configuration to profiles yaml test file 244 | app.config.profiles = Some("src/fixtures/tests/profiles.test.yaml".to_string()); 245 | 246 | let r = app.update_profile_config().await; 247 | assert_eq!(true, r); 248 | 249 | let feeds = [ 250 | Feed { 251 | id: "test1".to_string(), 252 | ..Default::default() 253 | }, 254 | Feed { 255 | id: "test2".to_string(), 256 | ..Default::default() 257 | }, 258 | Feed { 259 | id: "test3".to_string(), 260 | ..Default::default() 261 | }, 262 | ] 263 | .to_vec(); 264 | 265 | app.config.feeds = Some("src/fixtures/tests/rss.test.yaml".to_string()); 266 | 267 | let r = app.update_feeds_config(&feeds).await; 268 | assert_eq!(true, r); 269 | 270 | app.config.feeds = Some("src/fixtures/tests/rss.test.json".to_string()); 271 | 272 | let r = app.update_feeds_config(&feeds).await; 273 | assert_eq!(true, r); 274 | 275 | clean_test_files() 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /nostrss-core/src/rss/config.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use log::{error, info}; 4 | use serde::{Deserialize, Serialize}; 5 | use std::{ 6 | env::{self, VarError}, 7 | fs::File, 8 | path::Path, 9 | str::FromStr, 10 | }; 11 | 12 | #[derive(Debug)] 13 | pub enum RssConfigErrors { 14 | LocationError, 15 | FormatError, 16 | ParsingError, 17 | } 18 | 19 | /// The [`Feed`] struct represents a feed as provided through 20 | /// external file, be it either a `json` or a `yaml` file. 21 | /// 22 | /// Examples of the struct is provided through in the [Fixtures](../fixtures/) folder. 23 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 24 | pub struct Feed { 25 | // The id of the feed. Used for indexing in memory on runtime 26 | pub id: String, 27 | // The feed name to be displayed in the nostr messages 28 | pub name: String, 29 | // The URL to the RSS feed 30 | pub url: nostr_sdk::Url, 31 | // The cronjob ticker rule for the feed job 32 | pub schedule: String, 33 | // The clients profiles to be used for publishing updates. Will use default profile if none provided 34 | pub profiles: Option>, 35 | // The tags to be applied with the feed messages 36 | pub tags: Option>, 37 | // The template path for publication 38 | pub template: Option, 39 | #[serde(default = "Feed::default_cache_size")] 40 | pub cache_size: Option, 41 | #[serde(default = "Feed::default_pow_level")] 42 | pub pow_level: u8, 43 | } 44 | 45 | impl Feed { 46 | // Retrieves the id of the feed 47 | pub fn get_id(&self) -> String { 48 | self.id.clone() 49 | } 50 | 51 | // Retrieves the optional profile id of the feed 52 | pub fn get_profiles(&self) -> Option> { 53 | self.profiles.clone() 54 | } 55 | 56 | // Checks if a feed uses a profile 57 | pub fn has_profile(&self, id: String) -> bool { 58 | if self.profiles.clone().is_none() { 59 | return false; 60 | } 61 | 62 | let profiles = &self.profiles.clone().unwrap(); 63 | 64 | profiles.contains(&id) 65 | } 66 | 67 | // sets the tags for the feed 68 | fn set_tags(mut self, tags: Vec) -> Self { 69 | self.tags = Some(tags); 70 | self 71 | } 72 | 73 | pub fn default_cache_size() -> Option { 74 | match env::var("DEFAULT_CACHE_SIZE") { 75 | Ok(r) => Some(r.parse::().unwrap_or(1000)), 76 | Err(e) => None, 77 | } 78 | // let default_value = env::var("DEFAULT_CACHE_SIZE") 79 | // .unwrap_or("100".to_string()) 80 | // .parse::() 81 | // .unwrap_or(1000); 82 | } 83 | 84 | pub fn default_pow_level() -> u8 { 85 | env::var("DEFAULT_POW_LEVEL") 86 | .unwrap_or("0".to_string()) 87 | .parse::() 88 | .unwrap_or(0) 89 | } 90 | } 91 | 92 | impl Default for Feed { 93 | fn default() -> Self { 94 | Self { 95 | id: "default".to_string(), 96 | name: "Generic feed".to_string(), 97 | url: nostr_sdk::Url::from_str("https://www.nostr.info").unwrap(), 98 | schedule: "0/10 * * * * *".to_string(), 99 | profiles: None, 100 | tags: Some(Vec::new()), 101 | template: None, 102 | cache_size: Self::default_cache_size(), 103 | pow_level: 0, 104 | } 105 | } 106 | } 107 | /// Builds a RSS config 108 | #[derive(Debug, Clone, Default)] 109 | pub struct RssConfig { 110 | pub feeds: Vec, 111 | } 112 | 113 | impl RssConfig { 114 | // Builds a new instance of RSS feeds. 115 | // Takes an optional string that represents 116 | // the `path` to the feeds to load. 117 | pub fn new(path: Option) -> Self { 118 | let mut config = Self { feeds: Vec::new() }; 119 | 120 | if let Some(path) = path { 121 | info!("Found Rss file path argument. Parsing file..."); 122 | config = config.load_feeds(&path); 123 | } 124 | 125 | config 126 | } 127 | 128 | // Loads the feeds into the [`RssConfig`] struct instance 129 | pub fn load_feeds(self, path: &str) -> Self { 130 | let path = Path::new(path); 131 | 132 | if path.is_file() { 133 | match path.extension() { 134 | Some(ext) => match ext.to_str() { 135 | Some("yml") => { 136 | return self.load_yaml_feeds(path); 137 | } 138 | Some("yaml") => { 139 | return self.load_yaml_feeds(path); 140 | } 141 | Some("json") => { 142 | return self.load_json_feeds(path); 143 | } 144 | _ => { 145 | return self; 146 | } 147 | }, 148 | None => { 149 | return self; 150 | } 151 | } 152 | } 153 | 154 | self 155 | } 156 | 157 | // Parses and serializes the config file when it is a `json` file 158 | pub fn load_json_feeds(mut self, path: &Path) -> Self { 159 | let file = match std::fs::File::open(path) { 160 | Ok(file) => file, 161 | Err(_) => { 162 | error!("Feeds file not found"); 163 | return self; 164 | } 165 | }; 166 | let feeds: Vec = match serde_json::from_reader(file) { 167 | Ok(feeds) => feeds, 168 | Err(e) => { 169 | error!("Error parsing json feed file : {}", e); 170 | return self; 171 | } 172 | }; 173 | 174 | self.feeds = feeds; 175 | self 176 | } 177 | 178 | // Parses and serializes the config file when it is a `yaml` file 179 | pub fn load_yaml_feeds(mut self, path: &Path) -> Self { 180 | let file = match std::fs::File::open(path) { 181 | Ok(file) => file, 182 | Err(_) => { 183 | error!("Feeds file not found"); 184 | return self; 185 | } 186 | }; 187 | let feeds: Vec = match serde_yaml::from_reader(file) { 188 | Ok(feeds) => feeds, 189 | Err(e) => { 190 | error!("Error parsing yaml feed file : {}", e); 191 | return self; 192 | } 193 | }; 194 | 195 | self.feeds = feeds; 196 | self 197 | } 198 | 199 | pub fn save_feeds(self, path: &str, feeds: &Vec) -> bool { 200 | let path = Path::new(path); 201 | 202 | if path.is_file() { 203 | match path.extension() { 204 | Some(ext) => match ext.to_str() { 205 | Some("yml") => { 206 | return self.save_yaml_feeds(path, feeds); 207 | } 208 | Some("yaml") => { 209 | return self.save_yaml_feeds(path, feeds); 210 | } 211 | Some("json") => { 212 | return self.save_json_feeds(path, feeds); 213 | } 214 | _ => { 215 | return false; 216 | } 217 | }, 218 | None => { 219 | return false; 220 | } 221 | } 222 | } 223 | 224 | false 225 | } 226 | 227 | pub fn save_json_feeds(self, path: &Path, feeds: &Vec) -> bool { 228 | // let serialized = serde_json::to_string(&feeds).unwrap(); 229 | 230 | // let result = serde_json::to_writer_pretty(path, &feeds); 231 | 232 | let file = File::create(path).unwrap(); 233 | let writer = std::io::BufWriter::new(file); 234 | let result = serde_json::to_writer_pretty(writer, &feeds); 235 | 236 | match result { 237 | Ok(_) => true, 238 | Err(e) => { 239 | error!("{}", e); 240 | false 241 | } 242 | } 243 | } 244 | 245 | pub fn save_yaml_feeds(self, path: &Path, feeds: &Vec) -> bool { 246 | // let serialized = serde_json::to_string(&feeds).unwrap(); 247 | 248 | // let result = serde_json::to_writer_pretty(path, &feeds); 249 | 250 | let file = File::create(path).unwrap(); 251 | let writer = std::io::BufWriter::new(file); 252 | let result = serde_yaml::to_writer(writer, &feeds); 253 | 254 | match result { 255 | Ok(_) => true, 256 | Err(e) => { 257 | error!("{}", e); 258 | false 259 | } 260 | } 261 | } 262 | } 263 | 264 | #[cfg(test)] 265 | pub mod tests { 266 | use std::env::remove_var; 267 | 268 | use dotenv::from_filename; 269 | 270 | use super::*; 271 | 272 | #[test] 273 | fn test_load_valid_json_feed() { 274 | let path = Some("./src/fixtures/rss.json".to_string()); 275 | 276 | let config = RssConfig::new(path); 277 | assert_eq!(config.feeds.len(), 3); 278 | } 279 | 280 | #[test] 281 | fn test_load_valid_yaml_feed() { 282 | let path = Some("./src/fixtures/rss.yaml".to_string()); 283 | 284 | let config = RssConfig::new(path); 285 | assert_eq!(config.feeds.len(), 3); 286 | } 287 | 288 | #[test] 289 | fn test_load_feeds_invalid_path() { 290 | let path = Some("invalid_path.json".to_string()); 291 | let config = RssConfig::new(path); 292 | assert_eq!(config.feeds.len(), 0); 293 | } 294 | 295 | #[test] 296 | fn test_load_feeds_invalid_extension() { 297 | let path = Some("invalid_path.text".to_string()); 298 | 299 | let config = RssConfig::new(path); 300 | assert_eq!(config.feeds.len(), 0); 301 | } 302 | 303 | #[test] 304 | fn test_cache_size_behaviour_without_env_fallback() { 305 | remove_var("DEFAULT_CACHE_SIZE"); 306 | let path = Some("./src/fixtures/rss.json".to_string()); 307 | let config = RssConfig::new(path); 308 | 309 | assert_eq!(config.feeds[0].cache_size, Some(5)); 310 | 311 | // Test undeclared cache size that should fall back to hard-coded cache value 312 | assert_eq!(config.feeds[1].cache_size, None); 313 | } 314 | 315 | #[test] 316 | fn test_cache_size_behaviour_with_env_fallback() { 317 | from_filename(".env.test").ok(); 318 | let path = Some("./src/fixtures/rss.json".to_string()); 319 | let config = RssConfig::new(path); 320 | 321 | // Test declared cache size 322 | assert_eq!(config.feeds[0].cache_size, Some(5)); 323 | 324 | // Test undeclared cache size that should fall back on env var value 325 | assert_eq!(config.feeds[1].cache_size, Some(20)); 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /nostrss-cli/src/commands/profile.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use clap::{Parser, ValueEnum}; 4 | use nostrss_grpc::grpc::{ 5 | nostrss_grpc_client::NostrssGrpcClient, AddProfileRequest, DeleteProfileRequest, 6 | NewProfileItem, ProfileInfoRequest, ProfileItem, ProfilesListRequest, 7 | }; 8 | use tabled::{Table, Tabled}; 9 | use tonic::{async_trait, transport::Channel}; 10 | 11 | use crate::{ 12 | input::{formatter::InputFormatter, input::InputValidators}, 13 | CliOptions, 14 | }; 15 | 16 | use super::CommandsHandler; 17 | 18 | #[derive(Clone, PartialEq, Parser, Debug, ValueEnum)] 19 | pub enum ProfileActions { 20 | Add, 21 | Delete, 22 | List, 23 | Info, 24 | } 25 | 26 | pub struct ProfileCommandsHandler { 27 | pub client: NostrssGrpcClient, 28 | } 29 | 30 | #[derive(Tabled)] 31 | pub struct ProfileDetailsTemplate { 32 | pub key: String, 33 | pub value: String, 34 | } 35 | 36 | #[derive(Tabled)] 37 | pub struct ListProfileTemplate { 38 | pub id: String, 39 | pub name: String, 40 | pub public_key: String, 41 | } 42 | 43 | impl From for ListProfileTemplate { 44 | fn from(data: ProfileItem) -> Self { 45 | Self { 46 | id: data.id, 47 | public_key: data.public_key, 48 | name: data.name.unwrap_or_default(), 49 | } 50 | } 51 | } 52 | 53 | #[derive(Tabled)] 54 | pub struct FullProfileTemplate { 55 | pub id: String, 56 | pub public_key: String, 57 | pub name: String, 58 | pub relays: String, 59 | pub display_name: String, 60 | pub description: String, 61 | pub picture: String, 62 | pub banner: String, 63 | pub nip05: String, 64 | pub lud16: String, 65 | pub pow_level: i32, 66 | pub recommended_relays: String, 67 | } 68 | 69 | impl FullProfileTemplate { 70 | // Builds a table row from each property of profile struct 71 | pub fn properties_to_vec(&self) -> Vec { 72 | let pow_level = &self.pow_level.to_string(); 73 | 74 | let properties: Vec<(String, &String)> = [ 75 | ("id".to_string(), &self.id), 76 | ("public_key".to_string(), &self.public_key), 77 | ("name".to_string(), &self.name), 78 | ("relays".to_string(), &self.relays), 79 | ("display_name".to_string(), &self.display_name), 80 | ("description".to_string(), &self.description), 81 | ("picture".to_string(), &self.picture), 82 | ("banner".to_string(), &self.banner), 83 | ("nip05".to_string(), &self.nip05), 84 | ("lud16".to_string(), &self.lud16), 85 | ("pow_level".to_string(), pow_level), 86 | ("recommended_relays".to_string(), &self.recommended_relays), 87 | ] 88 | .to_vec(); 89 | 90 | properties 91 | .into_iter() 92 | .map(|p| ProfileDetailsTemplate { 93 | key: p.0, 94 | value: p.1.to_string(), 95 | }) 96 | .collect() 97 | } 98 | } 99 | 100 | impl From for FullProfileTemplate { 101 | fn from(data: ProfileItem) -> Self { 102 | Self { 103 | id: data.id, 104 | public_key: data.public_key, 105 | name: data.name.unwrap_or_default(), 106 | relays: data.relays.join(","), 107 | display_name: data.display_name.unwrap_or_default(), 108 | description: data.description.unwrap_or_default(), 109 | picture: data.picture.unwrap_or_default(), 110 | banner: data.banner.unwrap_or_default(), 111 | nip05: data.nip05.unwrap_or_default(), 112 | lud16: data.lud16.unwrap_or_default(), 113 | pow_level: data.pow_level.unwrap_or(0), 114 | recommended_relays: data.recommended_relays.join(","), 115 | } 116 | } 117 | } 118 | 119 | impl FullProfileTemplate { 120 | fn new( 121 | id: String, 122 | public_key: String, 123 | name: String, 124 | relays: Vec, 125 | display_name: Option, 126 | description: Option, 127 | picture: Option, 128 | banner: Option, 129 | nip05: Option, 130 | lud16: Option, 131 | pow_level: Option, 132 | recommended_relays: Vec, 133 | ) -> Self { 134 | Self { 135 | id, 136 | public_key, 137 | name, 138 | relays: relays.join(","), 139 | display_name: display_name.unwrap_or_default(), 140 | description: description.unwrap_or_default(), 141 | picture: picture.unwrap_or_default(), 142 | banner: banner.unwrap_or_default(), 143 | nip05: nip05.unwrap_or_default(), 144 | lud16: lud16.unwrap_or_default(), 145 | pow_level: pow_level.unwrap_or(0), 146 | recommended_relays: recommended_relays.join(","), 147 | } 148 | } 149 | } 150 | #[async_trait] 151 | impl CommandsHandler for ProfileCommandsHandler {} 152 | 153 | impl ProfileCommandsHandler { 154 | pub async fn handle(&mut self, action: ProfileActions, opts: CliOptions) { 155 | match action { 156 | ProfileActions::Add => self.add(opts).await, 157 | ProfileActions::Delete => self.delete(opts).await, 158 | ProfileActions::List => self.list().await, 159 | ProfileActions::Info => self.info().await, 160 | } 161 | } 162 | 163 | async fn list(&mut self) { 164 | // Case logic should come here 165 | let request = tonic::Request::new(ProfilesListRequest {}); 166 | let response = self.client.profiles_list(request).await; 167 | match response { 168 | Ok(response) => { 169 | let raws: Vec = response 170 | .into_inner() 171 | .profiles 172 | .into_iter() 173 | .map(ListProfileTemplate::from) 174 | .collect(); 175 | 176 | let table = Table::new(raws); 177 | println!("=== Profiles list ==="); 178 | println!("{}", table); 179 | } 180 | Err(e) => { 181 | println!("Error {}: {}", e.code(), e.message()); 182 | } 183 | } 184 | } 185 | 186 | async fn add(&mut self, opts: CliOptions) { 187 | println!("=== Add a profile ==="); 188 | let id = self.get_input("Id: ", Some(InputValidators::required_input_validator)); 189 | let private_key: String = self 190 | .get_input( 191 | "Private key (hex or bech32): ", 192 | Some(InputValidators::key_validator), 193 | ) 194 | .trim() 195 | .to_string(); 196 | let name: Option = 197 | InputFormatter::string_nullifier(self.get_input("(optional) Name: ", None)); 198 | let relays = InputFormatter::input_to_vec( 199 | self.get_input("(optional) Relays ids (separated with coma):", None), 200 | ); 201 | let display_name: Option = 202 | InputFormatter::string_nullifier(self.get_input("(optional) Display name: ", None)); 203 | let description: Option = 204 | InputFormatter::string_nullifier(self.get_input("(optional) Description: ", None)); 205 | let picture: Option = InputFormatter::string_nullifier( 206 | self.get_input("(optional) Profile picture URL: ", None), 207 | ); 208 | let banner: Option = InputFormatter::string_nullifier( 209 | self.get_input("(optional) Banner picture URL: ", None), 210 | ); 211 | let nip05: Option = 212 | InputFormatter::string_nullifier(self.get_input("(optional) NIP-05: ", None)); 213 | let lud16: Option = 214 | InputFormatter::string_nullifier(self.get_input("(optional) Lud16: ", None)); 215 | let pow_level: String = self.get_input("(optional) Publishing PoW level: ", None); 216 | let pow_level = pow_level.parse().unwrap_or(0); 217 | 218 | let recommended_relays: Vec = InputFormatter::input_to_vec(self.get_input( 219 | "(optional) Recommended relays ids (seperated with coma): ", 220 | None, 221 | )); 222 | 223 | let request = tonic::Request::new(AddProfileRequest { 224 | profile: NewProfileItem { 225 | id, 226 | private_key, 227 | name, 228 | relays, 229 | display_name, 230 | description, 231 | picture, 232 | banner, 233 | nip05, 234 | lud16, 235 | pow_level: Some(pow_level), 236 | recommended_relays, 237 | }, 238 | save: Some(opts.save), 239 | }); 240 | 241 | let response = self.client.add_profile(request).await; 242 | 243 | match response { 244 | Ok(_) => { 245 | println!("Profile successfuly added"); 246 | } 247 | Err(e) => { 248 | println!("Error: {}: {}", e.code(), e.message()); 249 | } 250 | } 251 | } 252 | 253 | async fn delete(&mut self, opts: CliOptions) { 254 | let id = self.get_input("Id: ", Some(InputValidators::default_guard_validator)); 255 | let request = tonic::Request::new(DeleteProfileRequest { 256 | id, 257 | save: Some(opts.save), 258 | }); 259 | let response = self.client.delete_profile(request).await; 260 | 261 | match response { 262 | Ok(_) => { 263 | println!("Profile successfully deleted"); 264 | } 265 | Err(e) => { 266 | println!( 267 | "An error happened with code {} : {} ", 268 | e.code(), 269 | e.message() 270 | ); 271 | } 272 | } 273 | } 274 | 275 | async fn info(&mut self) { 276 | let id = self.get_input("Id: ", None); 277 | 278 | let request = tonic::Request::new(ProfileInfoRequest { id }); 279 | let response = self.client.profile_info(request).await; 280 | 281 | match response { 282 | Ok(response) => { 283 | let profile = response.into_inner().profile; 284 | 285 | let profile = FullProfileTemplate::from(profile); 286 | // profile.fields() 287 | let table = Table::new(profile.properties_to_vec()); 288 | println!("{}", table); 289 | // println!("No profile found for this id"); 290 | } 291 | Err(e) => { 292 | println!( 293 | "An error happened with code {} : {} ", 294 | e.code(), 295 | e.message() 296 | ); 297 | } 298 | } 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /nostrss-core/src/grpc/grpc_service.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | use std::sync::Arc; 3 | 4 | use nostr_sdk::{prelude::ToBech32, Keys}; 5 | 6 | use crate::rss::config::Feed; 7 | use nostrss_grpc::grpc::{ 8 | self, nostrss_grpc_server::NostrssGrpc, AddFeedRequest, AddFeedResponse, AddProfileRequest, 9 | AddProfileResponse, DeleteFeedRequest, DeleteFeedResponse, DeleteProfileRequest, 10 | DeleteProfileResponse, FeedInfoRequest, FeedInfoResponse, FeedItem, FeedsListRequest, 11 | FeedsListResponse, ProfileInfoRequest, ProfileInfoResponse, ProfileItem, ProfilesListRequest, 12 | ProfilesListResponse, StartJobRequest, StartJobResponse, StateRequest, StateResponse, 13 | StopJobRequest, StopJobResponse, 14 | }; 15 | use tokio::sync::{Mutex, MutexGuard}; 16 | use tonic::{Request, Response, Status}; 17 | 18 | use crate::{app::app::App, profiles::config::Profile}; 19 | 20 | use super::{feed_request::FeedRequestHandler, profile_request::ProfileRequestHandler}; 21 | 22 | /// Provides the gRPC service handling that allows 23 | /// remote operations. 24 | pub struct NostrssServerService { 25 | pub app: Arc>, 26 | } 27 | 28 | impl From for Feed { 29 | fn from(value: FeedItem) -> Self { 30 | let url = value.url.as_str(); 31 | 32 | let cache_size = match value.cache_size { 33 | Some(r) => match usize::try_from(r) { 34 | Ok(result) => Some(result), 35 | Err(_) => Self::default_cache_size(), 36 | }, 37 | None => None, 38 | }; 39 | // let cache_size = match usize::try_from(value.cache_size) { 40 | // Ok(result) => Some(result), 41 | // Err(_) => Self::default_cache_size(), 42 | // }; 43 | 44 | let pow_level = match u8::try_from(value.pow_level) { 45 | Ok(result) => result, 46 | Err(_) => Self::default_pow_level(), 47 | }; 48 | 49 | Self { 50 | id: value.id, 51 | name: value.name, 52 | url: nostr_sdk::Url::from_str(url).unwrap(), 53 | schedule: value.schedule, 54 | profiles: Some(value.profiles), 55 | tags: Some(value.tags), 56 | template: value.template, 57 | cache_size, 58 | pow_level, 59 | } 60 | } 61 | } 62 | 63 | impl From for ProfileItem { 64 | fn from(value: Profile) -> Self { 65 | let public_key = match Keys::parse(&value.private_key) { 66 | Ok(keys) => keys.public_key().to_bech32().unwrap(), 67 | Err(_) => "".to_string(), 68 | }; 69 | 70 | Self { 71 | id: value.id, 72 | public_key, 73 | name: value.name, 74 | relays: Vec::new(), 75 | display_name: value.display_name, 76 | description: value.description, 77 | picture: value.picture, 78 | banner: value.banner, 79 | nip05: value.nip05, 80 | lud16: value.lud16, 81 | pow_level: Some(value.pow_level.into()), 82 | recommended_relays: Vec::new(), 83 | } 84 | } 85 | } 86 | 87 | impl From for FeedItem { 88 | fn from(value: Feed) -> FeedItem { 89 | let profiles = match value.profiles { 90 | Some(profiles) => profiles, 91 | None => Vec::new(), 92 | }; 93 | 94 | let tags = match value.tags { 95 | Some(t) => t, 96 | None => Vec::new(), 97 | }; 98 | 99 | let cache_size = match value.cache_size { 100 | Some(r) => Some(r as u64), 101 | None => None, 102 | }; 103 | let pow_level = value.pow_level as u64; 104 | 105 | FeedItem { 106 | id: value.id, 107 | name: value.name, 108 | url: value.url.to_string(), 109 | schedule: value.schedule, 110 | profiles, 111 | tags, 112 | template: value.template, 113 | cache_size, 114 | pow_level, 115 | } 116 | } 117 | } 118 | 119 | impl NostrssServerService { 120 | async fn get_app_lock(&self) -> MutexGuard { 121 | self.app.lock().await 122 | } 123 | } 124 | 125 | #[tonic::async_trait] 126 | impl NostrssGrpc for NostrssServerService { 127 | // Retrieves state of the core nostrss application 128 | async fn state( 129 | &self, 130 | request: Request, 131 | ) -> Result, Status> { 132 | let app_lock = self.app.lock().await; 133 | let n = app_lock.nostr_service.profiles.keys().len(); 134 | let _ = request.into_inner(); 135 | Ok(Response::new(grpc::StateResponse { 136 | state: format!("App is alive. Number of profiles : {}", n), 137 | })) 138 | } 139 | 140 | // Interface to retrieve the list of feed on instance 141 | async fn feeds_list( 142 | &self, 143 | request: Request, 144 | ) -> Result, Status> { 145 | FeedRequestHandler::feeds_list(self.get_app_lock().await, request).await 146 | } 147 | 148 | async fn feed_info( 149 | &self, 150 | request: Request, 151 | ) -> Result, Status> { 152 | FeedRequestHandler::feed_info(self.get_app_lock().await, request).await 153 | } 154 | async fn add_feed( 155 | &self, 156 | request: Request, 157 | ) -> Result, Status> { 158 | FeedRequestHandler::add_feed(self.get_app_lock().await, request).await 159 | } 160 | 161 | // Interface to delete a feed on instance 162 | async fn delete_feed( 163 | &self, 164 | request: Request, 165 | ) -> Result, Status> { 166 | FeedRequestHandler::delete_feed(self.get_app_lock().await, request).await 167 | } 168 | 169 | // Interface to retrieve the list of profiles on instance 170 | async fn profiles_list( 171 | &self, 172 | request: Request, 173 | ) -> Result, Status> { 174 | ProfileRequestHandler::profiles_list(self.get_app_lock().await, request).await 175 | } 176 | 177 | // Interface to retrieve the detailed configuration of a single profile on instance 178 | async fn profile_info( 179 | &self, 180 | request: Request, 181 | ) -> Result, Status> { 182 | ProfileRequestHandler::profile_info(self.get_app_lock().await, request).await 183 | } 184 | 185 | // Interface to delete a profile on instance 186 | async fn add_profile( 187 | &self, 188 | request: Request, 189 | ) -> Result, Status> { 190 | ProfileRequestHandler::add_profile(self.get_app_lock().await, request).await 191 | } 192 | 193 | // Interface to delete a profile on instance 194 | async fn delete_profile( 195 | &self, 196 | request: Request, 197 | ) -> Result, Status> { 198 | ProfileRequestHandler::delete_profile(self.get_app_lock().await, request).await 199 | } 200 | 201 | // Interface to start a job on instance 202 | async fn start_job( 203 | &self, 204 | request: Request, 205 | ) -> Result, Status> { 206 | let _app_lock = self.app.lock().await; 207 | let _feed_id = &request.into_inner().feed_id; 208 | 209 | Ok(Response::new(grpc::StartJobResponse {})) 210 | } 211 | 212 | // Interface to retrieve a job instance 213 | // This should be renamed `stop_jobs` and 214 | // should only shutdown the scheduler 215 | async fn stop_job( 216 | &self, 217 | request: Request, 218 | ) -> Result, Status> { 219 | let _app_lock = self.app.lock().await; 220 | let _feed_id = &request.into_inner().feed_id; 221 | 222 | Ok(Response::new(grpc::StopJobResponse {})) 223 | } 224 | } 225 | 226 | #[cfg(test)] 227 | mod tests { 228 | 229 | use super::*; 230 | use crate::rss::config::Feed; 231 | use nostrss_grpc::grpc::AddFeedRequest; 232 | 233 | #[test] 234 | fn feed_from_add_feed_request_test() { 235 | let request = AddFeedRequest { 236 | feed: FeedItem { 237 | id: "test".to_string(), 238 | name: "test".to_string(), 239 | url: "https://myrss.rs".to_string(), 240 | schedule: "1/10 * * * * *".to_string(), 241 | profiles: Vec::new(), 242 | tags: Vec::new(), 243 | template: None, 244 | cache_size: Some(10), 245 | pow_level: 20, 246 | }, 247 | save: Some(false), 248 | }; 249 | 250 | let feed = Feed::from(request.feed); 251 | 252 | let expected = "test"; 253 | assert_eq!(feed.id.as_str(), expected); 254 | 255 | let expected = "test"; 256 | assert_eq!(feed.name.as_str(), expected); 257 | 258 | let expected = "https://myrss.rs/"; 259 | let url = feed.url.as_str(); 260 | assert_eq!(url, expected); 261 | } 262 | 263 | #[test] 264 | fn profile_item_from_profile_test() { 265 | let profile = Profile { 266 | id: "test".to_string(), 267 | private_key: "6789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345" 268 | .to_string(), 269 | relays: Vec::new(), 270 | about: Some("Ad lorem ipsum".to_string()), 271 | name: Some("Some test account".to_string()), 272 | display_name: Some("Some test account display name".to_string()), 273 | description: Some("Ad lorem ipsum description".to_string()), 274 | picture: Some("http://myimage.jpg".to_string()), 275 | banner: None, 276 | nip05: None, 277 | lud16: None, 278 | pow_level: 23, 279 | recommended_relays: Some(Vec::new()), 280 | }; 281 | 282 | let profile_item = ProfileItem::from(profile.clone()); 283 | 284 | assert_eq!(profile_item.id, profile.id); 285 | 286 | let keys = Keys::parse(profile.private_key.as_str()).unwrap(); 287 | 288 | assert_eq!( 289 | profile_item.public_key, 290 | keys.public_key().to_bech32().unwrap() 291 | ); 292 | 293 | assert_eq!(profile_item.banner, None); 294 | assert_eq!(profile_item.pow_level, Some(23)); 295 | } 296 | 297 | #[test] 298 | fn feed_item_from_feed_test() { 299 | let feed = Feed { 300 | id: "test".to_string(), 301 | name: "My test".to_string(), 302 | url: nostr_sdk::Url::from_str("https://myrss.rss").unwrap(), 303 | schedule: "1/10 * * * * *".to_string(), 304 | ..Default::default() 305 | }; 306 | 307 | let feed_item = FeedItem::from(feed.clone()); 308 | 309 | assert_eq!(feed_item.id, feed.id); 310 | assert_eq!(feed_item.url.as_str(), feed.url.as_str()); 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /nostrss-core/src/nostr/nostr.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use log::{debug, error, info}; 4 | use std::net::SocketAddr; 5 | 6 | use nostr_sdk::client::Error as NostrError; 7 | use nostr_sdk::prelude::{EventId, Metadata, Url}; 8 | use nostr_sdk::{Client, EventBuilder, Keys, Result, Tag}; 9 | 10 | use crate::profiles::config::Profile; 11 | 12 | use crate::nostr::relay::Relay; 13 | 14 | 15 | /// Nostr connection instance. 16 | /// 17 | /// NostrInstance provides a nostr client instance with a loaded configuration. 18 | /// This allows to use multiple clients instances with multiples profiles and identities. 19 | /// 20 | #[derive(Clone, Debug)] 21 | pub struct NostrInstance { 22 | pub client: Client, 23 | pub config: Profile, 24 | } 25 | 26 | impl NostrInstance { 27 | pub async fn new(config: Profile) -> Self { 28 | let keys = &config.get_keys(); 29 | let client = Client::new(keys); 30 | 31 | for relay in &config.get_relays().clone() { 32 | if relay.active { 33 | let target: &String = &relay.target.clone(); 34 | client.add_relay(target, relay.proxy).await.unwrap(); 35 | } 36 | } 37 | 38 | client.connect().await; 39 | 40 | Self { client, config } 41 | } 42 | 43 | // Broadcast message to network (NIP-02) 44 | pub async fn send_message(&self, message: &str, tags: &[Tag]) { 45 | let response = &self.client.publish_text_note(message, tags).await; 46 | 47 | match response { 48 | Ok(event_id) => { 49 | info!("Message sent successfully. Event Id : {:?}", event_id) 50 | } 51 | Err(e) => { 52 | error!("Error on messsaging : {:?}", e); 53 | } 54 | } 55 | } 56 | 57 | pub async fn new_update_profile(&self, profile: Profile) -> Result { 58 | let mut metadata = Metadata::new(); 59 | 60 | if profile.clone().get_display_name().is_some() { 61 | // metadata.name(self.config.display_name.clone().unwrap()); 62 | metadata = metadata.display_name(profile.clone().get_display_name().unwrap()); 63 | metadata = metadata.name(profile.clone().get_name().unwrap()); 64 | }; 65 | 66 | if profile.clone().get_description().is_some() { 67 | metadata = metadata.about(profile.clone().get_description().unwrap()); 68 | }; 69 | 70 | if profile.clone().get_picture().is_some() { 71 | let parsed_url = Url::parse(profile.clone().get_picture().unwrap().as_str()); 72 | 73 | if parsed_url.is_ok() { 74 | metadata = metadata.picture(parsed_url.unwrap()); 75 | } 76 | }; 77 | 78 | if profile.clone().get_banner().is_some() { 79 | let parsed_url = Url::parse(profile.clone().get_banner().unwrap().as_str()); 80 | 81 | if parsed_url.is_ok() { 82 | metadata = metadata.banner(parsed_url.unwrap()); 83 | } 84 | }; 85 | 86 | if profile.clone().get_nip05().is_some() { 87 | metadata = metadata.nip05(profile.clone().get_nip05().unwrap()); 88 | }; 89 | 90 | if profile.clone().get_lud16().is_some() { 91 | metadata = metadata.lud16(profile.clone().get_lud16().unwrap()); 92 | }; 93 | 94 | debug!("{:?}", metadata); 95 | 96 | let event = EventBuilder::set_metadata(metadata) 97 | .to_event(&profile.get_keys()) 98 | .unwrap(); 99 | // Broadcast metadata (NIP-01) to relays 100 | let result = self.get_client().send_event(event).await.unwrap(); 101 | Ok(result) 102 | } 103 | // Broadcasts profile metadata (NIP-01) to relays using a 104 | pub async fn update_profile(&self) -> Result { 105 | let mut metadata = Metadata::new(); 106 | 107 | if self.config.clone().get_display_name().is_some() { 108 | // metadata.name(self.config.display_name.clone().unwrap()); 109 | metadata = metadata.display_name(self.config.clone().get_display_name().unwrap()); 110 | metadata = metadata.name(self.config.clone().get_name().unwrap()); 111 | }; 112 | 113 | if self.config.clone().get_description().is_some() { 114 | metadata = metadata.about(self.config.clone().get_description().unwrap()); 115 | }; 116 | 117 | if self.config.clone().get_picture().is_some() { 118 | let parsed_url = Url::parse(self.config.clone().get_picture().unwrap().as_str()); 119 | 120 | if parsed_url.is_ok() { 121 | metadata = metadata.picture(parsed_url.unwrap()); 122 | } 123 | }; 124 | 125 | if self.config.clone().get_banner().is_some() { 126 | let parsed_url = Url::parse(self.config.clone().get_banner().unwrap().as_str()); 127 | 128 | if parsed_url.is_ok() { 129 | metadata = metadata.banner(parsed_url.unwrap()); 130 | } 131 | }; 132 | 133 | if self.config.clone().get_nip05().is_some() { 134 | metadata = metadata.nip05(self.config.clone().get_nip05().unwrap()); 135 | }; 136 | 137 | if self.config.clone().get_lud16().is_some() { 138 | metadata = metadata.lud16(self.config.lud16.clone().unwrap()); 139 | }; 140 | 141 | debug!("{:?}", metadata); 142 | 143 | // Broadcast metadata (NIP-01) to relays 144 | let profile_result = self.get_client().set_metadata(metadata).await.unwrap(); 145 | 146 | Ok(profile_result) 147 | } 148 | 149 | // Add a relay in the current client instance 150 | pub async fn add_relay(&self, url: &str, proxy: Option) -> Result<(), NostrError> { 151 | self.client.add_relay(url, proxy).await 152 | } 153 | // Remove a relay in the current client instance 154 | pub async fn remove_relay(&self, url: &str) -> Result<(), NostrError> { 155 | self.client.remove_relay(url).await 156 | } 157 | 158 | // Broadcasts message (NIP-02) to nostr relays 159 | pub async fn publish(self, _message: String) -> Result<()> { 160 | // self.client.send_client_msg(message).await; 161 | Ok(()) 162 | } 163 | 164 | // Get current client instance 165 | pub fn get_client(&self) -> &Client { 166 | &self.client 167 | } 168 | } 169 | 170 | #[cfg(test)] 171 | mod tests { 172 | use super::*; 173 | use dotenv::from_filename; 174 | use std::str::FromStr; 175 | 176 | #[tokio::test] 177 | async fn test_new_nostr_default_instance() { 178 | from_filename(".env.test").ok(); 179 | 180 | let profile = Profile { 181 | ..Default::default() 182 | }; 183 | 184 | let client = NostrInstance::new(profile).await; 185 | 186 | // Check if default data from env is effectively loaded 187 | assert_eq!( 188 | client.config.display_name, 189 | Some("satoshi-nakamoto".to_string()) 190 | ); 191 | assert_eq!(client.config.id, "default".to_string()); 192 | assert_eq!( 193 | client.config.private_key, 194 | "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string() 195 | ); 196 | 197 | assert_eq!( 198 | client.client.keys().public_key().to_string(), 199 | "4646ae5047316b4230d0086c8acec687f00b1cd9d1dc634f6cb358ac0a9a8fff".to_string() 200 | ); 201 | 202 | // Check if disabled relays are not loaded by default 203 | 204 | let relays = client.client.relays().await; 205 | let disabled_relay_url = Url::from_str("wss://some-disabled-relay.com").unwrap(); 206 | let disabled_relay = relays.get(&disabled_relay_url); 207 | 208 | assert_eq!(disabled_relay, None); 209 | } 210 | 211 | #[tokio::test] 212 | async fn test_new_nostr_instance_with_custom_profile() { 213 | from_filename(".env.test").ok(); 214 | 215 | let profile = Profile { 216 | id: "Hal-Finney".to_string(), 217 | display_name: Some("Hal Finney".to_string()), 218 | about: Some("Running Bitcoin".to_string()), 219 | private_key: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" 220 | .to_string(), 221 | ..Default::default() 222 | }; 223 | 224 | let client = NostrInstance::new(profile).await; 225 | 226 | assert_eq!(client.config.display_name, Some("Hal Finney".to_string())); 227 | assert_eq!(client.config.id, "Hal-Finney".to_string()); 228 | assert_eq!( 229 | client.config.private_key, 230 | "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string() 231 | ); 232 | 233 | assert_eq!( 234 | client.client.keys().public_key().to_string(), 235 | "4deb5e4bf849790657361d0559b96d9277fdfcf02f6f78f021e834b7282c9db8".to_string() 236 | ); 237 | } 238 | 239 | #[tokio::test] 240 | async fn test_custom_relays_load() { 241 | from_filename(".env.test").ok(); 242 | let mut relays = Vec::new(); 243 | 244 | relays.push(Relay { 245 | name: "test".to_string(), 246 | target: "ws://umbrel.local".to_string(), 247 | proxy: None, 248 | active: true, 249 | pow_level: 0, 250 | }); 251 | 252 | let profile = Profile { 253 | id: "Hal-Finney".to_string(), 254 | display_name: Some("Hal Finney".to_string()), 255 | about: Some("Running Bitcoin".to_string()), 256 | private_key: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" 257 | .to_string(), 258 | relays, 259 | ..Default::default() 260 | }; 261 | 262 | let client = NostrInstance::new(profile).await; 263 | 264 | let relays = client.client.relays().await; 265 | let url = Url::from_str("ws://umbrel.local").unwrap(); 266 | let relay = &relays[&url]; 267 | 268 | assert_eq!(relay.url().as_str(), "ws://umbrel.local/"); 269 | } 270 | 271 | #[tokio::test] 272 | async fn test_client_add_relay() { 273 | from_filename(".env.test").ok(); 274 | 275 | let profile = Profile { 276 | id: "Hal-Finney".to_string(), 277 | display_name: Some("Hal Finney".to_string()), 278 | about: Some("Running Bitcoin".to_string()), 279 | private_key: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" 280 | .to_string(), 281 | ..Default::default() 282 | }; 283 | 284 | let client = NostrInstance::new(profile).await; 285 | 286 | let original_size = &client.client.relays().await.keys().len(); 287 | 288 | assert_eq!(original_size, &0); 289 | 290 | let _ = &client.add_relay("ws://umbrel.local", None).await; 291 | 292 | let new_size = client.client.relays().await.keys().len(); 293 | 294 | assert_eq!(new_size, 1); 295 | } 296 | 297 | #[tokio::test] 298 | async fn test_client_remove_relay() { 299 | from_filename(".env.test").ok(); 300 | 301 | let mut relays = Vec::new(); 302 | 303 | relays.push(Relay { 304 | name: "test".to_string(), 305 | target: "ws://umbrel.local".to_string(), 306 | proxy: None, 307 | active: true, 308 | pow_level: 0, 309 | }); 310 | 311 | let profile = Profile { 312 | id: "Hal-Finney".to_string(), 313 | display_name: Some("Hal Finney".to_string()), 314 | about: Some("Running Bitcoin".to_string()), 315 | private_key: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" 316 | .to_string(), 317 | relays, 318 | ..Default::default() 319 | }; 320 | 321 | let client = NostrInstance::new(profile).await; 322 | 323 | let original_size = &client.client.relays().await.keys().len(); 324 | 325 | assert_eq!(original_size, &1); 326 | 327 | let url = "ws://umbrel.local/"; 328 | let _ = &client.remove_relay(url).await; 329 | 330 | let new_size = &client.client.relays().await.keys().len(); 331 | 332 | assert_eq!(new_size, &0); 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /nostrss-core/src/scheduler/scheduler.rs: -------------------------------------------------------------------------------- 1 | use feed_rs::model::Entry; 2 | use log::{debug, error}; 3 | use nostr_sdk::{Client, EventBuilder, JsonUtil, Keys, Tag}; 4 | use std::{collections::HashMap, sync::Arc}; 5 | use tokio::sync::{Mutex, MutexGuard}; 6 | use tokio_cron_scheduler::Job; 7 | 8 | use crate::{ 9 | app::app::AppConfig, 10 | nostr::relay::Relay, 11 | profiles::config::Profile, 12 | rss::{config::Feed, parser::RssParser}, 13 | template::template::TemplateProcessor, 14 | }; 15 | 16 | /// Cronjob creation method 17 | pub async fn schedule( 18 | rule: &str, 19 | feed: Feed, 20 | map: Arc>>>, 21 | client: Arc>, 22 | profiles: Arc>>, 23 | app_config: Arc>, 24 | ) -> Job { 25 | // Create a copy of the map arc that will be solely used into the job 26 | let map_job_copy = Arc::clone(&map); 27 | 28 | let job_feed = feed.clone(); 29 | let job = Job::new_async(rule, move |uuid, _lock| { 30 | // Copy feed for job execution 31 | let feed = job_feed.clone(); 32 | 33 | // Get the profiles ids associated to the feed for further use 34 | let profile_ids = feed 35 | .profiles 36 | .clone() 37 | .unwrap_or(["default".to_string()].to_vec()); 38 | 39 | // Arc instances for current job 40 | 41 | let map_arc = Arc::clone(&map_job_copy); 42 | let profiles_arc = Arc::clone(&profiles); 43 | 44 | let app_config_arc = Arc::clone(&app_config); 45 | let client_arc = Arc::clone(&client); 46 | Box::pin(async move { 47 | let mut map_lock = map_arc.lock().await; 48 | let feed = feed.clone(); 49 | let uuid = &uuid.to_string(); 50 | let mut map = map_lock[uuid].clone(); 51 | 52 | let client_lock = client_arc.lock().await; 53 | 54 | let profiles_lock = profiles_arc.lock().await; 55 | let app_config_lock = app_config_arc.lock().await; 56 | match RssParser::get_items(feed.url.to_string()).await { 57 | Ok(entries) => { 58 | // Calls the method that 59 | RssNostrJob::process( 60 | feed.clone(), 61 | profile_ids, 62 | entries, 63 | &mut map, 64 | client_lock, 65 | profiles_lock, 66 | app_config_lock, 67 | ) 68 | .await; 69 | 70 | if feed.cache_size.is_some() { 71 | map.truncate(feed.cache_size.unwrap()); 72 | } 73 | 74 | _ = &map_lock.insert(uuid.to_string(), map); 75 | } 76 | Err(_) => { 77 | error!( 78 | "Error while parsing RSS stream for feed with {} id. Skipping...", 79 | feed.id 80 | ); 81 | } 82 | }; 83 | }) 84 | }); 85 | 86 | let job = match job { 87 | Ok(j) => j, 88 | Err(e) => { 89 | println!("{:?}", e); 90 | panic!() 91 | } 92 | }; 93 | 94 | let f = feed.clone(); 95 | 96 | // Initialize the Vec that will store the retained entries of feed for current feed. 97 | // This avoids to spam the network on first fetch 98 | let mut map_lock = map.lock().await; 99 | let initial_snapshot = feed_snapshot(f).await; 100 | map_lock.insert(job.guid().to_string(), initial_snapshot); 101 | 102 | job 103 | } 104 | 105 | // Retrieves a feed and returns a vec of ids for the feed. 106 | // This method is used to provide initial snapshot of the rss feeds 107 | // In order to avoid to spam relays with initial rss feed fetch. 108 | pub async fn feed_snapshot(feed: Feed) -> Vec { 109 | let mut entries_snapshot = Vec::new(); 110 | match RssParser::get_items(feed.url.to_string()).await { 111 | Ok(entries) => { 112 | for entry in entries { 113 | entries_snapshot.push(entry.id) 114 | } 115 | } 116 | Err(_) => { 117 | error!( 118 | "Error while parsing RSS stream for feed with {} id. Skipping initial snapshot", 119 | feed.id 120 | ); 121 | } 122 | }; 123 | 124 | entries_snapshot 125 | } 126 | 127 | pub struct RssNostrJob {} 128 | 129 | impl RssNostrJob { 130 | pub async fn _client_prepare( 131 | _client: MutexGuard<'_, Client>, 132 | _profile: MutexGuard<'_, Profile>, 133 | ) { 134 | } 135 | 136 | pub async fn _client_clean(_client: Client) {} 137 | 138 | pub async fn process( 139 | feed: Feed, 140 | profile_ids: Vec, 141 | entries: Vec, 142 | map: &mut Vec, 143 | client: MutexGuard<'_, Client>, 144 | profiles_lock: MutexGuard<'_, HashMap>, 145 | app_config_lock: MutexGuard<'_, AppConfig>, 146 | ) { 147 | for entry in entries { 148 | let entry_id = &entry.id; 149 | 150 | match &map.contains(entry_id) { 151 | true => { 152 | debug!( 153 | "Found entry for {} on feed with id {}, skipping publication.", 154 | entry_id, &feed.id 155 | ); 156 | } 157 | false => { 158 | debug!( 159 | "Entry not found for {} on feed with id {}, publishing...", 160 | entry_id, &feed.id 161 | ); 162 | 163 | let message = match TemplateProcessor::parse(feed.clone(), entry.clone()) { 164 | Ok(message) => message, 165 | Err(e) => { 166 | // make tick fail in non-critical way 167 | error!("{}", e); 168 | return; 169 | } 170 | }; 171 | 172 | for profile_id in &profile_ids { 173 | let mut tags = Self::get_tags(&feed.tags); 174 | 175 | // Declare NIP-48. 176 | tags.push(Self::get_nip48(entry.id.clone())); 177 | 178 | let profile = profiles_lock.get(profile_id); 179 | 180 | if profile.is_none() { 181 | error!( 182 | "Profile {} for stream {} not found. Job skipped.", 183 | profile_id, feed.name 184 | ); 185 | return; 186 | } 187 | 188 | let profile = profile.unwrap(); 189 | 190 | let keys = match Keys::parse(profile.private_key.as_str()) { 191 | Ok(val) => val, 192 | Err(e) => { 193 | println!("{:?}", e); 194 | // warn!("Invalid private key found for Nostr. Generating random keys..."); 195 | panic!("Invalid private key found. This should not happen."); 196 | } 197 | }; 198 | 199 | // _ = RssNostrJob::client_prepare(client,profile).await; 200 | 201 | let recommended_relays_ids = 202 | profile.recommended_relays.clone().unwrap_or(Vec::new()); 203 | let mut recommended_relays_tags = Self::get_recommended_relays( 204 | recommended_relays_ids, 205 | &profile.relays.clone(), 206 | ); 207 | 208 | _ = &tags.append(&mut recommended_relays_tags); 209 | 210 | let event = EventBuilder::new(nostr_sdk::Kind::TextNote, &message, tags) 211 | .to_pow_event(&keys, profile.pow_level); 212 | 213 | match event { 214 | Ok(e) => { 215 | let dry_run_flag = app_config_lock.dry_run; 216 | 217 | match dry_run_flag { 218 | true => { 219 | log::info!("dry-mode on : {:?}", e.as_json()); 220 | } 221 | false => match client.send_event(e).await { 222 | Ok(event_id) => { 223 | log::info!("Entry published with id {}", event_id) 224 | } 225 | Err(e) => log::error!("Error publishing entry : {}", e), 226 | }, 227 | } 228 | } 229 | Err(_) => panic!("Note couldn't be sent"), 230 | }; 231 | 232 | // _ = RssNostrJob::client_clean(client,profile).await; 233 | } 234 | map.insert(0, entry.id); 235 | } 236 | } 237 | } 238 | } 239 | 240 | fn get_tags(feed_tags: &Option>) -> Vec { 241 | let mut tags = Vec::new(); 242 | 243 | if feed_tags.is_some() { 244 | for tag in feed_tags.clone().unwrap() { 245 | tags.push(Tag::Hashtag(tag.clone())); 246 | } 247 | } 248 | tags 249 | } 250 | 251 | fn get_nip48(guid: String) -> Tag { 252 | // Declare NIP-48. 253 | // NIP-48 : declares to be a proxy from an external signal (rss,activityPub) 254 | Tag::Proxy { 255 | id: guid, 256 | protocol: nostr_sdk::prelude::Protocol::Rss, 257 | } 258 | } 259 | 260 | fn get_recommended_relays(recommended_relays_ids: Vec, relays: &[Relay]) -> Vec { 261 | let mut relay_tags = Vec::new(); 262 | for relay_name in recommended_relays_ids { 263 | let r = relays.iter().find(|relay| relay.name == relay_name); 264 | if r.clone().is_none() { 265 | continue; 266 | } 267 | 268 | let tag = Tag::RelayMetadata(r.unwrap().target.clone().into(), None); 269 | relay_tags.push(tag); 270 | } 271 | 272 | relay_tags 273 | } 274 | } 275 | 276 | #[cfg(test)] 277 | mod tests { 278 | 279 | use nostr_sdk::Alphabet::{R, T}; 280 | use nostr_sdk::{prelude::TagKind, SingleLetterTag}; 281 | 282 | use super::*; 283 | 284 | #[test] 285 | fn test_nip_48_signal() {} 286 | 287 | #[test] 288 | fn test_get_tags() { 289 | let relay_ids = ["test".to_string()].to_vec(); 290 | let relays = [ 291 | Relay { 292 | name: "test".to_string(), 293 | target: "wss://nostr.up".to_string(), 294 | active: true, 295 | proxy: None, 296 | pow_level: 0, 297 | }, 298 | Relay { 299 | name: "mushroom".to_string(), 300 | target: "wss://mushroom.dev".to_string(), 301 | active: true, 302 | proxy: None, 303 | pow_level: 0, 304 | }, 305 | ] 306 | .to_vec(); 307 | let tags = RssNostrJob::get_recommended_relays(relay_ids, &relays); 308 | 309 | let tag = tags[0].clone(); 310 | assert_eq!( 311 | tag.kind(), 312 | TagKind::SingleLetter(SingleLetterTag { 313 | character: R, 314 | uppercase: false 315 | }) 316 | ); 317 | assert_eq!(tag.as_vec()[0], "r"); 318 | assert_eq!(tag.as_vec()[1], "wss://nostr.up"); 319 | } 320 | 321 | #[test] 322 | fn test_nip_48() { 323 | let guid = "https://www.test.com"; 324 | let mut tags: Vec = [].to_vec(); 325 | let nip_48 = RssNostrJob::get_nip48(guid.clone().to_string()); 326 | 327 | assert_eq!(nip_48.kind(), TagKind::Proxy); 328 | } 329 | 330 | #[test] 331 | fn test_recommended_relays() { 332 | let feed_tags = ["ad".to_string(), "lorem".to_string(), "ipsum".to_string()].to_vec(); 333 | let tags = RssNostrJob::get_tags(&Some(feed_tags)); 334 | 335 | assert_eq!(tags.len(), 3); 336 | let tag = tags[0].clone(); 337 | assert_eq!( 338 | tag.kind(), 339 | TagKind::SingleLetter(SingleLetterTag { 340 | character: T, 341 | uppercase: false 342 | }) 343 | ); 344 | assert_eq!(tag.as_vec()[0], "t"); 345 | assert_eq!(tag.as_vec()[1], "ad"); 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /nostrss-core/src/profiles/config.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use core::panic; 4 | use log::warn; 5 | use nostr_sdk::prelude::*; 6 | use serde::{Deserialize, Serialize}; 7 | use std::{env, path::Path}; 8 | 9 | use crate::nostr::{relay::Relay, NostrProfile}; 10 | 11 | #[derive(Debug)] 12 | pub enum ConfigErrors { 13 | FileLocationError, 14 | FileFormatError, 15 | FileParsingError, 16 | KeyParsingError, 17 | } 18 | 19 | #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] 20 | pub struct Profile { 21 | pub id: String, 22 | pub private_key: String, 23 | #[serde(default)] 24 | pub relays: Vec, 25 | pub about: Option, 26 | pub name: Option, 27 | pub display_name: Option, 28 | pub description: Option, 29 | pub picture: Option, 30 | pub banner: Option, 31 | pub nip05: Option, 32 | pub lud16: Option, 33 | #[serde(default = "Profile::default_pow_level")] 34 | pub pow_level: u8, 35 | #[serde(default)] 36 | pub recommended_relays: Option>, 37 | } 38 | 39 | impl Default for Profile { 40 | fn default() -> Self { 41 | Self { 42 | private_key: Self::get_env_private_key(), 43 | id: "default".to_string(), 44 | relays: Vec::new(), 45 | about: Self::get_env_description(), 46 | name: Self::get_env_name(), 47 | display_name: Self::get_env_display_name(), 48 | description: Self::get_env_description(), 49 | picture: Self::get_env_picture(), 50 | banner: Self::get_env_banner(), 51 | nip05: Self::get_env_nip05(), 52 | lud16: Self::get_env_lud16(), 53 | pow_level: Self::default_pow_level(), 54 | recommended_relays: None, 55 | } 56 | } 57 | } 58 | 59 | impl NostrProfile for Profile { 60 | fn get_display_name(self) -> Option { 61 | if self.display_name.is_some() { 62 | return self.display_name; 63 | } 64 | 65 | Self::get_env_display_name() 66 | } 67 | 68 | fn get_description(self) -> Option { 69 | if self.description.is_some() { 70 | return self.description; 71 | } 72 | 73 | Self::get_env_description() 74 | } 75 | 76 | fn get_picture(self) -> Option { 77 | if self.picture.is_some() { 78 | return self.picture; 79 | } 80 | 81 | Self::get_env_picture() 82 | } 83 | 84 | fn get_banner(self) -> Option { 85 | if self.banner.is_some() { 86 | return self.banner; 87 | } 88 | 89 | Self::get_env_banner() 90 | } 91 | 92 | fn get_nip05(self) -> Option { 93 | if self.nip05.is_some() { 94 | return self.nip05; 95 | } 96 | 97 | Self::get_env_nip05() 98 | } 99 | 100 | fn get_lud16(self) -> Option { 101 | if self.lud16.is_some() { 102 | return self.lud16; 103 | } 104 | 105 | Self::get_env_lud16() 106 | } 107 | 108 | fn get_relays(&self) -> Vec { 109 | self.relays.clone() 110 | } 111 | 112 | fn get_keys(&self) -> Keys { 113 | match Keys::parse(&self.private_key) { 114 | Ok(val) => val, 115 | Err(_) => { 116 | // warn!("Invalid private key found for Nostr. Generating random keys..."); 117 | panic!("Invalid private key found. This should not happen."); 118 | } 119 | } 120 | } 121 | } 122 | 123 | impl Profile { 124 | pub fn new(private_key: String, relays: Option) -> Self { 125 | let mut profile = Profile { 126 | private_key, 127 | ..Default::default() 128 | }; 129 | 130 | if let Some(relays) = relays { 131 | profile = profile.load_relays(&relays); 132 | } 133 | 134 | profile 135 | } 136 | 137 | pub fn set_relays(mut self, relays: Vec) -> Self { 138 | self.relays = relays; 139 | self 140 | } 141 | 142 | pub fn set_display_name(mut self, display_name: Option) -> Self { 143 | self.display_name = display_name; 144 | self 145 | } 146 | 147 | fn get_env_display_name() -> Option { 148 | match env::var("NOSTR_DISPLAY_NAME") 149 | .unwrap_or("".to_string()) 150 | .parse::() 151 | { 152 | Ok(result) => match !result.is_empty() { 153 | true => Some(result), 154 | false => None, 155 | }, 156 | Err(_) => None, 157 | } 158 | } 159 | 160 | fn default_pow_level() -> u8 { 161 | env::var("DEFAULT_POW_LEVEL") 162 | .unwrap_or("0".to_string()) 163 | .parse::() 164 | .unwrap_or(0) 165 | } 166 | 167 | pub fn set_name(mut self, name: Option) -> Self { 168 | self.name = name; 169 | self 170 | } 171 | 172 | pub fn get_name(self) -> Option { 173 | if self.name.is_some() { 174 | return self.name; 175 | } 176 | 177 | Self::get_env_name() 178 | } 179 | 180 | fn get_env_name() -> Option { 181 | match env::var("NOSTR_NAME") 182 | .unwrap_or("".to_string()) 183 | .parse::() 184 | { 185 | Ok(result) => match !result.is_empty() { 186 | true => Some(result), 187 | false => None, 188 | }, 189 | Err(_) => None, 190 | } 191 | } 192 | 193 | pub fn set_description(mut self, description: Option) -> Self { 194 | self.description = description; 195 | self 196 | } 197 | 198 | fn get_env_description() -> Option { 199 | match env::var("NOSTR_DESCRIPTION") 200 | .unwrap_or("".to_string()) 201 | .parse::() 202 | { 203 | Ok(result) => match !result.is_empty() { 204 | true => Some(result), 205 | false => None, 206 | }, 207 | Err(_) => None, 208 | } 209 | } 210 | 211 | pub fn set_picture(mut self, picture: Option) -> Self { 212 | self.picture = picture; 213 | self 214 | } 215 | 216 | fn get_env_picture() -> Option { 217 | match env::var("NOSTR_PICTURE") 218 | .unwrap_or("".to_string()) 219 | .parse::() 220 | { 221 | Ok(result) => match !result.is_empty() { 222 | true => Some(result), 223 | false => None, 224 | }, 225 | Err(_) => None, 226 | } 227 | } 228 | 229 | pub fn set_banner(mut self, banner: Option) -> Self { 230 | self.banner = banner; 231 | self 232 | } 233 | 234 | fn get_env_banner() -> Option { 235 | match env::var("NOSTR_BANNER") 236 | .unwrap_or("".to_string()) 237 | .parse::() 238 | { 239 | Ok(result) => match !result.is_empty() { 240 | true => Some(result), 241 | false => None, 242 | }, 243 | Err(_) => None, 244 | } 245 | } 246 | 247 | pub fn set_nip05(mut self, nip05: Option) -> Self { 248 | self.nip05 = nip05; 249 | self 250 | } 251 | 252 | fn get_env_nip05() -> Option { 253 | match env::var("NOSTR_NIP05") 254 | .unwrap_or("".to_string()) 255 | .parse::() 256 | { 257 | Ok(result) => match !result.is_empty() { 258 | true => Some(result), 259 | false => None, 260 | }, 261 | Err(_) => None, 262 | } 263 | } 264 | 265 | pub fn set_lud16(mut self, lud16: Option) -> Self { 266 | self.lud16 = lud16; 267 | self 268 | } 269 | 270 | fn get_env_lud16() -> Option { 271 | match env::var("NOSTR_LUD16") 272 | .unwrap_or("".to_string()) 273 | .parse::() 274 | { 275 | Ok(result) => match !result.is_empty() { 276 | true => Some(result), 277 | false => None, 278 | }, 279 | Err(_) => None, 280 | } 281 | } 282 | 283 | pub fn set_recommended_relays(mut self, relays: Vec) -> Self { 284 | self.recommended_relays = Some(relays); 285 | self 286 | } 287 | 288 | fn get_env_var(var_name: &str) -> Option { 289 | match env::var(format!("NOSTR_{}", var_name.to_uppercase())) { 290 | Ok(val) => Some(val), 291 | Err(_) => None, 292 | } 293 | } 294 | 295 | pub fn set_keys(secret_key: &str) -> Result { 296 | match Keys::parse(secret_key) { 297 | Ok(keys) => Ok(keys), 298 | Err(_) => Err(ConfigErrors::KeyParsingError), 299 | } 300 | } 301 | 302 | fn get_env_private_key() -> String { 303 | match env::var("NOSTR_PK") { 304 | Ok(val) => val, 305 | Err(_) => { 306 | warn!("No private key found for Nostr. Generating random keys..."); 307 | panic!("No default profile key defined. Declare using NOSTR_PK in env file."); 308 | } 309 | } 310 | } 311 | 312 | pub fn load_json_relays(mut self, path: &Path) -> Self { 313 | let file = match std::fs::File::open(path) { 314 | Ok(file) => file, 315 | Err(_) => { 316 | return self; 317 | } 318 | }; 319 | 320 | let relays: Vec = match serde_json::from_reader(file) { 321 | Ok(relays) => relays, 322 | Err(_) => return self, 323 | }; 324 | 325 | self.relays = relays; 326 | self 327 | } 328 | 329 | pub fn load_relays(self, path: &str) -> Self { 330 | let path = Path::new(path); 331 | 332 | if path.is_file() { 333 | match path.extension() { 334 | Some(ext) => match ext.to_str() { 335 | Some("yml") => { 336 | return self.load_yaml_relays(path); 337 | } 338 | Some("yaml") => { 339 | return self.load_yaml_relays(path); 340 | } 341 | Some("json") => { 342 | return self.load_json_relays(path); 343 | } 344 | _ => { 345 | return self; 346 | } 347 | }, 348 | None => { 349 | return self; 350 | } 351 | } 352 | } 353 | 354 | self 355 | } 356 | 357 | pub fn load_yaml_relays(mut self, path: &Path) -> Self { 358 | let file = match std::fs::File::open(path) { 359 | Ok(file) => file, 360 | Err(_) => { 361 | return self; 362 | } 363 | }; 364 | let relays: Vec = match serde_yaml::from_reader(file) { 365 | Ok(relays) => relays, 366 | Err(_) => return self, 367 | }; 368 | 369 | self.relays = relays; 370 | self 371 | } 372 | 373 | pub fn set_relays_from_file(self, path: &str) -> Self { 374 | self.load_relays(path) 375 | } 376 | } 377 | 378 | #[cfg(test)] 379 | mod tests { 380 | 381 | use dotenv::from_filename; 382 | 383 | #[tokio::test] 384 | async fn test_default_profile() { 385 | from_filename(".env.test").ok(); 386 | 387 | let profile = super::Profile { 388 | ..Default::default() 389 | }; 390 | 391 | assert_eq!(profile.id, "default"); 392 | assert_eq!(profile.display_name, Some("satoshi-nakamoto".to_string())); 393 | assert_eq!( 394 | profile.description, 395 | Some("Craig Wright is not satoshi".to_string()) 396 | ) 397 | } 398 | 399 | #[tokio::test] 400 | async fn test_new_profile() { 401 | from_filename(".env.test").ok(); 402 | 403 | let profile = super::Profile::new("abcdef".to_string(), None); 404 | 405 | assert_eq!(profile.id, "default"); 406 | } 407 | 408 | #[test] 409 | fn test_nostrconfig_profile_setters() { 410 | use super::Relay; 411 | 412 | from_filename(".env.test").ok(); 413 | 414 | let mut profile = super::Profile::new("abcde".to_string(), None); 415 | 416 | profile = profile.set_banner(Some("https://domain.com/image.jpg".to_string())); 417 | assert_eq!( 418 | profile.banner, 419 | Some("https://domain.com/image.jpg".to_string()) 420 | ); 421 | 422 | profile = profile.set_picture(Some("https://domain.com/image.jpg".to_string())); 423 | assert_eq!( 424 | profile.picture, 425 | Some("https://domain.com/image.jpg".to_string()) 426 | ); 427 | 428 | profile = profile.set_name(Some("John doe".to_string())); 429 | assert_eq!(profile.name, Some("John doe".to_string())); 430 | 431 | profile = profile.set_description(Some("Ad lorem ipsum".to_string())); 432 | assert_eq!(profile.description, Some("Ad lorem ipsum".to_string())); 433 | 434 | let relays: Vec = vec![Relay { 435 | name: "test".to_string(), 436 | target: "wss://localhost".to_string(), 437 | active: true, 438 | proxy: None, 439 | pow_level: 0, 440 | }]; 441 | profile = profile.set_relays(relays); 442 | assert_eq!(profile.description, Some("Ad lorem ipsum".to_string())); 443 | } 444 | } 445 | -------------------------------------------------------------------------------- /nostrss-core/src/nostr/config.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use core::panic; 4 | use log::{info, warn, error}; 5 | use nostr_sdk::prelude::*; 6 | use serde::{Deserialize, Serialize}; 7 | use std::{env, net::SocketAddr, path::Path}; 8 | 9 | use super::nostr::NostrProfile; 10 | 11 | #[derive(Debug)] 12 | pub enum NostrConfigErrors { 13 | FileLocationError, 14 | FileFormatError, 15 | FileParsingError, 16 | KeyParsingError, 17 | } 18 | 19 | impl NostrProfile for NostrConfig { 20 | fn get_display_name(self) -> Option { 21 | if self.display_name.is_some() { 22 | return self.display_name; 23 | } 24 | 25 | Self::get_env_display_name() 26 | } 27 | 28 | fn get_description(self) -> Option { 29 | if self.description.is_some() { 30 | return self.description; 31 | } 32 | 33 | Self::get_env_description() 34 | } 35 | 36 | fn get_picture(self) -> Option { 37 | if self.picture.is_some() { 38 | return self.picture; 39 | } 40 | 41 | Self::get_env_picture() 42 | } 43 | 44 | fn get_banner(self) -> Option { 45 | if self.banner.is_some() { 46 | return self.banner; 47 | } 48 | 49 | Self::get_env_banner() 50 | } 51 | 52 | fn get_nip05(self) -> Option { 53 | if self.nip05.is_some() { 54 | return self.nip05; 55 | } 56 | 57 | Self::get_env_nip05() 58 | } 59 | 60 | fn get_lud16(self) -> Option { 61 | if self.lud16.is_some() { 62 | return self.lud16; 63 | } 64 | 65 | Self::get_env_lud16() 66 | } 67 | 68 | fn get_keys(&self) -> Keys { 69 | self.keys.clone() 70 | // match Keys::from_sk_str(&self.private_key) { 71 | // Ok(val) => val, 72 | // Err(_) => { 73 | // // warn!("Invalid private key found for Nostr. Generating random keys..."); 74 | // panic!("Invalid private key found. This should not happen."); 75 | // } 76 | // } 77 | } 78 | 79 | fn get_relays(&self) -> Vec { 80 | self.relays.clone() 81 | } 82 | } 83 | 84 | #[derive(Debug, Clone)] 85 | pub struct NostrConfig { 86 | pub keys: Keys, 87 | pub relays: Vec, 88 | pub name: Option, 89 | pub display_name: Option, 90 | pub about: Option, 91 | pub description: Option, 92 | pub picture: Option, 93 | pub banner: Option, 94 | pub nip05: Option, 95 | pub lud16: Option, 96 | } 97 | 98 | impl Default for NostrConfig { 99 | fn default() -> Self { 100 | Self { 101 | keys: Self::load_keys(), 102 | relays: Vec::new(), 103 | about: Self::get_env_description(), 104 | name: Self::get_env_name(), 105 | display_name: Self::get_env_display_name(), 106 | description: Self::get_env_description(), 107 | picture: None, 108 | banner: None, 109 | nip05: None, 110 | lud16: None, 111 | } 112 | } 113 | } 114 | 115 | impl NostrConfig { 116 | pub fn new(private_key: Option, relays: Option) -> Self { 117 | // Init default configuration 118 | let mut config: Self = Default::default(); 119 | 120 | // Load private key if provided 121 | if let Some(private_key) = private_key { 122 | let keys = match Self::set_keys(&private_key) { 123 | Ok(keys) => keys, 124 | Err(_) => { 125 | panic!("{:#?}", NostrConfigErrors::KeyParsingError) 126 | } 127 | }; 128 | 129 | config.keys = keys; 130 | } 131 | 132 | // Displays keys in logger. This is useful 133 | // as config can be started with random keys. 134 | info!("public key : {:?}", &config.keys.public_key()); 135 | info!( 136 | "bech32 public key : {:?}", 137 | &config.keys.public_key().to_bech32().unwrap() 138 | ); 139 | 140 | if relays.is_some() { 141 | info!("Found relays file path argument. Parsing file..."); 142 | config = config.load_relays(relays.as_ref().unwrap()); 143 | } 144 | 145 | config 146 | } 147 | 148 | pub fn set_relays(mut self, relays: Vec) -> Self { 149 | self.relays = relays; 150 | self 151 | } 152 | 153 | pub fn set_display_name(mut self, display_name: Option) -> Self { 154 | self.display_name = display_name; 155 | self 156 | } 157 | 158 | pub fn get_display_name(self) -> Option { 159 | if self.display_name.is_some() { 160 | return self.display_name; 161 | } 162 | 163 | Self::get_env_display_name() 164 | } 165 | 166 | fn get_env_display_name() -> Option { 167 | match env::var("NOSTR_DISPLAY_NAME") 168 | .unwrap_or("".to_string()) 169 | .parse::() 170 | { 171 | Ok(result) => match !result.is_empty() { 172 | true => Some(result), 173 | false => None, 174 | }, 175 | Err(_) => None, 176 | } 177 | } 178 | 179 | pub fn set_name(mut self, name: Option) -> Self { 180 | self.name = name; 181 | self 182 | } 183 | 184 | pub fn get_name(self) -> Option { 185 | if self.name.is_some() { 186 | return self.name; 187 | } 188 | 189 | Self::get_env_name() 190 | } 191 | 192 | fn get_env_name() -> Option { 193 | match env::var("NOSTR_NAME") 194 | .unwrap_or("".to_string()) 195 | .parse::() 196 | { 197 | Ok(result) => match !result.is_empty() { 198 | true => Some(result), 199 | false => None, 200 | }, 201 | Err(_) => None, 202 | } 203 | } 204 | 205 | pub fn set_description(mut self, description: Option) -> Self { 206 | self.description = description; 207 | self 208 | } 209 | 210 | pub fn get_description(self) -> Option { 211 | if self.description.is_some() { 212 | return self.description; 213 | } 214 | 215 | Self::get_env_description() 216 | } 217 | 218 | fn get_env_description() -> Option { 219 | match env::var("NOSTR_DESCRIPTION") 220 | .unwrap_or("".to_string()) 221 | .parse::() 222 | { 223 | Ok(result) => match !result.is_empty() { 224 | true => Some(result), 225 | false => None, 226 | }, 227 | Err(_) => None, 228 | } 229 | } 230 | 231 | pub fn set_picture(mut self, picture: Option) -> Self { 232 | self.picture = picture; 233 | self 234 | } 235 | 236 | pub fn get_picture(self) -> Option { 237 | if self.picture.is_some() { 238 | return self.picture; 239 | } 240 | 241 | Self::get_env_picture() 242 | } 243 | 244 | fn get_env_picture() -> Option { 245 | match env::var("NOSTR_PICTURE") 246 | .unwrap_or("".to_string()) 247 | .parse::() 248 | { 249 | Ok(result) => match !result.is_empty() { 250 | true => Some(result), 251 | false => None, 252 | }, 253 | Err(_) => None, 254 | } 255 | } 256 | 257 | pub fn set_banner(mut self, banner: Option) -> Self { 258 | self.banner = banner; 259 | self 260 | } 261 | 262 | pub fn get_banner(self) -> Option { 263 | if self.banner.is_some() { 264 | return self.banner; 265 | } 266 | 267 | Self::get_env_banner() 268 | } 269 | 270 | fn get_env_banner() -> Option { 271 | match env::var("NOSTR_BANNER") 272 | .unwrap_or("".to_string()) 273 | .parse::() 274 | { 275 | Ok(result) => match !result.is_empty() { 276 | true => Some(result), 277 | false => None, 278 | }, 279 | Err(_) => None, 280 | } 281 | } 282 | 283 | fn get_env_nip05() -> Option { 284 | match env::var("NOSTR_NIP05") 285 | .unwrap_or("".to_string()) 286 | .parse::() 287 | { 288 | Ok(result) => match !result.is_empty() { 289 | true => Some(result), 290 | false => None, 291 | }, 292 | Err(_) => None, 293 | } 294 | } 295 | 296 | pub fn set_nip05(mut self, nip05: Option) -> Self { 297 | self.nip05 = nip05; 298 | self 299 | } 300 | 301 | pub fn get_nip05(self) -> Option { 302 | if self.nip05.is_some() { 303 | return self.nip05; 304 | } 305 | 306 | match env::var("NOSTR_NIP05") 307 | .unwrap_or("".to_string()) 308 | .parse::() 309 | { 310 | Ok(result) => match !result.is_empty() { 311 | true => Some(result), 312 | false => None, 313 | }, 314 | Err(_) => None, 315 | } 316 | } 317 | 318 | fn get_env_lud16() -> Option { 319 | match env::var("NOSTR_LUD16") 320 | .unwrap_or("".to_string()) 321 | .parse::() 322 | { 323 | Ok(result) => match !result.is_empty() { 324 | true => Some(result), 325 | false => None, 326 | }, 327 | Err(_) => None, 328 | } 329 | } 330 | 331 | pub fn get_lud16(self) -> Option { 332 | if self.lud16.is_some() { 333 | return self.lud16; 334 | } 335 | 336 | match env::var("NOSTR_LUD16") 337 | .unwrap_or("".to_string()) 338 | .parse::() 339 | { 340 | Ok(result) => match !result.is_empty() { 341 | true => Some(result), 342 | false => None, 343 | }, 344 | Err(_) => None, 345 | } 346 | } 347 | 348 | pub fn set_lud16(mut self, lud16: Option) -> Self { 349 | self.lud16 = lud16; 350 | self 351 | } 352 | 353 | fn get_env_var(var_name: &str) -> Option { 354 | match env::var(format!("NOSTR_{}", var_name.to_uppercase())) { 355 | Ok(val) => Some(val), 356 | Err(_) => None, 357 | } 358 | } 359 | 360 | pub fn set_keys(secret_key: &str) -> Result { 361 | match Keys::from_sk_str(secret_key) { 362 | Ok(keys) => Ok(keys), 363 | Err(_) => Err(NostrConfigErrors::KeyParsingError), 364 | } 365 | } 366 | 367 | pub fn load_keys() -> Keys { 368 | match env::var("NOSTR_PK") { 369 | Ok(val) => match Keys::from_sk_str(&val) { 370 | Ok(val) => val, 371 | Err(_) => { 372 | warn!("Invalid private key found for Nostr. Generating random keys..."); 373 | Keys::generate() 374 | } 375 | }, 376 | Err(_) => { 377 | warn!("No private key found for Nostr. Generating random keys..."); 378 | Keys::generate() 379 | } 380 | } 381 | } 382 | 383 | pub fn load_json_relays(mut self, path: &Path) -> Self { 384 | let file = match std::fs::File::open(path) { 385 | Ok(file) => file, 386 | Err(e) => { 387 | error!("Error loading relay yaml file : {}",e); 388 | return self; 389 | } 390 | }; 391 | let relays: Vec = match serde_json::from_reader(file) { 392 | Ok(relays) => relays, 393 | Err(e) => { 394 | error!("Error parsing relay json file : {}",e); 395 | return self 396 | }, 397 | }; 398 | 399 | self.relays = relays; 400 | self 401 | } 402 | 403 | pub fn load_relays(self, path: &str) -> Self { 404 | let path = Path::new(path); 405 | 406 | if path.is_file() { 407 | match path.extension() { 408 | Some(ext) => match ext.to_str() { 409 | Some("yml") => { 410 | return self.load_yaml_relays(path); 411 | } 412 | Some("yaml") => { 413 | return self.load_yaml_relays(path); 414 | } 415 | Some("json") => { 416 | return self.load_json_relays(path); 417 | } 418 | _ => { 419 | return self; 420 | } 421 | }, 422 | None => { 423 | return self; 424 | } 425 | } 426 | } 427 | 428 | self 429 | } 430 | 431 | pub fn load_yaml_relays(mut self, path: &Path) -> Self { 432 | let file = match std::fs::File::open(path) { 433 | Ok(file) => file, 434 | Err(e) => { 435 | error!("Error loading relay yaml file : {}",e); 436 | return self; 437 | } 438 | }; 439 | let relays: Vec = match serde_yaml::from_reader(file) { 440 | Ok(relays) => relays, 441 | Err(e) => { 442 | error!("Error parsing relay yaml file : {}",e); 443 | return self 444 | } 445 | }; 446 | 447 | self.relays = relays; 448 | self 449 | } 450 | } 451 | 452 | #[cfg(test)] 453 | mod tests { 454 | 455 | use super::*; 456 | // Test the `NostrConfig` constructor with empty arguments 457 | #[test] 458 | fn test_nostrconfig_new_empty_args() { 459 | let args = Profile { 460 | ..Default::default() 461 | }; 462 | 463 | let config = NostrConfig::new(args.private_key, args.relays); 464 | assert_eq!(config.relays.len(), 0); 465 | assert_eq!(config.name, None); 466 | assert_eq!(config.display_name, None); 467 | assert_eq!(config.about, None); 468 | assert_eq!(config.description, None); 469 | assert_eq!(config.picture, None); 470 | assert_eq!(config.banner, None); 471 | assert_eq!(config.nip05, None); 472 | assert_eq!(config.lud16, None); 473 | } 474 | 475 | // Test the `NostrConfig` constructor with private key argument 476 | #[test] 477 | fn test_nostrconfig_new_private_key_arg() { 478 | let args = Args { 479 | private_key: Some(String::from( 480 | "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 481 | )), 482 | relays: None, 483 | feeds: None, 484 | profiles: None, 485 | }; 486 | let config = NostrConfig::new(args.private_key, args.relays); 487 | assert_eq!( 488 | config.keys.public_key().to_bech32().unwrap(), 489 | "npub1ger2u5z8x945yvxsppkg4nkxslcqk8xe68wxxnmvkdv2cz563lls9fwehy" 490 | ); 491 | } 492 | 493 | // Test the `NostrConfig` constructor with relays file path argument 494 | #[test] 495 | fn test_nostrconfig_new_relays_arg() { 496 | let relays_file_path = "./src/fixtures/relays.json".to_string(); 497 | let feeds_file_path = "./src/fixtures/rss.json".to_string(); 498 | let args = Args { 499 | private_key: None, 500 | relays: Some(relays_file_path), 501 | feeds: Some(feeds_file_path), 502 | profiles: None, 503 | }; 504 | let config = NostrConfig::new(args.private_key, args.relays); 505 | assert_eq!(config.relays.len(), 4); 506 | assert_eq!(config.relays[0].name, String::from("noslol")); 507 | assert_eq!(config.relays[0].target, String::from("wss://nos.lol")); 508 | assert_eq!(config.relays[0].active, true); 509 | assert_eq!(config.relays[0].proxy, None); 510 | } 511 | 512 | #[test] 513 | fn test_nostrconfig_profile_setters() { 514 | use super::Relay; 515 | 516 | let relays_file_path = "./src/fixtures/relays.json".to_string(); 517 | let feeds_file_path = "./src/fixtures/rss.json".to_string(); 518 | let args = Args { 519 | private_key: None, 520 | relays: Some(relays_file_path), 521 | feeds: Some(feeds_file_path), 522 | profiles: None, 523 | }; 524 | let mut config = NostrConfig::new(args.private_key, args.relays); 525 | 526 | config = config.set_banner(Some("https://domain.com/image.jpg".to_string())); 527 | assert_eq!( 528 | config.banner, 529 | Some("https://domain.com/image.jpg".to_string()) 530 | ); 531 | 532 | config = config.set_picture(Some("https://domain.com/image.jpg".to_string())); 533 | assert_eq!( 534 | config.picture, 535 | Some("https://domain.com/image.jpg".to_string()) 536 | ); 537 | 538 | config = config.set_name(Some("John doe".to_string())); 539 | assert_eq!(config.name, Some("John doe".to_string())); 540 | 541 | config = config.set_description(Some("Ad lorem ipsum".to_string())); 542 | assert_eq!(config.description, Some("Ad lorem ipsum".to_string())); 543 | 544 | let relays: Vec = vec![Relay { 545 | name: "test".to_string(), 546 | target: "wss://localhost".to_string(), 547 | active: true, 548 | proxy: None, 549 | }]; 550 | config = config.set_relays(relays); 551 | assert_eq!(config.description, Some("Ad lorem ipsum".to_string())); 552 | } 553 | 554 | #[test] 555 | fn test_nostrconfig_into() { 556 | use super::Relay; 557 | 558 | let relays_file_path = "./src/fixtures/relays.json".to_string(); 559 | let feeds_file_path = "./src/fixtures/rss.json".to_string(); 560 | let args = Args { 561 | private_key: None, 562 | relays: Some(relays_file_path), 563 | feeds: Some(feeds_file_path), 564 | profiles: None, 565 | }; 566 | let config = NostrConfig::new(args.private_key, args.relays); 567 | 568 | let slice = config.relays.iter().next(); 569 | // let url: String = *slice.unwrap().into(); 570 | 571 | // assert_eq!(url,"wss://nos.lol".to_string()); 572 | } 573 | } 574 | --------------------------------------------------------------------------------