mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 17:38:10 +00:00
rename characters
This commit is contained in:
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,
|
||||
}
|
||||
+203
@@ -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
|
||||
}
|
||||
+86
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user