botserver/src/channels/snapchat.rs
Rodrigo Rodriguez (Pragmatismo) b674d85583 Fix SafeCommand to allow shell scripts with redirects and command chaining
- Add shell_script_arg() method for bash/sh/cmd -c scripts
- Allow > < redirects in shell scripts (blocked in regular args)
- Allow && || command chaining in shell scripts
- Update safe_sh_command functions to use shell_script_arg
- Update run_commands, start, and LLM server commands
- Block dangerous patterns: backticks, path traversal
- Fix struct field mismatches and type errors
2026-01-08 23:50:38 -03:00

1500 lines
45 KiB
Rust

//! Snapchat Marketing API Integration
//!
//! Provides Snap Ads management, audience targeting, and content publishing capabilities.
//! Supports OAuth 2.0 authentication flow for Snapchat Marketing API.
use crate::channels::{
ChannelAccount, ChannelCredentials, ChannelError, ChannelProvider, ChannelType, PostContent,
PostResult,
};
use serde::{Deserialize, Serialize};
/// Snapchat Marketing API provider
pub struct SnapchatProvider {
client: reqwest::Client,
api_base_url: String,
oauth_base_url: String,
}
impl SnapchatProvider {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
api_base_url: "https://adsapi.snapchat.com/v1".to_string(),
oauth_base_url: "https://accounts.snapchat.com".to_string(),
}
}
/// Get authenticated user info
pub async fn get_me(&self, access_token: &str) -> Result<SnapchatUser, ChannelError> {
let url = format!("{}/me", 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 result: MeResponse = response.json().await.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
result.me.ok_or_else(|| ChannelError::ApiError {
code: None,
message: "No user data in response".to_string(),
})
}
/// List organizations for the authenticated user
pub async fn list_organizations(
&self,
access_token: &str,
) -> Result<Vec<Organization>, ChannelError> {
let url = format!("{}/me/organizations", 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 result: OrganizationsResponse =
response.json().await.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
Ok(result
.organizations
.into_iter()
.filter_map(|wrapper| wrapper.organization)
.collect())
}
/// List ad accounts for an organization
pub async fn list_ad_accounts(
&self,
access_token: &str,
organization_id: &str,
) -> Result<Vec<AdAccount>, ChannelError> {
let url = format!(
"{}/organizations/{}/adaccounts",
self.api_base_url, organization_id
);
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 result: AdAccountsResponse =
response.json().await.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
Ok(result
.adaccounts
.into_iter()
.filter_map(|wrapper| wrapper.adaccount)
.collect())
}
/// Create a campaign
pub async fn create_campaign(
&self,
access_token: &str,
ad_account_id: &str,
campaign: &CampaignCreateRequest,
) -> Result<Campaign, ChannelError> {
let url = format!(
"{}/adaccounts/{}/campaigns",
self.api_base_url, ad_account_id
);
let request_body = serde_json::json!({
"campaigns": [{
"name": campaign.name,
"status": campaign.status,
"objective": campaign.objective,
"start_time": campaign.start_time,
"end_time": campaign.end_time,
"daily_budget_micro": campaign.daily_budget_micro,
"lifetime_spend_cap_micro": campaign.lifetime_spend_cap_micro
}]
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json")
.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 result: CampaignsResponse =
response.json().await.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
result
.campaigns
.into_iter()
.next()
.and_then(|wrapper| wrapper.campaign)
.ok_or_else(|| ChannelError::ApiError {
code: None,
message: "No campaign in response".to_string(),
})
}
/// List campaigns for an ad account
pub async fn list_campaigns(
&self,
access_token: &str,
ad_account_id: &str,
) -> Result<Vec<Campaign>, ChannelError> {
let url = format!(
"{}/adaccounts/{}/campaigns",
self.api_base_url, ad_account_id
);
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 result: CampaignsResponse =
response.json().await.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
Ok(result
.campaigns
.into_iter()
.filter_map(|wrapper| wrapper.campaign)
.collect())
}
/// Create an ad squad (ad set)
pub async fn create_ad_squad(
&self,
access_token: &str,
campaign_id: &str,
ad_squad: &AdSquadCreateRequest,
) -> Result<AdSquad, ChannelError> {
let url = format!("{}/campaigns/{}/adsquads", self.api_base_url, campaign_id);
let request_body = serde_json::json!({
"adsquads": [{
"name": ad_squad.name,
"status": ad_squad.status,
"type": ad_squad.squad_type,
"placement_v2": ad_squad.placement,
"billing_event": ad_squad.billing_event,
"bid_micro": ad_squad.bid_micro,
"daily_budget_micro": ad_squad.daily_budget_micro,
"start_time": ad_squad.start_time,
"end_time": ad_squad.end_time,
"optimization_goal": ad_squad.optimization_goal,
"targeting": ad_squad.targeting
}]
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json")
.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 result: AdSquadsResponse =
response.json().await.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
result
.adsquads
.into_iter()
.next()
.and_then(|wrapper| wrapper.adsquad)
.ok_or_else(|| ChannelError::ApiError {
code: None,
message: "No ad squad in response".to_string(),
})
}
/// Create a creative
pub async fn create_creative(
&self,
access_token: &str,
ad_account_id: &str,
creative: &CreativeCreateRequest,
) -> Result<Creative, ChannelError> {
let url = format!(
"{}/adaccounts/{}/creatives",
self.api_base_url, ad_account_id
);
let request_body = serde_json::json!({
"creatives": [{
"name": creative.name,
"type": creative.creative_type,
"headline": creative.headline,
"brand_name": creative.brand_name,
"shareable": creative.shareable,
"call_to_action": creative.call_to_action,
"top_snap_media_id": creative.top_snap_media_id,
"top_snap_crop_position": creative.top_snap_crop_position,
"longform_video_properties": creative.longform_video_properties,
"web_view_properties": creative.web_view_properties
}]
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json")
.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 result: CreativesResponse =
response.json().await.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
result
.creatives
.into_iter()
.next()
.and_then(|wrapper| wrapper.creative)
.ok_or_else(|| ChannelError::ApiError {
code: None,
message: "No creative in response".to_string(),
})
}
/// Create an ad
pub async fn create_ad(
&self,
access_token: &str,
ad_squad_id: &str,
ad: &AdCreateRequest,
) -> Result<Ad, ChannelError> {
let url = format!("{}/adsquads/{}/ads", self.api_base_url, ad_squad_id);
let request_body = serde_json::json!({
"ads": [{
"name": ad.name,
"status": ad.status,
"creative_id": ad.creative_id,
"type": ad.ad_type
}]
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json")
.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 result: AdsResponse = response.json().await.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
result
.ads
.into_iter()
.next()
.and_then(|wrapper| wrapper.ad)
.ok_or_else(|| ChannelError::ApiError {
code: None,
message: "No ad in response".to_string(),
})
}
/// Upload media (initialize upload)
pub async fn init_media_upload(
&self,
access_token: &str,
ad_account_id: &str,
media: &MediaUploadRequest,
) -> Result<Media, ChannelError> {
let url = format!("{}/adaccounts/{}/media", self.api_base_url, ad_account_id);
let request_body = serde_json::json!({
"media": [{
"name": media.name,
"type": media.media_type,
"ad_account_id": ad_account_id
}]
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json")
.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 result: MediaResponse = response.json().await.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
result
.media
.into_iter()
.next()
.and_then(|wrapper| wrapper.media)
.ok_or_else(|| ChannelError::ApiError {
code: None,
message: "No media in response".to_string(),
})
}
/// Upload media chunk
pub async fn upload_media_chunk(
&self,
access_token: &str,
ad_account_id: &str,
media_id: &str,
chunk_data: &[u8],
chunk_number: u32,
) -> Result<(), ChannelError> {
let url = format!(
"{}/adaccounts/{}/media/{}/upload",
self.api_base_url, ad_account_id, media_id
);
let part = reqwest::multipart::Part::bytes(chunk_data.to_vec())
.file_name("chunk")
.mime_str("application/octet-stream")
.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
let form = reqwest::multipart::Form::new()
.part("file", part)
.text("chunk_number", chunk_number.to_string());
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", access_token))
.multipart(form)
.send()
.await
.map_err(|e| ChannelError::NetworkError(e.to_string()))?;
if !response.status().is_success() {
return Err(self.parse_error_response(response).await);
}
Ok(())
}
/// Complete media upload
pub async fn complete_media_upload(
&self,
access_token: &str,
ad_account_id: &str,
media_id: &str,
total_chunks: u32,
) -> Result<Media, ChannelError> {
let url = format!(
"{}/adaccounts/{}/media/{}/complete",
self.api_base_url, ad_account_id, media_id
);
let request_body = serde_json::json!({
"total_chunks": total_chunks
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json")
.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 result: SingleMediaResponse =
response.json().await.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
result.media.ok_or_else(|| ChannelError::ApiError {
code: None,
message: "No media in response".to_string(),
})
}
/// Get media status
pub async fn get_media(
&self,
access_token: &str,
ad_account_id: &str,
media_id: &str,
) -> Result<Media, ChannelError> {
let url = format!(
"{}/adaccounts/{}/media/{}",
self.api_base_url, ad_account_id, media_id
);
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 result: SingleMediaResponse =
response.json().await.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
result.media.ok_or_else(|| ChannelError::ApiError {
code: None,
message: "No media in response".to_string(),
})
}
/// Create a custom audience
pub async fn create_audience(
&self,
access_token: &str,
ad_account_id: &str,
audience: &AudienceCreateRequest,
) -> Result<Audience, ChannelError> {
let url = format!(
"{}/adaccounts/{}/segments",
self.api_base_url, ad_account_id
);
let request_body = serde_json::json!({
"segments": [{
"name": audience.name,
"description": audience.description,
"source_type": audience.source_type,
"retention_in_days": audience.retention_in_days,
"ad_account_id": ad_account_id
}]
});
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json")
.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 result: AudiencesResponse =
response.json().await.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})?;
result
.segments
.into_iter()
.next()
.and_then(|wrapper| wrapper.segment)
.ok_or_else(|| ChannelError::ApiError {
code: None,
message: "No audience in response".to_string(),
})
}
/// Get campaign stats
pub async fn get_campaign_stats(
&self,
access_token: &str,
campaign_id: &str,
granularity: &str,
start_time: &str,
end_time: &str,
) -> Result<CampaignStats, ChannelError> {
let url = format!("{}/campaigns/{}/stats", self.api_base_url, campaign_id);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", access_token))
.query(&[
("granularity", granularity),
("start_time", start_time),
("end_time", end_time),
])
.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::<CampaignStats>()
.await
.map_err(|e| ChannelError::ApiError {
code: None,
message: e.to_string(),
})
}
/// Refresh OAuth token
pub async fn refresh_oauth_token(
&self,
client_id: &str,
client_secret: &str,
refresh_token: &str,
) -> Result<OAuthTokenResponse, ChannelError> {
let url = format!("{}/login/oauth2/access_token", self.oauth_base_url);
let response = self
.client
.post(&url)
.form(&[
("client_id", client_id),
("client_secret", client_secret),
("refresh_token", refresh_token),
("grant_type", "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(),
})
}
/// Generate OAuth authorization URL
pub fn get_authorization_url(
&self,
client_id: &str,
redirect_uri: &str,
scope: &str,
state: &str,
) -> String {
format!(
"{}/login/oauth2/authorize?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}",
self.oauth_base_url,
client_id,
urlencoding::encode(redirect_uri),
urlencoding::encode(scope),
urlencoding::encode(state)
)
}
/// Exchange authorization code for access token
pub async fn exchange_code_for_token(
&self,
client_id: &str,
client_secret: &str,
code: &str,
redirect_uri: &str,
) -> Result<OAuthTokenResponse, ChannelError> {
let url = format!("{}/login/oauth2/access_token", self.oauth_base_url);
let response = self
.client
.post(&url)
.form(&[
("client_id", client_id),
("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("retry-after")
.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(error_response) = serde_json::from_str::<SnapchatErrorResponse>(&error_text) {
return ChannelError::ApiError {
code: error_response.request_status.clone(),
message: error_response
.debug_message
.or(error_response.display_message)
.unwrap_or_else(|| error_response.request_status.unwrap_or_default()),
};
}
ChannelError::ApiError {
code: Some(status.to_string()),
message: error_text,
}
}
}
impl Default for SnapchatProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl ChannelProvider for SnapchatProvider {
fn channel_type(&self) -> ChannelType {
ChannelType::Snapchat
}
fn max_text_length(&self) -> usize {
34 // Headline character limit for ads
}
fn supports_images(&self) -> bool {
true
}
fn supports_video(&self) -> bool {
true
}
fn supports_links(&self) -> bool {
true
}
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 Snapchat".to_string(),
))
}
};
// Get required IDs from settings
let ad_account_id = account
.settings
.custom
.get("ad_account_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::ApiError {
code: None,
message: "ad_account_id required in settings".to_string(),
})?;
account
.settings
.custom
.get("campaign_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::ApiError {
code: None,
message: "campaign_id required in settings".to_string(),
})?;
let ad_squad_id = account
.settings
.custom
.get("ad_squad_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::ApiError {
code: None,
message: "ad_squad_id required in settings".to_string(),
})?;
let text = content.text.as_deref().unwrap_or("");
// For Snapchat Ads, we need a media file first
// This is a simplified flow - real implementation would handle media upload
let media_id = content
.metadata
.get("media_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::ApiError {
code: None,
message: "media_id required for Snapchat ads".to_string(),
})?;
// Create creative
let creative_request = CreativeCreateRequest {
name: format!("Creative - {}", chrono::Utc::now().format("%Y%m%d%H%M%S")),
creative_type: "SNAP_AD".to_string(),
headline: Some(text.chars().take(34).collect()),
brand_name: content
.metadata
.get("brand_name")
.and_then(|v| v.as_str())
.map(String::from),
shareable: Some(true),
call_to_action: content
.metadata
.get("cta")
.and_then(|v| v.as_str())
.map(String::from),
top_snap_media_id: media_id.to_string(),
top_snap_crop_position: Some("MIDDLE".to_string()),
longform_video_properties: None,
web_view_properties: content.link.as_ref().map(|url| WebViewProperties {
url: url.clone(),
allow_snap_javascript_sdk: Some(false),
use_immersive_mode: Some(false),
deep_link_urls: None,
}),
};
let creative = self
.create_creative(&access_token, ad_account_id, &creative_request)
.await?;
// Create ad
let ad_request = AdCreateRequest {
name: format!("Ad - {}", chrono::Utc::now().format("%Y%m%d%H%M%S")),
status: "ACTIVE".to_string(),
creative_id: creative.id.clone(),
ad_type: "SNAP_AD".to_string(),
};
let ad = self
.create_ad(&access_token, ad_squad_id, &ad_request)
.await?;
Ok(PostResult::success(ChannelType::Snapchat, ad.id, None))
}
async fn validate_credentials(
&self,
credentials: &ChannelCredentials,
) -> Result<bool, ChannelError> {
match credentials {
ChannelCredentials::OAuth { access_token, .. } => {
match self.get_me(access_token).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_id, 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_id = account
.settings
.custom
.get("client_id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ChannelError::AuthenticationFailed("Missing client_id".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_id.to_string(),
client_secret.to_string(),
)
}
_ => {
return Err(ChannelError::AuthenticationFailed(
"OAuth credentials required".to_string(),
))
}
};
let token_response = self
.refresh_oauth_token(&client_id, &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 CampaignCreateRequest {
pub name: String,
pub status: String,
pub objective: String,
pub start_time: Option<String>,
pub end_time: Option<String>,
pub daily_budget_micro: Option<i64>,
pub lifetime_spend_cap_micro: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdSquadCreateRequest {
pub name: String,
pub status: String,
pub squad_type: String,
pub placement: Option<PlacementV2>,
pub billing_event: String,
pub bid_micro: i64,
pub daily_budget_micro: Option<i64>,
pub start_time: Option<String>,
pub end_time: Option<String>,
pub optimization_goal: String,
pub targeting: Option<Targeting>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlacementV2 {
pub config: String, // "AUTOMATIC" or "CUSTOM"
pub platforms: Option<Vec<String>>,
pub snap_positions: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Targeting {
pub geos: Option<Vec<GeoTarget>>,
pub demographics: Option<Vec<Demographic>>,
pub interests: Option<Vec<Interest>>,
pub devices: Option<Vec<Device>>,
pub segments: Option<Vec<SegmentTarget>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeoTarget {
pub country_code: String,
pub region_id: Option<String>,
pub metro_id: Option<String>,
pub postal_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Demographic {
pub age_groups: Option<Vec<String>>,
pub genders: Option<Vec<String>>,
pub languages: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Interest {
pub category_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Device {
pub os_type: Option<String>,
pub os_version_min: Option<String>,
pub os_version_max: Option<String>,
pub make: Option<Vec<String>>,
pub connection_type: Option<Vec<String>>,
pub carrier: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SegmentTarget {
pub segment_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreativeCreateRequest {
pub name: String,
pub creative_type: String,
pub headline: Option<String>,
pub brand_name: Option<String>,
pub shareable: Option<bool>,
pub call_to_action: Option<String>,
pub top_snap_media_id: String,
pub top_snap_crop_position: Option<String>,
pub longform_video_properties: Option<LongformVideoProperties>,
pub web_view_properties: Option<WebViewProperties>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LongformVideoProperties {
pub video_media_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebViewProperties {
pub url: String,
pub allow_snap_javascript_sdk: Option<bool>,
pub use_immersive_mode: Option<bool>,
pub deep_link_urls: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdCreateRequest {
pub name: String,
pub status: String,
pub creative_id: String,
pub ad_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaUploadRequest {
pub name: String,
pub media_type: String, // "VIDEO" or "IMAGE"
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudienceCreateRequest {
pub name: String,
pub description: Option<String>,
pub source_type: String,
pub retention_in_days: Option<i32>,
}
// ============================================================================
// Response Types
// ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapchatErrorResponse {
pub request_status: Option<String>,
pub request_id: Option<String>,
pub debug_message: Option<String>,
pub display_message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeResponse {
pub request_status: Option<String>,
pub request_id: Option<String>,
pub me: Option<SnapchatUser>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapchatUser {
pub id: String,
pub updated_at: Option<String>,
pub created_at: Option<String>,
pub email: Option<String>,
pub organization_id: Option<String>,
pub display_name: Option<String>,
pub member_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrganizationsResponse {
pub request_status: Option<String>,
pub request_id: Option<String>,
pub organizations: Vec<OrganizationWrapper>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrganizationWrapper {
pub organization: Option<Organization>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Organization {
pub id: String,
pub updated_at: Option<String>,
pub created_at: Option<String>,
pub name: String,
pub address_line_1: Option<String>,
pub locality: Option<String>,
pub administrative_district_level_1: Option<String>,
pub country: Option<String>,
pub postal_code: Option<String>,
pub organization_type: Option<String>,
pub state: Option<String>,
pub roles: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdAccountsResponse {
pub request_status: Option<String>,
pub request_id: Option<String>,
pub adaccounts: Vec<AdAccountWrapper>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdAccountWrapper {
pub adaccount: Option<AdAccount>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdAccount {
pub id: String,
pub updated_at: Option<String>,
pub created_at: Option<String>,
pub name: String,
pub organization_id: String,
pub currency: String,
pub timezone: String,
pub advertiser: Option<String>,
pub status: Option<String>,
pub funding_source_ids: Option<Vec<String>>,
pub lifetime_spend_cap_micro: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CampaignsResponse {
pub request_status: Option<String>,
pub request_id: Option<String>,
pub campaigns: Vec<CampaignWrapper>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CampaignWrapper {
pub campaign: Option<Campaign>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Campaign {
pub id: String,
pub updated_at: Option<String>,
pub created_at: Option<String>,
pub name: String,
pub ad_account_id: String,
pub status: String,
pub objective: String,
pub start_time: Option<String>,
pub end_time: Option<String>,
pub daily_budget_micro: Option<i64>,
pub lifetime_spend_cap_micro: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdSquadsResponse {
pub request_status: Option<String>,
pub request_id: Option<String>,
pub adsquads: Vec<AdSquadWrapper>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdSquadWrapper {
pub adsquad: Option<AdSquad>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdSquad {
pub id: String,
pub updated_at: Option<String>,
pub created_at: Option<String>,
pub name: String,
pub campaign_id: String,
pub status: String,
pub squad_type: Option<String>,
pub billing_event: Option<String>,
pub bid_micro: Option<i64>,
pub daily_budget_micro: Option<i64>,
pub start_time: Option<String>,
pub end_time: Option<String>,
pub optimization_goal: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreativesResponse {
pub request_status: Option<String>,
pub request_id: Option<String>,
pub creatives: Vec<CreativeWrapper>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreativeWrapper {
pub creative: Option<Creative>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Creative {
pub id: String,
pub updated_at: Option<String>,
pub created_at: Option<String>,
pub name: String,
pub ad_account_id: String,
pub creative_type: Option<String>,
pub headline: Option<String>,
pub brand_name: Option<String>,
pub shareable: Option<bool>,
pub call_to_action: Option<String>,
pub top_snap_media_id: Option<String>,
pub top_snap_crop_position: Option<String>,
pub review_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdsResponse {
pub request_status: Option<String>,
pub request_id: Option<String>,
pub ads: Vec<AdWrapper>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdWrapper {
pub ad: Option<Ad>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ad {
pub id: String,
pub updated_at: Option<String>,
pub created_at: Option<String>,
pub name: String,
pub ad_squad_id: String,
pub creative_id: String,
pub status: String,
pub ad_type: Option<String>,
pub review_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaResponse {
pub request_status: Option<String>,
pub request_id: Option<String>,
pub media: Vec<MediaWrapper>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaWrapper {
pub media: Option<Media>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SingleMediaResponse {
pub request_status: Option<String>,
pub request_id: Option<String>,
pub media: Option<Media>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Media {
pub id: String,
pub updated_at: Option<String>,
pub created_at: Option<String>,
pub name: String,
pub ad_account_id: String,
pub media_type: String,
pub media_status: Option<String>,
pub file_name: Option<String>,
pub download_link: Option<String>,
pub duration_secs: Option<f64>,
pub upload_link: Option<String>,
}
impl Media {
pub fn is_ready(&self) -> bool {
self.media_status.as_deref() == Some("READY")
}
pub fn is_pending(&self) -> bool {
self.media_status.as_deref() == Some("PENDING_UPLOAD")
}
pub fn is_processing(&self) -> bool {
self.media_status.as_deref() == Some("PROCESSING")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudiencesResponse {
pub request_status: Option<String>,
pub request_id: Option<String>,
pub segments: Vec<AudienceWrapper>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudienceWrapper {
pub segment: Option<Audience>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Audience {
pub id: String,
pub updated_at: Option<String>,
pub created_at: Option<String>,
pub name: String,
pub ad_account_id: String,
pub description: Option<String>,
pub source_type: String,
pub retention_in_days: Option<i32>,
pub status: Option<String>,
pub approximate_number_users: Option<i64>,
pub upload_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CampaignStats {
pub request_status: Option<String>,
pub request_id: Option<String>,
pub total_stats: Option<Vec<StatsEntry>>,
pub timeseries_stats: Option<Vec<TimeseriesStats>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatsEntry {
pub id: Option<String>,
pub stats: Option<Stats>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Stats {
pub impressions: Option<i64>,
pub swipes: Option<i64>,
pub spend: Option<i64>,
pub quartile_1: Option<i64>,
pub quartile_2: Option<i64>,
pub quartile_3: Option<i64>,
pub view_completion: Option<i64>,
pub screen_time_millis: Option<i64>,
pub video_views: Option<i64>,
pub video_views_time_based: Option<i64>,
pub video_views_15s: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeseriesStats {
pub id: Option<String>,
pub timeseries: Option<Vec<TimeseriesEntry>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeseriesEntry {
pub start_time: Option<String>,
pub end_time: Option<String>,
pub stats: Option<Stats>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthTokenResponse {
pub access_token: String,
pub refresh_token: String,
pub expires_in: u64,
pub token_type: String,
pub scope: String,
}
// ============================================================================
// Constants
// ============================================================================
/// Campaign objectives
pub struct CampaignObjectives;
impl CampaignObjectives {
pub const AWARENESS: &'static str = "AWARENESS";
pub const APP_INSTALLS: &'static str = "APP_INSTALLS";
pub const ENGAGEMENT: &'static str = "ENGAGEMENT";
pub const VIDEO_VIEWS: &'static str = "VIDEO_VIEWS";
pub const WEB_CONVERSIONS: &'static str = "WEB_CONVERSIONS";
pub const LEAD_GENERATION: &'static str = "LEAD_GENERATION";
pub const CATALOG_SALES: &'static str = "CATALOG_SALES";
}
/// Ad status values
pub struct AdStatus;
impl AdStatus {
pub const ACTIVE: &'static str = "ACTIVE";
pub const PAUSED: &'static str = "PAUSED";
}
/// Billing events
pub struct BillingEvents;
impl BillingEvents {
pub const IMPRESSION: &'static str = "IMPRESSION";
pub const SWIPE: &'static str = "SWIPE";
pub const VIDEO_VIEW: &'static str = "VIDEO_VIEW";
}
/// Optimization goals
pub struct OptimizationGoals;
impl OptimizationGoals {
pub const IMPRESSIONS: &'static str = "IMPRESSIONS";
pub const SWIPES: &'static str = "SWIPES";
pub const APP_INSTALLS: &'static str = "APP_INSTALLS";
pub const VIDEO_VIEWS: &'static str = "VIDEO_VIEWS";
pub const VIDEO_VIEWS_15_SEC: &'static str = "VIDEO_VIEWS_15_SEC";
pub const USES: &'static str = "USES";
pub const STORY_OPENS: &'static str = "STORY_OPENS";
pub const PIXEL_PAGE_VIEW: &'static str = "PIXEL_PAGE_VIEW";
pub const PIXEL_ADD_TO_CART: &'static str = "PIXEL_ADD_TO_CART";
pub const PIXEL_PURCHASE: &'static str = "PIXEL_PURCHASE";
pub const PIXEL_SIGNUP: &'static str = "PIXEL_SIGNUP";
}
/// Call to action types
pub struct CallToAction;
impl CallToAction {
pub const APPLY_NOW: &'static str = "APPLY_NOW";
pub const BOOK_NOW: &'static str = "BOOK_NOW";
pub const BUY_TICKETS: &'static str = "BUY_TICKETS";
pub const CONTACT_US: &'static str = "CONTACT_US";
pub const DONATE: &'static str = "DONATE";
pub const DOWNLOAD: &'static str = "DOWNLOAD";
pub const GET_NOW: &'static str = "GET_NOW";
pub const INSTALL_NOW: &'static str = "INSTALL_NOW";
pub const LEARN_MORE: &'static str = "LEARN_MORE";
pub const LISTEN: &'static str = "LISTEN";
pub const MORE: &'static str = "MORE";
pub const ORDER_NOW: &'static str = "ORDER_NOW";
pub const PLAY: &'static str = "PLAY";
pub const READ: &'static str = "READ";
pub const SHOP_NOW: &'static str = "SHOP_NOW";
pub const SHOW_TIMES: &'static str = "SHOW_TIMES";
pub const SIGN_UP: &'static str = "SIGN_UP";
pub const SUBSCRIBE: &'static str = "SUBSCRIBE";
pub const USE_APP: &'static str = "USE_APP";
pub const VIEW: &'static str = "VIEW";
pub const VIEW_MORE: &'static str = "VIEW_MORE";
pub const VOTE_NOW: &'static str = "VOTE_NOW";
pub const WATCH: &'static str = "WATCH";
}
/// Audience source types
pub struct AudienceSourceTypes;
impl AudienceSourceTypes {
pub const FIRST_PARTY: &'static str = "FIRST_PARTY";
pub const ENGAGEMENT_SNAPCHAT: &'static str = "ENGAGEMENT_SNAPCHAT";
pub const PIXEL: &'static str = "PIXEL";
pub const MOBILE_APP: &'static str = "MOBILE_APP";
pub const LOOKALIKE: &'static str = "LOOKALIKE";
}