diff --git a/crates/api_clients/seedance2pro_client/src/requests/mod.rs b/crates/api_clients/seedance2pro_client/src/requests/mod.rs index 3255083b76..767c04fc68 100644 --- a/crates/api_clients/seedance2pro_client/src/requests/mod.rs +++ b/crates/api_clients/seedance2pro_client/src/requests/mod.rs @@ -4,4 +4,5 @@ pub mod kinovi_host; pub mod poll_characters; pub mod poll_orders; pub mod prepare_file_upload; +pub mod update_character; pub mod upload_file; diff --git a/crates/api_clients/seedance2pro_client/src/requests/update_character/mod.rs b/crates/api_clients/seedance2pro_client/src/requests/update_character/mod.rs new file mode 100644 index 0000000000..e7abf05796 --- /dev/null +++ b/crates/api_clients/seedance2pro_client/src/requests/update_character/mod.rs @@ -0,0 +1,2 @@ +pub mod update_character; +pub(crate) mod request_types; diff --git a/crates/api_clients/seedance2pro_client/src/requests/update_character/request_types.rs b/crates/api_clients/seedance2pro_client/src/requests/update_character/request_types.rs new file mode 100644 index 0000000000..963523f3aa --- /dev/null +++ b/crates/api_clients/seedance2pro_client/src/requests/update_character/request_types.rs @@ -0,0 +1,44 @@ +use serde_derive::{Deserialize, Serialize}; + +// ======================== Request ======================== + +#[derive(Serialize, Debug)] +pub(super) struct BatchRequest { + #[serde(rename = "0")] + pub zero: BatchRequestInner, +} + +#[derive(Serialize, Debug)] +pub(super) struct BatchRequestInner { + pub json: BatchRequestJson, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(super) struct BatchRequestJson { + pub character_id: String, + pub name: String, + pub description: String, +} + +// ======================== Response ======================== + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseItem { + pub result: BatchResponseResult, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseResult { + pub data: BatchResponseData, +} + +#[derive(Deserialize, Debug)] +pub(super) struct BatchResponseData { + pub json: UpdateCharacterResponseJson, +} + +#[derive(Deserialize, Debug)] +pub(super) struct UpdateCharacterResponseJson { + pub success: bool, +} diff --git a/crates/api_clients/seedance2pro_client/src/requests/update_character/update_character.rs b/crates/api_clients/seedance2pro_client/src/requests/update_character/update_character.rs new file mode 100644 index 0000000000..c9ca66b658 --- /dev/null +++ b/crates/api_clients/seedance2pro_client/src/requests/update_character/update_character.rs @@ -0,0 +1,203 @@ +use crate::creds::seedance2pro_session::Seedance2ProSession; +use crate::error::seedance2pro_client_error::Seedance2ProClientError; +use crate::error::seedance2pro_error::Seedance2ProError; +use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError; +use crate::requests::update_character::request_types::*; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use log::info; +use wreq::Client; +use wreq_util::Emulation; + +// --- Public args --- + +pub struct UpdateCharacterArgs<'a> { + pub session: &'a Seedance2ProSession, + + /// The Kinovi character ID (e.g. "char_1775184656440_vsy3eu"). + pub character_id: String, + + /// The new name for the character. + pub name: String, + + /// The new description (empty string clears it). + pub description: String, + + /// Override the default host (kinovi.ai). + pub host_override: Option, +} + +// --- Public response --- + +#[derive(Debug)] +pub struct UpdateCharacterResponse { + pub success: bool, +} + +// --- Implementation --- + +pub async fn update_character(args: UpdateCharacterArgs<'_>) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + let url = format!("{}/api/trpc/character.updateCharacter?batch=1", base_url); + + info!("Updating character '{}' (name='{}', description='{}')", args.character_id, args.name, args.description); + + let request_body = BatchRequest { + zero: BatchRequestInner { + json: BatchRequestJson { + character_id: args.character_id.clone(), + name: args.name, + description: args.description, + }, + }, + }; + + let cookie = args.session.cookies.as_str(); + let referer = format!("{}/app/characters", base_url); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| Seedance2ProClientError::WreqClientError(err))?; + + let response = client.post(&url) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("Content-Type", "application/json") + .header("x-trpc-source", "client") + .header("Origin", base_url) + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .json(&request_body) + .send() + .await + .map_err(|err| Seedance2ProGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| Seedance2ProGenericApiError::WreqError(err))?; + + info!("Update character response: status={}, body={}", status, response_body); + + if !status.is_success() { + return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + let batch_response: Vec = serde_json::from_str(&response_body) + .map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?; + + let data = batch_response + .into_iter() + .next() + .ok_or_else(|| Seedance2ProGenericApiError::UncategorizedBadResponse( + "Empty batch response array".to_string() + ))? + .result + .data + .json; + + Ok(UpdateCharacterResponse { + success: data.success, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::seedance2pro_session::Seedance2ProSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(Seedance2ProSession::from_cookies_string(cookies)) + } + + // Steampunk Clown character + const TEST_CHARACTER_ID: &str = "char_1775176566518_sik0te"; + + #[tokio::test] + #[ignore] // manually test — requires real cookies, modifies real data + async fn test_rename_character() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let result = update_character(UpdateCharacterArgs { + session: &session, + character_id: TEST_CHARACTER_ID.to_string(), + name: "Steampunk Clown (renamed)".to_string(), + description: "A steampunk clown character for testing".to_string(), + host_override: None, + }).await?; + + println!("Success: {}", result.success); + assert!(result.success); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, modifies real data + async fn test_rename_character_clear_description() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + let result = update_character(UpdateCharacterArgs { + session: &session, + character_id: TEST_CHARACTER_ID.to_string(), + name: "Steampunk Clown".to_string(), + description: "".to_string(), + host_override: None, + }).await?; + + println!("Success: {}", result.success); + assert!(result.success); + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } + + #[tokio::test] + #[ignore] // manually test — requires real cookies, modifies real data + async fn test_rename_and_restore() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Trace); + let session = test_session()?; + + // Rename + let result = update_character(UpdateCharacterArgs { + session: &session, + character_id: TEST_CHARACTER_ID.to_string(), + name: "Temporary Name".to_string(), + description: "Temporary description".to_string(), + host_override: None, + }).await?; + assert!(result.success); + + // Restore + let result = update_character(UpdateCharacterArgs { + session: &session, + character_id: TEST_CHARACTER_ID.to_string(), + name: "Steampunk Clown".to_string(), + description: "A steampunk clown".to_string(), + host_override: None, + }).await?; + assert!(result.success); + + assert_eq!(1, 2); // NB: Intentional failure to inspect output. + Ok(()) + } +} diff --git a/crates/schema/database/mysql_queries/src/queries/characters/get_character_by_token.rs b/crates/schema/database/mysql_queries/src/queries/characters/get_character_by_token.rs new file mode 100644 index 0000000000..adbcdb507b --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/characters/get_character_by_token.rs @@ -0,0 +1,54 @@ +use sqlx::pool::PoolConnection; +use sqlx::MySql; + +use enums::by_table::characters::character_type::CharacterType; +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +/// Full character record for the GET endpoint. +pub struct CharacterRecord { + pub token: CharacterToken, + pub character_type: Option, + pub is_active: bool, + pub character_name: String, + pub maybe_description: Option, + pub maybe_avatar_media_token: Option, + pub maybe_full_image_media_token: Option, + pub maybe_original_upload_media_token: Option, + pub maybe_creator_user_token: Option, + pub kinovi_character_id: Option, + pub kinovi_character_name: Option, + pub maybe_kinovi_asset_id: Option, +} + +/// Look up a character by token (excluding soft-deleted). +pub async fn get_character_by_token( + character_token: &CharacterToken, + connection: &mut PoolConnection, +) -> Result, sqlx::Error> { + sqlx::query_as!( + CharacterRecord, + r#" +SELECT + token as `token: tokens::tokens::characters::CharacterToken`, + character_type as `character_type: enums::by_table::characters::character_type::CharacterType`, + is_active as `is_active: bool`, + character_name, + maybe_description, + maybe_avatar_media_token as `maybe_avatar_media_token: tokens::tokens::media_files::MediaFileToken`, + maybe_full_image_media_token as `maybe_full_image_media_token: tokens::tokens::media_files::MediaFileToken`, + maybe_original_upload_media_token as `maybe_original_upload_media_token: tokens::tokens::media_files::MediaFileToken`, + maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`, + kinovi_character_id, + kinovi_character_name, + maybe_kinovi_asset_id +FROM characters +WHERE token = ? + AND deleted_at IS NULL + "#, + character_token.as_str(), + ) + .fetch_optional(&mut **connection) + .await +} diff --git a/crates/schema/database/mysql_queries/src/queries/characters/list_active_characters_for_user.rs b/crates/schema/database/mysql_queries/src/queries/characters/list_active_characters_for_user.rs new file mode 100644 index 0000000000..68d0e44b1a --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/characters/list_active_characters_for_user.rs @@ -0,0 +1,86 @@ +use sqlx::pool::PoolConnection; +use sqlx::MySql; + +use enums::by_table::characters::character_type::CharacterType; +use tokens::tokens::characters::CharacterToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::users::UserToken; + +const DEFAULT_PAGE_SIZE: u32 = 50; + +/// A character row for the list endpoint. +pub struct CharacterListRow { + pub id: u64, + pub token: CharacterToken, + pub character_type: Option, + pub is_active: bool, + pub character_name: String, + pub maybe_description: Option, + pub maybe_avatar_media_token: Option, + pub maybe_full_image_media_token: Option, + pub maybe_original_upload_media_token: Option, + pub maybe_creator_user_token: Option, + pub kinovi_character_id: Option, + pub kinovi_character_name: Option, + pub maybe_kinovi_asset_id: Option, +} + +pub struct ListActiveCharactersResult { + pub characters: Vec, + pub next_cursor: Option, +} + +/// List active, non-deleted characters for a user, paginated by descending id. +pub async fn list_active_characters_for_user( + user_token: &UserToken, + maybe_cursor: Option, + connection: &mut PoolConnection, +) -> Result { + let fetch_limit = (DEFAULT_PAGE_SIZE + 1) as i64; + let id_cursor = maybe_cursor.map(|c| c as i64).unwrap_or(i64::MAX); + + let rows = sqlx::query_as!( + CharacterListRow, + r#" +SELECT + id as `id: u64`, + token as `token: tokens::tokens::characters::CharacterToken`, + character_type as `character_type: enums::by_table::characters::character_type::CharacterType`, + is_active as `is_active: bool`, + character_name, + maybe_description, + maybe_avatar_media_token as `maybe_avatar_media_token: tokens::tokens::media_files::MediaFileToken`, + maybe_full_image_media_token as `maybe_full_image_media_token: tokens::tokens::media_files::MediaFileToken`, + maybe_original_upload_media_token as `maybe_original_upload_media_token: tokens::tokens::media_files::MediaFileToken`, + maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`, + kinovi_character_id, + kinovi_character_name, + maybe_kinovi_asset_id +FROM characters +WHERE maybe_creator_user_token = ? + AND is_active = true + AND deleted_at IS NULL + AND id < ? +ORDER BY id DESC +LIMIT ? + "#, + user_token.as_str(), + id_cursor, + fetch_limit, + ) + .fetch_all(&mut **connection) + .await?; + + let has_next = rows.len() as u32 >= DEFAULT_PAGE_SIZE; + let characters: Vec = rows.into_iter().take(DEFAULT_PAGE_SIZE as usize).collect(); + let next_cursor = if has_next { + characters.last().map(|c| c.id) + } else { + None + }; + + Ok(ListActiveCharactersResult { + characters, + next_cursor, + }) +}