diff --git a/Cargo.lock b/Cargo.lock index 81ae7ac..0f416a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5000,8 +5000,8 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusx" -version = "0.5.0" -source = "git+https://github.com/Quantus-Network/rusx?tag=v0.5.0#c94af4059c58b942a5740fc1e3ab234f44d837eb" +version = "0.6.0" +source = "git+https://github.com/Quantus-Network/rusx?tag=v0.6.0#1f5383af185509649fcbc117fc97e166a6cb47d3" dependencies = [ "async-trait", "mockall 0.12.1", diff --git a/Cargo.toml b/Cargo.toml index 42475fd..7c4325d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ path = "src/bin/create_raid.rs" qp-human-checkphrase = "0.1.2" qp-rusty-crystals-dilithium = "2.0.0" quantus-cli = "0.3.0" -rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.5.0"} +rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.6.0"} # Async runtime tokio = {version = "1.46", features = ["full", "test-util"]} @@ -90,4 +90,4 @@ tiny-keccak = {version = "2.0.2", features = ["keccak"]} mockall = "0.13" wiremock = "0.5" # Enable the testing feature ONLY for tests -rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.5.0", features = ["testing"]} +rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.6.0", features = ["testing"]} diff --git a/config/default.toml b/config/default.toml index 9438f70..6e33593 100644 --- a/config/default.toml +++ b/config/default.toml @@ -58,7 +58,6 @@ client_secret = "lfXc45dZLqYTzP62Ms32EhXinGQzxcIP9TvjJml2B-h0T1nIJK" api_key = "some-key" interval_in_hours = 24 keywords = "quantum" -whitelist = ["username"] [tg_bot] base_url = "https://api.telegram.org" diff --git a/config/example.toml b/config/example.toml index 2c8f04e..916a4c8 100644 --- a/config/example.toml +++ b/config/example.toml @@ -68,7 +68,6 @@ client_secret = "example-secret" api_key = "some-key" interval_in_hours = 24 keywords = "example" -whitelist = ["example-username"] [tg_bot] base_url = "https://api.telegram.org" diff --git a/config/test.toml b/config/test.toml index ac1fb8d..3a040cd 100644 --- a/config/test.toml +++ b/config/test.toml @@ -58,7 +58,6 @@ client_secret = "test-secret" api_key = "some-key" interval_in_hours = 24 keywords = "test" -whitelist = ["test-username"] [tg_bot] base_url = "https://api.telegram.org" diff --git a/migrations/008_add_is_ignore_col_to_tweet_authors_table.sql b/migrations/008_add_is_ignore_col_to_tweet_authors_table.sql new file mode 100644 index 0000000..10bf570 --- /dev/null +++ b/migrations/008_add_is_ignore_col_to_tweet_authors_table.sql @@ -0,0 +1,5 @@ +ALTER TABLE tweet_authors +ADD COLUMN is_ignored BOOLEAN NOT NULL DEFAULT true; + +CREATE INDEX IF NOT EXISTS idx_tweet_authors_is_ignored ON tweet_authors(is_ignored) +WHERE is_ignored = false; \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 57e0b03..4539884 100644 --- a/src/config.rs +++ b/src/config.rs @@ -71,7 +71,6 @@ pub struct JwtConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TweetSyncConfig { - pub whitelist: Vec, pub interval_in_hours: u64, pub keywords: String, pub api_key: String, @@ -207,7 +206,6 @@ impl Default for Config { client_secret: "example".to_string(), }, tweet_sync: TweetSyncConfig { - whitelist: vec![], interval_in_hours: 24, keywords: "hello".to_string(), api_key: "key".to_string(), diff --git a/src/handlers/raid_quest.rs b/src/handlers/raid_quest.rs index ac1dd65..c46404d 100644 --- a/src/handlers/raid_quest.rs +++ b/src/handlers/raid_quest.rs @@ -289,6 +289,7 @@ mod tests { listed_count: 0, like_count: 0, media_count: 0, + is_ignored: Some(true), }; state.db.tweet_authors.upsert_many(&vec![author]).await.unwrap(); diff --git a/src/handlers/relevant_tweet.rs b/src/handlers/relevant_tweet.rs index 798c4e1..834db71 100644 --- a/src/handlers/relevant_tweet.rs +++ b/src/handlers/relevant_tweet.rs @@ -89,6 +89,7 @@ mod tests { listed_count: 0, like_count: 0, media_count: 0, + is_ignored: Some(true), }, NewAuthorPayload { id: "auth_B".to_string(), @@ -100,6 +101,7 @@ mod tests { listed_count: 0, like_count: 0, media_count: 0, + is_ignored: Some(true), }, ]; state diff --git a/src/handlers/tweet_author.rs b/src/handlers/tweet_author.rs index d8b9e48..a80807a 100644 --- a/src/handlers/tweet_author.rs +++ b/src/handlers/tweet_author.rs @@ -1,15 +1,20 @@ use axum::{ - extract::{self, Query, State}, + extract::{self, Path, Query, State}, + http::StatusCode, + response::NoContent, Extension, Json, }; +use rusx::resources::{user::UserParams, UserField}; use crate::{ db_persistence::DbError, - handlers::{calculate_total_pages, ListQueryParams, PaginatedResponse, PaginationMetadata, SuccessResponse}, + handlers::{ + calculate_total_pages, HandlerError, ListQueryParams, PaginatedResponse, PaginationMetadata, SuccessResponse, + }, http_server::AppState, models::{ admin::Admin, - tweet_author::{AuthorFilter, AuthorSortColumn, TweetAuthor}, + tweet_author::{AuthorFilter, AuthorSortColumn, CreateTweetAuthorInput, NewAuthorPayload, TweetAuthor}, }, AppError, }; @@ -40,6 +45,60 @@ pub async fn handle_get_tweet_authors( Ok(Json(response)) } +/// POST /tweet-authors +pub async fn handle_create_tweet_author( + State(state): State, + Extension(_): Extension, + Json(payload): Json, +) -> Result<(StatusCode, Json>), AppError> { + let mut params = UserParams::new(); + params.user_fields = Some(vec![ + UserField::PublicMetrics, + UserField::Id, + UserField::Name, + UserField::Username, + ]); + + let author_response = state + .twitter_gateway + .users() + .get_by_username(&payload.username, Some(params.clone())) + .await?; + let Some(author) = author_response.data else { + return Err(AppError::Handler(HandlerError::InvalidBody(format!( + "Tweet Author {} not found", + payload.username + )))); + }; + + let new_author = NewAuthorPayload::new(author); + let create_response = state.db.tweet_authors.upsert(&new_author).await?; + + Ok((StatusCode::CREATED, SuccessResponse::new(create_response))) +} + +/// PUT /tweet-authors/:id/ignore +pub async fn handle_ignore_tweet_author( + State(state): State, + Extension(_): Extension, + Path(id): Path, +) -> Result { + state.db.tweet_authors.set_ignore_status(&id, true).await?; + + Ok(NoContent) +} + +/// PUT /tweet-authors/:id/watch +pub async fn handle_watch_tweet_author( + State(state): State, + Extension(_): Extension, + Path(id): Path, +) -> Result { + state.db.tweet_authors.set_ignore_status(&id, false).await?; + + Ok(NoContent) +} + /// GET /tweet-authors/:id /// Gets a single author by their X ID pub async fn handle_get_tweet_author_by_id( @@ -60,12 +119,30 @@ pub async fn handle_get_tweet_author_by_id( #[cfg(test)] mod tests { - use axum::{body::Body, extract::Request, http::StatusCode, routing::get, Extension, Router}; + use std::sync::Arc; + + use axum::{ + body::Body, + extract::Request, + http::StatusCode, + routing::{get, post, put}, + Extension, Router, + }; + use rusx::{ + resources::{ + user::{User, UserApi, UserPublicMetrics}, + TwitterApiResponse, + }, + MockTwitterGateway, MockUserApi, + }; use serde_json::Value; use tower::ServiceExt; use crate::{ - handlers::tweet_author::{handle_get_tweet_author_by_id, handle_get_tweet_authors}, + handlers::tweet_author::{ + handle_create_tweet_author, handle_get_tweet_author_by_id, handle_get_tweet_authors, + handle_ignore_tweet_author, handle_watch_tweet_author, + }, models::tweet_author::NewAuthorPayload, utils::{ test_app_state::create_test_app_state, @@ -86,6 +163,7 @@ mod tests { listed_count: 1, like_count: 200, media_count: 5, + is_ignored: Some(true), }, NewAuthorPayload { id: "auth_2".to_string(), @@ -97,6 +175,7 @@ mod tests { listed_count: 5, like_count: 1000, media_count: 10, + is_ignored: Some(true), }, NewAuthorPayload { id: "auth_3".to_string(), @@ -108,6 +187,7 @@ mod tests { listed_count: 0, like_count: 20, media_count: 0, + is_ignored: Some(true), }, ]; @@ -263,7 +343,6 @@ mod tests { async fn test_get_tweet_author_by_id_not_found() { let state = create_test_app_state().await; reset_database(&state.db.pool).await; - // No authors seeded let router = Router::new() .route("/tweet-authors/:id", get(handle_get_tweet_author_by_id)) @@ -282,4 +361,117 @@ mod tests { assert_eq!(response.status(), 404); } + + #[tokio::test] + async fn test_create_tweet_author_success() { + let mut state = create_test_app_state().await; + reset_database(&state.db.pool).await; + + // --- Setup Twitter Mock --- + let mut mock_gateway = MockTwitterGateway::new(); + let mut mock_user = MockUserApi::new(); + + mock_user.expect_get_by_username().returning(|_, _| { + Ok(TwitterApiResponse { + data: Some(User { + id: "hello".to_string(), + name: "hello".to_string(), + username: "test_user".to_string(), + public_metrics: Some(UserPublicMetrics { + followers_count: 100, + following_count: 50, + tweet_count: 10, + listed_count: 5, + like_count: Some(0), + media_count: Some(0), + }), + }), + includes: None, + meta: None, + }) + }); + + let user_api_arc: Arc = Arc::new(mock_user); + + mock_gateway.expect_users().times(1).return_const(user_api_arc); + + state.twitter_gateway = Arc::new(mock_gateway); + + let router = Router::new() + .route("/tweet-authors", post(handle_create_tweet_author)) + .layer(Extension(create_mock_admin())) + .with_state(state.clone()); + + let payload = serde_json::json!({ + "username": "test_user" + }); + + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/tweet-authors") + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_vec(&payload).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert!(response.status() == StatusCode::CREATED); + + let author = state.db.tweet_authors.find_by_id("hello").await.unwrap().unwrap(); + + assert_eq!(author.is_ignored, true); + } + + #[tokio::test] + async fn test_ignore_and_watch_tweet_author() { + let state = create_test_app_state().await; + reset_database(&state.db.pool).await; + seed_authors(&state).await; + + let router = Router::new() + .route("/tweet-authors/:id/ignore", put(handle_ignore_tweet_author)) + .route("/tweet-authors/:id/watch", put(handle_watch_tweet_author)) + .layer(Extension(create_mock_admin())) + .with_state(state.clone()); + + // 1. Test Ignore + let ignore_res = router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/tweet-authors/auth_1/ignore") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(ignore_res.status(), StatusCode::NO_CONTENT); + + // Verify in DB + let author = state.db.tweet_authors.find_by_id("auth_1").await.unwrap().unwrap(); + assert!(author.is_ignored); + + // 2. Test Watch (Un-ignore) + let watch_res = router + .oneshot( + Request::builder() + .method("PUT") + .uri("/tweet-authors/auth_1/watch") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(watch_res.status(), StatusCode::NO_CONTENT); + + // Verify in DB + let author_updated = state.db.tweet_authors.find_by_id("auth_1").await.unwrap().unwrap(); + assert!(!author_updated.is_ignored); + } } diff --git a/src/models/tweet_author.rs b/src/models/tweet_author.rs index d352e8c..0945281 100644 --- a/src/models/tweet_author.rs +++ b/src/models/tweet_author.rs @@ -1,5 +1,5 @@ use chrono::{DateTime, Utc}; -use rusx::resources::user::{User as TwitterUser, UserPublicMetrics}; +use rusx::resources::user::User as TwitterUser; use serde::{Deserialize, Serialize}; use sqlx::{postgres::PgRow, FromRow, Row}; @@ -8,6 +8,7 @@ pub struct TweetAuthor { pub id: String, pub name: String, pub username: String, + pub is_ignored: bool, pub followers_count: i32, pub following_count: i32, pub tweet_count: i32, @@ -22,6 +23,7 @@ impl<'r> FromRow<'r, PgRow> for TweetAuthor { id: row.try_get("id")?, name: row.try_get("name")?, username: row.try_get("username")?, + is_ignored: row.try_get("is_ignored")?, followers_count: row.try_get("followers_count")?, following_count: row.try_get("following_count")?, tweet_count: row.try_get("tweet_count")?, @@ -77,18 +79,12 @@ pub struct NewAuthorPayload { pub listed_count: i32, pub like_count: i32, pub media_count: i32, + pub is_ignored: Option, } impl NewAuthorPayload { pub fn new(author: TwitterUser) -> Self { - let public_metrics = author - .public_metrics - .ok_or_else(|| UserPublicMetrics { - media_count: Some(0), - like_count: Some(0), - ..Default::default() - }) - .unwrap(); + let public_metrics = author.public_metrics.unwrap_or_default(); let new_author = NewAuthorPayload { id: author.id, @@ -98,10 +94,16 @@ impl NewAuthorPayload { following_count: public_metrics.following_count as i32, tweet_count: public_metrics.tweet_count as i32, listed_count: public_metrics.listed_count as i32, - media_count: public_metrics.media_count.unwrap() as i32, - like_count: public_metrics.like_count.unwrap() as i32, + media_count: public_metrics.media_count.unwrap_or(0) as i32, + like_count: public_metrics.like_count.unwrap_or(0) as i32, + is_ignored: Some(true), }; new_author } } + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct CreateTweetAuthorInput { + pub username: String, +} diff --git a/src/repositories/relevant_tweet.rs b/src/repositories/relevant_tweet.rs index a3d8555..acf8810 100644 --- a/src/repositories/relevant_tweet.rs +++ b/src/repositories/relevant_tweet.rs @@ -271,6 +271,7 @@ mod tests { listed_count: 1, like_count: 200, media_count: 5, + is_ignored: Some(true), }]; repo.upsert_many(&authors).await.expect("Failed to seed authors"); diff --git a/src/repositories/tweet_author.rs b/src/repositories/tweet_author.rs index 7ad0271..bf2b1fc 100644 --- a/src/repositories/tweet_author.rs +++ b/src/repositories/tweet_author.rs @@ -107,6 +107,62 @@ impl TweetAuthorRepository { Ok(authors) } + pub async fn get_whitelist(&self) -> Result, DbError> { + let ids = sqlx::query_scalar::<_, String>("SELECT id FROM tweet_authors WHERE is_ignored = false") + .fetch_all(&self.pool) + .await?; + + Ok(ids) + } + + pub async fn set_ignore_status(&self, id: &str, status: bool) -> Result<(), DbError> { + sqlx::query("UPDATE tweet_authors SET is_ignored = $1 WHERE id = $2") + .bind(status) + .bind(id) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn upsert(&self, payload: &NewAuthorPayload) -> DbResult { + let id = sqlx::query_scalar::<_, String>( + r#" + INSERT INTO tweet_authors ( + id, name, username, is_ignored, followers_count, following_count, + tweet_count, listed_count, like_count, media_count, fetched_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW()) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + username = EXCLUDED.username, + is_ignored = EXCLUDED.is_ignored, + followers_count = EXCLUDED.followers_count, + following_count = EXCLUDED.following_count, + tweet_count = EXCLUDED.tweet_count, + listed_count = EXCLUDED.listed_count, + like_count = EXCLUDED.like_count, + media_count = EXCLUDED.media_count, + fetched_at = NOW() + RETURNING id + "#, + ) + .bind(&payload.id) + .bind(&payload.name) + .bind(&payload.username) + .bind(&payload.is_ignored) + .bind(payload.followers_count) + .bind(payload.following_count) + .bind(payload.tweet_count) + .bind(payload.listed_count) + .bind(payload.like_count) + .bind(payload.media_count) + .fetch_one(&self.pool) + .await?; + + Ok(id) + } + /// Batch Upsert for Authors pub async fn upsert_many(&self, authors: &Vec) -> DbResult { if authors.is_empty() { diff --git a/src/routes/tweet_author.rs b/src/routes/tweet_author.rs index 4343c85..ea11689 100644 --- a/src/routes/tweet_author.rs +++ b/src/routes/tweet_author.rs @@ -1,7 +1,15 @@ -use axum::{handler::Handler, middleware, routing::get, Router}; +use axum::{ + handler::Handler, + middleware, + routing::{get, put}, + Router, +}; use crate::{ - handlers::tweet_author::{handle_get_tweet_author_by_id, handle_get_tweet_authors}, + handlers::tweet_author::{ + handle_create_tweet_author, handle_get_tweet_author_by_id, handle_get_tweet_authors, + handle_ignore_tweet_author, handle_watch_tweet_author, + }, http_server::AppState, middlewares::jwt_auth, }; @@ -10,12 +18,22 @@ pub fn tweet_author_routes(state: AppState) -> Router { Router::new() .route( "/tweet-authors", - get(handle_get_tweet_authors - .layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_admin_auth))), + get(handle_get_tweet_authors.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_admin_auth))) + .post(handle_create_tweet_author.layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_admin_auth))), ) .route( "/tweet-authors/:id", get(handle_get_tweet_author_by_id .layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_admin_auth))), ) + .route( + "/tweet-authors/:id/ignore", + put(handle_ignore_tweet_author + .layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_admin_auth))), + ) + .route( + "/tweet-authors/:id/watch", + put(handle_watch_tweet_author + .layer(middleware::from_fn_with_state(state.clone(), jwt_auth::jwt_admin_auth))), + ) } diff --git a/src/services/tweet_synchronizer_service.rs b/src/services/tweet_synchronizer_service.rs index 0edbf95..0164c5a 100644 --- a/src/services/tweet_synchronizer_service.rs +++ b/src/services/tweet_synchronizer_service.rs @@ -170,11 +170,10 @@ impl TweetSynchronizerService { pub async fn sync_relevant_tweets(&self) -> Result<(), AppError> { let last_id = self.db.relevant_tweets.get_newest_tweet_id().await?; + let whitelist = self.db.tweet_authors.get_whitelist().await?; - let whitelist_queries = SearchParams::build_batched_whitelist_queries( - &self.config.tweet_sync.whitelist, - Some(&self.config.tweet_sync.keywords), - ); + let whitelist_queries = + SearchParams::build_batched_whitelist_queries(&whitelist, Some(&self.config.tweet_sync.keywords)); for query in whitelist_queries { let mut params = SearchParams::new(query); @@ -305,19 +304,38 @@ mod tests { async fn test_sync_saves_data_no_raid_notification() { let (db, _mock_tg, telegram_service, config) = setup_deps().await; + // --- Setup DB with existing watched tweet authors --- + let dummy_author = crate::models::tweet_author::NewAuthorPayload { + id: "old_u".to_string(), + name: "Old".to_string(), + username: "old".to_string(), + followers_count: 0, + following_count: 0, + tweet_count: 0, + listed_count: 0, + like_count: 0, + media_count: 0, + is_ignored: Some(false), + }; + + db.tweet_authors.upsert(&dummy_author).await.unwrap(); + // --- Setup Mocks --- let mut mock_gateway = MockTwitterGateway::new(); let mut mock_search = MockSearchApi::new(); // Expect search().recent() to be called + let mock_data = create_mock_tweet("t1", &dummy_author.id); + let mock_users = create_mock_user(&dummy_author.id, &dummy_author.username); + mock_search .expect_recent() .times(1) // Expect 1 batch (based on whitelist loop) - .returning(|_| { + .returning(move |_| { Ok(TwitterApiResponse::> { - data: Some(vec![create_mock_tweet("t1", "u1")]), + data: Some(vec![mock_data.clone()]), includes: Some(Includes { - users: Some(vec![create_mock_user("u1", "user_one")]), + users: Some(vec![mock_users.clone()]), tweets: None, }), meta: None, @@ -337,9 +355,9 @@ mod tests { assert!(result.is_ok()); // 1. Check DB for Authors - let author = db.tweet_authors.find_by_id("u1").await.unwrap(); + let author = db.tweet_authors.find_by_id(&dummy_author.id).await.unwrap(); assert!(author.is_some()); - assert_eq!(author.unwrap().username, "user_one"); + assert_eq!(author.unwrap().username, dummy_author.username); // 2. Check DB for Tweets let tweet = db.relevant_tweets.find_by_id("t1").await.unwrap(); @@ -351,6 +369,22 @@ mod tests { async fn test_sync_sends_telegram_when_raid_active() { let (db, mock_tg, telegram_service, config) = setup_deps().await; + // --- Setup DB with existing watched tweet authors --- + let dummy_author = crate::models::tweet_author::NewAuthorPayload { + id: "old_u".to_string(), + name: "Old".to_string(), + username: "old".to_string(), + followers_count: 0, + following_count: 0, + tweet_count: 0, + listed_count: 0, + like_count: 0, + media_count: 0, + is_ignored: Some(false), + }; + + db.tweet_authors.upsert(&dummy_author).await.unwrap(); + // --- Setup Active Raid --- db.raid_quests .create(&CreateRaidQuest { @@ -372,11 +406,11 @@ mod tests { let mut mock_gateway = MockTwitterGateway::new(); let mut mock_search = MockSearchApi::new(); - mock_search.expect_recent().returning(|_| { + mock_search.expect_recent().returning(move |_| { Ok(TwitterApiResponse { - data: Some(vec![create_mock_tweet("t2", "u2")]), + data: Some(vec![create_mock_tweet("t2", &dummy_author.id)]), includes: Some(Includes { - users: Some(vec![create_mock_user("u2", "user_two")]), + users: Some(vec![create_mock_user(&dummy_author.id, &dummy_author.username)]), tweets: None, }), meta: None, @@ -407,20 +441,20 @@ mod tests { // --- Setup DB with existing tweet --- // We insert a tweet directly to simulate "last state" // Note: We need a valid author first due to FK - db.tweet_authors - .upsert_many(&vec![crate::models::tweet_author::NewAuthorPayload { - id: "old_u".to_string(), - name: "Old".to_string(), - username: "old".to_string(), - followers_count: 0, - following_count: 0, - tweet_count: 0, - listed_count: 0, - like_count: 0, - media_count: 0, - }]) - .await - .unwrap(); + let dummy_author = crate::models::tweet_author::NewAuthorPayload { + id: "old_u".to_string(), + name: "Old".to_string(), + username: "old".to_string(), + followers_count: 0, + following_count: 0, + tweet_count: 0, + listed_count: 0, + like_count: 0, + media_count: 0, + is_ignored: Some(false), + }; + + db.tweet_authors.upsert(&dummy_author).await.unwrap(); db.relevant_tweets .upsert_many(&vec![crate::models::relevant_tweet::NewTweetPayload {