mirror of
https://github.com/nostrlabs-io/notepush.git
synced 2025-06-16 19:58:50 +00:00
Merge pull request #5 from damus-io/#3
Improve performance with nostr event caching and better mutex design
This commit is contained in:
4
Cargo.lock
generated
4
Cargo.lock
generated
@ -1562,9 +1562,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.10.5"
|
version = "1.10.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
|
checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
use crate::nip98_auth;
|
use crate::nip98_auth;
|
||||||
|
use crate::notification_manager::notification_manager::UserNotificationSettings;
|
||||||
use crate::relay_connection::RelayConnection;
|
use crate::relay_connection::RelayConnection;
|
||||||
use http_body_util::Full;
|
use http_body_util::Full;
|
||||||
use hyper::body::Buf;
|
use hyper::body::Buf;
|
||||||
@ -9,50 +10,21 @@ use hyper_tungstenite;
|
|||||||
|
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use nostr;
|
use nostr;
|
||||||
|
use serde_json::from_value;
|
||||||
|
|
||||||
use crate::notification_manager::NotificationManager;
|
use crate::notification_manager::NotificationManager;
|
||||||
use hyper::Method;
|
use hyper::Method;
|
||||||
use log;
|
use log;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
struct ParsedRequest {
|
|
||||||
uri: String,
|
|
||||||
method: Method,
|
|
||||||
body_bytes: Option<Vec<u8>>,
|
|
||||||
authorized_pubkey: nostr::PublicKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ParsedRequest {
|
|
||||||
fn body_json(&self) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
|
||||||
if let Some(body_bytes) = &self.body_bytes {
|
|
||||||
Ok(serde_json::from_slice(body_bytes)?)
|
|
||||||
} else {
|
|
||||||
Ok(json!({}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct APIResponse {
|
|
||||||
status: StatusCode,
|
|
||||||
body: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct APIHandler {
|
pub struct APIHandler {
|
||||||
notification_manager: Arc<NotificationManager>,
|
notification_manager: Arc<NotificationManager>,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Clone for APIHandler {
|
|
||||||
fn clone(&self) -> Self {
|
|
||||||
APIHandler {
|
|
||||||
notification_manager: self.notification_manager.clone(),
|
|
||||||
base_url: self.base_url.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl APIHandler {
|
impl APIHandler {
|
||||||
pub fn new(notification_manager: Arc<NotificationManager>, base_url: String) -> Self {
|
pub fn new(notification_manager: Arc<NotificationManager>, base_url: String) -> Self {
|
||||||
APIHandler {
|
APIHandler {
|
||||||
@ -61,6 +33,8 @@ impl APIHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - HTTP handling
|
||||||
|
|
||||||
pub async fn handle_http_request(
|
pub async fn handle_http_request(
|
||||||
&self,
|
&self,
|
||||||
req: Request<Incoming>,
|
req: Request<Incoming>,
|
||||||
@ -186,22 +160,37 @@ impl APIHandler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Router
|
||||||
|
|
||||||
async fn handle_parsed_http_request(
|
async fn handle_parsed_http_request(
|
||||||
&self,
|
&self,
|
||||||
parsed_request: &ParsedRequest,
|
parsed_request: &ParsedRequest,
|
||||||
) -> Result<APIResponse, Box<dyn std::error::Error>> {
|
) -> Result<APIResponse, Box<dyn std::error::Error>> {
|
||||||
match (&parsed_request.method, parsed_request.uri.as_str()) {
|
|
||||||
(&Method::POST, "/user-info") => self.handle_user_info(parsed_request).await,
|
if let Some(url_params) = route_match(&Method::PUT, "/user-info/:pubkey/:deviceToken", &parsed_request) {
|
||||||
(&Method::POST, "/user-info/remove") => {
|
return self.handle_user_info(parsed_request, &url_params).await;
|
||||||
self.handle_user_info_remove(parsed_request).await
|
|
||||||
}
|
}
|
||||||
_ => Ok(APIResponse {
|
|
||||||
|
if let Some(url_params) = route_match(&Method::DELETE, "/user-info/:pubkey/:deviceToken", &parsed_request) {
|
||||||
|
return self.handle_user_info_remove(parsed_request, &url_params).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url_params) = route_match(&Method::GET, "/user-info/:pubkey/:deviceToken/preferences", &parsed_request) {
|
||||||
|
return self.get_user_settings(parsed_request, &url_params).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url_params) = route_match(&Method::PUT, "/user-info/:pubkey/:deviceToken/preferences", &parsed_request) {
|
||||||
|
return self.set_user_settings(parsed_request, &url_params).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(APIResponse {
|
||||||
status: StatusCode::NOT_FOUND,
|
status: StatusCode::NOT_FOUND,
|
||||||
body: json!({ "error": "Not found" }),
|
body: json!({ "error": "Not found" }),
|
||||||
}),
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Authentication
|
||||||
|
|
||||||
async fn authenticate(
|
async fn authenticate(
|
||||||
&self,
|
&self,
|
||||||
req: &Request<Incoming>,
|
req: &Request<Incoming>,
|
||||||
@ -221,50 +210,282 @@ impl APIHandler {
|
|||||||
.await)
|
.await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Endpoint handlers
|
||||||
|
|
||||||
async fn handle_user_info(
|
async fn handle_user_info(
|
||||||
&self,
|
&self,
|
||||||
req: &ParsedRequest,
|
req: &ParsedRequest,
|
||||||
|
url_params: &HashMap<&str, String>,
|
||||||
) -> Result<APIResponse, Box<dyn std::error::Error>> {
|
) -> Result<APIResponse, Box<dyn std::error::Error>> {
|
||||||
let body = req.body_json()?;
|
// Early return if `deviceToken` is missing
|
||||||
|
let device_token = match url_params.get("deviceToken") {
|
||||||
if let Some(device_token) = body["deviceToken"].as_str() {
|
Some(token) => token,
|
||||||
self.notification_manager.save_user_device_info(req.authorized_pubkey, device_token).await?;
|
None => return Ok(APIResponse {
|
||||||
return Ok(APIResponse {
|
|
||||||
status: StatusCode::OK,
|
|
||||||
body: json!({ "message": "User info saved successfully" }),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return Ok(APIResponse {
|
|
||||||
status: StatusCode::BAD_REQUEST,
|
status: StatusCode::BAD_REQUEST,
|
||||||
body: json!({ "error": "deviceToken is required" }),
|
body: json!({ "error": "deviceToken is required on the URL" }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Early return if `pubkey` is missing
|
||||||
|
let pubkey = match url_params.get("pubkey") {
|
||||||
|
Some(key) => key,
|
||||||
|
None => return Ok(APIResponse {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
body: json!({ "error": "pubkey is required on the URL" }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate the `pubkey` and prepare it for use
|
||||||
|
let pubkey = match nostr::PublicKey::from_hex(pubkey) {
|
||||||
|
Ok(key) => key,
|
||||||
|
Err(_) => return Ok(APIResponse {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
body: json!({ "error": "Invalid pubkey" }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Early return if `pubkey` does not match `req.authorized_pubkey`
|
||||||
|
if pubkey != req.authorized_pubkey {
|
||||||
|
return Ok(APIResponse {
|
||||||
|
status: StatusCode::FORBIDDEN,
|
||||||
|
body: json!({ "error": "Forbidden" }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Proceed with the main logic after passing all checks
|
||||||
|
self.notification_manager.save_user_device_info(pubkey, device_token).await?;
|
||||||
|
Ok(APIResponse {
|
||||||
|
status: StatusCode::OK,
|
||||||
|
body: json!({ "message": "User info saved successfully" }),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_user_info_remove(
|
async fn handle_user_info_remove(
|
||||||
&self,
|
&self,
|
||||||
req: &ParsedRequest,
|
req: &ParsedRequest,
|
||||||
|
url_params: &HashMap<&str, String>,
|
||||||
) -> Result<APIResponse, Box<dyn std::error::Error>> {
|
) -> Result<APIResponse, Box<dyn std::error::Error>> {
|
||||||
let body: Value = req.body_json()?;
|
// Early return if `deviceToken` is missing
|
||||||
|
let device_token = match url_params.get("deviceToken") {
|
||||||
|
Some(token) => token,
|
||||||
|
None => return Ok(APIResponse {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
body: json!({ "error": "deviceToken is required on the URL" }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(device_token) = body["deviceToken"].as_str() {
|
// Early return if `pubkey` is missing
|
||||||
self.notification_manager.remove_user_device_info(req.authorized_pubkey, device_token).await?;
|
let pubkey = match url_params.get("pubkey") {
|
||||||
|
Some(key) => key,
|
||||||
|
None => return Ok(APIResponse {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
body: json!({ "error": "pubkey is required on the URL" }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate the `pubkey` and prepare it for use
|
||||||
|
let pubkey = match nostr::PublicKey::from_hex(pubkey) {
|
||||||
|
Ok(key) => key,
|
||||||
|
Err(_) => return Ok(APIResponse {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
body: json!({ "error": "Invalid pubkey" }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Early return if `pubkey` does not match `req.authorized_pubkey`
|
||||||
|
if pubkey != req.authorized_pubkey {
|
||||||
return Ok(APIResponse {
|
return Ok(APIResponse {
|
||||||
|
status: StatusCode::FORBIDDEN,
|
||||||
|
body: json!({ "error": "Forbidden" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed with the main logic after passing all checks
|
||||||
|
self.notification_manager.remove_user_device_info(pubkey, device_token).await?;
|
||||||
|
|
||||||
|
Ok(APIResponse {
|
||||||
status: StatusCode::OK,
|
status: StatusCode::OK,
|
||||||
body: json!({ "message": "User info removed successfully" }),
|
body: json!({ "message": "User info removed successfully" }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_user_settings(
|
||||||
|
&self,
|
||||||
|
req: &ParsedRequest,
|
||||||
|
url_params: &HashMap<&str, String>,
|
||||||
|
) -> Result<APIResponse, Box<dyn std::error::Error>> {
|
||||||
|
// Early return if `deviceToken` is missing
|
||||||
|
let device_token = match url_params.get("deviceToken") {
|
||||||
|
Some(token) => token,
|
||||||
|
None => return Ok(APIResponse {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
body: json!({ "error": "deviceToken is required on the URL" }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Early return if `pubkey` is missing
|
||||||
|
let pubkey = match url_params.get("pubkey") {
|
||||||
|
Some(key) => key,
|
||||||
|
None => return Ok(APIResponse {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
body: json!({ "error": "pubkey is required on the URL" }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate the `pubkey` and prepare it for use
|
||||||
|
let pubkey = match nostr::PublicKey::from_hex(pubkey) {
|
||||||
|
Ok(key) => key,
|
||||||
|
Err(_) => return Ok(APIResponse {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
body: json!({ "error": "Invalid pubkey" }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Early return if `pubkey` does not match `req.authorized_pubkey`
|
||||||
|
if pubkey != req.authorized_pubkey {
|
||||||
|
return Ok(APIResponse {
|
||||||
|
status: StatusCode::FORBIDDEN,
|
||||||
|
body: json!({ "error": "Forbidden" }),
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
// Proceed with the main logic after passing all checks
|
||||||
|
let body = req.body_json()?;
|
||||||
|
|
||||||
|
let settings: UserNotificationSettings = match from_value(body.clone()) {
|
||||||
|
Ok(settings) => settings,
|
||||||
|
Err(_) => {
|
||||||
return Ok(APIResponse {
|
return Ok(APIResponse {
|
||||||
status: StatusCode::BAD_REQUEST,
|
status: StatusCode::BAD_REQUEST,
|
||||||
body: json!({ "error": "deviceToken is required" }),
|
body: json!({ "error": "Invalid settings" }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.notification_manager.save_user_notification_settings(&req.authorized_pubkey, device_token.to_string(), settings).await?;
|
||||||
|
return Ok(APIResponse {
|
||||||
|
status: StatusCode::OK,
|
||||||
|
body: json!({ "message": "User settings saved successfully" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user_settings(
|
||||||
|
&self,
|
||||||
|
req: &ParsedRequest,
|
||||||
|
url_params: &HashMap<&str, String>,
|
||||||
|
) -> Result<APIResponse, Box<dyn std::error::Error>> {
|
||||||
|
// Early return if `deviceToken` is missing
|
||||||
|
let device_token = match url_params.get("deviceToken") {
|
||||||
|
Some(token) => token,
|
||||||
|
None => return Ok(APIResponse {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
body: json!({ "error": "deviceToken is required on the URL" }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Early return if `pubkey` is missing
|
||||||
|
let pubkey = match url_params.get("pubkey") {
|
||||||
|
Some(key) => key,
|
||||||
|
None => return Ok(APIResponse {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
body: json!({ "error": "pubkey is required on the URL" }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate the `pubkey` and prepare it for use
|
||||||
|
let pubkey = match nostr::PublicKey::from_hex(pubkey) {
|
||||||
|
Ok(key) => key,
|
||||||
|
Err(_) => return Ok(APIResponse {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
body: json!({ "error": "Invalid pubkey" }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Early return if `pubkey` does not match `req.authorized_pubkey`
|
||||||
|
if pubkey != req.authorized_pubkey {
|
||||||
|
return Ok(APIResponse {
|
||||||
|
status: StatusCode::FORBIDDEN,
|
||||||
|
body: json!({ "error": "Forbidden" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed with the main logic after passing all checks
|
||||||
|
let settings = self.notification_manager.get_user_notification_settings(&req.authorized_pubkey, device_token.to_string()).await?;
|
||||||
|
|
||||||
|
Ok(APIResponse {
|
||||||
|
status: StatusCode::OK,
|
||||||
|
body: json!(settings),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
impl Clone for APIHandler {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
APIHandler {
|
||||||
|
notification_manager: self.notification_manager.clone(),
|
||||||
|
base_url: self.base_url.clone(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper types
|
||||||
|
|
||||||
// Define enum error types including authentication error
|
// Define enum error types including authentication error
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
enum APIError {
|
enum APIError {
|
||||||
#[error("Authentication error: {0}")]
|
#[error("Authentication error: {0}")]
|
||||||
AuthenticationError(String),
|
AuthenticationError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ParsedRequest {
|
||||||
|
uri: String,
|
||||||
|
method: Method,
|
||||||
|
body_bytes: Option<Vec<u8>>,
|
||||||
|
authorized_pubkey: nostr::PublicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParsedRequest {
|
||||||
|
fn body_json(&self) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||||
|
if let Some(body_bytes) = &self.body_bytes {
|
||||||
|
Ok(serde_json::from_slice(body_bytes)?)
|
||||||
|
} else {
|
||||||
|
Ok(json!({}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct APIResponse {
|
||||||
|
status: StatusCode,
|
||||||
|
body: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper functions
|
||||||
|
|
||||||
|
/// Matches the request to a specified route, returning a hashmap of the route parameters
|
||||||
|
/// e.g. GET /user/:id/info route against request GET /user/123/info matches to { "id": "123" }
|
||||||
|
fn route_match<'a>(method: &Method, path: &'a str, req: &ParsedRequest) -> Option<HashMap<&'a str, String>> {
|
||||||
|
if method != req.method {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut params = HashMap::new();
|
||||||
|
let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
let req_segments: Vec<&str> = req.uri.split('/').filter(|s| !s.is_empty()).collect();
|
||||||
|
|
||||||
|
if path_segments.len() != req_segments.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, segment) in path_segments.iter().enumerate() {
|
||||||
|
if segment.starts_with(':') {
|
||||||
|
let key = &segment[1..];
|
||||||
|
let value = req_segments[i].to_string();
|
||||||
|
params.insert(key, value);
|
||||||
|
} else if segment != &req_segments[i] {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(params)
|
||||||
|
}
|
||||||
|
@ -1 +1,2 @@
|
|||||||
pub mod notification_manager;
|
pub mod notification_manager;
|
||||||
|
mod utils;
|
||||||
|
@ -12,6 +12,7 @@ mod notepush_env;
|
|||||||
use notepush_env::NotePushEnv;
|
use notepush_env::NotePushEnv;
|
||||||
mod api_request_handler;
|
mod api_request_handler;
|
||||||
mod nip98_auth;
|
mod nip98_auth;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
@ -3,8 +3,8 @@ use nostr;
|
|||||||
use nostr::bitcoin::hashes::sha256::Hash as Sha256Hash;
|
use nostr::bitcoin::hashes::sha256::Hash as Sha256Hash;
|
||||||
use nostr::bitcoin::hashes::Hash;
|
use nostr::bitcoin::hashes::Hash;
|
||||||
use nostr::util::hex;
|
use nostr::util::hex;
|
||||||
use nostr::Timestamp;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use super::utils::time_delta::TimeDelta;
|
||||||
|
|
||||||
pub async fn nip98_verify_auth_header(
|
pub async fn nip98_verify_auth_header(
|
||||||
auth_header: String,
|
auth_header: String,
|
||||||
@ -106,36 +106,3 @@ pub async fn nip98_verify_auth_header(
|
|||||||
|
|
||||||
Ok(note.pubkey)
|
Ok(note.pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TimeDelta {
|
|
||||||
delta_abs_seconds: u64,
|
|
||||||
negative: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TimeDelta {
|
|
||||||
/// Safely calculate the difference between two timestamps in seconds
|
|
||||||
/// This function is safer against overflows than subtracting the timestamps directly
|
|
||||||
fn subtracting(t1: Timestamp, t2: Timestamp) -> TimeDelta {
|
|
||||||
if t1 > t2 {
|
|
||||||
TimeDelta {
|
|
||||||
delta_abs_seconds: (t1 - t2).as_u64(),
|
|
||||||
negative: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
TimeDelta {
|
|
||||||
delta_abs_seconds: (t2 - t1).as_u64(),
|
|
||||||
negative: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for TimeDelta {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
||||||
if self.negative {
|
|
||||||
write!(f, "-{}", self.delta_abs_seconds)
|
|
||||||
} else {
|
|
||||||
write!(f, "{}", self.delta_abs_seconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
pub mod mute_manager;
|
pub mod nostr_network_helper;
|
||||||
mod nostr_event_extensions;
|
mod nostr_event_extensions;
|
||||||
|
mod nostr_event_cache;
|
||||||
pub mod notification_manager;
|
pub mod notification_manager;
|
||||||
|
|
||||||
pub use mute_manager::MuteManager;
|
pub use nostr_network_helper::NostrNetworkHelper;
|
||||||
use nostr_event_extensions::{ExtendedEvent, SqlStringConvertible};
|
use nostr_event_extensions::{ExtendedEvent, SqlStringConvertible};
|
||||||
pub use notification_manager::NotificationManager;
|
pub use notification_manager::NotificationManager;
|
||||||
|
@ -1,128 +0,0 @@
|
|||||||
use super::ExtendedEvent;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use tokio::time::{timeout, Duration};
|
|
||||||
|
|
||||||
pub struct MuteManager {
|
|
||||||
client: Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MuteManager {
|
|
||||||
pub async fn new(relay_url: String) -> Result<Self, Box<dyn std::error::Error>> {
|
|
||||||
let client = Client::new(&Keys::generate());
|
|
||||||
client.add_relay(relay_url.clone()).await?;
|
|
||||||
client.connect().await;
|
|
||||||
Ok(MuteManager { client })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn should_mute_notification_for_pubkey(
|
|
||||||
&self,
|
|
||||||
event: &Event,
|
|
||||||
pubkey: &PublicKey,
|
|
||||||
) -> bool {
|
|
||||||
log::debug!(
|
|
||||||
"Checking if event {:?} should be muted for pubkey {:?}",
|
|
||||||
event,
|
|
||||||
pubkey
|
|
||||||
);
|
|
||||||
if let Some(mute_list) = self.get_public_mute_list(pubkey).await {
|
|
||||||
for tag in mute_list.tags() {
|
|
||||||
match tag.kind() {
|
|
||||||
TagKind::SingleLetter(SingleLetterTag {
|
|
||||||
character: Alphabet::P,
|
|
||||||
uppercase: false,
|
|
||||||
}) => {
|
|
||||||
let tagged_pubkey: Option<PublicKey> =
|
|
||||||
tag.content().and_then(|h| PublicKey::from_hex(h).ok());
|
|
||||||
if let Some(tagged_pubkey) = tagged_pubkey {
|
|
||||||
if event.pubkey == tagged_pubkey {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TagKind::SingleLetter(SingleLetterTag {
|
|
||||||
character: Alphabet::E,
|
|
||||||
uppercase: false,
|
|
||||||
}) => {
|
|
||||||
let tagged_event_id: Option<EventId> =
|
|
||||||
tag.content().and_then(|h| EventId::from_hex(h).ok());
|
|
||||||
if let Some(tagged_event_id) = tagged_event_id {
|
|
||||||
if event.id == tagged_event_id
|
|
||||||
|| event.referenced_event_ids().contains(&tagged_event_id)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TagKind::SingleLetter(SingleLetterTag {
|
|
||||||
character: Alphabet::T,
|
|
||||||
uppercase: false,
|
|
||||||
}) => {
|
|
||||||
let tagged_hashtag: Option<String> = tag.content().map(|h| h.to_string());
|
|
||||||
if let Some(tagged_hashtag) = tagged_hashtag {
|
|
||||||
let tags_content =
|
|
||||||
event.get_tags_content(TagKind::SingleLetter(SingleLetterTag {
|
|
||||||
character: Alphabet::T,
|
|
||||||
uppercase: false,
|
|
||||||
}));
|
|
||||||
let should_mute = tags_content.iter().any(|t| t == &tagged_hashtag);
|
|
||||||
return should_mute;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TagKind::Word => {
|
|
||||||
let tagged_word: Option<String> = tag.content().map(|h| h.to_string());
|
|
||||||
if let Some(tagged_word) = tagged_word {
|
|
||||||
if event
|
|
||||||
.content
|
|
||||||
.to_lowercase()
|
|
||||||
.contains(&tagged_word.to_lowercase())
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_public_mute_list(&self, pubkey: &PublicKey) -> Option<Event> {
|
|
||||||
let subscription_filter = Filter::new()
|
|
||||||
.kinds(vec![Kind::MuteList])
|
|
||||||
.authors(vec![pubkey.clone()])
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let this_subscription_id = self
|
|
||||||
.client
|
|
||||||
.subscribe(Vec::from([subscription_filter]), None)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let mut mute_list: Option<Event> = None;
|
|
||||||
let mut notifications = self.client.notifications();
|
|
||||||
|
|
||||||
let timeout_duration = Duration::from_secs(10);
|
|
||||||
while let Ok(result) = timeout(timeout_duration, notifications.recv()).await {
|
|
||||||
if let Ok(notification) = result {
|
|
||||||
if let RelayPoolNotification::Event {
|
|
||||||
subscription_id,
|
|
||||||
event,
|
|
||||||
..
|
|
||||||
} = notification
|
|
||||||
{
|
|
||||||
if this_subscription_id == subscription_id && event.kind == Kind::MuteList {
|
|
||||||
mute_list = Some((*event).clone());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if mute_list.is_none() {
|
|
||||||
log::debug!("Mute list not found for pubkey {:?}", pubkey);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.client.unsubscribe(this_subscription_id).await;
|
|
||||||
mute_list
|
|
||||||
}
|
|
||||||
}
|
|
144
src/notification_manager/nostr_event_cache.rs
Normal file
144
src/notification_manager/nostr_event_cache.rs
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
use crate::utils::time_delta::TimeDelta;
|
||||||
|
use tokio::time::Duration;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use log;
|
||||||
|
|
||||||
|
use super::nostr_event_extensions::MaybeConvertibleToMuteList;
|
||||||
|
|
||||||
|
struct CacheEntry {
|
||||||
|
event: Option<Event>, // `None` means the event does not exist as far as we know (It does NOT mean expired)
|
||||||
|
added_at: nostr::Timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheEntry {
|
||||||
|
fn is_expired(&self, max_age: Duration) -> bool {
|
||||||
|
let time_delta = TimeDelta::subtracting(nostr::Timestamp::now(), self.added_at);
|
||||||
|
time_delta.negative || (time_delta.delta_abs_seconds > max_age.as_secs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Cache {
|
||||||
|
entries: HashMap<EventId, Arc<CacheEntry>>,
|
||||||
|
mute_lists: HashMap<PublicKey, Arc<CacheEntry>>,
|
||||||
|
contact_lists: HashMap<PublicKey, Arc<CacheEntry>>,
|
||||||
|
max_age: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cache {
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
pub fn new(max_age: Duration) -> Self {
|
||||||
|
Cache {
|
||||||
|
entries: HashMap::new(),
|
||||||
|
mute_lists: HashMap::new(),
|
||||||
|
contact_lists: HashMap::new(),
|
||||||
|
max_age,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Adding items to the cache
|
||||||
|
|
||||||
|
pub fn add_optional_mute_list_with_author(&mut self, author: &PublicKey, mute_list: Option<Event>) {
|
||||||
|
if let Some(mute_list) = mute_list {
|
||||||
|
self.add_event(mute_list);
|
||||||
|
} else {
|
||||||
|
self.mute_lists.insert(
|
||||||
|
author.clone(),
|
||||||
|
Arc::new(CacheEntry {
|
||||||
|
event: None,
|
||||||
|
added_at: nostr::Timestamp::now(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_optional_contact_list_with_author(&mut self, author: &PublicKey, contact_list: Option<Event>) {
|
||||||
|
if let Some(contact_list) = contact_list {
|
||||||
|
self.add_event(contact_list);
|
||||||
|
} else {
|
||||||
|
self.contact_lists.insert(
|
||||||
|
author.clone(),
|
||||||
|
Arc::new(CacheEntry {
|
||||||
|
event: None,
|
||||||
|
added_at: nostr::Timestamp::now(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_event(&mut self, event: Event) {
|
||||||
|
let entry = Arc::new(CacheEntry {
|
||||||
|
event: Some(event.clone()),
|
||||||
|
added_at: nostr::Timestamp::now(),
|
||||||
|
});
|
||||||
|
self.entries.insert(event.id.clone(), entry.clone());
|
||||||
|
|
||||||
|
match event.kind {
|
||||||
|
Kind::MuteList => {
|
||||||
|
self.mute_lists.insert(event.pubkey.clone(), entry.clone());
|
||||||
|
log::debug!("Added mute list to the cache. Event ID: {}", event.id.to_hex());
|
||||||
|
}
|
||||||
|
Kind::ContactList => {
|
||||||
|
self.contact_lists
|
||||||
|
.insert(event.pubkey.clone(), entry.clone());
|
||||||
|
log::debug!("Added contact list to the cache. Event ID: {}", event.id.to_hex());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
log::debug!("Added event to the cache. Event ID: {}", event.id.to_hex());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fetching items from the cache
|
||||||
|
|
||||||
|
pub fn get_mute_list(&mut self, pubkey: &PublicKey) -> Result<Option<MuteList>, CacheError> {
|
||||||
|
if let Some(entry) = self.mute_lists.get(pubkey) {
|
||||||
|
let entry = entry.clone(); // Clone the Arc to avoid borrowing issues
|
||||||
|
if !entry.is_expired(self.max_age) {
|
||||||
|
if let Some(event) = entry.event.clone() {
|
||||||
|
return Ok(event.to_mute_list());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::debug!("Mute list for pubkey {} is expired, removing it from the cache", pubkey.to_hex());
|
||||||
|
self.mute_lists.remove(pubkey);
|
||||||
|
self.remove_event_from_all_maps(&entry.event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(CacheError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_contact_list(&mut self, pubkey: &PublicKey) -> Result<Option<Event>, CacheError> {
|
||||||
|
if let Some(entry) = self.contact_lists.get(pubkey) {
|
||||||
|
let entry = entry.clone(); // Clone the Arc to avoid borrowing issues
|
||||||
|
if !entry.is_expired(self.max_age) {
|
||||||
|
return Ok(entry.event.clone());
|
||||||
|
} else {
|
||||||
|
log::debug!("Contact list for pubkey {} is expired, removing it from the cache", pubkey.to_hex());
|
||||||
|
self.contact_lists.remove(pubkey);
|
||||||
|
self.remove_event_from_all_maps(&entry.event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(CacheError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Removing items from the cache
|
||||||
|
|
||||||
|
fn remove_event_from_all_maps(&mut self, event: &Option<Event>) {
|
||||||
|
if let Some(event) = event {
|
||||||
|
let event_id = event.id.clone();
|
||||||
|
let pubkey = event.pubkey.clone();
|
||||||
|
self.entries.remove(&event_id);
|
||||||
|
self.mute_lists.remove(&pubkey);
|
||||||
|
self.contact_lists.remove(&pubkey);
|
||||||
|
}
|
||||||
|
// We can't remove an event from all maps if the event does not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error type
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CacheError {
|
||||||
|
NotFound,
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
use nostr::{self, key::PublicKey, Alphabet, SingleLetterTag, TagKind::SingleLetter};
|
use nostr::{self, key::PublicKey, nips::nip51::MuteList, Alphabet, SingleLetterTag, TagKind::SingleLetter};
|
||||||
|
use nostr_sdk::{Kind, TagKind};
|
||||||
|
|
||||||
/// Temporary scaffolding of old methods that have not been ported to use native Event methods
|
/// Temporary scaffolding of old methods that have not been ported to use native Event methods
|
||||||
pub trait ExtendedEvent {
|
pub trait ExtendedEvent {
|
||||||
@ -13,6 +14,9 @@ pub trait ExtendedEvent {
|
|||||||
|
|
||||||
/// Retrieves a set of event IDs referenced by the note
|
/// Retrieves a set of event IDs referenced by the note
|
||||||
fn referenced_event_ids(&self) -> std::collections::HashSet<nostr::EventId>;
|
fn referenced_event_ids(&self) -> std::collections::HashSet<nostr::EventId>;
|
||||||
|
|
||||||
|
/// Retrieves a set of hashtags (t tags) referenced by the note
|
||||||
|
fn referenced_hashtags(&self) -> std::collections::HashSet<String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is a wrapper around the Event type from strfry-policies, which adds some useful methods
|
// This is a wrapper around the Event type from strfry-policies, which adds some useful methods
|
||||||
@ -44,6 +48,14 @@ impl ExtendedEvent for nostr::Event {
|
|||||||
.filter_map(|tag| nostr::EventId::from_hex(tag).ok())
|
.filter_map(|tag| nostr::EventId::from_hex(tag).ok())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retrieves a set of hashtags (t tags) referenced by the note
|
||||||
|
fn referenced_hashtags(&self) -> std::collections::HashSet<String> {
|
||||||
|
self.get_tags_content(SingleLetter(SingleLetterTag::lowercase(Alphabet::T)))
|
||||||
|
.iter()
|
||||||
|
.map(|tag| tag.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SQL String Convertible
|
// MARK: - SQL String Convertible
|
||||||
@ -85,3 +97,21 @@ impl SqlStringConvertible for nostr::Timestamp {
|
|||||||
Ok(nostr::Timestamp::from(u64_timestamp))
|
Ok(nostr::Timestamp::from(u64_timestamp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait MaybeConvertibleToMuteList {
|
||||||
|
fn to_mute_list(&self) -> Option<MuteList>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MaybeConvertibleToMuteList for nostr::Event {
|
||||||
|
fn to_mute_list(&self) -> Option<MuteList> {
|
||||||
|
if self.kind != Kind::MuteList {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(MuteList {
|
||||||
|
public_keys: self.referenced_pubkeys().iter().map(|pk| pk.clone()).collect(),
|
||||||
|
hashtags: self.referenced_hashtags().iter().map(|tag| tag.clone()).collect(),
|
||||||
|
event_ids: self.referenced_event_ids().iter().map(|id| id.clone()).collect(),
|
||||||
|
words: self.get_tags_content(TagKind::Word).iter().map(|tag| tag.to_string()).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
161
src/notification_manager/nostr_network_helper.rs
Normal file
161
src/notification_manager/nostr_network_helper.rs
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
use tokio::sync::Mutex;
|
||||||
|
use super::nostr_event_extensions::MaybeConvertibleToMuteList;
|
||||||
|
use super::ExtendedEvent;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use super::nostr_event_cache::Cache;
|
||||||
|
use tokio::time::{timeout, Duration};
|
||||||
|
|
||||||
|
const NOTE_FETCH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const CACHE_MAX_AGE: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
pub struct NostrNetworkHelper {
|
||||||
|
client: Client,
|
||||||
|
cache: Mutex<Cache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NostrNetworkHelper {
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
pub async fn new(relay_url: String) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let client = Client::new(&Keys::generate());
|
||||||
|
client.add_relay(relay_url.clone()).await?;
|
||||||
|
client.connect().await;
|
||||||
|
|
||||||
|
Ok(NostrNetworkHelper { client, cache: Mutex::new(Cache::new(CACHE_MAX_AGE)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Answering questions about a user
|
||||||
|
|
||||||
|
pub async fn should_mute_notification_for_pubkey(
|
||||||
|
&self,
|
||||||
|
event: &Event,
|
||||||
|
pubkey: &PublicKey,
|
||||||
|
) -> bool {
|
||||||
|
log::debug!(
|
||||||
|
"Checking if event {:?} should be muted for pubkey {:?}",
|
||||||
|
event,
|
||||||
|
pubkey
|
||||||
|
);
|
||||||
|
if let Some(mute_list) = self.get_public_mute_list(pubkey).await {
|
||||||
|
for muted_public_key in mute_list.public_keys {
|
||||||
|
if event.pubkey == muted_public_key {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for muted_event_id in mute_list.event_ids {
|
||||||
|
if event.id == muted_event_id
|
||||||
|
|| event.referenced_event_ids().contains(&muted_event_id)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for muted_hashtag in mute_list.hashtags {
|
||||||
|
if event
|
||||||
|
.referenced_hashtags()
|
||||||
|
.iter()
|
||||||
|
.any(|t| t == &muted_hashtag)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for muted_word in mute_list.words {
|
||||||
|
if event
|
||||||
|
.content
|
||||||
|
.to_lowercase()
|
||||||
|
.contains(&muted_word.to_lowercase())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn does_pubkey_follow_pubkey(
|
||||||
|
&self,
|
||||||
|
source_pubkey: &PublicKey,
|
||||||
|
target_pubkey: &PublicKey,
|
||||||
|
) -> bool {
|
||||||
|
log::debug!(
|
||||||
|
"Checking if pubkey {:?} follows pubkey {:?}",
|
||||||
|
source_pubkey,
|
||||||
|
target_pubkey
|
||||||
|
);
|
||||||
|
if let Some(contact_list) = self.get_contact_list(source_pubkey).await {
|
||||||
|
return contact_list.referenced_pubkeys().contains(target_pubkey);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Getting specific event types with caching
|
||||||
|
|
||||||
|
pub async fn get_public_mute_list(&self, pubkey: &PublicKey) -> Option<MuteList> {
|
||||||
|
{
|
||||||
|
let mut cache_mutex_guard = self.cache.lock().await;
|
||||||
|
if let Ok(optional_mute_list) = cache_mutex_guard.get_mute_list(pubkey) {
|
||||||
|
return optional_mute_list;
|
||||||
|
}
|
||||||
|
} // Release the lock here for improved performance
|
||||||
|
|
||||||
|
// We don't have an answer from the cache, so we need to fetch it
|
||||||
|
let mute_list_event = self.fetch_single_event(pubkey, Kind::MuteList).await;
|
||||||
|
let mut cache_mutex_guard = self.cache.lock().await;
|
||||||
|
cache_mutex_guard.add_optional_mute_list_with_author(pubkey, mute_list_event.clone());
|
||||||
|
mute_list_event?.to_mute_list()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_contact_list(&self, pubkey: &PublicKey) -> Option<Event> {
|
||||||
|
{
|
||||||
|
let mut cache_mutex_guard = self.cache.lock().await;
|
||||||
|
if let Ok(optional_contact_list) = cache_mutex_guard.get_contact_list(pubkey) {
|
||||||
|
return optional_contact_list;
|
||||||
|
}
|
||||||
|
} // Release the lock here for improved performance
|
||||||
|
|
||||||
|
// We don't have an answer from the cache, so we need to fetch it
|
||||||
|
let contact_list_event = self.fetch_single_event(pubkey, Kind::ContactList).await;
|
||||||
|
let mut cache_mutex_guard = self.cache.lock().await;
|
||||||
|
cache_mutex_guard.add_optional_contact_list_with_author(pubkey, contact_list_event.clone());
|
||||||
|
contact_list_event
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lower level fetching functions
|
||||||
|
|
||||||
|
async fn fetch_single_event(&self, author: &PublicKey, kind: Kind) -> Option<Event> {
|
||||||
|
let subscription_filter = Filter::new()
|
||||||
|
.kinds(vec![kind])
|
||||||
|
.authors(vec![author.clone()])
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let mut notifications = self.client.notifications();
|
||||||
|
let this_subscription_id = self
|
||||||
|
.client
|
||||||
|
.subscribe(Vec::from([subscription_filter]), None)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut event: Option<Event> = None;
|
||||||
|
|
||||||
|
while let Ok(result) = timeout(NOTE_FETCH_TIMEOUT, notifications.recv()).await {
|
||||||
|
if let Ok(notification) = result {
|
||||||
|
if let RelayPoolNotification::Event {
|
||||||
|
subscription_id,
|
||||||
|
event: event_option,
|
||||||
|
..
|
||||||
|
} = notification
|
||||||
|
{
|
||||||
|
if this_subscription_id == subscription_id && event_option.kind == kind {
|
||||||
|
event = Some((*event_option).clone());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.is_none() {
|
||||||
|
log::info!("Event of kind {:?} not found for pubkey {:?}", kind, author);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.client.unsubscribe(this_subscription_id).await;
|
||||||
|
event
|
||||||
|
}
|
||||||
|
}
|
@ -4,13 +4,16 @@ use nostr::event::EventId;
|
|||||||
use nostr::key::PublicKey;
|
use nostr::key::PublicKey;
|
||||||
use nostr::types::Timestamp;
|
use nostr::types::Timestamp;
|
||||||
use nostr_sdk::JsonUtil;
|
use nostr_sdk::JsonUtil;
|
||||||
|
use nostr_sdk::Kind;
|
||||||
use rusqlite;
|
use rusqlite;
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use tokio;
|
use tokio;
|
||||||
|
|
||||||
use super::mute_manager::MuteManager;
|
use super::nostr_network_helper::NostrNetworkHelper;
|
||||||
use super::ExtendedEvent;
|
use super::ExtendedEvent;
|
||||||
use super::SqlStringConvertible;
|
use super::SqlStringConvertible;
|
||||||
use nostr::Event;
|
use nostr::Event;
|
||||||
@ -24,8 +27,7 @@ pub struct NotificationManager {
|
|||||||
db: Mutex<r2d2::Pool<SqliteConnectionManager>>,
|
db: Mutex<r2d2::Pool<SqliteConnectionManager>>,
|
||||||
apns_topic: String,
|
apns_topic: String,
|
||||||
apns_client: Mutex<Client>,
|
apns_client: Mutex<Client>,
|
||||||
|
nostr_network_helper: NostrNetworkHelper,
|
||||||
mute_manager: Mutex<MuteManager>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NotificationManager {
|
impl NotificationManager {
|
||||||
@ -40,8 +42,6 @@ impl NotificationManager {
|
|||||||
apns_environment: a2::client::Endpoint,
|
apns_environment: a2::client::Endpoint,
|
||||||
apns_topic: String,
|
apns_topic: String,
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let mute_manager = MuteManager::new(relay_url.clone()).await?;
|
|
||||||
|
|
||||||
let connection = db.get()?;
|
let connection = db.get()?;
|
||||||
Self::setup_database(&connection)?;
|
Self::setup_database(&connection)?;
|
||||||
|
|
||||||
@ -58,13 +58,15 @@ impl NotificationManager {
|
|||||||
apns_topic,
|
apns_topic,
|
||||||
apns_client: Mutex::new(client),
|
apns_client: Mutex::new(client),
|
||||||
db: Mutex::new(db),
|
db: Mutex::new(db),
|
||||||
mute_manager: Mutex::new(mute_manager),
|
nostr_network_helper: NostrNetworkHelper::new(relay_url.clone()).await?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Database setup operations
|
// MARK: - Database setup operations
|
||||||
|
|
||||||
pub fn setup_database(db: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
|
pub fn setup_database(db: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
|
||||||
|
// Initial schema setup
|
||||||
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS notifications (
|
"CREATE TABLE IF NOT EXISTS notifications (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@ -94,8 +96,17 @@ impl NotificationManager {
|
|||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Self::add_column_if_not_exists(&db, "notifications", "sent_at", "INTEGER")?;
|
Self::add_column_if_not_exists(&db, "notifications", "sent_at", "INTEGER", None)?;
|
||||||
Self::add_column_if_not_exists(&db, "user_info", "added_at", "INTEGER")?;
|
Self::add_column_if_not_exists(&db, "user_info", "added_at", "INTEGER", None)?;
|
||||||
|
|
||||||
|
// Notification settings migration (https://github.com/damus-io/damus/issues/2360)
|
||||||
|
|
||||||
|
Self::add_column_if_not_exists(&db, "user_info", "zap_notifications_enabled", "BOOLEAN", Some("true"))?;
|
||||||
|
Self::add_column_if_not_exists(&db, "user_info", "mention_notifications_enabled", "BOOLEAN", Some("true"))?;
|
||||||
|
Self::add_column_if_not_exists(&db, "user_info", "repost_notifications_enabled", "BOOLEAN", Some("true"))?;
|
||||||
|
Self::add_column_if_not_exists(&db, "user_info", "reaction_notifications_enabled", "BOOLEAN", Some("true"))?;
|
||||||
|
Self::add_column_if_not_exists(&db, "user_info", "dm_notifications_enabled", "BOOLEAN", Some("true"))?;
|
||||||
|
Self::add_column_if_not_exists(&db, "user_info", "only_notifications_from_following_enabled", "BOOLEAN", Some("false"))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -105,6 +116,7 @@ impl NotificationManager {
|
|||||||
table_name: &str,
|
table_name: &str,
|
||||||
column_name: &str,
|
column_name: &str,
|
||||||
column_type: &str,
|
column_type: &str,
|
||||||
|
default_value: Option<&str>,
|
||||||
) -> Result<(), rusqlite::Error> {
|
) -> Result<(), rusqlite::Error> {
|
||||||
let query = format!("PRAGMA table_info({})", table_name);
|
let query = format!("PRAGMA table_info({})", table_name);
|
||||||
let mut stmt = db.prepare(&query)?;
|
let mut stmt = db.prepare(&query)?;
|
||||||
@ -115,8 +127,11 @@ impl NotificationManager {
|
|||||||
|
|
||||||
if !column_names.contains(&column_name.to_string()) {
|
if !column_names.contains(&column_name.to_string()) {
|
||||||
let query = format!(
|
let query = format!(
|
||||||
"ALTER TABLE {} ADD COLUMN {} {}",
|
"ALTER TABLE {} ADD COLUMN {} {} {}",
|
||||||
table_name, column_name, column_type
|
table_name, column_name, column_type, match default_value {
|
||||||
|
Some(value) => format!("DEFAULT {}", value),
|
||||||
|
None => "".to_string(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
db.execute(&query, [])?;
|
db.execute(&query, [])?;
|
||||||
}
|
}
|
||||||
@ -203,8 +218,7 @@ impl NotificationManager {
|
|||||||
let mut pubkeys_to_notify = HashSet::new();
|
let mut pubkeys_to_notify = HashSet::new();
|
||||||
for pubkey in relevant_pubkeys_yet_to_receive {
|
for pubkey in relevant_pubkeys_yet_to_receive {
|
||||||
let should_mute: bool = {
|
let should_mute: bool = {
|
||||||
let mute_manager_mutex_guard = self.mute_manager.lock().await;
|
self.nostr_network_helper
|
||||||
mute_manager_mutex_guard
|
|
||||||
.should_mute_notification_for_pubkey(event, &pubkey)
|
.should_mute_notification_for_pubkey(event, &pubkey)
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
@ -251,12 +265,40 @@ impl NotificationManager {
|
|||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let user_device_tokens = self.get_user_device_tokens(pubkey).await?;
|
let user_device_tokens = self.get_user_device_tokens(pubkey).await?;
|
||||||
for device_token in user_device_tokens {
|
for device_token in user_device_tokens {
|
||||||
|
if !self.user_wants_notification(pubkey, device_token.clone(), event).await? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
self.send_event_notification_to_device_token(event, &device_token)
|
self.send_event_notification_to_device_token(event, &device_token)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn user_wants_notification(
|
||||||
|
&self,
|
||||||
|
pubkey: &PublicKey,
|
||||||
|
device_token: String,
|
||||||
|
event: &Event,
|
||||||
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
|
let notification_preferences = self.get_user_notification_settings(pubkey, device_token).await?;
|
||||||
|
if notification_preferences.only_notifications_from_following_enabled {
|
||||||
|
if !self.nostr_network_helper.does_pubkey_follow_pubkey(pubkey, &event.author()).await {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match event.kind {
|
||||||
|
Kind::TextNote => Ok(notification_preferences.mention_notifications_enabled), // TODO: Not 100% accurate
|
||||||
|
Kind::EncryptedDirectMessage => Ok(notification_preferences.dm_notifications_enabled),
|
||||||
|
Kind::Repost => Ok(notification_preferences.repost_notifications_enabled),
|
||||||
|
Kind::GenericRepost => Ok(notification_preferences.repost_notifications_enabled),
|
||||||
|
Kind::Reaction => Ok(notification_preferences.reaction_notifications_enabled),
|
||||||
|
Kind::ZapPrivateMessage => Ok(notification_preferences.zap_notifications_enabled),
|
||||||
|
Kind::ZapRequest => Ok(notification_preferences.zap_notifications_enabled),
|
||||||
|
Kind::ZapReceipt => Ok(notification_preferences.zap_notifications_enabled),
|
||||||
|
_ => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_user_device_tokens(
|
async fn get_user_device_tokens(
|
||||||
&self,
|
&self,
|
||||||
pubkey: &PublicKey,
|
pubkey: &PublicKey,
|
||||||
@ -344,6 +386,8 @@ impl NotificationManager {
|
|||||||
(title, "".to_string(), body)
|
(title, "".to_string(), body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - User device info and settings
|
||||||
|
|
||||||
pub async fn save_user_device_info(
|
pub async fn save_user_device_info(
|
||||||
&self,
|
&self,
|
||||||
pubkey: nostr::PublicKey,
|
pubkey: nostr::PublicKey,
|
||||||
@ -375,6 +419,65 @@ impl NotificationManager {
|
|||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_notification_settings(
|
||||||
|
&self,
|
||||||
|
pubkey: &PublicKey,
|
||||||
|
device_token: String,
|
||||||
|
) -> Result<UserNotificationSettings, Box<dyn std::error::Error>> {
|
||||||
|
let db_mutex_guard = self.db.lock().await;
|
||||||
|
let connection = db_mutex_guard.get()?;
|
||||||
|
let mut stmt = connection.prepare(
|
||||||
|
"SELECT zap_notifications_enabled, mention_notifications_enabled, repost_notifications_enabled, reaction_notifications_enabled, dm_notifications_enabled, only_notifications_from_following_enabled FROM user_info WHERE pubkey = ? AND device_token = ?",
|
||||||
|
)?;
|
||||||
|
let settings = stmt
|
||||||
|
.query_row([pubkey.to_sql_string(), device_token], |row| {
|
||||||
|
Ok(UserNotificationSettings {
|
||||||
|
zap_notifications_enabled: row.get(0)?,
|
||||||
|
mention_notifications_enabled: row.get(1)?,
|
||||||
|
repost_notifications_enabled: row.get(2)?,
|
||||||
|
reaction_notifications_enabled: row.get(3)?,
|
||||||
|
dm_notifications_enabled: row.get(4)?,
|
||||||
|
only_notifications_from_following_enabled: row.get(5)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_user_notification_settings(
|
||||||
|
&self,
|
||||||
|
pubkey: &PublicKey,
|
||||||
|
device_token: String,
|
||||||
|
settings: UserNotificationSettings,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let db_mutex_guard = self.db.lock().await;
|
||||||
|
let connection = db_mutex_guard.get()?;
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE user_info SET zap_notifications_enabled = ?, mention_notifications_enabled = ?, repost_notifications_enabled = ?, reaction_notifications_enabled = ?, dm_notifications_enabled = ?, only_notifications_from_following_enabled = ? WHERE pubkey = ? AND device_token = ?",
|
||||||
|
params![
|
||||||
|
settings.zap_notifications_enabled,
|
||||||
|
settings.mention_notifications_enabled,
|
||||||
|
settings.repost_notifications_enabled,
|
||||||
|
settings.reaction_notifications_enabled,
|
||||||
|
settings.dm_notifications_enabled,
|
||||||
|
settings.only_notifications_from_following_enabled,
|
||||||
|
pubkey.to_sql_string(),
|
||||||
|
device_token,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct UserNotificationSettings {
|
||||||
|
zap_notifications_enabled: bool,
|
||||||
|
mention_notifications_enabled: bool,
|
||||||
|
repost_notifications_enabled: bool,
|
||||||
|
reaction_notifications_enabled: bool,
|
||||||
|
dm_notifications_enabled: bool,
|
||||||
|
only_notifications_from_following_enabled: bool
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NotificationStatus {
|
struct NotificationStatus {
|
||||||
|
1
src/utils/mod.rs
Normal file
1
src/utils/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod time_delta;
|
34
src/utils/time_delta.rs
Normal file
34
src/utils/time_delta.rs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
use nostr_sdk::Timestamp;
|
||||||
|
|
||||||
|
pub struct TimeDelta {
|
||||||
|
pub delta_abs_seconds: u64,
|
||||||
|
pub negative: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimeDelta {
|
||||||
|
/// Safely calculate the difference between two timestamps in seconds
|
||||||
|
/// This function is safer against overflows than subtracting the timestamps directly
|
||||||
|
pub fn subtracting(t1: Timestamp, t2: Timestamp) -> TimeDelta {
|
||||||
|
if t1 > t2 {
|
||||||
|
TimeDelta {
|
||||||
|
delta_abs_seconds: (t1 - t2).as_u64(),
|
||||||
|
negative: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TimeDelta {
|
||||||
|
delta_abs_seconds: (t2 - t1).as_u64(),
|
||||||
|
negative: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TimeDelta {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
if self.negative {
|
||||||
|
write!(f, "-{}", self.delta_abs_seconds)
|
||||||
|
} else {
|
||||||
|
write!(f, "{}", self.delta_abs_seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user