diff --git a/src/main/java/kz/konturai/parser/service/ImageStorageService.java b/src/main/java/kz/konturai/parser/service/ImageStorageService.java new file mode 100644 index 0000000..2ab08c4 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/ImageStorageService.java @@ -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); +} diff --git a/src/main/java/kz/konturai/parser/service/InstagramPostingService.java b/src/main/java/kz/konturai/parser/service/InstagramPostingService.java new file mode 100644 index 0000000..b05ae2c --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/InstagramPostingService.java @@ -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); + } + } +}