rename characters

This commit is contained in:
Brandon Thomas committed 2026-04-02 23:26:07 -04:00
1 parent de1b88da59
commit 80a5a9ad04
6 files changed
+390

No files matched your search

@@ -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;
@@ -0,0 +1,2 @@
pub mod update_character;
pub(crate) mod request_types;
@@ -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,
}
@@ -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<KinoviHost>,
}
// --- Public response ---
#[derive(Debug)]
pub struct UpdateCharacterResponse {
pub success: bool,
}
// --- Implementation ---
pub async fn update_character(args: UpdateCharacterArgs<'_>) -> Result<UpdateCharacterResponse, Seedance2ProError> {
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<BatchResponseItem> = 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<Seedance2ProSession> {
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(())
}
}
@@ -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<CharacterType>,
pub is_active: bool,
pub character_name: String,
pub maybe_description: Option<String>,
pub maybe_avatar_media_token: Option<MediaFileToken>,
pub maybe_full_image_media_token: Option<MediaFileToken>,
pub maybe_original_upload_media_token: Option<MediaFileToken>,
pub maybe_creator_user_token: Option<UserToken>,
pub kinovi_character_id: Option<String>,
pub kinovi_character_name: Option<String>,
pub maybe_kinovi_asset_id: Option<String>,
}
/// Look up a character by token (excluding soft-deleted).
pub async fn get_character_by_token(
character_token: &CharacterToken,
connection: &mut PoolConnection<MySql>,
) -> Result<Option<CharacterRecord>, 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
}
@@ -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<CharacterType>,
pub is_active: bool,
pub character_name: String,
pub maybe_description: Option<String>,
pub maybe_avatar_media_token: Option<MediaFileToken>,
pub maybe_full_image_media_token: Option<MediaFileToken>,
pub maybe_original_upload_media_token: Option<MediaFileToken>,
pub maybe_creator_user_token: Option<UserToken>,
pub kinovi_character_id: Option<String>,
pub kinovi_character_name: Option<String>,
pub maybe_kinovi_asset_id: Option<String>,
}
pub struct ListActiveCharactersResult {
pub characters: Vec<CharacterListRow>,
pub next_cursor: Option<u64>,
}
/// 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<u64>,
connection: &mut PoolConnection<MySql>,
) -> Result<ListActiveCharactersResult, sqlx::Error> {
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<CharacterListRow> = 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,
})
}