.
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
package kz.konturai.parser.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service interface for uploading images to storage and returning public URLs.
|
||||||
|
* This is required by Instagram API which only accepts public image URLs, not
|
||||||
|
* byte arrays.
|
||||||
|
*/
|
||||||
|
public interface ImageStorageService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads an image to storage and returns a public URL.
|
||||||
|
*
|
||||||
|
* @param imageBytes The image data as byte array
|
||||||
|
* @param filename The filename for the image (e.g., "image.jpg")
|
||||||
|
* @return Public URL to the uploaded image
|
||||||
|
* @throws RuntimeException if upload fails
|
||||||
|
*/
|
||||||
|
String uploadImage(byte[] imageBytes, String filename);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package kz.konturai.parser.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||||
|
import reactor.netty.http.client.HttpClient;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class InstagramPostingService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(InstagramPostingService.class);
|
||||||
|
private static final String FACEBOOK_GRAPH_API_BASE = "https://graph.facebook.com/v18.0";
|
||||||
|
|
||||||
|
private final WebClient webClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ImageStorageService imageStorageService;
|
||||||
|
|
||||||
|
@Value("${facebook.api.timeout:30000}")
|
||||||
|
private int timeoutMs;
|
||||||
|
|
||||||
|
public InstagramPostingService(ImageStorageService imageStorageService) {
|
||||||
|
HttpClient httpClient = HttpClient.create()
|
||||||
|
.responseTimeout(Duration.ofMillis(30000));
|
||||||
|
|
||||||
|
this.webClient = WebClient.builder()
|
||||||
|
.baseUrl(FACEBOOK_GRAPH_API_BASE)
|
||||||
|
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
|
this.imageStorageService = imageStorageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes an image post to Instagram Business account via Graph API.
|
||||||
|
*
|
||||||
|
* This method implements the 3-step Instagram publishing workflow:
|
||||||
|
* 1. Get Instagram Business Account ID from Facebook Page
|
||||||
|
* 2. Create Media Container with image URL
|
||||||
|
* 3. Publish the media container
|
||||||
|
*
|
||||||
|
* @param pageAccessToken Facebook Page Access Token (must have instagram_basic
|
||||||
|
* permission)
|
||||||
|
* @param caption The caption for the Instagram post
|
||||||
|
* @param imageBytes The image data as byte array
|
||||||
|
* @return ID of the published Instagram post
|
||||||
|
* @throws RuntimeException if posting fails at any step
|
||||||
|
*/
|
||||||
|
public String postToInstagram(String pageAccessToken, String caption, byte[] imageBytes) {
|
||||||
|
try {
|
||||||
|
// Step 1: Get Instagram Business Account ID
|
||||||
|
String igUserId = getInstagramBusinessAccountId(pageAccessToken);
|
||||||
|
logger.info("Retrieved Instagram Business Account ID: {}", igUserId);
|
||||||
|
|
||||||
|
// Upload image to storage and get public URL
|
||||||
|
String imageUrl = imageStorageService.uploadImage(imageBytes, "instagram-image.jpg");
|
||||||
|
logger.info("Image uploaded to storage, URL: {}", imageUrl);
|
||||||
|
|
||||||
|
// Step 2: Create Media Container
|
||||||
|
String creationId = createMediaContainer(igUserId, imageUrl, caption, pageAccessToken);
|
||||||
|
logger.info("Media container created with ID: {}", creationId);
|
||||||
|
|
||||||
|
// Step 3: Publish Media
|
||||||
|
String postId = publishMedia(igUserId, creationId, pageAccessToken);
|
||||||
|
logger.info("Successfully posted to Instagram. Post ID: {}", postId);
|
||||||
|
|
||||||
|
return postId;
|
||||||
|
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
logger.error("Instagram API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
throw new RuntimeException("Failed to post to Instagram: " + e.getMessage(), e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Unexpected error posting to Instagram", e);
|
||||||
|
throw new RuntimeException("Failed to post to Instagram", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1: Gets the Instagram Business Account ID linked to the Facebook Page.
|
||||||
|
*
|
||||||
|
* @param pageAccessToken Facebook Page Access Token
|
||||||
|
* @return Instagram Business Account ID
|
||||||
|
* @throws RuntimeException if the account ID cannot be retrieved
|
||||||
|
*/
|
||||||
|
private String getInstagramBusinessAccountId(String pageAccessToken) {
|
||||||
|
try {
|
||||||
|
String response = webClient.get()
|
||||||
|
.uri(uriBuilder -> uriBuilder
|
||||||
|
.path("/me")
|
||||||
|
.queryParam("access_token", pageAccessToken)
|
||||||
|
.queryParam("fields", "instagram_business_account")
|
||||||
|
.build())
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block(Duration.ofMillis(timeoutMs));
|
||||||
|
|
||||||
|
JsonNode root = objectMapper.readTree(response);
|
||||||
|
JsonNode instagramAccount = root.get("instagram_business_account");
|
||||||
|
|
||||||
|
if (instagramAccount == null || !instagramAccount.has("id")) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Instagram Business Account is not linked to this Facebook Page. " +
|
||||||
|
"Please link your Instagram Business account to the Facebook Page first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return instagramAccount.get("id").asText();
|
||||||
|
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
String responseBody = e.getResponseBodyAsString();
|
||||||
|
logger.error("Failed to get Instagram Business Account ID: {} - {}",
|
||||||
|
e.getStatusCode(), responseBody != null ? responseBody : "No response body");
|
||||||
|
throw new RuntimeException("Failed to get Instagram Business Account ID: " + e.getMessage(), e);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
logger.error("Failed to parse Instagram Business Account ID response", e);
|
||||||
|
throw new RuntimeException("Failed to parse Instagram Business Account ID response", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to get Instagram Business Account ID", e);
|
||||||
|
throw new RuntimeException("Failed to get Instagram Business Account ID: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2: Creates a media container for the Instagram post.
|
||||||
|
*
|
||||||
|
* @param igUserId Instagram Business Account ID
|
||||||
|
* @param imageUrl Public URL of the image (must be accessible by Instagram)
|
||||||
|
* @param caption Caption for the post
|
||||||
|
* @param accessToken Page Access Token
|
||||||
|
* @return Creation ID (media container ID)
|
||||||
|
* @throws RuntimeException if media container creation fails
|
||||||
|
*/
|
||||||
|
private String createMediaContainer(String igUserId, String imageUrl, String caption, String accessToken) {
|
||||||
|
try {
|
||||||
|
String response = webClient.post()
|
||||||
|
.uri(uriBuilder -> uriBuilder
|
||||||
|
.path("/{igUserId}/media")
|
||||||
|
.queryParam("access_token", accessToken)
|
||||||
|
.queryParam("image_url", imageUrl)
|
||||||
|
.queryParam("caption", caption != null ? caption : "")
|
||||||
|
.build(igUserId))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block(Duration.ofMillis(timeoutMs));
|
||||||
|
|
||||||
|
JsonNode jsonNode = objectMapper.readTree(response);
|
||||||
|
|
||||||
|
if (!jsonNode.has("id")) {
|
||||||
|
throw new RuntimeException("Media container creation response missing 'id' field");
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonNode.get("id").asText();
|
||||||
|
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
String responseBody = e.getResponseBodyAsString();
|
||||||
|
logger.error("Failed to create Instagram media container: {} - {}",
|
||||||
|
e.getStatusCode(), responseBody != null ? responseBody : "No response body");
|
||||||
|
throw new RuntimeException("Failed to create media container: " + e.getMessage(), e);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
logger.error("Failed to parse media container creation response", e);
|
||||||
|
throw new RuntimeException("Failed to parse media container creation response", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Unexpected error creating media container", e);
|
||||||
|
throw new RuntimeException("Failed to create media container: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3: Publishes the media container to Instagram.
|
||||||
|
*
|
||||||
|
* @param igUserId Instagram Business Account ID
|
||||||
|
* @param creationId The creation ID from Step 2
|
||||||
|
* @param accessToken Page Access Token
|
||||||
|
* @return Final Instagram post ID
|
||||||
|
* @throws RuntimeException if publishing fails
|
||||||
|
*/
|
||||||
|
private String publishMedia(String igUserId, String creationId, String accessToken) {
|
||||||
|
try {
|
||||||
|
String response = webClient.post()
|
||||||
|
.uri(uriBuilder -> uriBuilder
|
||||||
|
.path("/{igUserId}/media_publish")
|
||||||
|
.queryParam("access_token", accessToken)
|
||||||
|
.queryParam("creation_id", creationId)
|
||||||
|
.build(igUserId))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block(Duration.ofMillis(timeoutMs));
|
||||||
|
|
||||||
|
JsonNode jsonNode = objectMapper.readTree(response);
|
||||||
|
|
||||||
|
if (!jsonNode.has("id")) {
|
||||||
|
throw new RuntimeException("Media publish response missing 'id' field");
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonNode.get("id").asText();
|
||||||
|
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
String responseBody = e.getResponseBodyAsString();
|
||||||
|
logger.error("Failed to publish Instagram media: {} - {}",
|
||||||
|
e.getStatusCode(), responseBody != null ? responseBody : "No response body");
|
||||||
|
throw new RuntimeException("Failed to publish media: " + e.getMessage(), e);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
logger.error("Failed to parse media publish response", e);
|
||||||
|
throw new RuntimeException("Failed to parse media publish response", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Unexpected error publishing media", e);
|
||||||
|
throw new RuntimeException("Failed to publish media: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user