diff --git a/src/main/java/kz/konturai/parser/service/FacebookPostingService.java b/src/main/java/kz/konturai/parser/service/FacebookPostingService.java index 159a984..7935126 100644 --- a/src/main/java/kz/konturai/parser/service/FacebookPostingService.java +++ b/src/main/java/kz/konturai/parser/service/FacebookPostingService.java @@ -20,12 +20,6 @@ import java.time.Duration; import java.util.List; import java.util.stream.Collectors; -/** - * Сервис публикации постов в Facebook. - * - * Использует надежную схему с Long-Lived Page Access Token. - * Настройки приоритетно берутся из application.properties. - */ @Service @Slf4j public class FacebookPostingService { @@ -38,7 +32,7 @@ public class FacebookPostingService { @Value("${facebook.app.secret:}") private String appSecret; - @Value("${facebook.page.token:EAAaocqgT3JoBQhROtnPQ3echpBNU7pnrRxmj7XOoyFzDzOeJIZCjjck4ilraxYEBNXgDZCWfvUZBjDWPLGKrvF7ZCc1ZAmnDj1RHnW06I69ZBvCVbpoiKzsMqPihLZAwZB7WJykDAlaCVbhSBFmOY2YdMlf1fwi3cUq0tvKAaiJesKJbfOuXgMV0c7lSFKNz2yxsWiRfTzpMjhjk8Y9EsYpM8FW0HMEktzeHaoGOGOoI8oEZD}") + @Value("${facebook.page.token:}") private String pageToken; @Value("${facebook.page.id:918835061309575}") @@ -54,83 +48,146 @@ public class FacebookPostingService { this.objectMapper = new ObjectMapper(); HttpClient httpClient = HttpClient.create() - .responseTimeout(Duration.ofMillis(30000)); + .responseTimeout(Duration.ofMillis(60000)); // увеличен таймаут для видео this.webClient = WebClient.builder() .baseUrl(FACEBOOK_GRAPH_API_BASE) .clientConnector(new ReactorClientHttpConnector(httpClient)) + .codecs(configurer -> configurer.defaultCodecs() + .maxInMemorySize(100 * 1024 * 1024)) // 100MB для видео .build(); } - // ── Credentials management ──────────────────────────────────────────────── + // ── Credentials ────────────────────────────────────────────────────────── public PageCredentials getPageCredentials() { return new PageCredentials(pageId, pageToken); } + // ── Public posting methods ──────────────────────────────────────────────── + /** - * Обмен короткого токена пользователя на долгосрочный токен страницы. + * Публикация текстового поста */ - public String exchangeForLongLivedPageToken(String shortLivedUserToken, String targetPageId) { - log.info("[Facebook] Запуск обмена токена для страницы {}", targetPageId); - - if (appId == null || appId.contains("dummy") || appSecret == null || appSecret.contains("dummy")) { - throw new IllegalStateException("Hеобходимo настроить facebook.app.id и facebook.app.secret"); - } - - try { - // 1. Короткий токен -> Долгосрочный User Token (60 дней) - JsonNode userTokenNode = webClient.get() - .uri(u -> u.path("/oauth/access_token") - .queryParam("grant_type", "fb_exchange_token") - .queryParam("client_id", appId) - .queryParam("client_secret", appSecret) - .queryParam("fb_exchange_token", shortLivedUserToken) - .build()) - .retrieve() - .bodyToMono(JsonNode.class) - .block(Duration.ofMillis(timeoutMs)); - - String longLivedUserToken = userTokenNode.path("access_token").asText(); - log.info("[Facebook] Получен долгосрочный User Token"); - - // 2. Список страниц этого пользователя - JsonNode accounts = webClient.get() - .uri(u -> u.path("/me/accounts") - .queryParam("access_token", longLivedUserToken) - .build()) - .retrieve() - .bodyToMono(JsonNode.class) - .block(Duration.ofMillis(timeoutMs)); - - if (accounts != null && accounts.has("data")) { - for (JsonNode page : accounts.get("data")) { - if (page.path("id").asText().equals(targetPageId)) { - String permToken = page.path("access_token").asText(); - log.info("[Facebook] УСПЕХ! Получен надежный Page Token для {}", targetPageId); - log.info("[Facebook] ПЕРЕЗАПИШИТЕ ЕГО В application.properties: {}", permToken); - return permToken; - } - } - } - throw new RuntimeException("Страница " + targetPageId + " не найдена в аккаунте"); - } catch (Exception e) { - log.error("[Facebook] Ошибка обмена токена: {}", e.getMessage()); - throw new RuntimeException("Ошибка Meta API: " + e.getMessage(), e); - } - } - - // ── Posting methods ─────────────────────────────────────────────────────── - public String postToPage(String postText, List hashtags) { - return internalPost(getPageCredentials(), postText, hashtags, null); + return postTextOnly(getPageCredentials(), postText, hashtags); } + /** + * Публикация поста с фото + */ public String postToPageWithImage(String postText, List hashtags, byte[] imageData) { - return internalPost(getPageCredentials(), postText, hashtags, imageData); + return postWithPhoto(getPageCredentials(), postText, hashtags, imageData); } - // ── Messaging ──────────────────────────────────────────────────────────── + /** + * Публикация поста с видео — НОВЫЙ МЕТОД + */ + public String postToPageWithVideo(String postText, List hashtags, byte[] videoData) { + return postWithVideo(getPageCredentials(), postText, hashtags, videoData); + } + + // ── Core posting implementations ───────────────────────────────────────── + + /** + * Текстовый пост через /feed + */ + private String postTextOnly(PageCredentials creds, String postText, List hashtags) { + validateToken(creds); + try { + String fullText = buildPostText(postText, hashtags); + + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("access_token", creds.pageToken); + formData.add("message", fullText); + + String response = webClient.post() + .uri(u -> u.path("/{pageId}/feed").build(creds.pageId)) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromMultipartData(formData)) + .retrieve() + .bodyToMono(String.class) + .block(Duration.ofMillis(timeoutMs)); + + return extractPostId(response, creds.pageId); + } catch (WebClientResponseException e) { + handleFacebookError(e); + return null; + } catch (Exception e) { + throw new RuntimeException("[Facebook] Ошибка публикации текста: " + e.getMessage(), e); + } + } + + /** + * Пост с фото через /{pageId}/photos + */ + private String postWithPhoto(PageCredentials creds, String postText, List hashtags, byte[] imageData) { + validateToken(creds); + try { + String fullText = buildPostText(postText, hashtags); + + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("access_token", creds.pageToken); + formData.add("caption", fullText); // для фото — caption, не message + formData.add("source", new ByteArrayResource(imageData) { + @Override public String getFilename() { return "post.jpg"; } + }); + + String response = webClient.post() + .uri(u -> u.path("/{pageId}/photos").build(creds.pageId)) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(formData)) + .retrieve() + .bodyToMono(String.class) + .block(Duration.ofMillis(timeoutMs * 2L)); + + return extractPostId(response, creds.pageId); + } catch (WebClientResponseException e) { + handleFacebookError(e); + return null; + } catch (Exception e) { + throw new RuntimeException("[Facebook] Ошибка публикации фото: " + e.getMessage(), e); + } + } + + /** + * Пост с видео через /{pageId}/videos + * Facebook требует отдельный endpoint для видео! + */ + private String postWithVideo(PageCredentials creds, String postText, List hashtags, byte[] videoData) { + validateToken(creds); + try { + String fullText = buildPostText(postText, hashtags); + + log.info("[Facebook] Загружаю видео размером {} MB для страницы {}", + String.format("%.1f", videoData.length / 1024.0 / 1024.0), creds.pageId); + + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("access_token", creds.pageToken); + formData.add("description", fullText); // для видео — description + formData.add("title", extractTitle(postText)); // короткий заголовок + formData.add("source", new ByteArrayResource(videoData) { + @Override public String getFilename() { return "video.mp4"; } + }); + + String response = webClient.post() + .uri(u -> u.path("/{pageId}/videos").build(creds.pageId)) + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(formData)) + .retrieve() + .bodyToMono(String.class) + .block(Duration.ofMillis(timeoutMs * 6L)); // видео грузится дольше + + return extractPostId(response, creds.pageId); + } catch (WebClientResponseException e) { + handleFacebookError(e); + return null; + } catch (Exception e) { + throw new RuntimeException("[Facebook] Ошибка публикации видео: " + e.getMessage(), e); + } + } + + // ── Messaging ───────────────────────────────────────────────────────────── public void sendPrivateMessage(String recipientId, String messageText) { PageCredentials creds = getPageCredentials(); @@ -150,7 +207,8 @@ public class FacebookPostingService { .block(Duration.ofMillis(timeoutMs)); log.info("[Facebook] Сообщение отправлено пользователю {}", recipientId); } catch (Exception e) { - log.error("[Facebook] Ошибка отправки сообщения: {}", e.getMessage()); + // Messaging ошибки не должны ломать основной флоу публикации + log.warn("[Facebook] Не удалось отправить сообщение (это не критично): {}", e.getMessage()); } } @@ -177,50 +235,62 @@ public class FacebookPostingService { log.info("[Facebook] Изображение отправлено пользователю {}", recipientId); } catch (Exception e) { - log.error("[Facebook] Ошибка отправки изображения: {}", e.getMessage()); + log.warn("[Facebook] Не удалось отправить изображение в личку (это не критично): {}", e.getMessage()); } } - // ── Internal ────────────────────────────────────────────────────────────── + // ── Token exchange ──────────────────────────────────────────────────────── - private String internalPost(PageCredentials creds, String postText, List hashtags, byte[] imageData) { - if (creds.pageToken == null || creds.pageToken.length() < 10) { - throw new IllegalStateException("Facebook Access Token не настроен! Проверьте application.properties"); + public String exchangeForLongLivedPageToken(String shortLivedUserToken, String targetPageId) { + log.info("[Facebook] Запуск обмена токена для страницы {}", targetPageId); + + if (appId == null || appId.isBlank() || appSecret == null || appSecret.isBlank()) { + throw new IllegalStateException("Необходимо настроить facebook.app.id и facebook.app.secret"); } try { - String fullText = buildPostText(postText, hashtags); - MultiValueMap formData = new LinkedMultiValueMap<>(); - formData.add("access_token", creds.pageToken); - formData.add("message", fullText); - - String endpoint; - if (imageData != null) { - endpoint = "/{pageId}/photos"; - formData.add("source", new ByteArrayResource(imageData) { - @Override public String getFilename() { return "post.jpg"; } - }); - } else { - endpoint = "/{pageId}/feed"; - } - - String response = webClient.post() - .uri(u -> u.path(endpoint).build(creds.pageId)) - .contentType(imageData != null ? MediaType.MULTIPART_FORM_DATA : MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromMultipartData(formData)) + JsonNode userTokenNode = webClient.get() + .uri(u -> u.path("/oauth/access_token") + .queryParam("grant_type", "fb_exchange_token") + .queryParam("client_id", appId) + .queryParam("client_secret", appSecret) + .queryParam("fb_exchange_token", shortLivedUserToken) + .build()) .retrieve() - .bodyToMono(String.class) - .block(Duration.ofMillis(timeoutMs * 2)); + .bodyToMono(JsonNode.class) + .block(Duration.ofMillis(timeoutMs)); - JsonNode json = objectMapper.readTree(response); - String postId = json.path("id").asText(null); - log.info("[Facebook] Пост опубликован. id={}, pageId={}", postId, creds.pageId); - return postId; - } catch (WebClientResponseException e) { - handleFacebookError(e); - return null; + String longLivedUserToken = userTokenNode.path("access_token").asText(); + + JsonNode accounts = webClient.get() + .uri(u -> u.path("/me/accounts") + .queryParam("access_token", longLivedUserToken) + .build()) + .retrieve() + .bodyToMono(JsonNode.class) + .block(Duration.ofMillis(timeoutMs)); + + if (accounts != null && accounts.has("data")) { + for (JsonNode page : accounts.get("data")) { + if (page.path("id").asText().equals(targetPageId)) { + String permToken = page.path("access_token").asText(); + log.info("[Facebook] Получен бессрочный Page Token для {}", targetPageId); + return permToken; + } + } + } + throw new RuntimeException("Страница " + targetPageId + " не найдена в аккаунте"); } catch (Exception e) { - throw new RuntimeException("[Facebook] Ошибка: " + e.getMessage(), e); + log.error("[Facebook] Ошибка обмена токена: {}", e.getMessage()); + throw new RuntimeException("Ошибка Meta API: " + e.getMessage(), e); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private void validateToken(PageCredentials creds) { + if (creds.pageToken == null || creds.pageToken.length() < 10) { + throw new IllegalStateException("Facebook Access Token не настроен! Проверьте application.properties"); } } @@ -235,11 +305,33 @@ public class FacebookPostingService { return sb.toString(); } + /** + * Извлекаем короткий заголовок из текста поста (первые 100 символов до точки/переноса) + */ + private String extractTitle(String postText) { + if (postText == null || postText.isBlank()) return "Новый пост"; + String[] lines = postText.split("\n"); + String firstLine = lines[0].trim(); + return firstLine.length() > 100 ? firstLine.substring(0, 97) + "..." : firstLine; + } + + private String extractPostId(String response, String pageId) throws Exception { + JsonNode json = objectMapper.readTree(response); + String postId = json.path("id").asText(null); + if (postId == null || postId.isBlank()) { + log.warn("[Facebook] Пост опубликован, но id не получен. Response: {}", response); + return pageId + "_unknown"; + } + log.info("[Facebook] Пост опубликован. id={}, pageId={}", postId, pageId); + return postId; + } + private void handleFacebookError(WebClientResponseException e) { log.error("[Facebook] API Error: status={}, body={}", e.getStatusCode().value(), e.getResponseBodyAsString()); String body = e.getResponseBodyAsString(); - if (body.contains("access token") || body.contains("expired")) { - throw new FacebookTokenExpiredException("Токен Facebook истёк. Получите новый через утилиту обмена.", + if (body.contains("access token") || body.contains("expired") || body.contains("463") || body.contains("467")) { + throw new FacebookTokenExpiredException( + "Токен Facebook истёк. Получите новый через утилиту обмена.", "Token Expired", 190, 463, e); } throw new RuntimeException("[Facebook] API Error: " + body, e); @@ -253,4 +345,4 @@ public class FacebookPostingService { this.pageToken = pageToken; } } -} +} \ No newline at end of file