1082 lines
35 KiB
Rust
1082 lines
35 KiB
Rust
//! TikTok Content Posting API Integration
|
|
//!
|
|
//! Provides video upload, publishing, and user management capabilities.
|
|
//! Supports OAuth 2.0 authentication flow for TikTok Login Kit.
|
|
|
|
use crate::channels::{
|
|
ChannelAccount, ChannelCredentials, ChannelError, ChannelProvider, ChannelType, PostContent,
|
|
PostResult,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// TikTok API provider for video uploads and content posting
|
|
pub struct TikTokProvider {
|
|
client: reqwest::Client,
|
|
api_base_url: String,
|
|
oauth_base_url: String,
|
|
}
|
|
|
|
impl TikTokProvider {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
client: reqwest::Client::new(),
|
|
api_base_url: "https://open.tiktokapis.com/v2".to_string(),
|
|
oauth_base_url: "https://open.tiktokapis.com/v2/oauth".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Initialize a video upload (Direct Post)
|
|
/// Returns upload URL and publish_id for tracking
|
|
pub async fn init_video_upload(
|
|
&self,
|
|
access_token: &str,
|
|
request: &VideoUploadRequest,
|
|
) -> Result<VideoUploadInit, ChannelError> {
|
|
let url = format!("{}/post/publish/video/init/", self.api_base_url);
|
|
|
|
let post_info = serde_json::json!({
|
|
"title": request.title,
|
|
"privacy_level": request.privacy_level,
|
|
"disable_duet": request.disable_duet.unwrap_or(false),
|
|
"disable_comment": request.disable_comment.unwrap_or(false),
|
|
"disable_stitch": request.disable_stitch.unwrap_or(false),
|
|
"video_cover_timestamp_ms": request.video_cover_timestamp_ms.unwrap_or(0),
|
|
"brand_content_toggle": request.brand_content_toggle.unwrap_or(false),
|
|
"brand_organic_toggle": request.brand_organic_toggle.unwrap_or(false)
|
|
});
|
|
|
|
let source_info = if let Some(url) = &request.video_url {
|
|
serde_json::json!({
|
|
"source": "PULL_FROM_URL",
|
|
"video_url": url
|
|
})
|
|
} else {
|
|
serde_json::json!({
|
|
"source": "FILE_UPLOAD",
|
|
"video_size": request.video_size.unwrap_or(0),
|
|
"chunk_size": request.chunk_size.unwrap_or(10_000_000),
|
|
"total_chunk_count": request.total_chunk_count.unwrap_or(1)
|
|
})
|
|
};
|
|
|
|
let request_body = serde_json::json!({
|
|
"post_info": post_info,
|
|
"source_info": source_info
|
|
});
|
|
|
|
let response = self
|
|
.client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {}", access_token))
|
|
.header("Content-Type", "application/json; charset=UTF-8")
|
|
.json(&request_body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(self.parse_error_response(response).await);
|
|
}
|
|
|
|
let api_response: TikTokApiResponse<VideoUploadInit> =
|
|
response.json().await.map_err(|e| ChannelError::ApiError {
|
|
code: None,
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
api_response.data.ok_or_else(|| ChannelError::ApiError {
|
|
code: api_response.error.as_ref().map(|e| e.code.clone()),
|
|
message: api_response
|
|
.error
|
|
.map(|e| e.message)
|
|
.unwrap_or_else(|| "Unknown error".to_string()),
|
|
})
|
|
}
|
|
|
|
/// Upload video chunk (for FILE_UPLOAD source)
|
|
pub async fn upload_video_chunk(
|
|
&self,
|
|
upload_url: &str,
|
|
chunk_data: &[u8],
|
|
chunk_index: u32,
|
|
total_chunks: u32,
|
|
) -> Result<(), ChannelError> {
|
|
let content_range = if total_chunks == 1 {
|
|
format!("bytes 0-{}/{}", chunk_data.len() - 1, chunk_data.len())
|
|
} else {
|
|
let start = chunk_index as usize * chunk_data.len();
|
|
let end = start + chunk_data.len() - 1;
|
|
let total = total_chunks as usize * chunk_data.len();
|
|
format!("bytes {}-{}/{}", start, end, total)
|
|
};
|
|
|
|
let response = self
|
|
.client
|
|
.put(upload_url)
|
|
.header("Content-Type", "video/mp4")
|
|
.header("Content-Length", chunk_data.len().to_string())
|
|
.header("Content-Range", content_range)
|
|
.body(chunk_data.to_vec())
|
|
.send()
|
|
.await
|
|
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(self.parse_error_response(response).await);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Check the status of a video upload/publish
|
|
pub async fn get_publish_status(
|
|
&self,
|
|
access_token: &str,
|
|
publish_id: &str,
|
|
) -> Result<PublishStatus, ChannelError> {
|
|
let url = format!("{}/post/publish/status/fetch/", self.api_base_url);
|
|
|
|
let request_body = serde_json::json!({
|
|
"publish_id": publish_id
|
|
});
|
|
|
|
let response = self
|
|
.client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {}", access_token))
|
|
.header("Content-Type", "application/json; charset=UTF-8")
|
|
.json(&request_body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(self.parse_error_response(response).await);
|
|
}
|
|
|
|
let api_response: TikTokApiResponse<PublishStatus> =
|
|
response.json().await.map_err(|e| ChannelError::ApiError {
|
|
code: None,
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
api_response.data.ok_or_else(|| ChannelError::ApiError {
|
|
code: api_response.error.as_ref().map(|e| e.code.clone()),
|
|
message: api_response
|
|
.error
|
|
.map(|e| e.message)
|
|
.unwrap_or_else(|| "Unknown error".to_string()),
|
|
})
|
|
}
|
|
|
|
/// Initialize a photo post
|
|
pub async fn init_photo_post(
|
|
&self,
|
|
access_token: &str,
|
|
request: &PhotoPostRequest,
|
|
) -> Result<PhotoUploadInit, ChannelError> {
|
|
let url = format!("{}/post/publish/content/init/", self.api_base_url);
|
|
|
|
let request_body = serde_json::json!({
|
|
"post_info": {
|
|
"title": request.title,
|
|
"description": request.description,
|
|
"privacy_level": request.privacy_level,
|
|
"disable_comment": request.disable_comment.unwrap_or(false),
|
|
"auto_add_music": request.auto_add_music.unwrap_or(true)
|
|
},
|
|
"source_info": {
|
|
"source": "PULL_FROM_URL",
|
|
"photo_cover_index": request.photo_cover_index.unwrap_or(0),
|
|
"photo_images": request.photo_urls
|
|
},
|
|
"post_mode": "DIRECT_POST",
|
|
"media_type": "PHOTO"
|
|
});
|
|
|
|
let response = self
|
|
.client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {}", access_token))
|
|
.header("Content-Type", "application/json; charset=UTF-8")
|
|
.json(&request_body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(self.parse_error_response(response).await);
|
|
}
|
|
|
|
let api_response: TikTokApiResponse<PhotoUploadInit> =
|
|
response.json().await.map_err(|e| ChannelError::ApiError {
|
|
code: None,
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
api_response.data.ok_or_else(|| ChannelError::ApiError {
|
|
code: api_response.error.as_ref().map(|e| e.code.clone()),
|
|
message: api_response
|
|
.error
|
|
.map(|e| e.message)
|
|
.unwrap_or_else(|| "Unknown error".to_string()),
|
|
})
|
|
}
|
|
|
|
/// Get user info (requires user.info.basic or user.info.profile scope)
|
|
pub async fn get_user_info(
|
|
&self,
|
|
access_token: &str,
|
|
fields: &[&str],
|
|
) -> Result<TikTokUser, ChannelError> {
|
|
let fields_param = fields.join(",");
|
|
let url = format!("{}/user/info/", self.api_base_url);
|
|
|
|
let response = self
|
|
.client
|
|
.get(&url)
|
|
.header("Authorization", format!("Bearer {}", access_token))
|
|
.query(&[("fields", fields_param)])
|
|
.send()
|
|
.await
|
|
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(self.parse_error_response(response).await);
|
|
}
|
|
|
|
let api_response: TikTokApiResponse<UserInfoData> =
|
|
response.json().await.map_err(|e| ChannelError::ApiError {
|
|
code: None,
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
api_response
|
|
.data
|
|
.and_then(|d| d.user)
|
|
.ok_or_else(|| ChannelError::ApiError {
|
|
code: api_response.error.as_ref().map(|e| e.code.clone()),
|
|
message: api_response
|
|
.error
|
|
.map(|e| e.message)
|
|
.unwrap_or_else(|| "User not found".to_string()),
|
|
})
|
|
}
|
|
|
|
/// List user videos (requires video.list scope)
|
|
pub async fn list_videos(
|
|
&self,
|
|
access_token: &str,
|
|
options: &VideoListOptions,
|
|
) -> Result<VideoListResponse, ChannelError> {
|
|
let url = format!("{}/video/list/", self.api_base_url);
|
|
|
|
let fields = options.fields.as_deref().unwrap_or(
|
|
"id,create_time,cover_image_url,share_url,video_description,duration,title",
|
|
);
|
|
|
|
let mut request_body = serde_json::json!({
|
|
"max_count": options.max_count.unwrap_or(20)
|
|
});
|
|
|
|
if let Some(cursor) = &options.cursor {
|
|
request_body["cursor"] = serde_json::json!(cursor);
|
|
}
|
|
|
|
let response = self
|
|
.client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {}", access_token))
|
|
.header("Content-Type", "application/json; charset=UTF-8")
|
|
.query(&[("fields", fields)])
|
|
.json(&request_body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(self.parse_error_response(response).await);
|
|
}
|
|
|
|
let api_response: TikTokApiResponse<VideoListResponse> =
|
|
response.json().await.map_err(|e| ChannelError::ApiError {
|
|
code: None,
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
api_response.data.ok_or_else(|| ChannelError::ApiError {
|
|
code: api_response.error.as_ref().map(|e| e.code.clone()),
|
|
message: api_response
|
|
.error
|
|
.map(|e| e.message)
|
|
.unwrap_or_else(|| "Failed to list videos".to_string()),
|
|
})
|
|
}
|
|
|
|
/// Query specific videos by IDs (requires video.list scope)
|
|
pub async fn query_videos(
|
|
&self,
|
|
access_token: &str,
|
|
video_ids: &[String],
|
|
fields: Option<&str>,
|
|
) -> Result<Vec<TikTokVideo>, ChannelError> {
|
|
let url = format!("{}/video/query/", self.api_base_url);
|
|
|
|
let fields_param = fields.unwrap_or(
|
|
"id,create_time,cover_image_url,share_url,video_description,duration,title,like_count,comment_count,share_count,view_count",
|
|
);
|
|
|
|
let request_body = serde_json::json!({
|
|
"filters": {
|
|
"video_ids": video_ids
|
|
}
|
|
});
|
|
|
|
let response = self
|
|
.client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {}", access_token))
|
|
.header("Content-Type", "application/json; charset=UTF-8")
|
|
.query(&[("fields", fields_param)])
|
|
.json(&request_body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(self.parse_error_response(response).await);
|
|
}
|
|
|
|
let api_response: TikTokApiResponse<VideoQueryResponse> =
|
|
response.json().await.map_err(|e| ChannelError::ApiError {
|
|
code: None,
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
api_response
|
|
.data
|
|
.map(|d| d.videos)
|
|
.ok_or_else(|| ChannelError::ApiError {
|
|
code: api_response.error.as_ref().map(|e| e.code.clone()),
|
|
message: api_response
|
|
.error
|
|
.map(|e| e.message)
|
|
.unwrap_or_else(|| "Failed to query videos".to_string()),
|
|
})
|
|
}
|
|
|
|
/// Get creator info for content posting (requires video.publish scope)
|
|
pub async fn get_creator_info(
|
|
&self,
|
|
access_token: &str,
|
|
) -> Result<CreatorInfo, ChannelError> {
|
|
let url = format!("{}/post/publish/creator_info/", self.api_base_url);
|
|
|
|
let response = self
|
|
.client
|
|
.get(&url)
|
|
.header("Authorization", format!("Bearer {}", access_token))
|
|
.send()
|
|
.await
|
|
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(self.parse_error_response(response).await);
|
|
}
|
|
|
|
let api_response: TikTokApiResponse<CreatorInfo> =
|
|
response.json().await.map_err(|e| ChannelError::ApiError {
|
|
code: None,
|
|
message: e.to_string(),
|
|
})?;
|
|
|
|
api_response.data.ok_or_else(|| ChannelError::ApiError {
|
|
code: api_response.error.as_ref().map(|e| e.code.clone()),
|
|
message: api_response
|
|
.error
|
|
.map(|e| e.message)
|
|
.unwrap_or_else(|| "Failed to get creator info".to_string()),
|
|
})
|
|
}
|
|
|
|
/// Refresh OAuth access token
|
|
pub async fn refresh_oauth_token(
|
|
&self,
|
|
client_key: &str,
|
|
client_secret: &str,
|
|
refresh_token: &str,
|
|
) -> Result<OAuthTokenResponse, ChannelError> {
|
|
let url = format!("{}/token/", self.oauth_base_url);
|
|
|
|
let response = self
|
|
.client
|
|
.post(&url)
|
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
|
.form(&[
|
|
("client_key", client_key),
|
|
("client_secret", client_secret),
|
|
("grant_type", "refresh_token"),
|
|
("refresh_token", refresh_token),
|
|
])
|
|
.send()
|
|
.await
|
|
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(self.parse_error_response(response).await);
|
|
}
|
|
|
|
response
|
|
.json::<OAuthTokenResponse>()
|
|
.await
|
|
.map_err(|e| ChannelError::ApiError {
|
|
code: None,
|
|
message: e.to_string(),
|
|
})
|
|
}
|
|
|
|
/// Revoke OAuth access token
|
|
pub async fn revoke_token(
|
|
&self,
|
|
client_key: &str,
|
|
client_secret: &str,
|
|
access_token: &str,
|
|
) -> Result<(), ChannelError> {
|
|
let url = format!("{}/revoke/", self.oauth_base_url);
|
|
|
|
let response = self
|
|
.client
|
|
.post(&url)
|
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
|
.form(&[
|
|
("client_key", client_key),
|
|
("client_secret", client_secret),
|
|
("token", access_token),
|
|
])
|
|
.send()
|
|
.await
|
|
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(self.parse_error_response(response).await);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Generate OAuth authorization URL
|
|
pub fn get_authorization_url(
|
|
&self,
|
|
client_key: &str,
|
|
redirect_uri: &str,
|
|
scope: &str,
|
|
state: &str,
|
|
) -> String {
|
|
format!(
|
|
"https://www.tiktok.com/v2/auth/authorize/?client_key={}&scope={}&response_type=code&redirect_uri={}&state={}",
|
|
client_key,
|
|
urlencoding::encode(scope),
|
|
urlencoding::encode(redirect_uri),
|
|
urlencoding::encode(state)
|
|
)
|
|
}
|
|
|
|
/// Exchange authorization code for access token
|
|
pub async fn exchange_code_for_token(
|
|
&self,
|
|
client_key: &str,
|
|
client_secret: &str,
|
|
code: &str,
|
|
redirect_uri: &str,
|
|
) -> Result<OAuthTokenResponse, ChannelError> {
|
|
let url = format!("{}/token/", self.oauth_base_url);
|
|
|
|
let response = self
|
|
.client
|
|
.post(&url)
|
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
|
.form(&[
|
|
("client_key", client_key),
|
|
("client_secret", client_secret),
|
|
("code", code),
|
|
("grant_type", "authorization_code"),
|
|
("redirect_uri", redirect_uri),
|
|
])
|
|
.send()
|
|
.await
|
|
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(self.parse_error_response(response).await);
|
|
}
|
|
|
|
response
|
|
.json::<OAuthTokenResponse>()
|
|
.await
|
|
.map_err(|e| ChannelError::ApiError {
|
|
code: None,
|
|
message: e.to_string(),
|
|
})
|
|
}
|
|
|
|
async fn parse_error_response(&self, response: reqwest::Response) -> ChannelError {
|
|
let status = response.status();
|
|
|
|
if status.as_u16() == 401 {
|
|
return ChannelError::AuthenticationFailed("Invalid or expired token".to_string());
|
|
}
|
|
|
|
if status.as_u16() == 429 {
|
|
let retry_after = response
|
|
.headers()
|
|
.get("x-ratelimit-reset")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| s.parse().ok());
|
|
return ChannelError::RateLimited { retry_after };
|
|
}
|
|
|
|
let error_text = response.text().await.unwrap_or_default();
|
|
|
|
if let Ok(api_response) = serde_json::from_str::<TikTokApiResponse<()>>(&error_text) {
|
|
if let Some(error) = api_response.error {
|
|
return ChannelError::ApiError {
|
|
code: Some(error.code),
|
|
message: error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
ChannelError::ApiError {
|
|
code: Some(status.to_string()),
|
|
message: error_text,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for TikTokProvider {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl ChannelProvider for TikTokProvider {
|
|
fn channel_type(&self) -> ChannelType {
|
|
ChannelType::TikTok
|
|
}
|
|
|
|
fn max_text_length(&self) -> usize {
|
|
2200 // Max caption length
|
|
}
|
|
|
|
fn supports_images(&self) -> bool {
|
|
true // Photo mode
|
|
}
|
|
|
|
fn supports_video(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn supports_links(&self) -> bool {
|
|
false // Links not clickable in TikTok captions
|
|
}
|
|
|
|
async fn post(
|
|
&self,
|
|
account: &ChannelAccount,
|
|
content: &PostContent,
|
|
) -> Result<PostResult, ChannelError> {
|
|
let access_token = match &account.credentials {
|
|
ChannelCredentials::OAuth { access_token, .. } => access_token.clone(),
|
|
_ => {
|
|
return Err(ChannelError::AuthenticationFailed(
|
|
"OAuth credentials required for TikTok".to_string(),
|
|
))
|
|
}
|
|
};
|
|
|
|
let text = content.text.as_deref().unwrap_or("");
|
|
|
|
if text.len() > self.max_text_length() {
|
|
return Err(ChannelError::ContentTooLong {
|
|
max_length: self.max_text_length(),
|
|
actual_length: text.len(),
|
|
});
|
|
}
|
|
|
|
// Determine privacy level from settings or default
|
|
let privacy_level = account
|
|
.settings
|
|
.custom
|
|
.get("default_privacy")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("SELF_ONLY")
|
|
.to_string();
|
|
|
|
// Check if we're posting a video or photos
|
|
if let Some(video_url) = &content.video_url {
|
|
// Video post via URL
|
|
let request = VideoUploadRequest {
|
|
title: text.to_string(),
|
|
privacy_level,
|
|
video_url: Some(video_url.clone()),
|
|
disable_duet: Some(false),
|
|
disable_comment: Some(false),
|
|
disable_stitch: Some(false),
|
|
video_cover_timestamp_ms: None,
|
|
brand_content_toggle: None,
|
|
brand_organic_toggle: None,
|
|
video_size: None,
|
|
chunk_size: None,
|
|
total_chunk_count: None,
|
|
};
|
|
|
|
let init_result = self.init_video_upload(&access_token, &request).await?;
|
|
|
|
Ok(PostResult::success(
|
|
ChannelType::TikTok,
|
|
init_result.publish_id,
|
|
None, // URL not available until processing complete
|
|
))
|
|
} else if !content.image_urls.is_empty() {
|
|
// Photo post
|
|
let request = PhotoPostRequest {
|
|
title: text.to_string(),
|
|
description: content
|
|
.metadata
|
|
.get("description")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from),
|
|
privacy_level,
|
|
photo_urls: content.image_urls.clone(),
|
|
photo_cover_index: Some(0),
|
|
disable_comment: Some(false),
|
|
auto_add_music: Some(true),
|
|
};
|
|
|
|
let init_result = self.init_photo_post(&access_token, &request).await?;
|
|
|
|
Ok(PostResult::success(
|
|
ChannelType::TikTok,
|
|
init_result.publish_id,
|
|
None,
|
|
))
|
|
} else {
|
|
Err(ChannelError::ApiError {
|
|
code: None,
|
|
message: "TikTok requires either a video or photos to post".to_string(),
|
|
})
|
|
}
|
|
}
|
|
|
|
async fn validate_credentials(
|
|
&self,
|
|
credentials: &ChannelCredentials,
|
|
) -> Result<bool, ChannelError> {
|
|
match credentials {
|
|
ChannelCredentials::OAuth { access_token, .. } => {
|
|
match self
|
|
.get_user_info(access_token, &["open_id", "display_name"])
|
|
.await
|
|
{
|
|
Ok(_) => Ok(true),
|
|
Err(ChannelError::AuthenticationFailed(_)) => Ok(false),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
_ => Ok(false),
|
|
}
|
|
}
|
|
|
|
async fn refresh_token(&self, account: &mut ChannelAccount) -> Result<(), ChannelError> {
|
|
let (refresh_token, client_key, client_secret) = match &account.credentials {
|
|
ChannelCredentials::OAuth { refresh_token, .. } => {
|
|
let refresh = refresh_token.as_ref().ok_or_else(|| {
|
|
ChannelError::AuthenticationFailed("No refresh token available".to_string())
|
|
})?;
|
|
let client_key = account
|
|
.settings
|
|
.custom
|
|
.get("client_key")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| {
|
|
ChannelError::AuthenticationFailed("Missing client_key".to_string())
|
|
})?;
|
|
let client_secret = account
|
|
.settings
|
|
.custom
|
|
.get("client_secret")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| {
|
|
ChannelError::AuthenticationFailed("Missing client_secret".to_string())
|
|
})?;
|
|
(
|
|
refresh.clone(),
|
|
client_key.to_string(),
|
|
client_secret.to_string(),
|
|
)
|
|
}
|
|
_ => {
|
|
return Err(ChannelError::AuthenticationFailed(
|
|
"OAuth credentials required".to_string(),
|
|
))
|
|
}
|
|
};
|
|
|
|
let token_response = self
|
|
.refresh_oauth_token(&client_key, &client_secret, &refresh_token)
|
|
.await?;
|
|
|
|
let expires_at =
|
|
chrono::Utc::now() + chrono::Duration::seconds(token_response.expires_in as i64);
|
|
|
|
account.credentials = ChannelCredentials::OAuth {
|
|
access_token: token_response.access_token,
|
|
refresh_token: Some(token_response.refresh_token),
|
|
expires_at: Some(expires_at),
|
|
scope: Some(token_response.scope),
|
|
};
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Request Types
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct VideoUploadRequest {
|
|
pub title: String,
|
|
pub privacy_level: String, // "PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "SELF_ONLY", "FOLLOWER_OF_CREATOR"
|
|
pub video_url: Option<String>,
|
|
pub disable_duet: Option<bool>,
|
|
pub disable_comment: Option<bool>,
|
|
pub disable_stitch: Option<bool>,
|
|
pub video_cover_timestamp_ms: Option<u64>,
|
|
pub brand_content_toggle: Option<bool>,
|
|
pub brand_organic_toggle: Option<bool>,
|
|
// For FILE_UPLOAD source
|
|
pub video_size: Option<u64>,
|
|
pub chunk_size: Option<u64>,
|
|
pub total_chunk_count: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PhotoPostRequest {
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub privacy_level: String,
|
|
pub photo_urls: Vec<String>,
|
|
pub photo_cover_index: Option<u32>,
|
|
pub disable_comment: Option<bool>,
|
|
pub auto_add_music: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct VideoListOptions {
|
|
pub cursor: Option<i64>,
|
|
pub max_count: Option<u32>,
|
|
pub fields: Option<String>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Response Types
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TikTokApiResponse<T> {
|
|
pub data: Option<T>,
|
|
pub error: Option<TikTokError>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TikTokError {
|
|
pub code: String,
|
|
pub message: String,
|
|
pub log_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct VideoUploadInit {
|
|
pub publish_id: String,
|
|
pub upload_url: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PhotoUploadInit {
|
|
pub publish_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PublishStatus {
|
|
pub status: String, // "PROCESSING_UPLOAD", "PROCESSING_DOWNLOAD", "SEND_TO_USER_INBOX", "PUBLISH_COMPLETE", "FAILED"
|
|
#[serde(default)]
|
|
pub fail_reason: Option<String>,
|
|
pub publicly_available_post_id: Option<Vec<String>>,
|
|
pub uploaded_bytes: Option<u64>,
|
|
}
|
|
|
|
impl PublishStatus {
|
|
pub fn is_complete(&self) -> bool {
|
|
self.status == "PUBLISH_COMPLETE"
|
|
}
|
|
|
|
pub fn is_failed(&self) -> bool {
|
|
self.status == "FAILED"
|
|
}
|
|
|
|
pub fn is_processing(&self) -> bool {
|
|
matches!(
|
|
self.status.as_str(),
|
|
"PROCESSING_UPLOAD" | "PROCESSING_DOWNLOAD" | "SEND_TO_USER_INBOX"
|
|
)
|
|
}
|
|
|
|
pub fn get_video_id(&self) -> Option<&str> {
|
|
self.publicly_available_post_id
|
|
.as_ref()
|
|
.and_then(|ids| ids.first())
|
|
.map(|s| s.as_str())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct UserInfoData {
|
|
pub user: Option<TikTokUser>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TikTokUser {
|
|
pub open_id: String,
|
|
pub union_id: Option<String>,
|
|
pub display_name: Option<String>,
|
|
pub avatar_url: Option<String>,
|
|
pub avatar_url_100: Option<String>,
|
|
pub avatar_large_url: Option<String>,
|
|
pub bio_description: Option<String>,
|
|
pub profile_deep_link: Option<String>,
|
|
pub is_verified: Option<bool>,
|
|
pub follower_count: Option<u64>,
|
|
pub following_count: Option<u64>,
|
|
pub likes_count: Option<u64>,
|
|
pub video_count: Option<u64>,
|
|
}
|
|
|
|
impl TikTokUser {
|
|
/// Get the user's TikTok profile URL
|
|
pub fn profile_url(&self) -> Option<String> {
|
|
self.profile_deep_link.clone()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct VideoListResponse {
|
|
pub videos: Vec<TikTokVideo>,
|
|
pub cursor: Option<i64>,
|
|
pub has_more: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct VideoQueryResponse {
|
|
pub videos: Vec<TikTokVideo>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TikTokVideo {
|
|
pub id: String,
|
|
pub create_time: Option<i64>,
|
|
pub cover_image_url: Option<String>,
|
|
pub share_url: Option<String>,
|
|
pub video_description: Option<String>,
|
|
pub duration: Option<u32>,
|
|
pub title: Option<String>,
|
|
pub height: Option<u32>,
|
|
pub width: Option<u32>,
|
|
pub like_count: Option<u64>,
|
|
pub comment_count: Option<u64>,
|
|
pub share_count: Option<u64>,
|
|
pub view_count: Option<u64>,
|
|
pub embed_html: Option<String>,
|
|
pub embed_link: Option<String>,
|
|
}
|
|
|
|
impl TikTokVideo {
|
|
/// Get the video URL
|
|
pub fn url(&self) -> Option<&str> {
|
|
self.share_url.as_deref()
|
|
}
|
|
|
|
/// Get video creation time as DateTime
|
|
pub fn created_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
|
|
self.create_time
|
|
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CreatorInfo {
|
|
pub creator_avatar_url: Option<String>,
|
|
pub creator_username: Option<String>,
|
|
pub creator_nickname: Option<String>,
|
|
pub privacy_level_options: Option<Vec<String>>,
|
|
pub comment_disabled: Option<bool>,
|
|
pub duet_disabled: Option<bool>,
|
|
pub stitch_disabled: Option<bool>,
|
|
pub max_video_post_duration_sec: Option<u32>,
|
|
}
|
|
|
|
impl CreatorInfo {
|
|
/// Check if public posting is available
|
|
pub fn can_post_public(&self) -> bool {
|
|
self.privacy_level_options
|
|
.as_ref()
|
|
.map(|opts| opts.contains(&"PUBLIC_TO_EVERYONE".to_string()))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Get max video duration in seconds
|
|
pub fn max_video_duration(&self) -> u32 {
|
|
self.max_video_post_duration_sec.unwrap_or(60)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct OAuthTokenResponse {
|
|
pub access_token: String,
|
|
pub refresh_token: String,
|
|
pub expires_in: u64,
|
|
pub refresh_expires_in: u64,
|
|
pub open_id: String,
|
|
pub scope: String,
|
|
pub token_type: String,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Privacy Level Constants
|
|
// ============================================================================
|
|
|
|
/// Privacy levels for TikTok posts
|
|
pub struct PrivacyLevel;
|
|
|
|
impl PrivacyLevel {
|
|
/// Video visible to everyone
|
|
pub const PUBLIC: &'static str = "PUBLIC_TO_EVERYONE";
|
|
/// Video visible only to mutual followers
|
|
pub const FRIENDS: &'static str = "MUTUAL_FOLLOW_FRIENDS";
|
|
/// Video visible only to creator
|
|
pub const PRIVATE: &'static str = "SELF_ONLY";
|
|
/// Video visible to followers only
|
|
pub const FOLLOWERS: &'static str = "FOLLOWER_OF_CREATOR";
|
|
}
|
|
|
|
// ============================================================================
|
|
// Scopes
|
|
// ============================================================================
|
|
|
|
/// OAuth scopes for TikTok API
|
|
pub struct TikTokScopes;
|
|
|
|
impl TikTokScopes {
|
|
/// Basic user info (open_id, union_id, avatar)
|
|
pub const USER_INFO_BASIC: &'static str = "user.info.basic";
|
|
/// Extended user profile (bio, verified status, stats)
|
|
pub const USER_INFO_PROFILE: &'static str = "user.info.profile";
|
|
/// User's email address
|
|
pub const USER_INFO_STATS: &'static str = "user.info.stats";
|
|
/// List user's videos
|
|
pub const VIDEO_LIST: &'static str = "video.list";
|
|
/// Upload and publish videos
|
|
pub const VIDEO_PUBLISH: &'static str = "video.publish";
|
|
/// Upload videos to user's inbox
|
|
pub const VIDEO_UPLOAD: &'static str = "video.upload";
|
|
|
|
/// Get recommended scopes for content posting
|
|
pub fn content_posting_scopes() -> &'static str {
|
|
"user.info.basic,video.publish"
|
|
}
|
|
|
|
/// Get all available scopes
|
|
pub fn all_scopes() -> &'static str {
|
|
"user.info.basic,user.info.profile,user.info.stats,video.list,video.publish,video.upload"
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
/// Build hashtag string from list
|
|
pub fn build_hashtags(tags: &[String]) -> String {
|
|
tags.iter()
|
|
.map(|t| {
|
|
if t.starts_with('#') {
|
|
t.clone()
|
|
} else {
|
|
format!("#{}", t)
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
/// Validate video file for TikTok upload
|
|
pub fn validate_video_file(size_bytes: u64, duration_seconds: u32) -> Result<(), ChannelError> {
|
|
// TikTok limits: 4GB max, typically 60s for regular users, up to 10 min for some
|
|
const MAX_SIZE: u64 = 4 * 1024 * 1024 * 1024; // 4GB
|
|
const MAX_DURATION: u32 = 600; // 10 minutes
|
|
|
|
if size_bytes > MAX_SIZE {
|
|
return Err(ChannelError::ApiError {
|
|
code: Some("VIDEO_TOO_LARGE".to_string()),
|
|
message: format!(
|
|
"Video file too large: {} bytes (max: {} bytes)",
|
|
size_bytes, MAX_SIZE
|
|
),
|
|
});
|
|
}
|
|
|
|
if duration_seconds > MAX_DURATION {
|
|
return Err(ChannelError::ApiError {
|
|
code: Some("VIDEO_TOO_LONG".to_string()),
|
|
message: format!(
|
|
"Video too long: {} seconds (max: {} seconds)",
|
|
duration_seconds, MAX_DURATION
|
|
),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Supported video formats for TikTok
|
|
pub struct VideoFormats;
|
|
|
|
impl VideoFormats {
|
|
pub const MP4: &'static str = "video/mp4";
|
|
pub const WEBM: &'static str = "video/webm";
|
|
pub const MOV: &'static str = "video/quicktime";
|
|
|
|
pub fn is_supported(content_type: &str) -> bool {
|
|
matches!(
|
|
content_type.to_lowercase().as_str(),
|
|
"video/mp4" | "video/webm" | "video/quicktime"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Supported image formats for TikTok photo posts
|
|
pub struct ImageFormats;
|
|
|
|
impl ImageFormats {
|
|
pub const JPEG: &'static str = "image/jpeg";
|
|
pub const PNG: &'static str = "image/png";
|
|
pub const WEBP: &'static str = "image/webp";
|
|
|
|
pub fn is_supported(content_type: &str) -> bool {
|
|
matches!(
|
|
content_type.to_lowercase().as_str(),
|
|
"image/jpeg" | "image/png" | "image/webp"
|
|
)
|
|
}
|
|
}
|